/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * Client-annex GC: the quarterly wholesale replacement of the annex * generation, and the collection of everything it leaves behind. * * The ceremony has two halves with different rhythms: * * - The SWAP runs on the fixed quarterly cadence (90 days, at the first * remembered login after the period elapses -- coarse on purpose, so the * account log's permanent pointer-entry rhythm reveals little about * transient-use frequency), and only when the pointed generation is * GC-quiet (the 24-hour max-visit quiet bound over its newest entry's * `versionTime`, with a skew-margin grace hour -- a deferral policy only, * never a session expiry). Its stage order is fixed: mint + genesis, * install the fresh generation's delegation service entry, revoke the old * generation's delegation, re-point the account document. Revoke lands * before the re-point (closing the window where a fail-open non-conforming * server still honors the old delegation -- and the revocation POST itself * only verifies while the pointer still makes the old chain resolve), and * before the delete (the POST needs the capability bytes the delete * destroys). * * - The COLLECT fan-out is predicate-driven and runs at every remembered login: * every `gen-` collection the pointer does not name -- the generation a * swap just superseded, a torn GC's leftover, a torn signup's orphan, a * double-genesis loser -- gets identical treatment (revoke its embedded * delegation blind, reading was-client's genuine `AlreadyRevokedError` as * success and an already-expired delegation as needing no POST at all; * write the digest; delete), so a GC torn anywhere resumes at the next * login instead of waiting a quarter. Failures are collected per generation * and never abort the fan-out: a partial pass is a resumable success. * * The completion predicate is durable state alone (exactly one `gen-` * collection exists in the auxiliary Space and it is the one the pointer * names); no marker resource exists anywhere -- the account document's * pointer-update entry is the record, and the cadence is read off its * `versionTime`. The digest (`GenerationCollect`, built by the caller's * `recordDigest` over `@interop/wallet-core/space`'s builder) is written * before the delete: it is the owner's only record of the collected window's * visits surviving the delete, and compromise detection ends at digest * granularity. * * Honest limitations: the "no unexpired delegation names a dead generation" * conjunct is an ordering obligation, not a check (the revocation protocol * exposes no read endpoint, and the dead generation's delegation bytes are * destroyed with its collection); orphaned generations are * authorization-inert (no delegation ever names an unpointed generation * under pointer equality), so what accretes between passes is a storage * leak, never an authority leak. */ import type { DIDLog } from '@interop/did-method-webvh'; import type { ZcapClient } from '@interop/ezcap'; import type { WasClient } from '@interop/was-client'; import type { AccountLogSigner } from '../webvh/accountEntry.js'; import type { ClientWebvhUpdateKeys, PublishedWebvhLog, WebvhIdStore } from '../webvh/didWebvh.js'; import type { ClientAnnexSwapRevokeOutcome } from '../unlock/retire.js'; /** * The fixed GC cadence: a generation is replaced at the first remembered login * 90 days after the current pointer value was established. Wallet GC policy * over the account log's own timestamps, never a stored or wire value. */ export declare const GENERATION_GC_PERIOD_MS: number; /** * The quiet bound: a generation is GC-quiet when its newest entry's * `versionTime` is over 24 hours old. The bound defers the swap only; it * never expires a session. A visit outliving it merely loses guaranteed * guard protection, and in the rare case a quarterly pass lands on one, the * session's next authorization failure maps to the generation-lapse retry * state. */ export declare const GENERATION_QUIET_BOUND_MS: number; /** * The skew-margin grace floor on the quiet bound: three clocks meet at the * guard check (the enrolling writer's, any prior entry writer's, and the * GC-running client's), and `versionTime` is asserted by the writer's clock, * so the guard compares against the bound plus this margin. An hour is * generous against real-world skew and defers the swap by at most an hour * against a quarterly cadence. */ export declare const GENERATION_QUIET_GRACE_MS: number; /** * The `versionTime` of the account-log entry that established the CURRENT * `#DelegatedClients` pointer value: the newest entry whose state names a * different annex DID than the entry before it (or the first entry, when * the pointer has been there since genesis). This is the cadence's clock -- * "the pointer-update entry is the record" -- and it is read off the log a * remembered login has already verified, so the cadence costs no extra fetch * and every enrolled client agrees on it. * * @param options {object} * @param options.log {DIDLog} the VERIFIED account log * @returns {string | undefined} the establishing entry's `versionTime`, * or undefined when no entry carries a pointer */ export declare function delegatedClientsPointerEstablishedAt({ log }: { log: DIDLog; }): string | undefined; /** * Whether the quarterly swap is due: the current pointer value was * established {@link GENERATION_GC_PERIOD_MS} or more ago. A log with no * pointer is never due (there is no generation to replace). * * @param options {object} * @param options.log {DIDLog} the VERIFIED account log * @param [options.now] {number} epoch milliseconds, for tests * @returns {boolean} */ export declare function clientAnnexGcDue({ log, now }: { log: DIDLog; now?: number; }): boolean; /** * The live-entry guard: whether a generation is GC-quiet -- its newest * entry's `versionTime` is older than the quiet bound plus the skew grace * margin. Applies to the POINTED generation only (an unpointed generation * authorizes nothing under pointer equality, so nothing inside it ever * defers deletion). An unparseable `versionTime` reads as not quiet: the * swap defers rather than abandoning a possibly-live visit. * * @param options {object} * @param options.log {DIDLog} the pointed generation's VERIFIED annex * log * @param [options.now] {number} epoch milliseconds, for tests * @returns {boolean} */ export declare function generationQuiet({ log, now }: { log: DIDLog; now?: number; }): boolean; /** * What the swap half of one GC pass did. `replaced` is the successful swap; * `not-due` and `deferred-live` are the two healthy skips (cadence and quiet * bound); `no-pointer` means the account has no annex inventory (the whole * pass no-ops -- without a pointer there is no auxiliary Space to list); * `no-ladder-seed` means the swap was due but the login held no ladder seed * to mint with (a non-standing record); `failed` means a swap stage threw -- * reported in `failed` under the pointed generation's id, with the collect * fan-out still run. */ export type ClientAnnexGcSwapOutcome = 'replaced' | 'not-due' | 'deferred-live' | 'no-pointer' | 'no-ladder-seed' | 'failed'; /** * One GC pass's report: the swap outcome, the annex DID the account * points at after the pass, the generation ids collected (revoked, digested, * deleted), and the per-generation failures. A report with `failed` entries * is a resumable success -- the next remembered login's pass picks up exactly * the generations still listed. */ export interface ClientAnnexGcReport { swap: ClientAnnexGcSwapOutcome; pointedDid?: string; collected: string[]; failed: Array<{ generationId: string; error: unknown; }>; } /** * One generation swap's outcome: the fresh annex DID the account now points * at, and what the revoke stage did with the old generation's embedded * delegation -- `revoked` (the POST landed, or was-client answered the * genuine `AlreadyRevokedError`), `expired` (the delegation's own `expires` * is past beyond doubt, so no POST was sent, or the server refused the POST * inside the skew band around `expires`), `signer-gone` (the server refused * the POST and the delegation's proof key has left the account document -- * a rotted chain), `refused` (the server, or was-client before the POST, * refused the revocation for a reason the stage could not classify; the * error rides in `revokeError`, the re-point still landed, and the old * generation is left for the collect fan-out, which re-attempts the * revocation and keeps the generation while it keeps failing), * `no-delegation` (the old log stands and embeds none), or `log-absent` (the * pointed log does not exist, so there were no bytes to revoke; pointer * equality retires the delegation on a conforming server). Reported rather * than folded into the DID so a caller can tell a swap that revoked from one * that could not. * * A refusal does not abort the swap: by the time the revoke stage runs the * fresh generation is minted and its delegation installed, and a swap that * threw here would leave that orphan behind and mint another at every due * login, with the old generation never superseded. Re-pointing anyway costs * nothing the refusal did not already cost: a delegation the server refused * to revoke fails the chain check an invocation under it runs too, a * refusal raised client-side before any POST is a configuration fault the * next pass meets again, and the fan-out keeps the bytes until a pass * succeeds. */ export interface ClientAnnexGenerationSwap { clientAnnexDid: string; revoke: ClientAnnexSwapRevokeOutcome; revokeError?: unknown; } /** * One annex GC pass: the quarterly swap when due and quiet, then the * predicate-driven collect fan-out over every non-pointed `gen-` collection. * See the module header for the stage order and its load-bearing constraints. * * Convergence: every stage detects completion from durable state. A re-run * after a tear re-POSTs the revocation blind (a genuine `AlreadyRevokedError` * reads as success; a delegation expired beyond the clock-skew margin is * skipped locally before any POST, and a refusal inside the skew band or of * a chain whose signer has left the document is read as that), re-writes * the digest (the deterministic payload id collapses the second row at read * time), and re-runs the idempotent delete; a swap torn before its re-point * leaves an unpointed fresh generation the same fan-out collects. * * @param options {object} * @param options.was {WasClient} the storage client, signing as an * enrolled client (root tier on both the account and auxiliary Spaces) * @param options.wasServerUrl {string} the account pointer's host * @param options.accountSpaceId {string} the ACCOUNT Space's id (the * generation delegation's target subtree) * @param options.account {object} the VERIFIED account log * (`{ did, doc, log }` -- `verifyAccountLog`'s shape) * @param options.idStore {WebvhIdStore} the account log's id store, for * the re-point * @param options.updateKeys {ClientWebvhUpdateKeys} this enrolled client's * update keys, signing the re-point entry * @param options.zcapClient {ZcapClient} signs the fresh generation * delegation (the promoted account keyId) * @param [options.ladderSeed] {Uint8Array} the login credential's ladder * seed; absent, a due swap reports `no-ladder-seed` and only the collect * fan-out runs * @param options.recordDigest {Function} * `({ generationId, firstEntry, lastEntry, entryCount }) => Promise` * -- writes the GenerationCollect wallet-activity row; called before the * delete, and a throw keeps the generation for the next pass * @param [options.onCollected] {Function} * `({ generationId }) => Promise` -- local cleanup after a * generation's delete (the caller's annex pin-slot drop); a throw is * reported but cannot be retried (the collection is already gone) * @param [options.now] {number} epoch milliseconds, for tests * @returns {Promise} */ export declare function runClientAnnexGc({ was, wasServerUrl, accountSpaceId, account, idStore, updateKeys, zcapClient, ladderSeed, recordDigest, onCollected, now }: { was: WasClient; wasServerUrl: string; accountSpaceId: string; account: Pick; idStore: WebvhIdStore; updateKeys: ClientWebvhUpdateKeys; zcapClient: ZcapClient; ladderSeed?: Uint8Array; recordDigest: (digest: { generationId: string; firstEntry?: string; lastEntry?: string; entryCount?: number; }) => Promise; onCollected?: (options: { generationId: string; }) => Promise; now?: number; }): Promise; /** * AN OFF-CADENCE GENERATION SWAP: replaces the pointed annex generation * outside the quarterly rhythm -- the credential-rotation ceremony's * fallback when no distinct committed rung can sign a strike entry * (`retireClientAnnexRung`): a fresh generation minted from a SURVIVING * credential's seed commits only that credential's rung-0 hash, so the * retired credential's annex inventory dies with the whole generation the * moment the re-point lands. The abandoned generation is an ordinary * non-pointed `gen-` collection the standing collect fan-out picks up at the * next remembered login. * * Same four swap stages and ordering as the quarterly GC swap, minus the * cadence and quiet gates (the caller's reason for swapping is authority * removal, not hygiene). The pointed generation's log is read for the revoke * stage's delegation bytes. A pointed log that does not exist (a collected * generation the pointer still names) carries no delegation to revoke, so * the revoke is skipped and reported as `log-absent`, and pointer equality * retires the old delegation on a conforming server; a served log that fails * verification or falls behind this client's pin throws, since a swap over a * log this client cannot trust would skip a revoke it may owe. This is the * arm credential retirement calls, and the standing delegation there is * often signed by a ladder VM a PRIOR retirement already struck: the server * refuses that chain, and the revoke stage reads the refusal against the * caller's `account.doc` and reports `signer-gone`. * * @param options {object} see {@link runClientAnnexGc} for the shared * members ({ was, wasServerUrl, accountSpaceId, account, idStore, * updateKeys, zcapClient }); `ladderSeed` here is the SURVIVING * credential's seed the fresh generation is minted from * @param [options.now] {number} epoch milliseconds, for tests * @returns {Promise} the fresh annex DID and * what the revoke stage did */ export declare function swapClientAnnexGeneration({ was, wasServerUrl, accountSpaceId, account, idStore, signer, zcapClient, ladderSeed, now }: { was: WasClient; wasServerUrl: string; accountSpaceId: string; account: Pick; idStore: WebvhIdStore; signer: AccountLogSigner; zcapClient: ZcapClient; ladderSeed: Uint8Array; now?: number; }): Promise; //# sourceMappingURL=gc.d.ts.map