/** * Per-scope manifest snapshot store (sync-reconciliation-audit US-002). * * The audit's client half uploads a manifest of every file in a scope. Doing * that in FULL on every cycle is wasteful for a 70k-file HQ root, so the * builder emits a DELTA against the last manifest it successfully produced. * That "last manifest" lives here: one small JSON file per scope, holding the * minimum needed to decide "changed / added / removed" — `hash`, `size`, * `mtimeMs` per path. Nothing else is retained (no ledger metadata, no * absolute paths), because anything more would be a second, drifting copy of * state the journal already owns. * * Two design rules, both load-bearing: * * 1. **Reads NEVER throw.** A missing, truncated, half-written, or * hand-edited snapshot must degrade to "no snapshot" → the builder emits * a FULL manifest. A snapshot is a cache, and a cache that can break the * feature it accelerates is worse than no cache. * 2. **Writes are atomic (tmp + rename).** A crash mid-write must leave the * previous snapshot intact rather than a truncated file that would be * read as "no snapshot" (safe) or, worse, as a partial file list — which * would manufacture a page of false `removedPaths` on the next delta. */ import { type SyncManifestScope } from "./contract.js"; /** Directory (under the state dir) holding one JSON file per scope. */ export declare const MANIFEST_SNAPSHOT_DIRNAME = "manifest-snapshots"; /** * A snapshot older than this is not trusted as a delta base: the longer it * sits, the more likely the ledger, ignore rules, or the tree itself moved in * ways a delta cannot express. Re-baselining weekly bounds how stale the * server's picture of a scope can get without a full re-send. */ export declare const MANIFEST_SNAPSHOT_MAX_AGE_MS: number; /** The three fields a delta comparison needs, and nothing more. */ export interface ManifestSnapshotEntry { hash: string; size: number; mtimeMs: number; } export interface ManifestSnapshot { /** Opaque id; becomes `baseSnapshotId` on the next delta upload. */ snapshotId: string; /** ISO-8601 UTC generation time — the input to the staleness check. */ generatedAt: string; /** The `sequence` of the manifest this snapshot was captured from. */ sequence: number; entries: Record; /** * ISO-8601 UTC time of the last manifest UPLOAD pass for this scope * (US-004). Additive and optional: a snapshot written by an older build has * no such field, and must read back as "never uploaded" rather than as a * corrupt record — so this is validated leniently and simply dropped when it * is not a parseable timestamp. * * It is deliberately kept HERE rather than in a second file: the throttle * decision and the delta-base decision are read together on every pass, and * two files would let them disagree after a partial write. */ lastUploadAt?: string; /** * `false` marks this record as an UPLOAD BOOKKEEPING record only — its * `entries` must NOT be used as a delta base (the server refused the fold, * or asked for a full resend). Absent means `true`, which is what every * pre-US-004 snapshot means. * * The reason this rides on the snapshot instead of deleting the file: the * `sequence` and `lastUploadAt` on it are still load-bearing (sequence must * never decrease, and the 24h throttle must still fire), and deleting the * file would drop both. */ baseValid?: boolean; /** * ISO-8601 UTC time of the last FAILED pass for this scope (US-004 failure * backoff). Distinct from `lastUploadAt`, which records a pass that actually * reached the server, because the two arm different gates: `lastUploadAt` * spaces out healthy passes (24h), while this one — paired with * {@link ManifestSnapshot.consecutiveFailures} — stops a broken endpoint from * buying a full tree walk + hash on EVERY sync cycle. * * Additive and optional, validated leniently: an older build's snapshot has * no such field and must read back as "no failures", not as corruption. */ lastAttemptAt?: string; /** * How many passes in a row have failed without reaching the server. Drives * the exponential retry floor; reset to absent by any pass the server * answered (including a 404 soft skip or a `resend_full`), because those * prove the endpoint is reachable. */ consecutiveFailures?: number; /** * WHY the last pass failed, as a fixed branch token — `walk_truncated`, * `build_error`, `transport_error`, `http_error`, `not_materialised`, and so * on. Never a path, a filename or an error message. * * This exists because the failure state was previously WRITE-ONLY: the pass * recorded `lastAttemptAt` and `consecutiveFailures` but threw the reason * away (it survived only as the label on a log line that fires when the * snapshot write ITSELF fails), so a scope stuck failing every cycle was * forensically indistinguishable from any other stuck scope. On a host whose * runner events are not plumbed anywhere, this record is the ONLY durable * evidence of what went wrong — see the 2026-09-07 personal-scope incident, * which had to be diagnosed by elimination and re-execution because this * field did not exist. * * Additive and optional, validated leniently: an older build's snapshot has * no such field and must read back as "reason unknown", not as corruption. */ lastFailureReason?: string; /** * The `error.kind` the pass returned alongside {@link lastFailureReason}, * when it had one. The reason names the BRANCH; the kind names the * classified fault within it (e.g. reason `http_error` with kind * `rejected`). Fixed tokens only, never a message. */ lastFailureKind?: string; /** * The HTTP status that produced the failure, when the failure came from an * answered request. Absent for every failure that never reached the wire. */ lastFailureStatus?: number; /** * The serialised-byte budget the NEXT pass should plan chunks against. * * Absent means "use the contract default" * (`SYNC_MANIFEST_DEFAULT_CHUNK_BYTE_BUDGET`), which is the state every * healthy scope stays in forever. It is written only by the 413 self-heal: * a scope whose entries are fat enough that the default budget still * produced a body the server refused halves it here, so the reduction * survives into the next pass instead of being re-discovered (and re-failed) * every cycle. * * Additive and optional, validated leniently for the same reason * {@link ManifestSnapshot.consecutiveFailures} is: an older build's snapshot * has no such field and must read back as "use the default", not as * corruption. */ chunkByteBudget?: number; } /** * Stable per-scope key. `personal` and `company:{uid}` are deliberately * different namespaces: a personal snapshot must never be reused as the delta * base for a company scope (that would emit every company file as "removed" * from the personal vault, and vice versa) — a tenant-boundary bug, not just * a correctness one. */ export declare function manifestSnapshotScopeKey(scope: SyncManifestScope): string; /** * Filename-safe form of a scope key. Every character outside `[A-Za-z0-9_-]` * collapses to `_`, so a hostile or merely surprising companyUid can never * escape the snapshot directory via `../` or a separator. * * That collapse is MANY-TO-ONE, and the contract's identifier rule admits * `.` and `:` inside a companyUid — so `company:acme.eu` and `company:acme:eu` * would otherwise sanitize to the SAME file and two tenants would share one * delta base. Sharing a base across companies is a tenant-boundary bug: the * second scope's delta would emit every file of the first as `removedPaths`. * A digest of the RAW key is therefore appended, which is injective in * practice while keeping the readable prefix for humans grepping the dir. * * {@link manifestSnapshotScopeSlug} is the extension-less form, reused by the * upload pass to name its per-scope lock file — the same injectivity argument * applies there, and for the same tenant-boundary reason: two scopes must * never share one lock any more than they may share one delta base. */ export declare function manifestSnapshotScopeSlug(scopeKey: string): string; export declare function manifestSnapshotFileName(scopeKey: string): string; export declare function manifestSnapshotPath(stateDir: string, scopeKey: string): string; /** Generate an id that satisfies the contract's opaque-identifier validator. */ export declare function newManifestSnapshotId(): string; /** * Read the snapshot for a scope, or `null` when there is nothing usable. * * "Nothing usable" covers absence, unreadable files, invalid JSON, and any * structurally wrong payload. All four collapse to the same safe outcome — a * full manifest — so the caller never has to branch on why. */ export declare function readManifestSnapshot(stateDir: string, scopeKey: string): ManifestSnapshot | null; /** True when this snapshot's entries may be used as a delta base. */ export declare function isManifestSnapshotBaseUsable(snapshot: ManifestSnapshot): boolean; /** * Persist a snapshot atomically. Returns `false` on any failure. * * A failed write is deliberately NOT an error for the caller: the manifest it * just built is still valid and still uploadable; the only consequence is * that the next cycle re-sends a full manifest. */ export declare function writeManifestSnapshot(stateDir: string, scopeKey: string, snapshot: ManifestSnapshot): boolean; /** True when the snapshot is too old to be trusted as a delta base. */ export declare function isManifestSnapshotStale(snapshot: ManifestSnapshot, nowMs: number, maxAgeMs?: number): boolean; //# sourceMappingURL=snapshot-store.d.ts.map