@sema-agent/core
Version:
Stateless, task-oriented AI agent core
247 lines (246 loc) • 15.4 kB
TypeScript
/**
* 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, type 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;
/** What a directory vetting reads from its host. Absent ⇒ this process's platform, and a skipped judgment goes unsaid. */
interface DirVetting {
/** The platform whose permission bits are read (default: this process's — see {@link posixModeBitsMeaningful}). */
readonly platform?: NodeJS.Platform;
/** Said when the group/other-writable judgment was NOT made because the platform reports no permission bits. */
readonly modeBitsUnjudged?: (dir: string) => void;
}
/** One principal's rule file. */
declare class FileDurableRulePartition implements WritableDurableRulePartition {
private readonly dir;
private readonly file;
private readonly acquireWriteLock;
private readonly onError?;
private readonly vetting?;
readonly durability: StoreDurability;
readonly fidelity: StoreFidelity;
constructor(dir: string, file: string, acquireWriteLock: () => void, onError?: ((message: string) => void) | undefined, vetting?: DirVetting | 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;
/** What every directory vetting of this instance reads; it says an unjudged mode check ONCE per instance. */
private readonly vetting;
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 platform whose permission bits the directory vetting reads. Absent ⇒ this process's platform. */
platform?: NodeJS.Platform;
});
/** 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 };