/** * Ledger entry shape + canonical JSON + sha256 helpers. * * This file holds the PURE primitives used by the hash-chained ledger: * the entry type, the deterministic (sort-stable) JSON encoder, and * the sha256 hasher that produces `prevHash` and `ledger.head()`. * * Everything here is validator-free and side-effect free — the only * runtime dep is Web Crypto's `subtle.digest` for the sha256 call, * which we already use for every other hashing operation in the core. * * The hash chain property works like this: * * hash(entry[i]) = sha256(canonicalJSON(entry[i])) * entry[i+1].prevHash = hash(entry[i]) * * Any modification to `entry[i]` (field values, field order, whitespace) * produces a different `hash(entry[i])`, which means `entry[i+1]`'s * stored `prevHash` no longer matches the recomputed hash, which means * `verify()` returns `{ ok: false, divergedAt: i + 1 }`. The chain is * append-only and tamper-evident without external anchoring. */ export interface LedgerEntry { /** * Zero-based sequential position of this entry in the chain. The * canonical adapter key is this number zero-padded to 10 digits * (`"0000000001"`) so lexicographic ordering matches numeric order. */ readonly index: number; /** * Hex-encoded sha256 of the canonical JSON of the PREVIOUS entry. * The genesis entry (index 0) has `prevHash === ''` — the first * entry in a fresh vault has nothing to point back to. */ readonly prevHash: string; /** * Which kind of mutation this entry records. only supports * data operations (`put`, `delete`, `amendment`). Access-control * operations (`grant`, `revoke`, `rotate`) will be added in a * follow-up once the keyring write path is instrumented — that's * tracked in the epic issue. * * `'amendment'` is the multi-record audit entry written by the * guards service when an admin/owner uses `withTransactions(...)` * to repair a constraint-violating state. See `amendment` field * below for the structured payload. * * `'lifecycle'` records a non-data audit event (e.g. partition * handover) — `collection`/`id` are empty and the event detail * lives in `reason` (e.g. `'partition-handed-over:'`). Like * `amendment`, it carries no data envelope, so `verifyBackupIntegrity` * skips it in the data cross-check (it still participates in the * tamper-evident chain). * * `'forget'` is the single summary entry written by `vault.forget()` * (GDPR crypto-shred). `collection`/`id` are empty and `version` * is 0 — a forget is not scoped to one record. `payloadHash` carries * `sha256Hex(subjectId)` so the ledger PROVES "subject X existed and * was erased on date D" without retaining the subject id or any * plaintext; `reason` holds a JSON summary of the shred counts. Like * `amendment`/`lifecycle` it carries no data envelope and is skipped * by the reconstruct walker (it still participates in the chain, so * `verify()` passes after a shred). */ readonly op: 'put' | 'delete' | 'amendment' | 'lifecycle' | 'migration' | 'forget'; /** The collection the mutation targeted. */ readonly collection: string; /** The record id the mutation targeted. */ readonly id: string; /** * The record version AFTER this mutation. For `put` this is the * newly assigned version; for `delete` this is the version that * was deleted (the last version visible to reads). */ readonly version: number; /** ISO timestamp of the mutation. */ readonly ts: string; /** User id of the actor who performed the mutation. */ readonly actor: string; /** * Hex-encoded sha256 over the encrypted envelope's `_data` field, plus its * sealed-field ciphertext map (`_sealed`) when the record carries one — * so the ledger attests to both the open body and every sealed * value. A record with no `_sealed` hashes `_data` alone. * For `put`, this is the hash of the new ciphertext. For `delete`, * it's the hash of the last visible ciphertext at deletion time, or the empty * string if nothing was there to delete. Hashing the ciphertext (not the * plaintext) preserves zero-knowledge — see the file docstring. The exact * recipe lives in `envelopePayloadHash` (`hash.ts`). */ readonly payloadHash: string; /** * Optional human-readable tag describing why this mutation happened. * Threaded through `collection.put(_, _, { reason })`. Common * values include `'import:csv'`, `'import:json'`, `'import:xlsx'` from * `as-*` ImportPlan.apply(), but consumers can use any string for * domain-specific audit filtering. Auto-strip via `canonicalJson` — * absent on the wire, never serialized as `null`. * * Audit consumers filter: `entries.filter(e => e.reason?.startsWith('import:'))`. */ readonly reason?: string; /** * Optional hex-encoded sha256 of the encrypted JSON Patch delta * blob stored alongside this entry in `_ledger_deltas/`. Present * only for `put` operations that had a previous version — the * genesis put of a new record, and every `delete`, leave this * field undefined. * * The delta payload itself lives in a sibling internal collection * (`_ledger_deltas/`) and is encrypted with the * ledger DEK. Callers use `ledger.loadDelta(index)` to decrypt and * deserialize it when reconstructing a historical version. * * Why optional instead of always-present: the first put of a * record has no previous version to diff against, so storing an * empty patch would be noise. For deletes there's no "next" state * to describe with a delta. Both cases set this field to undefined. * * Note: the canonical-JSON hasher treats `undefined` as invalid * (it's one of the guard rails), so on the wire this field is * either `{ deltaHash: '' }` or absent from the JSON * entirely — never `{ deltaHash: undefined }`. */ readonly deltaHash?: string; /** * Present only when `op === 'amendment'`. Records the human reason, * the role of the actor, the (collection, id, vBefore, vAfter) tuple * for every record touched, and which guard invariants passed. * * See docs/superpowers/specs/2026-05-18-guards-design.md. */ readonly amendment?: { readonly reason: string; readonly role: 'admin' | 'owner'; readonly changes: ReadonlyArray<{ readonly collection: string; readonly id: string; readonly vBefore: number; readonly vAfter: number; }>; readonly invariantsPassed: ReadonlyArray; }; } /** * Canonical (sort-stable) JSON encoder. * * This function is the load-bearing primitive of the hash chain: * `sha256(canonicalJSON(entry))` must produce the same hex string * every time, on every machine, for the same logical entry — otherwise * `verify()` would return `{ ok: false }` on cross-platform reads. * * JavaScript's `JSON.stringify` is almost canonical, but NOT quite: * it preserves the insertion order of object keys, which means * `{a:1,b:2}` and `{b:2,a:1}` serialize differently. We fix this by * recursively walking objects and sorting their keys before * concatenation. * * Arrays keep their original order (reordering them would change * semantics). Numbers, strings, booleans, and `null` use the default * JSON encoding. `undefined` and functions are rejected — ledger * entries are plain data, and silently dropping `undefined` would * break the "same input → same hash" property if a caller forgot to * omit a field. * * Performance: one pass per nesting level; O(n log n) for key sorting * at each object. Entries are small (< 1 KB) so this is negligible * compared to the sha256 call. */ export declare function canonicalJson(value: unknown): string; /** * Compute a hex-encoded sha256 of a string via Web Crypto's subtle API. * * We use hex (not base64) for hashes because hex is case-insensitive, * fixed-length (64 chars), and easier to compare visually in debug * output. Base64 would save a few bytes in storage but every encrypted * ledger entry is already much larger than the hash itself. */ export declare function sha256Hex(input: string): Promise; /** * Compute the canonical hash of a ledger entry. Short wrapper around * `canonicalJson` + `sha256Hex`; callers use this instead of composing * the two functions every time, so any future change to the hashing * pipeline (e.g., adding a domain-separation prefix) lives in one place. */ export declare function hashEntry(entry: LedgerEntry): Promise; /** * Pad an index to the canonical 10-digit form used as the adapter key. * Ten digits is enough for ~10 billion ledger entries per vault * — far beyond any realistic use case, but cheap enough that the extra * digits don't hurt storage. */ export declare function paddedIndex(index: number): string; /** Parse a padded adapter key back into a number. Returns NaN on malformed input. */ export declare function parseIndex(key: string): number;