UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

235 lines (234 loc) • 14.5 kB
/** * design/179 §5/§8 / design/389 — the file backend of the permission-rule store's DURABLE partition (one * bucket file per owner), plus the file home of the ORG partition's durable state (`org.json`). * * ## Schema 4 only * * Every row carries its `behavior`; the org state lives in one `org.json`, never inside a bucket. The * reader accepts schema 4 ONLY: any other schema word is refused whole, loudly, bytes left untouched — * this engine performs no migration of an older engine's buckets. * * ## Why this store builds its own symlink refusal * * The other file stores canonicalize with `realpath`, which RESOLVES a symlink rather than refusing one — * the right answer for a data directory, the wrong one for a file that decides what runs without asking. * So the refusal is assembled here from the pieces this repository already uses elsewhere: the directory * is `lstat`ed and refused if it is a symlink, not a directory, or owned by another user; the temp file is * opened `O_EXCL | O_NOFOLLOW` so no planted link is ever traversed; the publish is the standard * write-temp → fsync → rename → fsync-dir sequence; and the READ side `lstat`s too, because `readFileSync` * follows a link happily. * * ## One writer, enforced — reading is unrestricted * * Atomic rename keeps a file from tearing; it does not make "read the revision, decide, write" atomic. Two * processes can each read revision N, and the second rename discards the first one's add — or its * TOMBSTONE, leaving a rule somebody deleted standing. * * Three attempts at solving that inside the file established what does NOT work. Declaring single-process * use does not hold when the provider is independently constructible. A hand-rolled advisory lock brings * its own ownership bugs: pruning an abandoned lock races the next holder for the same path, and expiring * by age kills a live-but-slow writer. Write-then-read-back does not close it either — two writers can * each rename and each verify before the other renames, so both report success and one write is gone. * That last one is the instructive failure: no scheme that reasons only about the FILE can serialize * transactions, because the window opens before the first byte is written. * * So the boundary is enforced where it can be. Obtaining the WRITE face acquires the same process-level * lock the rest of this file-store family uses — write-then-link acquisition, no expiry by age, and a * prune only for a definitively dead owner — and a second holder, in this process or another, is refused * loudly instead of interleaving. Inside the one process that holds it, transactions are serialized and * the existing revision check is then a true compare-and-set. * * READING takes nothing. The gate reads on every shell call and must never be able to fail on a lock, so * the lock rides on the write face alone. A deployment that genuinely needs concurrent writers wants a * backend with real atomic compare-and-set, which is what the store seam exists for. * * ## Corruption is a whole-file refusal * * A file that does not parse, or whose checksum does not match its contents, yields ZERO rules and a loud * disclosure — never a partial load. Half a rule set is a set nobody reviewed. Note what the checksum is * and is not: it detects corruption, and it does not detect an editor, because an editor recomputes it. * For this backend the trust is host = user, exactly as for the settings file it mirrors. */ import type { PersistedRule, RuleTombstone } from "../../core/permission-rule-model.js"; import type { DurableRulePartition, DurableRulePartitionProvider, PermissionRuleWriter, QuarantinedRuleAdd, RuleOwner, RuleSyncFrontier, StoredRules, WritableDurableRulePartition } from "../../core/permission-rule-store.js"; import { PERMISSION_RULE_WRITER } from "../../core/permission-rule-store.js"; import type { OrgRuleStatePersistence, PersistedOrgRuleState } from "../../core/permission-rule-org.js"; import type { StoreDurability, StoreFidelity } from "../../core/checkpoint-store.js"; /** * The on-disk shape of one bucket. `schemaVersion` is the only field a future reader may rely on before * validating. Schema 4: the five core fields plus the OPTIONAL `sync` block (observation vector + round * timestamp) and `quarantined` area (rows moved out of the live view, bytes preserved); every rule, * tombstone and quarantine row carries its `behavior`. The org state is NOT here — see {@link OrgStateFile}. */ interface RuleFile { schemaVersion: 4; actor: string; counter: number; rev: number; rules: PersistedRule[]; tombstones: RuleTombstone[]; sync?: { observedVector?: RuleSyncFrontier; lastRoundAtMs?: number; }; quarantined?: QuarantinedRuleAdd[]; checksum: string; } /** The deployment-wide org state file: last-known-good snapshot + anti-rollback high-water, one blob. */ interface OrgStateFile { schemaVersion: 1; state: PersistedOrgRuleState; checksum: string; } /** The org partition's durable home in a rule directory. */ export declare const ORG_STATE_FILE = "org.json"; /** The anti-rollback contract's DESIGNED refusal (a lower high-water mark, or different content under * the same revision) — distinguished by class from every other install failure, because the two are * handled in opposite directions by the migration: a refusal means the block is superseded and may be * dropped; anything else means the block has no second home yet and the legacy copy must be kept. */ export declare class OrgRevisionRefusal extends Error { } /** The local-owner bucket's FIXED file name — never a principal-hash path (identity absence is a * structural position, not a name; design/182 §4.5). Cannot collide with `forPrincipal` names, which * are 64 hex characters. */ declare const LOCAL_OWNER_FILE = "local-owner.json"; /** The durable adoption marker (design/182 §4.5 — the recoverable six-phase state machine's truth). */ export type RuleAdoptionFile = { schemaVersion: 1; marker: { from: RuleOwner; toPrincipal: string; phase: 2 | 3 | 4 | 5; }; } | { schemaVersion: 1; adopted: { from: RuleOwner; toPrincipal: string; atMs: number; }; }; /** The ADOPTION voice of {@link judgeRuleAdoptionFile}: the adoption helper's reader is the operator who repairs the * marker, so it names the directory. A corrupt marker THROWS. */ export declare function readRuleAdoptionFile(dir: string): RuleAdoptionFile | undefined; /** Atomically publish the adoption marker (same write-temp → fsync → rename discipline as the store). */ export declare function writeRuleAdoptionFile(dir: string, content: RuleAdoptionFile): void; /** One principal's rule file. */ declare class FileDurableRulePartition implements WritableDurableRulePartition { private readonly dir; private readonly file; private readonly acquireWriteLock; private readonly onError?; readonly durability: StoreDurability; readonly fidelity: StoreFidelity; constructor(dir: string, file: string, acquireWriteLock: () => void, onError?: ((message: string) => void) | undefined); /** Read and validate. Any refusal answers with an empty set AND says so — never a silent empty store. The answer's * sentence names no location ({@link refusalText}); the disclosure that accompanies it does. */ private read; private disclose; /** * design/382 §4.3 — the AT-REST half of the durable two-member scope face: rows carrying a session * scope, read out of persisted bytes (a hand edit or a foreign writer — the engine's own write arms * refuse them loudly), are DROPPED from every read face and DISCLOSED. Never quarantined (the * quarantine area is itself durable), never silently ridden: a session authorization must not gain * an afterlife by being planted where the store cannot legally hold it. The write base * (`current()`) is deliberately NOT screened — foreign bytes are left in place for a person to look * at, exactly like every other damaged-row posture here; they simply never reach a live view, the * sync wire (`readRaw`) or adjudication. */ private screenDurableRows; private write; list(): Promise<StoredRules>; /** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`. Its posture * on an unreadable bucket is `list()`'s, for the reason `list()` has it: "the quarantine is empty" and * "the bucket cannot be read" are different facts about rows a person is meant to go and look at, and a * face that answers the first about the second is the one shape this store never renders. An ABSENT * bucket is still an empty quarantine. */ quarantined(): Promise<QuarantinedRuleAdd[]>; /** * The current file, for a WRITE. * * Only genuine absence initializes a fresh one. An unreadable file — bad JSON, a failed checksum, an * unexpected shape, a planted symlink — makes every write throw, and the original bytes are left exactly * where they are. Treating corruption as emptiness here is what turns a recoverable incident into an * unrecoverable one: the next redemption would write a fresh file over the damaged one, discarding every * standing rule, every tombstone, the revision and the replica identity, along with the evidence of what * went wrong. Reads already answer zero rules for the same condition, so the lane stays fail-closed * while a person looks at the file. */ private current; /** * Publish `next` and confirm it is readable and is the version just written. * * This is an integrity check, not concurrency control — a write lock is what makes the transaction * atomic (see the module doc). What it catches is the write that did not survive its own round trip: * a filesystem that reordered, a partial device failure, or a hand edit landing at exactly the wrong * moment. Failing here is loud rather than a reported conflict, because under the lock there is no * legitimate writer to have lost a race to. */ private writeAndVerify; /** Serializes this process's transactions. With the write lock held, this is the whole of the * serialization the revision check needs. */ private writeChain; private serialize; /** Acquiring the write face takes the process lock; reading never does. */ get [PERMISSION_RULE_WRITER](): PermissionRuleWriter; private readonly writer; } /** * The file-backed provider. One file per principal, named by a digest of the principal so no identity * string ever becomes a path component. * * An absent principal resolves to a store that reads zero rules and refuses to write — an unauthenticated * task does not share anyone's bucket. * * ROOT FREEZE (design/183 invariant I6, backlog #136⑤): every sibling file store gates construction on * the DATA ROOT's adoption marker, but this provider is built over the RULE BUCKET directory, which is * a caller-chosen path that need not sit under (or equal) the data root — so `dir` is not a usable * anchor for that gate and the bucket was writable straight through a root freeze. The anchor is * therefore taken EXPLICITLY, via `opts.dataRoot`: supply it whenever the rule bucket belongs to a data * root that `adoptLocalDataRoot` may adopt, and construction is refused while that root is mid-adoption * exactly like `FileStorageBackend`'s. Omitting it keeps the previous behavior (no root gate) — which is * also the EXEMPTION the adoption machine itself rides: its nested design/182 rule leg constructs this * provider without a `dataRoot` precisely because it runs while the marker is in flight, and the * bucket-level marker (read in `forPrincipal`/`forLocalOwner`) is what governs writes inside that arc. */ export declare class FileDurableRulePartitionProvider implements DurableRulePartitionProvider { private readonly dir; private readonly onError?; private lock; private orgPersistence; constructor(dir: string, onError?: ((message: string) => void) | undefined, opts?: { /** The data root whose adoption marker gates construction (design/183 I6). Absent ⇒ no root gate. */ dataRoot?: string; }); /** The org partition's durable home in this directory (design/182 §7.4 contract; `org.json`). Shares * this provider's writer lock — an install is a write. */ /** {@link judgeRuleAdoptionFile} at request time (owner resolution runs on every read), in {@link refusalText}'s voice. */ private adoptionMarker; orgStatePersistence(): OrgRuleStatePersistence; private fileOrgPersistence; /** Take the writer lock, once, on first use of a write face. Reading never calls this. */ private acquireWriteLock; forPrincipal(principal: string | undefined): DurableRulePartition; /** The read-only resolution of an adoption SOURCE: post-flip (phase ≥ 4 / terminal) always the * target; pre-flip, whichever path currently holds the bytes (source first — it is the pre-flip * truth when both somehow exist, which the resume path refuses loudly anyway). */ private readOnlySourceView; /** * design/182 §4.5 (F-011) — the identity-less LOCAL bucket: a fixed file name, never a principal-hash * path. Once an adoption reached phase 4 (or its permanent terminal record), local-owner resolution * points at the adopted principal's bucket FOREVER — the marker is the truth the host configuration * reads, not the other way round, so a crash between marker and host-config change cannot fork writes * into a retired bucket. A corrupt marker throws (fail-closed) rather than guessing an owner. */ forLocalOwner(): DurableRulePartition; /** Release the writer lock. A long-lived deployment holds it for its lifetime; a test or a short-lived * tool releases so the next holder is not told a live process owns the directory. */ dispose(): void; } /** The hash-derived file name of one principal's bucket — shared with the adoption helper so the rename * and the resolver can never disagree about where a principal lives. */ export declare function principalRuleFileName(principal: string): string; export { LOCAL_OWNER_FILE }; export type { FileDurableRulePartition, RuleFile, OrgStateFile };