/** * Sync manifest wire contract (sync-reconciliation-audit US-001) — CLIENT MIRROR. * * ONE versioned contract, mirrored type-identically in two repos: * * - hq-pro src/sync/server/sync-manifest-contract.ts (reference) * - hq-cloud src/manifest/contract.ts (this file) * * This file is a deliberate copy, NOT an import: hq-cloud ships to end-user * machines and must never depend on the server repo. Drift is caught by * {@link SYNC_MANIFEST_CONTRACT_DESCRIPTOR}, which both repos assert against * the byte-identical `test/fixtures/sync-manifest-contract/descriptor.json` * snapshot — a renamed field or a changed bound breaks a test in BOTH repos. * * Everything below this header must stay byte-identical with the hq-pro file. * * Design rules (modeled on hq-pro `client-health-contract.ts`): * * - Closed enums as `as const` arrays; a typed * {@link SyncManifestContractError} on every rejection. * - FAIL CLOSED on values, TOLERANT of unknown extra fields. * - Identity comes from AUTH, not the body: `personUid` is deliberately not * a field, and supplying one is rejected outright. * - Bounded by construction (see {@link SYNC_MANIFEST_MAX_ENTRIES_PER_CHUNK}). * - {@link hasControlCharacters} is kept in lock-step with the predicate of * the same name in `src/ignore.ts` (asserted by this repo's mirror test). */ /** * Bump ONLY for a wire-visible change. Both repos pin the same integer and the * mirrored descriptor snapshot (`sync-manifest-contract-descriptor.json`) * fails in BOTH repos when they drift. */ export declare const SYNC_MANIFEST_CONTRACT_VERSION = 1; /** Which vault a manifest describes. `company` additionally carries `companyUid`. */ export declare const SYNC_MANIFEST_SCOPE_KINDS: readonly ["personal", "company"]; export type SyncManifestScopeKind = (typeof SYNC_MANIFEST_SCOPE_KINDS)[number]; /** Which client produced the manifest. Same split as the client-health contract. */ export declare const SYNC_MANIFEST_SOURCES: readonly ["desktop", "cli"]; export type SyncManifestSource = (typeof SYNC_MANIFEST_SOURCES)[number]; /** * `full` = the manifest enumerates the whole scope as of `generatedAt`. * `delta` = it enumerates only what changed since `baseSnapshotId`, and * `removedPaths` is meaningful. */ export declare const SYNC_MANIFEST_MODES: readonly ["full", "delta"]; export type SyncManifestMode = (typeof SYNC_MANIFEST_MODES)[number]; /** * Where the client learned about an entry. * * - `ledger` — present in the v3 journal projection but NOT on disk. * - `disk` — found by the walk but the journal has no entry for it. * - `both` — the healthy case: journal entry and disk file agree. * * The three values are what makes a wedged daemon detectable server-side: a * scope whose entries are overwhelmingly `disk` means the journal stopped * tracking; entries that are `ledger`-only mean the local copy vanished. */ export declare const SYNC_MANIFEST_ENTRY_SOURCES: readonly ["ledger", "disk", "both"]; export type SyncManifestEntrySource = (typeof SYNC_MANIFEST_ENTRY_SOURCES)[number]; /** Journal `direction`: `up` = pushed / locally authored, `down` = pulled. */ export declare const SYNC_MANIFEST_LEDGER_DIRECTIONS: readonly ["up", "down"]; export type SyncManifestLedgerDirection = (typeof SYNC_MANIFEST_LEDGER_DIRECTIONS)[number]; /** * Hard cap on entries in ONE chunk. A client with more files splits across * `chunkIndex`/`chunkCount`; 50,001 in a single chunk is a contract violation * ({@link SYNC_MANIFEST_BOUNDED_SIZE_REASON_CODE}), never a silent truncation. */ export declare const SYNC_MANIFEST_MAX_ENTRIES_PER_CHUNK = 50000; export declare const SYNC_MANIFEST_MAX_REMOVED_PATHS_PER_CHUNK = 50000; export declare const SYNC_MANIFEST_MAX_PATH_LENGTH = 1024; /** Merged client ignore-rule list (DEFAULT_IGNORES + .gitignore + .hqignore/.hqsyncignore + .hqinclude). */ export declare const SYNC_MANIFEST_MAX_IGNORE_RULES = 2000; export declare const SYNC_MANIFEST_MAX_IGNORE_RULE_LENGTH = 1024; export declare const SYNC_MANIFEST_MAX_ID_LENGTH = 64; export declare const SYNC_MANIFEST_MAX_CHUNK_COUNT = 10000; /** * Hard cap the SERVER enforces on one chunk's raw request body, measured as * `Buffer.byteLength(rawBody, "utf8")` before `JSON.parse`. Mirrored from * hq-pro `sync-manifest-store.ts`, which imports this constant rather than * declaring its own — one number, one source of truth, asserted by the * descriptor snapshot in BOTH repos. * * WHY A BYTE BOUND EXISTS AT ALL, NEXT TO THE ENTRY BOUND * ------------------------------------------------------ * {@link SYNC_MANIFEST_MAX_ENTRIES_PER_CHUNK} bounds the COUNT, not the size, * and an entry is not a fixed-width record: its `path` alone may be up to * {@link SYNC_MANIFEST_MAX_PATH_LENGTH} bytes. 50,000 entries is therefore * anywhere from ~4 MiB to ~55 MiB of JSON, so a count-only bound bounds * nothing that a transport cares about. * * This is what @indigoai-us/hq-cloud 6.16.21 shipped without: a 199,394-entry * personal scope planned four count-sized chunks of 15.9-17.9 MiB and API * Gateway refused chunk 0 with a 413 long before this handler ran (the HTTP * API's own payload ceiling is 10 MB, and it answers with an opaque gateway * error rather than a reason code). */ export declare const SYNC_MANIFEST_MAX_CHUNK_BYTES: number; /** * The budget a CLIENT plans against — deliberately below * {@link SYNC_MANIFEST_MAX_CHUNK_BYTES}, which is the server's REJECTION line. * * The gap is margin, and it is margin for things the planner cannot see. The * planner measures the JSON body it hands the transport; what the gateway * counts is whatever that transport actually puts on the wire, plus headers. * hq-cloud does not compress — {@link ManifestUploadTransport} is injected by * hq-sync/hq-cli and hq-cloud never sets `Content-Encoding` — so today the * planned bytes ARE the counted bytes. That is exactly the kind of fact that * changes without this file being touched (a transport that gains gzip, a * proxy that re-encodes, a Lambda proxy integration that base64-inflates a * body it decides is binary and multiplies it by 4/3), so the client aims a * full MiB below the server's line and 6 MiB below the gateway's. * * A client that guesses wrong is not stuck: a 413 halves this budget for the * next pass (see `MANIFEST_CHUNK_BYTE_BUDGET_FLOOR` in `upload-manifest.ts`). */ export declare const SYNC_MANIFEST_DEFAULT_CHUNK_BYTE_BUDGET: number; /** * Machine-matchable reason code the server returns with a 413 for a chunk * whose raw body exceeded {@link SYNC_MANIFEST_MAX_CHUNK_BYTES}. * * A CLIENT-side configuration fault, not a server answer about the scope: * the same manifest planned with a smaller budget succeeds. The upload pass * classifies it accordingly (failure backoff + a halved budget), which is the * distinction 6.16.21 got wrong — it armed the 24h throttle on this and hid * the fault for a day per attempt. */ export declare const SYNC_MANIFEST_CHUNK_TOO_LARGE_REASON_CODE = "chunk_too_large"; /** * Machine-matchable reason code the server returns with a 413 when the FOLDED * view for a scope would exceed its total-entry ceiling. * * Shares a status code with {@link SYNC_MANIFEST_CHUNK_TOO_LARGE_REASON_CODE} * and nothing else. This one is a statement about the SCOPE — re-sending it at * any chunk size gets the same answer — so a client must not treat it as a * sizing fault to be retried smaller. */ export declare const SYNC_MANIFEST_TOO_LARGE_REASON_CODE = "manifest_too_large"; /** 1 TiB — larger than any syncable object; a bigger value is a bug, not a file. */ export declare const SYNC_MANIFEST_MAX_FILE_SIZE_BYTES = 1099511627776; /** 2100-01-01T00:00:00Z. Guards against overflowed/garbage mtimes. */ export declare const SYNC_MANIFEST_MAX_MTIME_MS = 4102444800000; export declare const SYNC_MANIFEST_MAX_ETAG_LENGTH = 128; export declare const SYNC_MANIFEST_MAX_ERROR_CLASS_LENGTH = 64; export declare const SYNC_MANIFEST_MAX_WALK_STAT = 100000000; /** * Ceiling on a CLAIMED `contractVersion`. Anything above this is garbage, not * a future client; anything in `1..this` that we do not implement reports * {@link SyncManifestContractErrorCode} `UNSUPPORTED_CONTRACT_VERSION` so a * newer client can tell "upgrade the server" apart from "your value is junk". */ export declare const SYNC_MANIFEST_MAX_CONTRACT_VERSION = 1000; /** * The single reason code a bounded-size rejection reports. Callers match on * this rather than on prose, so the 50,001-entry case is machine-classifiable. */ export declare const SYNC_MANIFEST_BOUNDED_SIZE_REASON_CODE = "BOUNDED_SIZE_EXCEEDED"; /** * Which vault the manifest describes. `companyUid` is REQUIRED when * `kind === "company"` and MUST be absent when `kind === "personal"` — a * personal manifest carrying a company uid is a tenant-boundary smell and * fails closed. */ export interface SyncManifestScope { kind: SyncManifestScopeKind; companyUid?: string; /** * The company's local directory name under `companies/` — the ANCHOR the * server needs to re-apply the client's shipped `ignoreRules` (which * hq-cloud probes against HQ-ROOT-relative paths) to the SCOPE-relative * paths this manifest ships. Optional and company-only: a manifest built * before this field existed simply omits it, and the server then derives * the slug from the company entity it already holds rather than guessing. */ companySlug?: string; /** * The scope's local root, relative to the HQ root and POSIX-separated — * `companies/` for a company scope. Shipped alongside * `companySlug` so the server prepends the SAME prefix hq-cloud walked * from, rather than reconstructing it from a convention that could drift. */ scopeRoot?: string; } /** * One file as the client sees it, from the union of the v3 journal projection * and the on-disk walk. `path` is POSIX and SCOPE-RELATIVE (never absolute, * never a vault key with the scope prefix already applied). */ export interface SyncManifestEntry { path: string; /** * Lowercase sha256 hex of the file contents, when the client has one. * * OPTIONAL since the stat-only default. The client's cheap path is a stat * walk: it reuses a hash from its journal when the journal's (size, mtime) * pair still matches, and otherwise has none. Computing one means READING * the file, and on a large vault whose journal predates this feature that is * every file — measured at ~690k files, ~9m40s and 2.9 GB RSS for a single * post-sync tail step on a laptop. That is not a price a background audit * may charge. * * An entry with no `hash` means "this path is PRESENT on the client, hash * unknown" — never "absent" and never "mismatched". Consumers must treat a * missing hash as unknown and fall back to the (size, mtimeMs) pair, which * is always present. Dropping such entries instead (the pre-existing * `ledger-only` behaviour) is strictly worse: it reports a file the client * holds as one it does not. */ hash?: string; size: number; mtimeMs: number; source: SyncManifestEntrySource; /** Journal `remoteEtag` — the ETag the client believes the vault object has. */ ledgerRemoteEtag?: string; /** Journal `syncedAt` (ISO-8601 UTC). */ ledgerSyncedAt?: string; ledgerDirection?: SyncManifestLedgerDirection; /** Client believes this file still owes an upload. */ pendingPush?: boolean; /** * True when the local entry is a SYMLINK. * * ADDITIVE and OPTIONAL: an older client omits it, and absence means * "unknown", never "regular file". It exists because a symlink's local `size` * is the byte length of its target string while the vault object is the * RESOLVED file, so any size comparison between the two is meaningless — the * reconciler skips drift entirely when this is set. Mirrored in hq-pro. */ symlink?: boolean; } /** Bounded walk telemetry — counts only, never a path. */ export interface SyncManifestWalkStats { filesWalked: number; walkMs: number; hashedFiles: number; ignoredFiles: number; /** * Entries emitted WITHOUT a hash (stat-only). Optional so an older client's * chunk still validates. `hashedFiles + unhashedFiles` does not have to equal * `filesWalked`: a hash reused from the journal costs no read and is counted * in neither. */ unhashedFiles?: number; } /** * Set when the client could NOT read its journal. The audit must then treat * every `disk`-sourced entry as "ledger unknown" rather than "ledger missing", * so a locked/corrupt journal does not manufacture findings. */ export interface SyncManifestLedgerUnavailable { /** Closed-ish error CLASS name (e.g. `JournalCorruptError`) — never a message, never a path. */ errorClass: string; } /** * One uploaded manifest chunk. * * `personUid` is deliberately NOT a body field: the server derives the person * from the authenticated principal. A body-supplied personUid would be a * cross-tenant forgery seam. */ export interface SyncManifestUpload { contractVersion: number; /** Stable random installation identity — NOT a hardware fingerprint. */ installationId: string; scope: SyncManifestScope; machineId: string; source: SyncManifestSource; mode: SyncManifestMode; /** Required in spirit for `delta`; the snapshot the delta is relative to. */ baseSnapshotId?: string; /** Client-side generation time (ISO-8601 UTC). */ generatedAt: string; /** Monotonic per-installation+scope sequence; older values never overwrite newer state. */ sequence: number; chunkIndex: number; chunkCount: number; entries: SyncManifestEntry[]; /** Scope-relative POSIX paths the client believes are gone. Meaningful in `delta` mode. */ removedPaths: string[]; walkStats: SyncManifestWalkStats; /** Merged ignore-pattern list the client actually applied, in precedence order. */ ignoreRules: string[]; ledgerUnavailable?: SyncManifestLedgerUnavailable; } export type SyncManifestContractErrorCode = "MISSING_FIELD" | "INVALID_TYPE" | "UNKNOWN_ENUM_VALUE" | "UNSAFE_VALUE" | "OUT_OF_BOUNDS" | "BOUNDED_SIZE_EXCEEDED" | "UNSUPPORTED_CONTRACT_VERSION"; export declare const SYNC_MANIFEST_CONTRACT_ERROR_CODES: readonly ["MISSING_FIELD", "INVALID_TYPE", "UNKNOWN_ENUM_VALUE", "UNSAFE_VALUE", "OUT_OF_BOUNDS", "BOUNDED_SIZE_EXCEEDED", "UNSUPPORTED_CONTRACT_VERSION"]; export declare class SyncManifestContractError extends Error { readonly code: SyncManifestContractErrorCode; readonly field: string; constructor(code: SyncManifestContractErrorCode, field: string, detail?: string); } /** * True when `value` contains a character the vault key validator rejects (C0 * controls plus DEL). Kept byte-for-byte in lock-step with hq-cloud * `hasControlCharacters` in `src/ignore.ts`: a path this returns `true` for * can never be stored, so accepting it into a manifest only manufactures a * guaranteed per-file failure downstream. */ export declare function hasControlCharacters(value: string): boolean; /** * True when `value` contains an UNPAIRED surrogate code unit. Such a string * has no valid UTF-8 encoding, so it can never become a vault key — accepting * it into a manifest only manufactures a guaranteed per-file failure later. */ export declare function hasLoneSurrogate(value: string): boolean; /** * True when `value` contains a bidi override or a Unicode line separator. * These render a path as something other than what it addresses (U+202E turns * `report.fdp.md` into a visual `report.md.pdf`) and break line-oriented * ignore-rule handling, so they fail closed alongside control characters. */ export declare function hasBidiOrLineSeparator(value: string): boolean; /** * Scope-relative POSIX path validation. Fails closed on: control characters * (same predicate as hq-cloud `hasControlCharacters`), absolute paths, Windows * separators or drive letters, `~` home refs, empty segments, and `.`/`..` * segments — a `..` segment is a scope-escape attempt, not a stylistic issue. */ export declare function assertSyncManifestPath(field: string, value: unknown): string; /** Parse + validate a manifest scope. Unknown extra fields are ignored. */ export declare function validateSyncManifestScope(field: string, input: unknown): SyncManifestScope; /** Parse + validate one manifest entry. Unknown extra fields are ignored. */ export declare function validateSyncManifestEntry(field: string, input: unknown): SyncManifestEntry; /** * Parse + validate one manifest chunk upload. * * Tolerant of unknown EXTRA fields (a newer client talking to an older server * must not fail) and fail-closed on every value it does consume. Rejections * carry a {@link SyncManifestContractErrorCode}; an over-sized chunk always * reports {@link SYNC_MANIFEST_BOUNDED_SIZE_REASON_CODE}. */ export declare function validateSyncManifestUpload(input: unknown): SyncManifestUpload; /** * A machine-comparable description of this contract's SHAPE: version, closed * enum value arrays, limit constants, and the field-name lists of every wire * type. Both repos snapshot this to the byte-identical * `sync-manifest-contract-descriptor.json` fixture, so ANY drift — a renamed * field, a new enum value, a changed bound — fails a test in BOTH repos. * * Field lists are declared explicitly (not derived) because TypeScript * interfaces are erased at runtime; the type-level half of the check is the * compile-time assertion in each repo's mirror test, which requires every * listed name to be a real key of the corresponding interface. */ export declare const SYNC_MANIFEST_CONTRACT_DESCRIPTOR: { readonly contractVersion: 1; readonly enums: { readonly scopeKinds: readonly ["personal", "company"]; readonly sources: readonly ["desktop", "cli"]; readonly modes: readonly ["full", "delta"]; readonly entrySources: readonly ["ledger", "disk", "both"]; readonly ledgerDirections: readonly ["up", "down"]; readonly errorCodes: readonly ["MISSING_FIELD", "INVALID_TYPE", "UNKNOWN_ENUM_VALUE", "UNSAFE_VALUE", "OUT_OF_BOUNDS", "BOUNDED_SIZE_EXCEEDED", "UNSUPPORTED_CONTRACT_VERSION"]; }; readonly limits: { readonly maxEntriesPerChunk: 50000; readonly maxRemovedPathsPerChunk: 50000; readonly maxPathLength: 1024; readonly maxIgnoreRules: 2000; readonly maxIgnoreRuleLength: 1024; readonly maxIdLength: 64; readonly maxChunkCount: 10000; readonly maxChunkBytes: number; readonly defaultChunkByteBudget: number; readonly maxFileSizeBytes: 1099511627776; readonly maxMtimeMs: 4102444800000; readonly maxEtagLength: 128; readonly maxErrorClassLength: 64; readonly maxWalkStat: 100000000; readonly maxContractVersion: 1000; }; readonly boundedSizeReasonCode: "BOUNDED_SIZE_EXCEEDED"; readonly chunkTooLargeReasonCode: "chunk_too_large"; readonly manifestTooLargeReasonCode: "manifest_too_large"; readonly fields: { readonly upload: readonly ["baseSnapshotId", "chunkCount", "chunkIndex", "contractVersion", "entries", "generatedAt", "ignoreRules", "installationId", "ledgerUnavailable", "machineId", "mode", "removedPaths", "scope", "sequence", "source", "walkStats"]; readonly entry: readonly ["hash", "ledgerDirection", "ledgerRemoteEtag", "ledgerSyncedAt", "mtimeMs", "path", "pendingPush", "size", "source", "symlink"]; readonly scope: readonly ["companySlug", "companyUid", "kind", "scopeRoot"]; readonly walkStats: readonly ["filesWalked", "hashedFiles", "ignoredFiles", "unhashedFiles", "walkMs"]; readonly ledgerUnavailable: readonly ["errorClass"]; }; }; //# sourceMappingURL=contract.d.ts.map