@sema-agent/core
Version:
Stateless, task-oriented AI agent core
1,116 lines • 81 kB
TypeScript
import { type MemoryAnnouncement } from "./types.js";
import type { AssertAllKeysHandled } from "../ask-origin.js";
import { physicalPathOfExisting } from "../physical-path.js";
/** Cursor sidecar for the design/84 Seam B pair on the FileBackend (`{ [scope]: cursor }`). B3: control plane. */
export declare const CURSORS_FILE = "cursors.json";
/** The control-plane subdir name under a key dir — ONE spelling for every derivation and for the
* migration probes that move a control plane between key dirs. Exported for the sibling planes
* (dual-root's personal plane) so no second literal exists; not part of the package surface. */
export declare const CONTROL_PLANE_DIR = ".engine";
/** B3 fail-closed: a control-plane sidecar exists but cannot be trusted (unparseable / wrong shape).
* Read paths propagate it (materialize fails ⇒ the runner falls open to a memory-less session with
* onError); harvest converts it into a refused report (incident `sidecar_corrupt`). It is NEVER
* silently degraded to an empty ledger / unclaimed root — that would let a corrupted (or truncated)
* sidecar erase CAS baselines and re-open the root claim. */
export declare class ControlPlaneCorruptError extends Error {
/** Structured discriminant for callers that fold ONE corruption family and rethrow the rest —
* branching on message text is banned (§B8). Only `"lock-ownership"` is minted today (the
* recoverable-as-incomplete family); absent = every other corruption (a definite state, throw). */
readonly facet?: "lock-ownership";
constructor(message: string, opts?: {
cause?: unknown;
facet?: "lock-ownership";
});
}
/**
* CC-style repo PARTITION key: the repo root path, canonicalized, then slugged. Collisions across
* repos require pathological sibling names and only merge their memory dirs (never corrupt them).
*
* RB-472 (design/161, four-repo receipts on record): the key CANONICALIZES first — without
* realpath, the same repo reached through a symlink alias and through its physical path minted two
* different partition keys = two memory dirs, silently splitting memory. `canonicalize` degrades to
* plain resolve semantics for a not-yet-existing path (nearest existing ancestor + remainder).
* Its input is the HOST-declared repo root; a model-reachable directory is keyed by
* {@link deriveDirKey} instead, and that asymmetry is deliberate (see there).
*
* The case axis (once a known limit here) closes with the same call: the mint answers with the ON-DISK
* spelling, so two case-variant spellings name ONE key on a case-INSENSITIVE volume, while on a
* case-SENSITIVE volume they are two distinct directories and keep two (FOLDING would merge that pair).
* Registered, not fixed: the PREVIOUS key was this realpath with the CALLER's casing = `deriveDirKey`'s
* spelling, which the arm below adopts, EXCEPT under a case-varying symlink on a non-folding library.
*/
export declare function deriveRepoKey(repoRoot: string): string;
/** Sink for the mount-time partition-key incidents (`memory.partition_split` /
* `memory.partition_adopt_failed`). ONE shape for every host that can receive them — the runner's
* onError bridge, the engine, and the file backend. */
export type MemoryPartitionIncidentSink = (err: Error & {
code?: string;
}) => void;
/** RB-472 adoption leg: a pre-5.x deployment keyed its repo partition by the resolve-only path, so
* when the two mints differ its `memory/` + `.engine/` + siblings live under {@link deriveDirKey}'s
* dir. Move the whole partition onto the canonical key (see {@link migrateKeyedPartition}). */
export declare function adoptLegacyRepoDirs(configRoot: string, repoRoot: string, onIncident?: MemoryPartitionIncidentSink): void;
/** Every spelling an earlier release keyed this repo's partition by, minus today's: the resolve-only key and the
* link-resolved, caller-cased key (`/var/TMP` keyed `-private-var-TMP`; today `-private-var-tmp`). */
export declare function earlierRepoKeysOf(repoRoot: string): string[];
/**
* The REVERSE one-shot migration (ruled 2026-08-03): for the width of ONE release the control-plane
* key canonicalized like the repo key does, which put a directory-pinned mount's `.engine` under the
* canonical key. {@link deriveDirKey} is the control-plane mint again (that ruling's security half),
* so such a control plane sits at an address this build no longer reads — move it back, once.
*
* Scoped to the `.engine` subtree on purpose: the canonical-key dir may host unrelated siblings.
*
* Known limit, registered rather than fixed: when the SAME physical directory is ALSO mounted under
* its physical spelling, that mount's own resolve-keyed plane IS this migration's source dir, and the
* probe cannot tell the two apart (both are `<key>/.engine`). Concurrently mounting one directory
* under two spellings is outside what this probe can discriminate; the window it recovers from is one
* release wide.
*/
export declare function adoptCanonicalKeyedControlDir(configRoot: string, dir: string, onIncident?: MemoryPartitionIncidentSink): void;
/**
* `<configRoot>/<repoKey>/memory` — the repo-level stable mount (§2.7: taskRoot 外, worktree-shared).
*
* Worktree note (验收 L5, documented behavior): the KEY must be the BASE repo root. The runner passes
* `deps.rootPath ?? taskRootPath` — when `rootPath` is configured, every derived worktree of that repo
* shares ONE memory dir (a worktree moves the task cwd, never the memory mount); when `rootPath` is
* NOT configured the taskRootPath fallback keys memory to the task's own checkout path, so an
* isolated-worktree task without `rootPath` gets a worktree-private memory dir. That fallback is the
* accepted degraded behavior (no silent cross-repo mixing; configure `rootPath` for worktree sharing).
*/
export declare function deriveRepoMemoryDir(configRoot: string, repoRoot: string): string;
/** design/142 §1.5 — the IDENTITY-keyed mount (v2 scope contract + a resolved project marker):
* `<configRoot>/proj-<projectId>/memory`. Unlike the path-derived {@link deriveRepoMemoryDir},
* this key survives rename/move/re-clone/container remounts — identity lives in the repo's marker,
* the mount stops referencing the path entirely. */
export declare function deriveProjectMemoryDir(configRoot: string, projectId: string): string;
/** Control-plane sibling of {@link deriveProjectMemoryDir} (B3: always on the config-root side). */
export declare function deriveProjectControlDir(configRoot: string, projectId: string): string;
/** design/142 §1.5 — the path→projectId HINT CACHE (`<configRoot>/project-id-hints.json`). NOT an
* identity source (the marker is the single truth source); purely the recovery breadcrumb for a
* deleted/lost marker: materialize can tell "this path used to carry marker X" and prompt a
* restore instead of silently minting a fresh identity. Best-effort on both faces (a corrupt hint
* file degrades to "no hints", never blocks a session). */
export declare const PROJECT_ID_HINTS_FILE = "project-id-hints.json";
export declare function recordProjectIdHint(configRoot: string, repoRoot: string, projectId: string): void;
export declare function lookupProjectIdHint(configRoot: string, repoRoot: string): string | undefined;
/** B3 — `<configRoot>/<dirKey>/.engine`: the engine CONTROL PLANE for a DIRECTORY-pinned mount (the
* memory dir itself is the key). ALWAYS on the config-root side — when the memory dir is configured
* in-repo, the control plane still lives here (model-invisible, not injected, not in-repo) — and
* keyed RESOLVE-ONLY, because the key input is a directory the model's file channel may be able to
* replace: see {@link deriveDirKey} for why canonicalizing it is a redirect handle.
* The repo-partition face has its own sibling mint, {@link deriveRepoControlPlaneDir}. */
export declare function deriveControlPlaneDir(configRoot: string, key: string): string;
/** B3 — the REPO-partition control plane: the sibling of {@link deriveRepoMemoryDir} under the SAME
* canonical repo key. Paired on purpose: {@link adoptLegacyRepoDirs} adopts the repo partition as ONE
* directory (`memory/` and `.engine/` inside it), so keying its control plane differently from its
* memory dir would strand one of the two on every alias-reached mount. Safe to canonicalize here —
* the key input is the host-declared repo root, not a directory the model can replace. */
export declare function deriveRepoControlPlaneDir(configRoot: string, repoRoot: string): string;
/**
* Resolve the memory-engine config root: explicit (`RunnerDeps.memoryEngineDir`) → `AGENT_DATA_DIR`
* env → `~/.ai-agent` (the same default family as the file stores' `resolveDataRoot`; re-stated here
* so core/memory-engine does not import from src/stores).
*/
export declare function resolveMemoryEngineRoot(explicit?: string): string;
/**
* Filesystem-safe scope dir label. 评审 H5 (唯一映射): the cleaned slug alone is LOSSY
* (`org:7` and `org/7` both fold to `org-7` — two tenants, one directory = cross-scope bleed), so any
* scope whose cleaned form differs from the original gets a short content-hash suffix
* (`org-7-a1b2c3`), making the mapping injective for all practical inputs; an already-safe scope maps
* to itself (stable, human-readable). {@link registerScope} additionally FAIL-CLOSES on a registry
* collision (two distinct scopes → one dir name), so even a hash collision cannot silently merge scopes.
*/
export declare function scopeDirName(scope: string): string;
/**
* Whether `dir`'s volume FOLDS letter case — two names differing only in case address ONE
* directory entry (the macOS/Windows default). Probed on the actual directory, never guessed from
* the platform: case-sensitive APFS volumes and case-insensitive Linux mounts both exist, and this
* answer gates a fail-closed isolation refusal, so it must describe the volume the scope dirs
* really land on — a platform guess would fail OPEN exactly on the mismatched deployments.
* `undefined` = the volume answered on neither leg (unwritable AND no letter-bearing entry to read
* against); the caller must treat that as unresolved, never as either determined answer.
* Exported for the layout pins; not part of the package surface.
*/
export declare function dirCaseFolds(dir: string): boolean | undefined;
/** The closure of every label {@link scopeDirName} can mint: a safe-form scope verbatim, or the
* folded form (leading `.`/`-` stripped) plus a hash suffix. One non-hidden path segment — never
* empty, never `.`/`..`, never a separator — so two labels are two distinct directory entries. */
export declare const SCOPE_DIR_LABEL_RE: RegExp;
/** Prefix of the case-fold probe files the layout leaves (briefly) at a memory root. One spelling
* for the probe writer and for the migration's residue removal. */
export declare const CASEFOLD_PROBE_PREFIX = ".casefold-probe-";
/** The four shapes a scope registry read can take. */
export declare const SCOPE_REGISTRY_SHAPES: readonly ["absent", "v1", "v2", "migrating"];
export type ScopeRegistryShape = (typeof SCOPE_REGISTRY_SHAPES)[number];
/** Which shapes the path-deriving read faces (`registerScope` / `scopeDirFor` / `registeredScopes`)
* may serve. `v1` (the pre-v2 file: a root-owning scope, `""` labels) and `migrating` (a v2 file
* carrying an unfinished move marker) are refused fail-closed: the registry then does not describe
* where entries physically live until {@link adoptRootScopeLayout} has run to completion. */
export declare const REGISTRY_SHAPE_DERIVES_PATHS: {
readonly absent: true;
readonly v1: false;
readonly v2: true;
readonly migrating: false;
};
export type DerivesPathsTableCoversEveryRegistryShape = AssertAllKeysHandled<Exclude<ScopeRegistryShape, keyof typeof REGISTRY_SHAPE_DERIVES_PATHS>>;
/** The unfinished-move marker: the physical root whose loose content is being moved into
* `<root>/<label>` for `owner`. Durable across crashes; whoever holds the registry lock next
* finishes it before doing anything else. */
export interface ScopeLayoutMigratingMarker {
root: string;
owner: string;
label: string;
}
/** The v2 registry record. `rootOwner` is the scope that historically owned the memory root; it is
* consulted only to attribute loose root content during adoption and never derives a path. */
export interface ScopesRecordV2 {
v: 2;
scopes: Record<string, string>;
rootOwner?: string;
migrating?: ScopeLayoutMigratingMarker;
}
/** A parsed registry, tagged by shape. `v1` keeps the pre-v2 fields for the one-time conversion. */
export type ScopeRegistryRead = {
shape: "absent";
rec: ScopesRecordV2;
} | {
shape: "v1";
rootScope?: string;
scopes: Record<string, string>;
} | {
shape: "v2";
rec: ScopesRecordV2;
} | {
shape: "migrating";
rec: ScopesRecordV2 & {
migrating: ScopeLayoutMigratingMarker;
};
};
/** Register `scope` in the durable registry (idempotent) and return its home `<memoryDir>/<label>`.
* The registered label is the truth for a scope that has one (a home never moves); the
* {@link scopeDirName} formula mints the label for a never-registered scope. Both collision arms
* run over the effective labels; the home is created on first registration and its physical form
* is proven on every call. Lock-serialized; a `v1`/`migrating` registry is refused. */
export declare function registerScope(memoryDir: string, controlDir: string, scope: string, opts?: {
caseFoldingFs?: boolean;
}): string;
/** All registered scopes → label. A `v1`/`migrating` registry is refused. */
export declare function registeredScopes(controlDir: string): Record<string, string>;
/** The stable home directory of `scope`'s entry files: `<memoryDir>/<label>`, the REGISTERED label
* when there is one, the formula otherwise. Registers nothing. A `v1`/`migrating` registry is refused. */
export declare function scopeDirFor(memoryDir: string, controlDir: string, scope: string): string;
/** The scope registry's shape as it stands on disk (lock-free, journal-aware). Exported for the
* backend's cheap pre-check and for the layout pins; not part of the package surface. */
export declare function scopeRegistryShape(controlDir: string): ScopeRegistryShape;
/** The v1 path-key resolver (design A6): maps a memory-dir-relative persistent key written under the
* root-owning layout onto the ledger form `(scope, rel)`. The first segment names a registered
* non-owner label ⇒ that scope's home; otherwise the key belonged to the root owner. `undefined`
* when the row cannot be attributed (no root owner on record) — the caller keeps the row in its
* old form and refuses the operations that depend on it. Reads any registry shape (the conversion
* is independent of the physical move). */
export declare function v1MemoryPathKeyResolver(controlDir: string): (relPath: string) => {
scope: string;
rel: string;
} | undefined;
/** Options of {@link adoptRootScopeLayout}. `ownerCandidate` attributes loose root content when the
* registry records no root owner (the mount's write scope / a standalone batch's first add scope).
* `renew` is the caller's outer-lock heartbeat (the backend's transaction lock) — called before
* every filesystem action and every sidecar write, together with the registry lock's own. */
export interface AdoptRootScopeLayoutOptions {
ownerCandidate?: string | null;
onIncident?: MemoryPartitionIncidentSink;
renew?: () => void;
/** The one-time v1 sidecar key conversions, run in the adoption (before any later scope
* registration can change what a v1 key's first segment names). Each conversion is idempotent
* and takes its own sidecar lock. */
convertSidecars?: (controlDir: string) => void;
}
/** The lock-free pre-check the backend's public faces ask before a registry-derived path: does the
* tree at `root` still need the adoption (a v1/migrating registry, or loose content at the root)?
* One journal-aware registry read plus one readdir; the adoption re-enumerates under the locks. */
export declare function layoutNeedsAdoption(root: string, controlDir: string): boolean;
/**
* Adopt the per-scope layout for the physical tree at `root` (the engine's memory dir, or the
* backend's directory root; both when they differ — they share one control plane, so the registry
* conversion happens once). Runs under the registry lock; the caller holds any outer lock. Every
* step is idempotent and the two-phase marker makes an interrupted move resumable by whoever holds
* the lock next:
* 0. registry shape: `absent` ⇒ an empty v2 record; `v1` ⇒ v2 with `rootOwner` = the old
* `rootScope` (the root owner is also given its label); `migrating` ⇒ that move is finished
* FIRST, whichever tree it names. The root's loose content is enumerated on every call — a v2
* registry proves nothing about THIS tree.
* 1. loose content ⇒ owner = `rootOwner ?? ownerCandidate`; neither ⇒ `memory.layout_unowned_root_content`
* (names capped), registry converted, nothing moved.
* 2. pre-checks, zero writes: the control plane inside the root; the owner's home a link/file, or
* a real non-empty directory without a marker for it; any loose name already present in the
* home — each refuses with {@link ControlPlaneCorruptError}.
* 3. commit A: the v2 record with the owner registered, `rootOwner`, and the `migrating` marker.
* 4. move: per entry, lstat the destination (absent ⇒ rename; present ⇒ refuse; source gone ⇒
* already moved); probe residue removed. No sidecar key and no index text is rewritten (the
* ledger form keys are location-free; the index heals through its rebuild law).
* 5. `index-revs.json` cleared (fail-open snapshot; the next rebuild keeps model wording once).
* 6. commit B: the marker removed; `memory.layout_migrated` disclosed.
*/
export declare function adoptRootScopeLayout(rootPath: string, controlDir: string, opts?: AdoptRootScopeLayoutOptions): void;
/** The memory layout's name for the tree's one physical-path mint (symlinks on the EXISTING prefix
* resolved, the missing tail rejoined). ONE implementation behind both names — a second canonicalizer
* here is what let a key minted by this layout name a directory the fence resolved differently. */
export declare const canonicalize: typeof physicalPathOfExisting;
/** True iff `child` (canonicalized) is `root` or inside it. */
export declare function isContainedIn(root: string, child: string): boolean;
/** The code an act on a store path is refused with when it would land outside the store ({@link storeTargetEscape}). */
export declare const TARGET_OUTSIDE_STORE = "memory.target_outside_store";
/** Where an act at `p` lands: the physical path of its normalized spelling (the spelling a child path
* joined under it carries), a MISSING tail rejoined under the deepest existing directory (what the act
* creates). Any other resolution fault (EACCES, ELOOP, ENOTDIR) is THROWN as itself: an act that
* cannot be placed is not performed — a failed resolution never implies the act would fail too
* (a link whose own mode refuses `realpath` is still followed by `unlink` and `rename`). */
export declare function landingPathOf(p: string): string;
/**
* Where a write, rename or delete at `target` would physically LAND, judged against the store's
* `physicalRoot`: `undefined` = inside, otherwise the reason. The target's directory, resolved through
* every link on its existing prefix (a missing tail is what the act creates, under the deepest existing
* directory), must be the root or inside it. `physicalRoot` is the root's physical form as the store
* was opened and is not re-resolved here: a store mounted through a link is judged by what the link
* resolved to, and a root spelling re-pointed after opening moves the acts, not the store. The final
* component is not judged: `rename` and `unlink` act on it and never through it, and the engine's
* writes open it no-follow — a link there is replaced, removed or refused, and what it points at is
* untouched. A lexical spelling under the root proves nothing: a directory under it replaced by a link
* keeps the spelling while the act follows the link out. A directory that cannot be resolved throws its
* fault ({@link landingPathOf}).
*/
export declare function storeTargetEscape(physicalRoot: string, target: string): string | undefined;
/** mkdir -p convenience used by the engine/backend (0o700 like the file stores). */
export declare function ensureDirExists(dir: string): void;
/** The one remedy every surfaced permission refusal carries — `undefined` unless `err` is a write
* the file system refused on PERMISSIONS (`EACCES` / `EPERM`) at a path INSIDE `memoryDir` (a
* refusal elsewhere — a control plane kept outside the store — is not this store's to fix). The
* engine never changes permission bits on a store — a session's read-only layers are its policy,
* not file-system state — so bits that refuse a write were set by someone else, and nothing here
* clears them (an engine from 7.30.x or earlier left inherited layers read-only on disk). */
export declare function permissionRemedyOf(err: unknown, memoryDir: string): string | undefined;
/** Attach {@link permissionRemedyOf} to a permission refusal inside `memoryDir` — in place, so the
* error keeps its class and `code` (callers branch on both), and once (an inner seat that already
* named its store wins over an outer one). Any other value is returned untouched. */
export declare function withPermissionRemedy<T>(err: T, memoryDir: string): T;
/** B2/L4 — the control-plane subdir holding gate-rejected originals. ONE name for all three writers
* (engine harvest, engine derived-index rung, backend inbound gate): they write the same directory,
* so its name and its permissions must not depend on which of them ran first. */
export declare const QUARANTINE_DIR = "quarantine";
/** REF-A9 — what {@link quarantineAndTombstone} reports back; both hosts escalate `detail` into
* their report channel (harvest `quarantineFailures` / inbound `quarantine_failed` findings). */
export interface QuarantineOutcome {
/** The captured copy under the quarantine dir. Absent ⇔ the capture itself failed. */
dest?: string;
/** True ⇔ the model-visible file no longer holds the suspect content (deleted, or tombstoned). */
removed: boolean;
/** WHAT this containment left at the model-visible path, which is the only thing a following restore
* may overwrite (see {@link restoreContainedBaseline}). ABSENT ⇔ the path was freed. Present ⇔ either
* this engine's own tombstone (the clear-in-place rung) or the suspect bytes themselves (neither rung
* worked) — in both cases replacing it takes nobody else's work, and in neither case may anything the
* restore did not put there be destroyed. */
leftOnPlane?: string;
/** Set on ANY deviation from the clean capture+delete path — never a silent best-effort downgrade. */
detail?: string;
}
/**
* B2/L4 + C-F9 (S2-0) — capture a gate-rejected file into control-plane quarantine FROM THE
* ALREADY-READ `content` (never re-reading `path`: the disk bytes may have been swapped since the
* gate scanned them — TOCTOU), then remove the suspect from the model-visible plane: delete, and if
* that fails overwrite in place with {@link DELETED_TOMBSTONE}.
*
* REF-A9: ONE implementation for the engine's harvest rung and the backend's inbound-gate rung.
* They were line-for-line twins carrying the same historical fix ("mirrors the engine-side fix",
* opus review 1.257.2) and had already drifted twice — the backend minted its quarantine filename
* off `Date.now()` instead of its own injected clock, and the two created the SHARED quarantine dir
* with different permissions (whoever ran first decided how readable the captured secrets were).
*
* NOT to be reused for the derived-index rung (`MemoryEngine.gateDerivedIndex`): that one CLEARS the
* file (the index is derived and is rebuilt from entry frontmatter), which is the opposite of a
* tombstone — folding them would launder that distinction away.
*/
/**
* Write `content` to `path`, refusing to follow a symlink at the FINAL component (`O_NOFOLLOW`).
*
* Every containment write in this domain is a check-then-use: the path was judged in-root when the
* scan read it, and the write happens later. A symlink dropped in that window turns a containment
* response — the thing whose job is removing suspect bytes — into a write through the link, i.e.
* this process putting its own committed memory content wherever the link points. Re-checking
* before writing cannot close a race; the kernel resolving and rejecting in one step can.
*
* SCOPE, stated exactly because the difference matters: `O_NOFOLLOW` constrains the LAST component
* only. An ANCESTOR directory swapped for a symlink in the same window still redirects the write,
* and closing that needs per-component `openat`-style traversal from a pinned root descriptor —
* which Node's `fs` does not expose (there is no relative-open API), so it is not reachable here
* without native bindings. What this buys is the leaf variant, which is the cheap one; the ancestor
* variant stays open and is recorded with the rest of the family rather than papered over.
*
* Throws like `writeFileSync`, including ELOOP when the final component IS a symlink. A HARD link is the
* same tamper by other means — the file's bytes live under a second name, possibly outside the store, and
* an in-place write would change them there too — so an opened file with more than one name is refused
* the same way, before anything is truncated (the check reads the opened descriptor: no window). The
* write itself goes through `writeFileSync` on the open descriptor, which writes every byte or throws —
* a bare `writeSync` may short-write, and reporting a truncated restore as contained (after the
* truncation already destroyed the destination) is the failure this guard exists to prevent.
*/
export declare function writeFileNoFollow(path: string, content: string): void;
/** {@link writeFileNoFollow}'s NO-REPLACE twin: write `content` only if nothing is at `path`. `"written"`
* when the bytes landed, `"kept"` when it was occupied; every other failure throws like the twin (a
* write that could not be ATTEMPTED must not read as one that chose not to). `O_EXCL | O_CREAT` is the
* atomic form — an `existsSync`-then-write pair is a TOCTOU with exactly the window this closes — and
* it refuses a symlink at the final component on its own (POSIX `EEXIST` on a link, dangling or not),
* so the twin's no-follow property is kept without a second check. */
export declare function writeFileNoReplace(path: string, content: string): "written" | "kept";
/**
* The ONE way a committed baseline goes back after {@link quarantineAndTombstone} emptied its seat —
* the inverse half of that function, beside it so the removal word and its readers stay in one module.
* Both containment arms (the capture-residue sweep and the polluted-harvest record loop) call it, and
* the rule is ONE sentence: THE RESTORE MAY ONLY OVERWRITE WHAT THE CONTAINMENT ITSELF LEFT AT THE PATH.
* The removal's own account of what it left (`leftOnPlane` — nothing, our tombstone, or the suspect
* bytes) is re-read and compared immediately before the write, through a read that refuses a link, a
* pipe and anything but a regular file: a caller that awaited the committed store cannot assume what
* the removal saw still holds, on ANY of the three arms. Anything else — a freed path, a path somebody
* took back, a path that could not be verified — takes the no-replace write, which is the right answer
* for all of them at once: it lands iff nothing is there. Residual window: the read-to-open gap, with
* nothing suspended in it.
*
* Declining never weakens containment. Where the suspect bytes were removed they are already in
* quarantine; where they were NOT, the only way the comparison fails is that somebody replaced them —
* which took them off the plane as surely as the removal would have.
*/
export declare function restoreContainedBaseline(path: string, committed: string, leftOnPlane: string | undefined): "written" | "kept" | "failed";
export declare function quarantineAndTombstone(path: string, content: string, quarantineDir: string, now: () => number): QuarantineOutcome;
/** S2-B — the queued memory-change announcements (control plane; drained at session-first inject). */
export declare const ANNOUNCEMENTS_FILE = "announcements.json";
/** S2-B — bounded queue: overflow FOLDS (oldest dropped, counted) instead of growing unbounded. */
export declare const MEMORY_ANNOUNCEMENTS_MAX = 20;
/** S2-C — per-file write-rejection counts (the beforeWrite silent-reject fuse; O-F9/C-F7: scoped
* state in `.engine/`, so the count survives suspend/resume and backend re-construction). */
export declare const SCAN_FUSE_FILE = "scan-fuse.json";
/** Rejections at which the write-time feedback mutes (S2-C: 拒 ≥3 次熔断为静默拒). */
export declare const SCAN_FUSE_THRESHOLD = 3;
/** Options of {@link acquireSidecarLock}. `waitCapMs` exists FOR the wait-bound pins (same hook shape
* as the file backend's txn-lock timings) — production callers leave it at {@link LOCK_WAIT_CAP_MS}. */
export interface SidecarLockOptions {
onDeadline?: "proceed" | "throw";
waitCapMs?: number;
}
/** Cross-process mutex via mkdir (atomic on POSIX). A lock older than {@link LOCK_STALE_MS} is
* presumed crashed and stolen; after the wait cap we proceed anyway (fail-open — these sidecars are
* not CAS-load-bearing, and wedging a session start on a lock would be worse).
*
* opus 1.258 review (MED, steal TOCTOU): a bare rmSync+mkdir steal lets TWO stealers both win (B's
* force-rm deletes A's FRESH lock). The steal is made single-winner by an atomic `renameSync` of the
* stale lock dir to a unique tombstone first — rename is atomic, exactly one racer succeeds; the
* loser's rename throws ENOENT and it loops back to a normal mkdir wait. The deadline branch keeps
* the documented proceed-anyway semantics but steals through the same single-winner gate.
*
* Wait-bound (ruled 2026-08-04): EVERY round runs the same three steps — attempt, diagnose, then
* the deadline check and the pause. The two "retry immediately" shortcuts this loop used to take
* (holder released between mkdir and stat; steal attempted) skipped both of the last two, so a
* condition that keeps answering the same way — an unwritable control dir (read-only mount, full
* volume, revoked permission) makes mkdir fail forever while the lock dir it would stat never
* exists — spun this synchronous loop at full speed and never reached its own cap. */
export declare function acquireSidecarLock(lockDir: string, opts?: SidecarLockOptions): string;
/** 证伪式复审 L2-analog: re-verify the owner token at the COMMIT POINT — a stolen-from holder
* aborts instead of writing over the stealer. A FOREIGN token always aborts (positive proof the
* lock is someone else's now). What an ABSENT/UNREADABLE owner file means splits by caller family
* (backlog #255): the fail-closed LEDGER family (`"strict"` — challenges/lineage via
* lockedStrictUpdate) treats unprovable ownership as lost — those sidecars promise their commit
* point is fenced, and "cannot disprove" is not a fence; the fail-open calibration family
* (`"lenient"`, the pre-#255 shape — e.g. the scope registry, whose sidecars self-heal at the next
* locked update) keeps the leniency, because its token writes are best-effort and an absent owner
* file there is the documented pre-token degraded shape, not evidence of a steal. */
export declare function assertSidecarLockOwnership(lockDir: string, token: string, what: string, mode?: "strict" | "lenient"): void;
interface AnnouncementsRecord {
/** How many announcements were dropped by the bounded-queue fold (disclosed at render). */
folded: number;
queue: MemoryAnnouncement[];
}
/**
* S2-B — enqueue one announcement (journaled + locked; bounded with overflow folding).
*
* `opts.supersedesPrefix` (#495) declares this announcement to be a STANDING CONDITION — one whose
* sentence is re-derived at every entry point until the condition clears — and names the family it
* belongs to by line prefix. Every queued line of that family is a STALE READING of the same
* condition, so the queue is made to hold exactly one of them, always the current one:
* · the identical line already queued unread ⇒ no-op, and no write at all;
* · otherwise every queued item with that prefix is dropped and this one appended.
* Merely SKIPPING a repeat is not enough, and that was the first form of this: a condition that goes
* A → B → A inside one unread window would leave `A, B` in the queue and a reader looking at B as the
* latest state — the fix for a flood turning into a wrong answer. Replacement makes the last word the
* true one.
*
* All of it runs INSIDE the locked read-modify-write, which is the point: a caller that peeked first
* and enqueued after would race a concurrent drain, and two processes could both peek-miss.
* Deliberately keyed on the LINE, not on a structural id — two states that render the same sentence
* are, to every consumer, the same statement. Deliberately opt-in: an ordinary event repeating IS
* news, and only its author knows which kind it is. Callers using it pass ONE item (a standing
* condition is one sentence), and the prefix must name the family precisely — anything else sharing
* it would be dropped as a stale sibling.
*/
export declare function enqueueMemoryAnnouncement(controlDir: string, ann: MemoryAnnouncement, opts?: {
supersedesPrefix?: string;
}): void;
/** S2-B — drain the queue (session-first inject, 时机①). Returns what was queued and resets it. */
export declare function drainMemoryAnnouncements(controlDir: string): AnnouncementsRecord;
/** Test/observability peek (journal-aware, non-destructive). */
export declare function peekMemoryAnnouncements(controlDir: string): AnnouncementsRecord;
/** The fuse key for a memory file: the ledger form `(scope, rel)` (`rel` relative to the scope's
* home, `/`-separated) — location-free, so a home move never re-keys a count. */
export declare function scanFuseKey(scope: string, rel: string): string;
/** S2-C — record one write-time scan rejection for `key` ({@link scanFuseKey}); returns the count
* BEFORE this bump (callers mute the feedback when it is already ≥ {@link SCAN_FUSE_THRESHOLD}). */
export declare function bumpScanFuse(controlDir: string, key: string): number;
/** S2-C — the current fuse count for `key` (0 = never rejected / cleared). */
/** The derived-index snapshot: target → the entry rev and the index line AS OF the last rebuild whose
* index landed. It answers one question for {@link indexLineIsStale}: did the entry change since the
* line was written while the line did not (the injected index would otherwise answer the OLD value
* to the next session)? `line` is in {@link normalizeIndexLine} form, and absent for a snapshot an
* older engine wrote (a bare rev string): the line is then unknown, so nothing is judged stale.
* Control plane, best-effort, fail-open (absent/corrupt ⇒ {}). */
export interface IndexRevSnapshot {
rev: string;
line?: string;
}
export declare function readIndexRevs(controlDir: string): Record<string, IndexRevSnapshot>;
/** The comparison form of an index line: trimmed, every run of whitespace one space. Case and every
* visible character stay — only a whitespace difference reads as "the model did not re-word it". */
export declare function normalizeIndexLine(line: string): string;
/** The one judge of a model-worded index line: stale = the entry's rev moved since the snapshot AND the
* line is still the text the snapshot recorded (the model has not re-worded it since), both sides
* compared in {@link normalizeIndexLine} form. A line the model changed is its own word and wins; a
* snapshot without a line cannot say, so it never judges stale. */
export declare function indexLineIsStale(snapshot: IndexRevSnapshot | undefined, entryRev: string, line: string): boolean;
export declare function writeIndexRevs(controlDir: string, revs: Record<string, Required<IndexRevSnapshot>>): void;
export declare function scanFuseCount(controlDir: string, key: string): number;
/**
* @contract memory.session_incarnation.proven_mismatch_only — only a PROVEN mismatch (both sides
* known and different) separates a record from the identity reading it; an absent incarnation on
* either side — a record written before the axis existed, a deployment that wires no resolver, a
* resolver that threw, a synthesized fail-closed record, a record imported from another plane —
* always BINDS, which is the pre-incarnation behavior. Every leg's conservative direction is
* therefore its old one, and the only way to lose a binding is to prove it belongs to someone else.
*/
export declare function sessionRecordBindsIncarnation(recorded: string | undefined, asking: string | undefined): boolean;
/** The incarnation this layer ACTS on: an unusable token is the unnamed occupant, never a throw. */
export declare function usableSessionIncarnation(raw: string | undefined): string | undefined;
/** How a per-session record read names the identity reading; absent ⇒ every record binds. */
export interface SessionIdentityRead {
incarnation?: string;
/** Called when a standing record was SET ASIDE as another occupant's, with the incarnation it carries — never silent. */
onSeparated?: (recorded: string) => void;
}
/** Directory (under the control plane) holding one marker file per polluted session. */
export declare const SESSION_POLLUTION_DIR = "session-pollution";
export interface SessionPollutionRecord {
at: number;
reason: string;
/** design/336 §2.2 — the structured mechanical cause of the exposure (the value an origin marker
* minted off this record carries). ADDITIVE: records written before the field existed read as
* honestly cause-less (the mint then falls back to `"observed"`); old readers narrow-take the
* two fields they know. */
cause?: import("./types.js").MemoryOriginCause;
/** the occupant that minted this record ({@link sessionRecordBindsIncarnation}). ADDITIVE:
* absent (no resolver wired, or written before the axis) ⇒ it binds every occupant. */
incarnation?: string;
}
/**
* Mark `sessionId` polluted (idempotent; the FIRST record wins — `wx` create refuses overwrite).
*
* The outcome distinguishes the two ways a durable marker can exist after the call, because the
* `wx` create is the only ATOMIC answer to "who made this transition" available anywhere in the
* stack (#324a, adversarial-review round 5): a check-then-act pre-read lets two concurrent markers
* of one session both believe they were first, and a once-per-session disclosure keyed on that would
* announce twice. `"created"` ⇔ THIS call minted the marker; `"existed"` ⇔ it was already there (a
* repeat mark, a resumed session, or a racing peer that won the create); `"unpersisted"` ⇔ the marker
* could not be written at all — the caller must keep its own in-memory mark and disclose (the session
* stays polluted for this process either way).
*/
export type SessionPollutionMarkOutcome = "created" | "existed" | "unpersisted";
export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number, cause?: import("./types.js").MemoryOriginCause, incarnation?: string): SessionPollutionMarkOutcome;
/** Read the durable pollution record for `sessionId` (undefined = no marker).
* A marker that EXISTS still reads as polluted whether or not its bytes can be read or parsed
* (fail-closed: neither corruption nor an unreadable node may launder the state) — a synthesized
* record stands in. Only ABSENCE is clean, so a control plane that cannot be stat'ed at all reads
* clean for every session rather than disabling memory deployment-wide. */
export declare function readSessionPollution(controlDir: string, sessionId: string, identity?: SessionIdentityRead): SessionPollutionRecord | undefined;
/**
* design/178 v2-c §3-3 — enumerate EVERY durable pollution marker (the export snapshot's pollution
* face; `readSessionPollution` stays the per-session read). Same fail-closed record posture as the
* per-session read: a marker file that exists but cannot be read or parsed answers a synthesized
* record, never a silent omission (corruption must not launder the state out of an export). An
* absent marker directory is the genuine empty state (ENOENT-only); any OTHER enumeration failure
* THROWS — a listing that silently dropped markers would export a package that launders pollution.
*/
export declare function listSessionPollution(controlDir: string): Record<string, SessionPollutionRecord>;
/**
* design/178 v2-c §5.3 — the STRICT durable pollution import. Deliberately NOT
* `markSessionPolluted`: that leg is best-effort-with-disclosure and mints its own `at`, so a
* bundle's source record (the governance fact being carried) could neither land verbatim nor prove
* durability. This leg writes the SOURCE record verbatim (`wx`); on EEXIST it reads the standing
* record back and COMPARES — equal = converged no-op, different = the destination's first-mark-wins
* record stands and the divergence is REPORTED (`"divergent"`), never silently absorbed. Any other
* write failure THROWS (the import aborts with its latch standing — §1-2⑤).
*/
export declare function importSessionPollution(controlDir: string, sessionId: string, record: SessionPollutionRecord): "written" | "already" | "divergent";
/** Directory (under the control plane) holding one record file per capture-opted-out session. */
export declare const SESSION_CAPTURE_OPTOUT_DIR = "session-capture-optout";
/** The durable capture opt-out record. `reason` is host/engine-authored (the declaring channel),
* never model text. */
export interface SessionCaptureOptOutRecord {
at: number;
reason: string;
/** the occupant that declared this opt-out ({@link sessionRecordBindsIncarnation}).
* ADDITIVE: absent ⇒ binds every occupant, which is also where a carrier that drops it lands. */
incarnation?: string;
}
/** Outcome vocabulary shared with the pollution marker (same `wx` one-way form): `"created"` ⇔ THIS
* call minted the record; `"existed"` ⇔ already recorded (a resumed session / an idempotent
* re-declaration); `"unpersisted"` ⇔ the record could not be written — the CALLER must refuse to
* run (design/383 §2.8: an opt-out that only holds in-process is broken by the first resume, so
* "could not persist" is a named refusal, never a silent in-process-only degrade — the deliberate
* divergence from the pollution marker's best-effort arm). */
export type SessionCaptureOptOutMarkOutcome = "created" | "existed" | "unpersisted";
/** Mark `sessionId` capture-opted-out (idempotent; the FIRST record wins — `wx` refuses overwrite).
* Incarnation law: first-record-wins is per IDENTITY — a new occupant whose declaration meets a stranger's
* standing record mints its own slot instead of answering `"existed"` ({@link sessionRecordPath}). */
export declare function markSessionCaptureOptOut(controlDir: string, sessionId: string, reason: string, now: () => number, incarnation?: string): SessionCaptureOptOutMarkOutcome;
/** Read the durable capture opt-out record for `sessionId` (undefined = capture is on). Fail-closed
* exactly like the pollution read: a record that EXISTS but cannot be read or parsed still answers
* opted-out (a synthesized record stands in) — corruption must not launder a privacy request back
* into capture. Only ABSENCE reads as "capture on". A store state where existence itself cannot be
* established (dir-level IO fault) THROWS — the engine's `sessionCaptureOptOutOrFault` catch turns
* it into the INDETERMINATE fault axis (same fail-closed suppression, honest attribution — see the
* catch below). */
export declare function readSessionCaptureOptOut(controlDir: string, sessionId: string, identity?: SessionIdentityRead): SessionCaptureOptOutRecord | undefined;
/**
* Enumerate EVERY durable capture opt-out record (the consolidation eligibility arm's roster read —
* design/383 §2.4). Same fail-closed posture as {@link listSessionPollution}: a record that exists
* but cannot be read answers synthesized (never silently omitted); an absent directory is the
* genuine empty state (ENOENT only); any OTHER enumeration failure THROWS — a listing that silently
* dropped records would let the distiller amplify content its author asked to withhold.
*/
export declare function listSessionCaptureOptOut(controlDir: string): Record<string, SessionCaptureOptOutRecord>;
/**
* design/383 §2.1b — the control-plane carrier a HOST may substitute for the capture opt-out
* record's read/write/list (server F1: on a stateless multi-replica deployment the file form is
* invisible to the resume replica — "record not found" would break exactly toward capture). The
* core default is the file trio above, byte-identical for every deployment that supplies nothing.
* CONTRACT (each leg mirrors the file form's law):
* - `mark` is create-only/first-record-wins and must answer `"unpersisted"` (never throw) when the
* carrier cannot durably land the record;
* - `read` is fail-closed: a record that exists but cannot be decoded answers a synthesized
* record, never `undefined`; a store that cannot establish whether a record EXISTS (an outage)
* may THROW — the engine treats a throwing read as the INDETERMINATE fault axis (commit
* boundaries suppress fail-closed), which is the honest answer there: it must not fabricate a
* record ("a user request that never happened") and must not answer `undefined` ("no record"
* breaks exactly toward capture);
* - `list` throws on an enumeration failure (an unreadable roster is not an empty one).
* REGISTERED COMPANION (named, not silently absent): the session-POLLUTION marker has the same
* cloud-form invisibility (server F1 names both). It is NOT routed through this seat in this
* slice because the delegation-settlement lane's consumers are contractually PURE-DATA
* (`controlDir` + `sessionId` coordinates that outlive any live object — see the settlement handle
* doc in prepare-task.ts), and a live store object cannot ride that lane; its migration needs its
* own seam design and is registered here rather than half-built.
*
* DUAL FORM (#511 件2, additive): each leg may answer `T` OR `Promise<T>` — a SQL/remote carrier is
* async by nature and could not implement the sync-only trio at all. Every core consumption point
* awaits (or `.then`-chains where a sync public seat must stay sync-transparent), so a sync store's
* behavior is unchanged and an async store's Promise legs keep the exact same contract per leg:
* a rejected `mark` promise is held to the `"unpersisted"` refusal arm, a rejected `read` promise
* is the INDETERMINATE fault axis, a rejected `list` promise propagates as the enumeration failure.
* The File trio below keeps its synchronous signatures byte-identical.
*/
export interface SessionCaptureRecordStore {
mark(sessionId: string, record: SessionCaptureOptOutRecord): SessionCaptureOptOutMarkOutcome | Promise<SessionCaptureOptOutMarkOutcome>;
/** `identity` names the occupant asking (absent ⇒ every record under the id binds, which is
* also what a carrier that ignores the parameter keeps — no carrier has to change). One that DOES
* persist the field keys its rows by (sessionId, incarnation): first record per IDENTITY wins. */
read(sessionId: string, identity?: SessionIdentityRead): SessionCaptureOptOutRecord | undefined | Promise<SessionCaptureOptOutRecord | undefined>;
list(): Record<string, SessionCaptureOptOutRecord> | Promise<Record<string, SessionCaptureOptOutRecord>>;
}
/** The core-default {@link SessionCaptureRecordStore}: the file trio over `controlDir` (single-
* process / single-host deployments keep today's carrier byte-identical). */
export declare function fileSessionCaptureRecordStore(controlDir: string): SessionCaptureRecordStore;
/** The retrieved-account sidecar (control plane): entry id → { count, lastAt }. */
export declare const USAGE_RETRIEVED_FILE = "usage-retrieved.json";
/** Bound on tracked ids — over the cap, the entries with the OLDEST `lastAt` are evicted first
* (a popularity account tolerates forgetting cold ids; unbounded growth does not tolerate itself). */
export declare const USAGE_RETRIEVED_MAX_IDS = 4096;
/** One retrieved-account row. */
export interface RetrievedAccountRow {
count: number;
lastAt: number;
}
/** Record one retrieval (a search/get hit actually RETURNED to the model) for each id. Journaled +
* locked like every sidecar; throws propagate to the caller, which treats them fail-open. */
export declare function recordRetrievedAccount(controlDir: string, ids: readonly string[], now: () => number): void;
/** Journal-aware read of the retrieved account (observability/tests; nothing in the engine's
* retention/ranking paths consumes it — see the section note). */
export declare function readRetrievedAccount(controlDir: string): Record<string, RetrievedAccountRow>;
/** S2-C — clear the fuse for files harvest just ACCEPTED (a committed write resets the strike count). */
export declare function clearScanFuse(controlDir: string, keys: Iterable<string>): void;
/** The write-ahead harvest lineage ledger (control plane, fail-closed). */
export declare const LINEAGE_FILE = "lineage.json";
/** The challenge generation-event ledger (control plane, fail-closed). */
export declare const CHALLENGES_FILE = "challenges.json";
/** Per-commit lineage AUDIT history (bookkeeping, fail-open, bounded rotation) — the load-bearing
* queries never read it; it exists so the deduped contribution set is not the only trace. */
export declare const LINEAGE_AUDIT_FILE = "lineage-audit.json";
/** Bound on retained audit rows (oldest rotated out first). */
export declare const LINEAGE_AUDIT_MAX_ROWS = 2048;
/** The challenged-history account (design/180 B-3: collect-only, fail-open bookkeeping —
* the mechanical shadow of "user saw a retrieved entry and corrected it in the same session"). */
export declare const CHALLENGED_HISTORY_FILE = "usage-challenged-history.json";
/** The locked, journaled, FAIL-CLOSED sidecar update. `coerce` must throw
* {@link ControlPlaneCorruptError} on any unacceptable shape; only ENOENT reads as the empty
* initial state (`undefined` handed to `coerce`). Exported module-level (not on the root barrel)
* for the strict-account family's siblings (design/336 slice 2: the delegation-settlement /
* session-account / instruction-hold sidecars live in their own module but MUST be this exact
* lock+journal discipline — a second implementation would be a drift seam). */
export declare function lockedStrictUpdate<S, T>(controlDir: string, fileName: string, what: string, coerce: (raw: unknown) => S, fn: (current: S) => {
next?: S;
result: T;
}): T;
/** Lock-less strict READ (journal-aware): a parseable journal wins (committed next state); an
* unparseable journal, or an unreadable/unparseable file, is corrupt. Exported module-level for
* the strict-account family's siblings (see {@link lockedStrictUpdate}). */
export declare function readStrictSidecar(controlDir: string, fileName: string, what: string): unknown;
/** One committed contribution: session → entry, latest rev/at only (the load-bearing invariant is
* "which sessions contributed this id", not the per-commit history — that is the audit account). */
export interface LineageContribution {
lastRev: string;
lastAt: number;
/** the occupant that made this contribution, carried from the staged txn at promotion as
* `marked` is. Absent ⇒ unknown ⇒ the row answers every occupant of the id (the old superset). */
incarnation?: string;
/** design/336 — carried from the staged row at promotion: the contribution's content (at
* `lastRev`) carries its external-origin marker, so the pollution retroaction sweeps owe it no
* challenge (the account travels with the entry). Absence = unmarked content — the sweeps'
* domain (older vintages read as unmarked, the conservative side). */
marked?: true;
}
/** One staged (pre-commit) row: an entry this transaction WOULD commit. `kind: "latch-only"` marks
* a bundle-import SYNTHETIC latch row (design/178 v2-c §1): it withholds the id on the model-visible
* read faces exactly like a real staged row, but it is NOT a contribution — the erasure lane's
* capture-and-clear strikes it without crediting its txn's sessionId, and the host adjudication
* refuses to touch its transaction (the import protocol's receipt-gated release is the only exit). */
export interface LineagePendingRow {
entryId: string;
rev: string;
kind?: "latch-only";
/** design/336 — set ⇔ the staged content CARRIES its external-origin marker (`frontmatter.origin`
* in the very rev this row names): the exposure account travels WITH the entry, so promotion
* settlement owes it no pollution challenge (challenging it would re-quarantine a tag-admission
* at the read face). Absence = the row's content is unmarked (older vintages included) — the
* conservative side: an unmarked row of a marked session is challenged. */
marked?: true;
}
/** One staged transaction: rows land BEFORE `applyPatches`; the commit credential lands after it
* succeeds; promotion consumes both. A pending txn without a credential is the crash window —
* it is NEVER auto-discarded (design/180 r5: the applyPatches-success→credential-write gap is
* undecidable on the pinned backend contract; the host adjudicates via the explicit API). */
export interface LineagePendingTxn {
sessionId: string;
at: number;
rows: LineagePendingRow[];
/** the occupant that staged this transaction, carried onto every contribution it
* promotes. Absent ⇒ unknown occupant, which binds every reader of the id. */
incarnation?: string;
/** Present ⇔ `applyPatches` returned and its applied set was durably recorded. */
credential?: {
appliedIds: string[];
at: number;
};
/** design/178 v2-c §1 — present ⇔ this txn is a bundle-import SYNTHETIC LATCH: it never promotes
* (no credential is ever written), the generic adjudication REFUSES both arms on it, and the only
* release channel is {@link releaseImportLatch} (durable completion-receipt precondition). An
* older binary reads it as an ordinary uncredentialed pending txn and reports it undecidable —
* the accepted mixed-version posture (the drain precondition of the ledger family applies). */
kind?: "latch-only";
}
/** Stage this transaction's would-commit rows BEFORE `applyPatches` (write-ahead). Throws on any
* write/lock/corruption failure — the caller refuses the whole harvest (fail-closed). */
export declare function stageLineagePending(controlDir: string, txnId: string, sessionId: string, rows: readonly LineagePendingRow[], now: () => number, incarnation?: string): void;
/** Discard a staged transaction that never reached `applyPatches` (the pre-commit abort path —
* step ③ of the handshake, or a refused commit). Safe by construction: nothing was committed. */
export declare function discardLineagePending(controlDir: string, txnId: string): void;
/** Record the commit credential — `applyPatches` returned, these ids were APPLIED (non-delete ops).
* Written before promotion so a crash between the two converges by credential, never by reading
* the backend's current state back (design/180 r4: later syncs/updates rewrite that scene). */
export declare function recordLineageCredential(controlDir: string, txnId: string, appliedIds: readonly string[], now: () => number): void;
/** What one promotion moved into the committed set. */
export interface LineagePromotion {
entryId: string;
sessionId: string;
rev: string;
/** design/336 — carried through from {@link LineagePendingRow.marked}: the promoted content
* already carries its external-origin marker, so promotion settlement skips the pollution
* challenge for it. */
marked?: true;
}
/** Promote a CREDENTIALED pending transaction: rows named by the credential's applied set join the
* committed contribution set (deduped by (entryId, sessionId) — a repeat pair takes the incoming
* lastRev and the credential's date, never grows the set); rows NOT in the applied set (conflicted/refused ops) are
* dropped without ever becoming ancestry. Throws when the txn is unknown or uncredentialed. */
export declare function promoteLineagePending(controlDir: string, txnId: string): LineagePromotion[];
/** The host adjudication for the UNDECIDABLE crash window (a pending txn with no credential —
* design/180 r5: never auto-discarded). `"promote"` = the host verified the commit landed (all
* staged rows join the committed set); `"discard"` = the host verified it did not. Idempotent on
* an unknown txn. Also accepts a credentialed txn (then `"promote"` follows the credential). */
export declare function adjudicateLineagePending(controlDir: string, txnId: string, action: "promote" | "discard", now: () => number): LineagePromotion[];
/** Reconciliation (harvest-opening / host-triggered): promote every CREDENTIALED orphan pending
* txn; report the uncredentialed ones (their latch stays until the host adjudicates). */
export declare function reconcileLineage(controlDir: string): {
promoted: LineagePromotion[];
undecidable: Array<{
txnId: string;
sessionId: string;
entryIds: string[];
kind?: "latch-only";
}>;
};
/** design/178 v2-c §1 — the import COMPLETION-RECEIPT directory (control plane): one `wx`-minted
* JSON per bundle hash, written only after the import's governance legs all landed. Its presence
* is {@link releaseImportLatch}'s hard precondition and the idempotent re-import's short-circuit
* credential. */
export declare const IMPORT_RECEIPTS_DIR = "import-receipts";
/** The lineage txn id of a bundle import's synthetic latch (ONE latch per bundle — a re-import of
* the same bundle re-stages the same txn id, which is what makes the latch idempotent). */
export declare function importLatchTxnId(bundleHash: string): string;
/**
* design/178 v2-c §1-1 — stage the bundle import's SYNTHETIC LATCH: one pending lineage txn
* (txnId = {@link importLatchTxnId}) whose rows name every bundle entry id, every row and the txn
* itself carrying the structural `kind: "latch-only"` seat. From this write until
* {@link releaseImportLatch}, those ids are withheld on every model-visible read face (the same
* dirty-latch consumption real staged rows get) — a crash anywhere inside the import leaves the
* withhold standing (durable pending row), and re-staging the same bundle is an idempotent
* overwrite with identical content. Never credentialed, never promoted.
*/
export declare function stageImportLatch(controlDir: string, bundleHash: string, rows: ReadonlyArray<{
entryId: string;
rev: string;
}>, now: () => number): string;
/**
* design/178 v2-c §1-2⑥ — the ONE latch release channel. Hard precondition: the durable completion
* receipt for the bundle is IN PLACE (`import-receipts/<bundleHash>.json`, minted after the
* governance legs all landed) — absent receipt ⇒ loud refusal, the latch stands, and convergence is
* re-running the same bundle import. Receipt-presence is ENOENT-only-absent (a probe failure never
* reads as "no receipt" — fail-closed). Idempotent: an already-released (absent) txn is a no-op;
* a NON-latch txn under the id is refused (this is never a generic discard).
*/
export declare function releaseImportLatch(controlDir: string, txnId: string): void;
/** The DIRTY-LATCH set: every entry id named by ANY pending row (credentialed or not). While an id
* is latched, its model-visible read faces (index mechanical rows / memory_search / memory_get)
* refuse — "the account is not settled, the content does not go on the table". Throws on a
* corrupt ledger (integrity unknown ≠ no latch). */
export declare function lineageLatchedIds(controlDir: string): Set<string>;
/** Every committed contribution of `sessionId` (the pollution-retroaction sweep input, and the
* erasure lane's sessionId-selector resolver). Throws on a corrupt ledger. Lookups are
* OWN-property (the twin law {@link lineageAccountOfEntry} states): a prototype-member sessionId
* such as "constructor" indexes truthy on EVERY plain object, so a bare lookup would attribute
* the WHOLE committed set to it — behind the erasure selector that phantom is a whole-store
* delete, the exact shape the selector type forbids. Belt over the null-proto parse rebuild:
* this guard stays honest even against a map minted elsewhere.
* `asking` names the occupant whose contributions are wanted: a row is kept unless it
* PROVABLY belongs to another ({@link sessionRecordBindsIncarnation}). An absent `asking`, and
* every unstamped row, keeps the whole by-id set — the superset this face has always answered, and
* the conservative side here (a sweep that challenges too little publishes). */
export declare function lineageContributionsOfSession(controlDir: string, sessionId: string, asking?: string): Array<{
entryId: string;
lastRev: string;
lastAt: number;
marked?: true;
}>;
/** design/178 v2-a §1.3 — the BY-ENTRY lineage account, from ONE parse of the ledger (the
* by-session read's transpose; the committed set is keyed by entryId, so the index is O(1) after
* the load — the load itself still parses and validates the whole account). BOTH halves come from
* the same read on purpose (adversarial r3): contributors (committed rows; pending rows are NOT
* contributions — the account is unsettled) and the dirty-latch fact (any pending row naming this
* id). Deriving them from two separate reads let a promotion land between them and assemble a
* scene no instant exhibited — empty ancestry AND no latch. Unknown entry ⇒ empty/false. Throws
* {@link ControlPlaneCorruptError} on a corrupt ledger — an audit answer is never assembled over
* an account whose integrity is unknown. Lookups are own-property (a prototype-member entryId
* such as "constructor" must answer the empty account, never a phantom). */
export declare function lineageAccountOfEntry(controlDir: string, entryId: string): {
contributors: Array<{
sessionId: string;
lastRev: string;
lastAt: number;
}>;
pendingLatched: boolean;
};
/** Drop an entry's lineage rows once the entry is DELETED (an applied tombstone) — space hygiene
* only, taken at the harvest that saw the applied delete (design/180 §10). */
export declare function clearLineageForEntries(controlDir: string, entryIds: readonly string[]): void;
/**
* design/178 v2-b §7.2-3 — capture-and-clear an erased id set's lineage in ONE strict-lock write:
* - the COMMITTED contributors of each id are captured (the attestation's testimony) and the rows
* dropped (the organic tombstone's space hygiene, same terminal state);
* - every PENDING txn's staged rows naming these ids are STRUCK in the same write — a promotion
* landing after this lock releases iterates rows the strike already removed, so a late harvest
* can never write a deleted id's lineage row back (the read and the clear being two separate
* lock spans was exactly the interleave that both missed a landing contributor and then erased
* the record of having missed it).
* Struck pending rows contribute their txn's sessionId to the returned set — EXCEPT rows carrying
* the structural `kind: "latch-only"` seat (an import protocol's synthetic latch is not a
* contributing session; the row is still struck). Returns id → contributing session ids
* (committed ∪ struck). NON-durable by design: a crash between the caller's journal close and this
* call loses the return set — the caller's replay re-runs it idempotently and its attestation
* carries the chain's pre-captured set (honest downgrade, never a fabricated rebuild).
*/
export declare function captureAndClearLineageForEntries(controlDir: string, entryIds: readonly string[]): Map<string, string[]>;
/** One (entryId, sessionId) pair whose imported value LOST to the destination's standing row —
* design/178 v2-c §5.1's disclosure seat (kept is always "destination": the in-account fact is
* never overwritten by an equal-time divergent import, only reported). */
export interface LineageImportDivergence {
entryId: string;
sessionId: string;
kept: "destination";
}
/**
* design/178 v2-c §5.1 — the lineage FIDELITY importer: write bundle lineage rows into the
* committed set VERBATIM (`lastRev`/`lastAt` carried byte-for-byte: the promote leg dates a contribution by its credential,
* the import carries the exporter's date — neither reads a clock here). Deterministic same-key merge, idempotent under
* re-import of the same bundle:
* - destination has no (entryId, sessionId) row ⇒ the source row lands verbatim;
* - both present, source `lastAt` is GREATER ⇒ source wins ("last contribution" is a time-axis
* fact); destination greater ⇒ destination stands (no divergence — the merge is deterministic);
* - equal `lastAt`, equal `lastRev` ⇒ no-op; equal `lastAt`, DIFFERENT `lastRev` ⇒ the
* destination row stands and the pair is reported (first-mark-wins side: an in-account fact is
* never rewritten by an外来 package; the disagreement must not be silent).
* Throws on a corrupt ledger (fail-closed family).
*/
export declare function importLineageCommitted(controlDir: string, rows: ReadonlyArray<{
entryId: string;
sessionId: string;
lastRev: string;
lastAt: number;
marked?: true;
}>): LineageImportDivergence[];
/** Full-ledger read (tests / host observability). Throws on corruption. */
export declare function readLineageRecord(controlDir: string): {
committed: Record<string, Record<string, LineageContribution>>;
pending: Record<string, LineagePendingTxn>;
};
/** Fail-open per-commit AUDIT history (bounded rotation) — never consulted by the load-bearing
* queries; a broken audit account degrades silently, exactly like the retrieved account. */
export declare function appendLineageAudit(controlDir: string, rows: ReadonlyArray<{
entryId: string;
sessionId: string;
rev: string;
at: number;
}>): void;
/** One challenge/resolve event. Append-only; the exclusion predicate is DERIVED ("any unresolved
* challenge generation exists"), never stored as a mutable flag. */
export interface ChallengeEvent {
/** Stable identity for idempotent replay: the automatic arm derives it from the pollution event
* anchor (`pollution:<sessionId>:<markedAt>:<entryId>`); the host arm derives it from the
* caller's requestId (`<requestId>:<entryId>`). Same eventId replayed = same generation (a lost
* ack retried never opens a second generation); a DIFFERENT eventId with the same reason is a
* genuinely new event and opens a new generation (no immunity hole). */
eventId: string;
entryId: string;
kind: "challenge" | "resolve";
/** The generation this event opened (challenge) or closed (resolve). */
generation: number;
at: number;
reason: string;
/** Evidence grade — v2 mints only "S" (structural) events. */
grade?: "S";
/** Audit record only (the exclusion predicate keys the entry ID, never the rev). */
challengedRev?: string;
}
/** One challenge assignment {@link appendChallengeEvents} returns. `replayed` = the eventId already
* existed (idempotent retry — the ORIGINAL generation is returned, no new one opens). */
export interface ChallengeAssignment {
entryId: string;
eventId: string;
generation: number;
at: number;
replayed: boolean;
}
/**
* design/180 §10 — the challenge ledger's SIZE CALIBRATION.
*
* The ledger is append-only and cannot be rotated: the exclusion predicate is DERIVED from the events
* ("a challenge generation with no matching resolve"), so dropping the oldest rows would silently
* un-withhold their entries — taint laundering, the exact failure the fail-closed family exists to
* prevent. The bound is therefore a DISCLOSURE threshold, not a cap: crossing it changes nothing about
* what the ledger holds, it tells the deployment the account needs attention.
*
* Calibrated against what actually degrades, which is not disk: every append REWRITES the whole file
* under the strict lock, and every model-visible read path ({@link challengedEntryIds}, called on each
* index rebuild and each memory tool call) PARSES it whole. At ~200 bytes per event, 10k events is a
* ~2 MB parse on a read path that runs per tool call — the point where "an audit account nobody looks
* at" becomes a latency the session feels. A deployment reaching it should resolve the settled
* generations (they stay in the file, and that is the point) or archive the account deliberately.
*/
export declare const CHALLENGE_LEDGER_MAX_EVENTS = 10000;
/** What {@link appendChallengeEvents} returns: the per-request assignments plus the ledger's size
* AFTER the append — the caller owns disclosure (this module has no sink), and reading the size here
* costs nothing because the record is already in hand under the lock. */
export interface ChallengeAppendResult {
assignments: ChallengeAssignment[];
/** Total events in the ledger after this append, or `undefined` when nothing was appended — the
* empty-request fast path takes no lock and reads nothing, so it has no size to report and must not
* report a made-up `0` (a caller comparing that against the bound would read "tiny", not "unknown"). */
eventCount: number | undefined;
}
/** Append S-grade challenge events (idempotent per eventId, generation allocation inside the strict
* lock). Every NEW eventId opens a NEW generation for its entry — a resolve of an earlier
* generation never immunizes the entry against later pollution (design/180 R-8). */
export declare function appendChallengeEvents(controlDir: string, events: ReadonlyArray<{
eventId: string;
entryId: string;
reason: string;
challengedRev?: string;
}>, now: () => number): ChallengeAppendResult;
/** The trusted resolve: closes EXACTLY the named generation (a later challenge's generation stays
* open — resolves never blanket-clear). Idempotent: resolving an already-resolved generation is a
* no-op. `resolved` is false ⇔ no such open generation existed.
*
* `eventCount` rides along for the same reason it does on {@link appendChallengeEvents}: a resolve
* APPENDS an event too, so it crosses the size bound exactly as a challenge does (independent
* falsification review, verified — hanging the disclosure off the challenge writer alone leaves a ledger
* that crossed the bound on a resolve silent until the next challenge, which may never come).
* `undefined` when nothing was appended. */
export declare function resolveChallengeEvent(controlDir: string, entryId: string, generation: number, reason: string, now: () => number, requestId?: string): {
resolved: boolean;
eventCount: number | undefined;
};
/** The exclusion predicate's input: entry id → its LATEST unresolved challenge generation. An entry
* appears iff at least one challenge generation has no matching resolve. Throws on corruption —
* consumers refuse service (integrity unknown ≠ clean). */
export declare function challengedEntryIds(controlDir: string): Map<string, {
generation: number;
at: number;
}>;
/** Full event read (host audit / tests). Throws on corruption. */
export declare function readChallengeEvents(controlDir: string): ChallengeEvent[];
/** Which fail-closed control-plane ledger a rebuild targets. */
export type StrictControlPlaneLedger = "lineage" | "challenges";
/** What one rebuild did — the deployment's record of a taint-bearing account it chose to reset. */
export interface ControlPlaneRebuildReceipt {
ledger: StrictControlPlaneLedger;
/** The corrupt file (and its journal, when one existed) moved aside — never deleted. */
quarantinedTo: string[];
at: number;
}
/**
* design/180 §10 — rebuild a CORRUPT fail-closed ledger, explicitly.
*
* The fail-closed family refuses service on corruption precisely because a silent "start fresh" would
* LAUNDER taint (a challenged entry re-enters the prompt; a polluted session's contributions become
* untraceable). That refusal is permanent by design — which left a deployment whose disk really did
* corrupt with no way back other than deleting engine files by hand. This is that way back, and its
* whole shape is "never automatic":
* · the caller names the ledger — nothing in the engine calls this on its own;
* · a HEALTHY ledger is REFUSED (`memory.control_plane_not_corrupt`) — a rebuild is only ever the
* answer to a corruption that is already blocking every read, never a way to clear an account;
* · the corrupt bytes are QUARANTINED beside the file, never deleted: what the ledger held is
* exactly the evidence someone will want after a laundering decision;
* · the caller (the engine's host API) announces the rebuild — a reset of this account changes what
* the model may see, so it is not a silent repair.
*/
/** Lock-less probe: does this fail-closed ledger currently REFUSE reads? The rebuild's own check runs
* again under the lock (this one is for a caller that must decide something — announce, refuse — before
* taking the lock). `false` for a ledger that reads cleanly, including an absent one (genuinely empty). */
export declare function isStrictControlPlaneLedgerCorrupt(controlDir: string, ledger: StrictControlPlaneLedger): boolean;
export declare function rebuildStrictControlPlaneLedger(controlDir: string, ledger: StrictControlPlaneLedger, now: () => number): ControlPlaneRebuildReceipt;
/** One challenged-history row: how often a retrieved-then-corrected event hit this entry. */
export interface ChallengedHistoryRow {
count: number;
lastAt: number;
lastOp: "update" | "tombstone";
}
/** Record mechanical correction shadows (same-session retrieved → update/tombstone committed).
* Collect-only by contract: NOTHING in the engine reads this back into any decision (v3 candidate
* evidence only — design/178 ③-1 三级账 discipline). Throws propagate; callers treat fail-open. */
export declare function recordChallengedHistory(controlDir: string, rows: ReadonlyArray<{
entryId: string;
op: "update" | "tombstone";
}>, now: () => number): void;
/** Journal-aware read (observability/tests only — no engine consumer exists, on purpose). */
export declare function readChallengedHistory(controlDir: string): Record<string, ChallengedHistoryRow>;
export declare const PROJECTION_DEBTS_FILE = "projection-debts.json";
/** One standing debt: the plane file at `(scope, rel)` — `rel` relative to the scope's home,
* `/`-separated — belongs to committed entry `entryId`, whose id write-back has not landed; `rev`
* is the rev the projection was staged against (the CAS baseline a reconciling harvest hands its
* update). The key is the ledger form: location-free, so a home move never re-keys a row. */
export interface ProjectionDebtRow {
scope: string;
rel: string;
entryId: string;
rev: string;
at: number;
}
/** WRITE-AHEAD staging. Upsert discipline: a stage lands only when the seat has NO standing row,
* the standing row is the stager's OWN entry (a rev refresh), or the standing row is the entry the
* stager itself just judged stale (`replaces` — the same-harvest stale-then-remint lane). It never
* blindly replaces ANOTHER writer's protection: a lagging process that validated an old row before
* pausing must not overwrite the row a faster sibling staged at the same seat (its own commit then
* CAS-conflicts and its tuple-keyed clear misses the survivor — the account converges instead of
* emptying). Called before the backend transaction; the caller degrades LOUDLY (report warning)
* on a refused stage rather than refusing the harvest — the ledger is duplicate-admission
* protection, not the commit's integrity. */
export declare function stageProjectionDebts(controlDir: string, rows: ReadonlyArray<{
scope: string;
rel: string;
entryId: string;
rev: string;
replaces?: string;
}>, now: () => number): {
refused: Array<{
scope: string;
rel: string;
entryId: string;
}>;
};
/** Settle (remove) rows by ROW IDENTITY — (scope, rel, entryId), never the bare seat: the write-back
* landed, the staged claim turned out stale, or a materialize re-projected the seat. Identity
* matters: a stale-clear judged against an OLD row must not delete the NEWER row a later staging
* upserted at the same seat. Missing rows are a no-op (idempotent). */
export declare function settleProjectionDebts(controlDir: string, rows: ReadonlyArray<{
scope: string;
rel: string;
entryId: string;
}>): void;
/** Strict read (ENOENT ⇒ empty; corrupt ⇒ throws — the caller's fail-closed arm owns the refusal).
* A v1 record is converted in the ledger's lock on this first read. Rows the registry
* could not attribute stay on the record's `unresolved` seat and are not served. */
export declare function readProjectionDebts(controlDir: string): ProjectionDebtRow[];
/** The rows a key-form conversion could not attribute (audit read; empty on a converted ledger). */
export declare function unresolvedProjectionDebts(controlDir: string): Array<{
relPath: string;
entryId: string;
}>;
/**
* REF-C6 — write EVERY byte of `data` to `fd`, looping until the OS has taken all of them.
*
* A bare `writeSync(fd, data)` issues ONE write(2) and may legally SHORT-WRITE (POSIX): it returns
* how many bytes it took and silently drops the rest. `src/stores/file/fs-atomic.ts` already
* documents and fixes this defect class twice (`writeThenLink`'s "silently SHORT-WRITES" note and
* `AppendLog.append`'s RB-66 loop) — it was never propagated to the memory-engine control plane,
* where a short write would fsync+rename a TRUNCATED sidecar/entry into place while every
* fail-closed check upstream still reported success. Zero progress throws instead of spinning.
*/
export declare function writeAllSync(fd: number, data: string): void;
/** B3/H4 — TORN-WRITE-safe atomic replace (R5, ruling 2026-08-02: wording narrowed, no dirsync
* added): tmp file in the SAME directory (rename atomicity needs one fs), content fsync'd, then
* renamed over the target. A crash can leave the OLD content or the NEW content, never a torn
* file. What this does NOT promise: the rename itself surviving a power loss — the directory entry
* is not fsync'd (stores/file/fs-atomic.ts has the dir-fsync variant for stores that need it), so
* a crash in that window can resurface the old content. Sidecars tolerate that (they are rebuilt
* or re-derived); do not route data that can't through here. Throws on failure (fail-closed: a
* sidecar that cannot be written must not be silently skipped). */
export declare function atomicWriteFileSync(path: string, data: string): void;
/** Content-gated {@link atomicWriteFileSync} for a DERIVED/control-plane file: a byte-identical
* rewrite (the common case on a repeat mount over an unchanged store) is skipped entirely, so the
* file's mtime stays put. ONLY for files whose mtime carries no meaning of its own — a lock or a
* heartbeat file's mtime IS its signal and must keep calling {@link atomicWriteFileSync} directly. */
export declare function writeIfChanged(path: string, data: string): void;
export {};