/** * settings-ingestion.ts, what a reader does with a setting it cannot ingest. * * ── The rule this file exists to state ──────────────────────────────────── * * A daemon that cannot ingest a setting must say so. Not "log it somewhere", * not "fall back quietly", say the FILE, the KEY, and the REASON, on stderr * (where a service journal captures it) and in the activity log (where whoever * finds the host later looks), and only then decide whether to carry on. * * That was not what happened. The settings reader had two silent modes and one * loud one, and the loud one was the least useful of the three: * * - a value of the wrong SHAPE was ingested as-is. `controlPlane.port` set to * the string "not-a-number" became the live port with no validation, no * warning, and nothing on disk to say so; * - a SECTION of the wrong shape was discarded and replaced by the default. * `"controlPlane": "…"` silently returned the daemon to port 3421 while the * operator's file said otherwise; * - and a file that would not parse at ALL killed the process, taking every * other key in it down over one stray byte. * * ── Skip or refuse: the rule, per key class ─────────────────────────────── * * The platform's own doctrine for persisted state is validate-by-content and * disclose. So the default is to QUARANTINE the single unreadable key, drop * it, fall back to the tier below it, say loudly what was dropped and why, and * keep serving everything else. A daemon that will not start helps nobody; a * daemon running one setting short, loudly, is recoverable. * * The exception is the class where running WITHOUT the operator's value is * worse than not running: a key whose fallback default permits more than the * value that was stored. If an operator wrote a permission gate and we cannot * read it, resolving to the shipped default can open something they closed. For * those keys, {@link SAFETY_GATE_CONFIG_PREFIXES}, the reader refuses, loudly * and with the same three facts. * * A whole file that does not parse is also a refusal, for the same reason: an * unreadable file may have held a safety-gate key, and the reader cannot tell. * * A credential is deliberately NOT in the refusing class. A daemon missing a * mailbox password runs with one connector down; that is a degraded surface, * not an open door, and it is exactly the case that must not crash-loop. */ /** What the reader did with a setting it could not ingest. */ export type SettingsIngestionAction = 'skipped' | 'refused'; /** One setting that did not make it into the resolved config, and why. */ export interface SettingsIngestionNotice { /** Absolute path of the settings file the value came from. */ readonly file: string; /** The dot-path key, or the file itself when the failure is whole-file. */ readonly key: string; /** Why it could not be ingested. Never contains a credential value. */ readonly reason: string; /** What the operator can do about it. */ readonly remedy: string; readonly action: SettingsIngestionAction; } /** Thrown when a setting is in the class that must not be silently dropped. */ export declare class SettingsIngestionRefusal extends Error { readonly notice: SettingsIngestionNotice; constructor(notice: SettingsIngestionNotice); } /** * The key classes where a fallback to the shipped default can permit more than * the operator's stored value did, so an unreadable value is refused rather * than skipped. * * A declared list rather than a name pattern, for the reason * secret-bearing-config-keys.ts already gives: a pattern is a habit, and every * key that does not fit the habit is silently outside it. This list is short on * purpose. Everything absent from it quarantines and the daemon keeps serving. */ export declare const SAFETY_GATE_CONFIG_PREFIXES: readonly string[]; /** * Deliberately NOT in the list above, and why, because the omissions are the * part someone will want to re-argue: * * - `danger.*`, `behavior.autoApprove`, `controlPlane.allowRemote`, * `controlPlane.trustProxy` all ship as `false`, and `sandbox.enabled` * ships as `true`. For every one of them the shipped default is the * RESTRICTIVE value, so falling back to it can only ever close something, * never open it. Refusing to start over these would trade a safe fallback * for a dead daemon. * - credential keys: a daemon short a mailbox password runs with one * connector down. That is a degraded surface, not an open door, and it is * exactly the case that must not crash-loop. */ /** True when an unreadable value at this key must refuse rather than skip. */ export declare function isSafetyGateConfigKey(key: string): boolean; /** The result of screening one settings file. */ export interface SettingsIngestionResult { /** The raw object with every unreadable key removed, safe to merge. */ readonly config: Record; readonly notices: readonly SettingsIngestionNotice[]; } /** * Screen one parsed settings file: remove what cannot be ingested and report * every removal. Does not throw, {@link ingestSettingsFile} decides that. */ export declare function screenSettingsForIngestion(raw: Record, file: string): SettingsIngestionResult; /** * Say it on stderr AND in the activity log, in that order. * * The default writer is a SYNCHRONOUS write to file descriptor 2, not * `process.stderr.write`. That is deliberate and load-bearing: the released * daemon binary died on exactly this path with zero bytes on either stream, * because a host's fatal handler is free to write nowhere and a host is free to * replace `process.stderr` (goodvibes-tui does, to keep a rendered screen * clean). Disclosing the refusal HERE, at the point of refusal and straight to * the descriptor, means every host speaks, including one whose own fatal tail * is silent. See daemon/fatal-boot-report.ts. * * The activity log follows, and is best-effort: on a refusal the process is * about to stop, and an asynchronously flushed log is exactly the part that * gets discarded. The descriptor write is the guarantee. */ export declare function announceIngestionNotice(entry: SettingsIngestionNotice, write?: (line: string) => void): void; /** A one-line, owner-facing summary of one notice, for a startup receipt. */ export declare function describeIngestionNotice(entry: SettingsIngestionNotice): string; /** Options for {@link ingestSettingsFile}; all seams are injectable for tests. */ export interface IngestSettingsOptions { /** This reader's version, compared against the file's recorded floor. */ readonly readerVersion?: string | undefined; /** Where the loud line goes. Defaults to a synchronous write to fd 2. */ readonly write?: ((line: string) => void) | undefined; /** Called for every notice, refusals included, before a refusal throws. */ readonly onNotice?: ((entry: SettingsIngestionNotice) => void) | undefined; /** * The caller's own load-time migrations, run BETWEEN the floor check and the * key screen. * * They have to run first or the screen lies. `sandbox.judgmentAutoApprove` is * a retired key the platform's own migration folds into `sandbox.judgment` on * every load, screening before that runs reports it as an unknown form of a * key the reader knows, which is true of the raw file and false of the config * the reader actually builds. A key the platform is about to rewrite itself is * not a key the platform does not understand. */ readonly migrate?: ((raw: Record) => Record) | undefined; } /** * The whole ingestion decision for one parsed settings file. * * Order is the design. The reader-floor check runs FIRST, so a file written by * a newer component reports the version mismatch rather than whichever key * happened to be shaped in a way this build could not parse, the symptom is * never the story. Then the per-key screen; every notice is announced; and a * refusal throws only after it has been said in both places. */ export declare function ingestSettingsFile(raw: Record, file: string, options?: IngestSettingsOptions): SettingsIngestionResult; /** * The notice for a file that could not be parsed at all. * * A refusal, and the one place the skip-by-default rule does not apply: the * reader cannot tell whether the unreadable bytes held a safety-gate key, so it * cannot know that carrying on is safe. Names the file and the parse error, * which is what turns "the daemon will not start" into a two-minute fix. */ export declare function unreadableSettingsFileNotice(file: string, reason: string): SettingsIngestionNotice; //# sourceMappingURL=settings-ingestion.d.ts.map