UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

167 lines (166 loc) • 10.6 kB
/** * design/183 (F-011) — `adoptLocalDataRoot`: the ROOT-level adoption state machine for the file * backend. It generalizes the design/182 rule-bucket arc (exclusive lock + durable intent marker + * monotone phase CAS + permanent terminal record + quarantine-not-delete) to the WHOLE data root: * * ① take the data-root locks (the engine's own boot lock + a dedicated ADOPTION-LOCK) — a live * writer refuses the adoption loudly instead of interleaving with it * ② land the durable root intent marker `adoption.json` → phase 2 * ③ identity-axis rebind legs (the ONLY stores whose data carries identity): * - permission rules: the design/182 machine, NESTED as-is — never re-implemented. Its own * sub-arc (including its first sync round) runs here; legs are independent, the root phase * only orders "all legs of a stage complete before the stage advances" * - session-policy rows: per-row ATOMIC REWRITE (the filename is a one-way hash of the * composite key, so a rename cannot rebind; the `__principal` metadata must change too) * → phase 3 * ④ resolution switch: publishing phase 4 is itself the switch (the marker is the truth host * configuration follows, never the reverse), and the affected-deployment CONFIG ACCOUNT is * landed — configs the machine cannot reach are honest `migrated: false` entries that stay * outstanding until a consumer WITNESSES them → phase 4 * ⑤ carriage legs (data to the cloud): pluggable, idempotent, re-run whole on resume; rows the * far side refuses land in the per-store QUARANTINE area — preserved and disclosed, never * deleted, and never blocking the arc (a quarantined row is a COMPLETED disposition) * → phase 5 * ⑥ the marker is atomically rewritten into its PERMANENT terminal record carrying the immutable * report. Never deleted; a re-run short-circuits to it. * * FREEZE (design/183 §8 load-bearing premise): from the moment ② lands, the root is closed to new * writers — and it is worth being exact about which mechanism covers what, because the two halves * meet at a seam rather than overlapping (backlog #136⑧): * · the LOCKS cover the boot-locked shape: this machine holds `LOCK` (the engine's own boot lock, * taken by `FileStorageBackend`) and `ADOPTION-LOCK` for the whole arc, so a live backend refuses * the adoption instead of interleaving with it, and a second adoption refuses too; * · invariant I6 (`assertAdoptionBootGate`) covers CONSTRUCTION over a DATA-ROOT ANCHOR: while the * marker is in flight, a store constructor handed the data root refuses it — including across a * crash, when no lock survives. * What NEITHER covers, enumerated rather than implied: * · an ALREADY-constructed instance over this root through a path that does not hold `LOCK` (the * independent store faces — `new FileMemoryStore(root)`, the mailbox family). It keeps its open * handles, and I6 is a construction-time gate, not a write-time one; * · a NEW construction whose anchor is not the data root. Two faces take a directory that need not * BE the data root — `FileDurableRulePartitionProvider` (the rule bucket) and * `createFileTaskListStore` (the document's own directory) — so each takes its root anchor as an * explicit option, and a caller that omits it constructs unGATED during a freeze. That default is * what this arc's own nested rule leg rides (it must reach the bucket while the marker is in * flight); a deployment wanting the gate passes the anchor. Both arms are pinned. * That is the same boundary the file family draws everywhere else (one live writer per data root is * the deployment's contract, mechanically enforced only for the backend-factory shape), and it is why * "stop the engine before adopting" remains an operational instruction and not merely a machine- * checked one. * Idempotent resume ("re-run completes the unfinished legs") is only sound because no per-store row * moves underneath it; the freeze is the premise of idempotence, not an implementation detail. * * What this half deliberately does NOT do: bulk data carriage for the zero-identity stores (session, * checkpoint, memory, …) — ownership of those rows rides with the carriage legs (server bulk-import * endpoints / existing sync faces), which plug into ⑤ through {@link AdoptionCarriageLeg}. Core's * file half ships the freeze face, the acceptance face and the rebind legs. */ import type { PermissionRuleSyncTransport } from "../../../core/permission-rule-sync.js"; import { type AdoptionReceipt, type AdoptionSource, type AffectedDeploymentConfig } from "./marker.js"; /** The per-leg context handed to a carriage leg: rows the far side REFUSES go here — preserved and * disclosed, never deleted (design/183 §7.2). Quarantining never fails the leg. */ export interface AdoptionCarriageLegContext { quarantine: (name: string, bytes: string) => void; } /** * One pluggable carriage leg for stage ⑤ (design/183 §5). MUST be idempotent: a resume re-runs every * carriage leg in full (a sync round is a join; an import is row-keyed) — only legs WITHOUT a * self-attesting completion predicate record completion bits, and carriage legs all have one (the * far side's row set). `run` resolves with the carried row count; a throw stalls the arc at the * ④→⑤ boundary and the marker keeps the root frozen until a re-run completes the leg. */ export interface AdoptionCarriageLeg { /** Store family name as it should appear in the report legs (e.g. "session", "memory"). */ store: string; run: (ctx: AdoptionCarriageLegContext) => Promise<{ rows?: number; } | void>; } export interface AdoptLocalDataRootOptions { /** The data root (the directory `FileStorageBackend` and the independent file stores build over). */ root: string; from: AdoptionSource; toPrincipal: string; /** The claim REFERENCE for the new identity's credentials (minted by the server; design/183 §4.3). * A reference, never the credential bytes — the marker file is a plain-text sidecar. */ credentials: { issuedBy: "server"; ref: string; }; /** The permission-rule bucket leg (design/182, nested unchanged). Absent ⇒ the deployment has no * file rule bucket and the leg is skipped. */ rules?: { dir: string; transport: PermissionRuleSyncTransport; }; /** Stage-⑤ carriage legs (server bulk-import / existing sync faces). All must complete for the arc * to reach its terminal record. */ carriage?: AdoptionCarriageLeg[]; /** Extra affected-deployment configs beyond the built-in minimum face (design/183 §6: the table may * GROW, never shrink — an entry with the same (deployment, key) overrides the built-in values). */ additionalConfigs?: AffectedDeploymentConfig[]; now?: () => number; onError?: (message: string) => void; } export type AdoptLocalDataRootResult = AdoptionReceipt /** A leg failed. The marker holds the last COMPLETED phase and keeps the root frozen (I6); call * again to resume from there. No bytes were lost — that is the state machine's whole contract. */ | { status: "stalled"; phase: 2 | 3 | 4 | 5; error: string; }; /** A consumer's typed read-back receipt: proof it OBSERVED the required value at runtime, bound to * this adoption. Only this clears an account entry — an operator ack is recorded, never clearing. */ export interface AdoptionConfigWitnessReceipt { adoptionId: string; deployment: string; key: string; observedValue: string; atMs: number; } /** Record an operator acknowledgement on one account entry. Recorded, NEVER clearing: the account's * discriminating power comes from consumer witness, not from a hand-written boolean (design/183 §3.3). */ export declare function ackAdoptionConfig(root: string, deployment: string, key: string, now?: () => number): void; /** * Clear one config-account entry with a consumer's bound runtime read-back receipt. The receipt must * name THIS adoption and carry the entry's exact required value — anything else is refused (typed): * a mis-bound witness would clear the account while the deployment is still locked out. */ export declare function witnessAdoptionConfig(root: string, receipt: AdoptionConfigWitnessReceipt): void; /** Enumerate the quarantine area (introspection face — a quarantined row is DISCLOSED, not buried). */ export declare function listAdoptionQuarantine(root: string): Array<{ store: string; name: string; path: string; }>; /** One root's adoption posture, readable WITHOUT the adoption parameters (the standing * introspection face design/183 §3.3 requires: while the config account is uncleared, the * outstanding list stays loudly readable — a standing answer, not a one-shot log line). */ export type AdoptionStatus = { state: "none"; } | { state: "in-flight"; from: AdoptionSource; toPrincipal: string; phase: 2 | 3 | 4 | 5; } | { state: "adopted"; receipt: AdoptionReceipt; }; /** Read the root's adoption posture: never throws on absence, throws (typed) on a corrupt marker. */ export declare function readAdoptionStatus(root: string): AdoptionStatus; /** * Run (or resume) the root-level adoption state machine over the data root (design/183 §3). * * Contract notes for the calling deployment: * - stop the engine first: this machine takes the SAME boot lock (`root/LOCK`) as * `FileStorageBackend`, so a live instance refuses the adoption loudly rather than interleaving — * plus the dedicated `ADOPTION-LOCK` against a concurrent adoption of independently-constructed * stores. Across crashes the in-flight marker + the I6 boot gate carry the same exclusion; * - idempotent: a re-run with the same (from → toPrincipal) answers the recorded receipt without * re-running any leg; a re-run toward a DIFFERENT principal is refused (typed); * - quarantined rows never block the arc, and post-terminal repair rides each store's NORMAL * channel (sync round / import generation — design/183 §7.2), never a re-opened adoption. */ export declare function adoptLocalDataRoot(opts: AdoptLocalDataRootOptions): Promise<AdoptLocalDataRootResult>;