@sema-agent/core
Version:
Stateless, task-oriented AI agent core
218 lines (217 loc) • 13 kB
TypeScript
/**
* design/183 §3 (F-011) — the ROOT-level adoption marker: the durable phase truth of the data-root
* adoption state machine (the design/182 rule-bucket marker, generalized to the whole data root).
*
* Two shapes live in `<dataRoot>/adoption.json`:
* - the IN-FLIGHT marker (`marker`): a monotonically-advanced `phase` plus per-leg completion bits
* for the one leg class without a self-attesting completion predicate (row rewrites). While this
* shape is on disk the root is FROZEN: every file store constructor ANCHORED TO THE DATA ROOT
* refuses to build over it ({@link assertAdoptionBootGate} — invariant I6). Coverage is stated
* precisely at the gate's own docstring: two faces whose constructor argument is not the root
* take an optional `dataRoot` anchor, and a caller omitting it is outside the gate (the seam is
* documented and pinned where those faces live).
* - the PERMANENT terminal record (`adopted`): never deleted. It is the idempotence anchor (a re-run
* with the same (from → toPrincipal) short-circuits to the recorded receipt) and it carries the
* IMMUTABLE report — the byte-stable half of every receipt.
*
* This module is deliberately tiny and dependency-light (node:fs + the atomic-write primitive): every
* file store imports the gate from here, so it must never grow an import back into a store.
*/
/** Who the data root belonged to before adoption. Shape-identical to the rule domain's `RuleOwner`
* (design/182): local-single-user identity is a STRUCTURAL ABSENCE, never a sentinel string. */
export type AdoptionSource = {
kind: "local-owner";
} | {
kind: "principal";
principal: string;
};
/** Typed refusal codes (design/183 §7.3): every adoption refusal is discriminable — from `not_found`,
* from a broken deployment, and from each other. */
export type AdoptionErrorCode =
/** I6 boot gate: a store construction (or engine boot) over a root whose adoption is mid-flight. */
"adoption_in_flight"
/** A DIFFERENT adoption is mid-flight in this root — resume the original or repair the marker. */
| "adoption_in_flight_mismatch"
/** The root already completed an adoption toward another principal; a second adoption is a
* multi-tenant transfer, out of this protocol's scope (design/183 §1 non-goals / D7). */
| "adoption_retarget"
/** A live instance (engine or another adoption) owns the data root right now. */
| "adoption_locked"
/** A row-rewrite target exists with DIFFERENT bytes: merging two existing rows is not a rewrite. */
| "adoption_row_collision"
/** The marker exists but cannot be read as a marker. Fail-closed: after phase 4 the marker IS the
* owner-resolution truth, so guessing would fork ownership. Repair by hand, then resume. */
| "adoption_marker_corrupt"
/** A config-witness receipt does not bind (wrong adoption id / value / unknown entry). */
| "adoption_witness_mismatch"
/** A resume supplies LESS than the recorded migration plan (a dropped rules/carriage leg or a
* changed credentials claim) — completing the arc on the thinner plan would terminalize an
* incompletely migrated root, so the mismatch is refused instead. */
| "adoption_plan_mismatch"
/** The nested permission-rule leg refused (its own collision / lock / marker grammar) — surfaced
* under the root arc's typed contract, original reason preserved in the message. */
| "adoption_rule_leg_refused"
/** Malformed request (empty principal, from == to, …). */
| "adoption_bad_request";
export declare class AdoptionError extends Error {
readonly code: AdoptionErrorCode;
constructor(code: AdoptionErrorCode, message: string);
}
/** One deployment-config the adoption obligates (design/183 §6): configs the state machine cannot
* reach carry `migrated: false` and stay on the outstanding account until a consumer WITNESSES them. */
export interface AffectedDeploymentConfig {
/** e.g. "web-client-bff" | "cli" | "env" | "core-decl" | … */
deployment: string;
key: string;
/** The value the consumer must now carry (the new principal, or "withdrawn" for a retired seat). */
requiredValue: string;
/** true ⇔ the adoption itself changed it; false ⇒ an explicit operator action remains. */
migrated: boolean;
}
/** Per-store line of the immutable report (design/183 §6 `legs`). */
export interface AdoptionLegReport {
store: string;
action: "bucket-rebind" | "row-rewrite" | "carried" | "none" | "reset";
rows?: number;
quarantined?: number;
}
/** The IMMUTABLE half of every receipt (design/183 §6): persisted byte-stable in the terminal record,
* identical on the first success and on every idempotent re-run. Current state (outstanding configs,
* live quarantine count) is NOT in here — it is derived fresh per answer ({@link AdoptionReceipt}). */
export interface AdoptionReport {
/** Minted at phase ② — the binding anchor config-witness receipts must carry. */
adoptionId: string;
from: AdoptionSource;
toPrincipal: string;
atMs: number;
/** The claim REFERENCE for the new identity's credentials — never the credentials themselves (the
* marker is a plain-text sidecar, not a credential store). The claim must be durable and
* repeatedly redeemable (server obligation, design/183 §4.3). */
credentials: {
issuedBy: "server";
ref: string;
};
affectedDeploymentConfigs: AffectedDeploymentConfig[];
legs: AdoptionLegReport[];
/** design/183 §6 (r4) — the machine-readable CLOSED SET of assets not migrated BY DESIGN, each
* naming its ruling. Black-box discriminable from "forgotten": an asset outside this list that
* did not arrive is a defect; the list growing beyond the design's written set is DRIFT (§10.5
* ① — new members enter through a design ruling, never through an implementation). */
notMigratedByDesign: Array<{
asset: string;
ruling: string;
}>;
}
/** The one receipt envelope (design/183 §6): first success and every idempotent re-run answer with
* this same shape. `immutableReport` is the persisted byte-stable snapshot; `current` is derived. */
export interface AdoptionReceipt {
status: "adopted";
immutableReport: AdoptionReport;
current: {
/** `deployment:key` of every config-account entry not yet WITNESSED (an operator ack does not
* clear the account — only a consumer's bound runtime read-back does; design/183 §3.3). */
outstandingConfigs: string[];
/** Live quarantine-area row count (local truth = what is actually on disk; design/183 §7.2). */
quarantined: number;
};
}
/** The durable root marker file. */
export type RootAdoptionFile = {
schemaVersion: 1;
marker: {
adoptionId: string;
from: AdoptionSource;
toPrincipal: string;
phase: 2 | 3 | 4 | 5;
/** The migration plan recorded at phase ② and grown MONOTONICALLY across resumes — what a
* resume must (at least) re-supply. Without it, an ordinary restart with thinner options
* would skip a stalled leg and terminalize an incompletely migrated root; the rules leg is
* bound to its canonical DIRECTORY (a boolean would accept a substituted bucket). REQUIRED:
* a marker without a readable plan is corrupt (fail-closed), never re-bound to the caller's
* current options (adversarial-review findings, rounds 1-2). */
plan: {
rulesDir?: string;
carriage: string[];
credentialsRef: string;
};
/** Completion bits for legs without a self-attesting predicate (row rewrites), plus the
* carriage RESULTS folded in at the phase-⑤ publish: the bits (and their counts, which the
* terminal report needs) survive a crash between a stage and the next phase publish — a
* ⑤→⑥ crash must not let the resumed run report a carried store as untouched. */
legs?: Record<string, {
done: true;
rows?: number;
quarantined?: number;
}>;
};
} | {
schemaVersion: 1;
adopted: {
from: AdoptionSource;
toPrincipal: string;
atMs: number;
immutableReport: AdoptionReport;
};
};
export declare const ROOT_ADOPTION_FILE = "adoption.json";
/**
* design/183 §6 (r4) — the CLOSED SET of by-design-not-migrated assets, verbatim from the design's
* written list. The WRITER emits exactly this list and the drift pin (§10.5 ① — the design-vs-code
* two-cell guard) lives in the TEST GRID, where a ruled growth of the set is a deliberate edit.
*
* #133: the terminal READ validator deliberately does NOT compare a stored report against this
* constant. A persisted report is a snapshot of the set AS RULED AT ADOPTION TIME; the set is
* allowed to grow by ruling (§10.5 ① names that road), so an exact-equality read check would mark
* every already-adopted root corrupt on the release after any growth (and again on rollback) — 13
* construction sites all refusing to serve. The read validator checks SHAPE and integrity
* (non-empty, well-formed entries, no duplicate assets — what an empty/mangled account rebuild
* actually needs), never version-crossed content equality.
*/
export declare const NOT_MIGRATED_BY_DESIGN: ReadonlyArray<{
asset: string;
ruling: string;
}>;
/** The five baseline obligation IDENTITIES (design/183 §6 minimum face). Since #133 the terminal READ
* validator deliberately does NOT compare a stored report against this table (the stored report is a
* snapshot of the set as ruled at adoption time; see the validator's own note) — the enforcement that
* the WRITER emits exactly this set lives in the test grid (test/file-adoption-root.test.ts pins the
* fresh report's identities against this constant), where a drift is a deliberate, reviewable edit. */
export declare const BASELINE_CONFIG_IDENTITIES: ReadonlyArray<{
deployment: string;
key: string;
}>;
/**
* Read the root adoption marker; `undefined` when none. A corrupt marker THROWS (typed): after
* phase 4 the marker is the resolution truth, so a guessed read could serve a half-migrated root or
* invent an adoption that never happened — the same fail-closed stance as the rule-domain marker.
*/
export declare function readRootAdoptionFile(root: string): RootAdoptionFile | undefined;
/** Atomically publish the root marker (write-temp → fsync → rename, same discipline as every store). */
export declare function writeRootAdoptionFile(root: string, content: RootAdoptionFile): void;
/**
* Invariant I6 (design/183 §3.1) — the adoption BOOT GATE: an in-flight root marker means the root is
* mid-adoption (possibly after a crash), and serving a half-migrated root is refused LOUDLY. The
* refusal names the in-flight arc and the way forward (resume `adoptLocalDataRoot` to completion). A
* terminal record passes: a completed adoption is a normal, readable root. A corrupt marker throws
* (fail-closed, see {@link readRootAdoptionFile}).
*
* WHO calls it, precisely: every store constructor whose argument IS the data root (that is most of
* the family — they derive their own subdirectory from it). The two faces whose argument is a
* caller-chosen directory instead — `FileDurableRulePartitionProvider` (a rule bucket) and
* `createFileTaskListStore` (the document's directory) — cannot anchor on their argument, so each
* takes the data root as an explicit option and is gated only when given one. `root` here is always
* the ANCHOR, never "wherever this store keeps its bytes": passing a subdirectory reads a marker that
* is not there and passes unconditionally.
*
* This gate is what turns "the engine must be stopped during adoption" from an operational assumption
* into a machine-checked invariant across crashes: the adoption's own locks die with its process, but
* the marker (and this gate) survive. Boundary, stated honestly: the gate fires at CONSTRUCTION time,
* and it is the ONLY thing this function does — it never reaches an object that already exists. So a
* store instance constructed BEFORE the marker landed keeps writing for as long as it is held, in
* ANOTHER OS process (cross-process sharing of one data dir is the file family's documented
* UNSUPPORTED shape for the task-list store — F-14; the mailbox store is the exception since its per-box lock upgrade, see `stores/file/mailbox-store.ts`) and, symmetrically, in THIS one (an independent
* store face built before the adoption started; the arc's `LOCK` acquisition excludes a live
* `FileStorageBackend`, which is the only shape that takes that lock). Adoption adds no new promise on
* either side of that seam — see the freeze note on `adoptLocalDataRoot` for the full division.
*/
export declare function assertAdoptionBootGate(root: string, storeName: string): void;