UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

793 lines (792 loc) • 48.1 kB
/** * design/179 §8 / design/389 — the DURABLE partition of the permission-rule store (the OR-Set CRDT that * holds the `user` and `project` sources), its backend write face, the pure fold/join law every backend * shares, and the removal entry. * * The host-visible STORE is `permission-rule-provider.ts` (`PermissionRuleStore` — one read query over * the four source partitions). What lives here is the partition a backend implements: `list()` over one * owner's bucket, the writer handle, and the delta semantics. A backend author implements * {@link DurableRulePartitionProvider}; the engine composes it into the store with * `createPermissionRuleStoreProvider`. * * ## Two faces, deliberately unequal * * A deployment sees a READ face: `list()`, scoped to one verified principal by a factory. The write face * is a BACKEND CONTRACT (ruled 2026-08-10: exported so an out-of-repo store twin builds against the same * definitions instead of mirroring them), not a host write API — the only way a rule enters the store * through the ENGINE is the redemption path (`permission-rule-consent.ts`), which requires an approved * durable approval record; that invariant lives in the engine's wiring and is pinned by the * writer-caller registry test, not in type visibility. * `expectedRev` is concurrency control, not authorization, so "hold a put API and skip the ticket" is not * a shape that exists here rather than a rule someone must remember. * * That claim is scoped to the API. A standard in-process deployment shares filesystem credentials with the * user, so it can edit a file backend's file directly — the same trust model the settings file it mirrors * has. The checksum detects corruption, not an editor who recomputes it. This is stated, not defended * against, in v1: the file backend's trust is host = user, no more and no less. * * ## Principal scoping is a factory boundary, not a parameter * * `forPrincipal` returns a store already anchored to one identity, so `list()` has no cross-tenant * argument to pass wrong. An unauthenticated task (`principal === undefined`) resolves to ZERO rules * rather than to a shared bucket — fail-closed on a loosening face means fewer allows, never a shared one. * * ## Delete is a tombstone, and the tombstone IS the operation's identity * * Removal takes the observed-remove form: the tombstone carries every add dot the deleting replica could * see at delete time, and that observation is FIXED when the call constructs it. An OCC retry inside that * one live call re-submits the SAME tombstone — re-snapshotting would suck a concurrently landed add into * `removedDots` and swallow a legitimate re-add. The fixing is scoped to one live call on purpose: there * is no cross-process or cross-restart continuation of a delete in v1, so calling the entry again after a * restart is a NEW delete intent (a new observation, a new tombstone) — which is the correct reading of a * person pressing delete a second time. */ import type { PersistedRule, RuleAdd, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js"; import { type RuleBehavior } from "./permission-rule-model.js"; import type { RuleSyncDropReason, RuleQuarantineReason } from "./governance-codes.js"; import type { StoreDurability, StoreFidelity } from "./checkpoint-store.js"; /** One store read: the rules as persisted, their tombstones, and the revision they were read at. */ export interface StoredRules { /** Rules with their FULL add sets — tombstoned dots already removed by the backend on read. */ rules: PersistedRule[]; tombstones: RuleTombstone[]; /** Monotonic revision of this store, for optimistic concurrency on every write. */ rev: number; /** Integrity fingerprint the backend computed, when it has one. Corruption detection only. */ checksum?: string; } /** * The durable partition's read face: read-only, already anchored to a single owner. Rows carry the two * durable scopes only (`global` = the `user` source, `project` = the `project` source); a session row * never lives here (design/382 §4.3 — the durable two-member face). * * A backend that fails to read must not silently report an empty set as if it were an empty store — it * throws, and the store's caller turns that into zero rules PLUS a loud disclosure. Both outcomes deny * more than they allow; only one of them is honest about why. */ export interface DurableRulePartition { list(): Promise<StoredRules>; /** * design/182 §5.2/§8.3 — the quarantine area, for introspection. Rows a sync round moved out of the * live view (fence arm, server rejection, validator rejection): bytes kept, never silently deleted, * re-enterable only through a new consent. Optional and additive — a backend without sync has none. */ quarantined?(): Promise<QuarantinedRuleAdd[]>; /** Declared durability; an undeclared backend reads fail-closed as `"process-local"`. */ readonly durability?: StoreDurability; /** Declared serialization fidelity, same doctrine as the other store families. */ readonly fidelity?: StoreFidelity; } /** * design/182 §3.3/§4.5 — who a rule bucket belongs to, as a STRUCTURAL position. * * Identity absence is spelled as a distinct union member, never as a sentinel string flowing through * `forPrincipal` — every consumer of a principal string is an identity surface, and a made-up name on an * identity surface is indistinguishable from a real one downstream. Cloud sync and cloud tickets accept * ONLY the `principal` kind: syncing is an authenticated act, and a local-owner bucket has no cloud home * until it is adopted (`adoptFilePermissionRuleStore`). */ export type RuleOwner = { kind: "principal"; principal: string; } | { kind: "local-owner"; }; /** Do two owners name the same bucket? */ export declare function sameRuleOwner(a: RuleOwner, b: RuleOwner): boolean; /** Resolves one owner's durable partition. `undefined` ⇒ a partition that reports zero rules. This is * the BACKEND contract (file, in-memory, an out-of-repo SQL twin); the engine never consumes it * directly — `createPermissionRuleStoreProvider({ durable })` composes it into the unified store. */ export interface DurableRulePartitionProvider { forPrincipal(principal: string | undefined): DurableRulePartition; /** * design/182 §4.5 (F-011) — the identity-less LOCAL bucket, for a deployment that explicitly declared * local-owner rules. Optional and additive: a provider without it simply has no local-owner form. * A file backend resolves a FIXED file name (never a principal-hash path), and after an adoption * completed it resolves the ADOPTED principal's bucket forever — the retired bucket never revives. */ forLocalOwner?(): DurableRulePartition; } /** The outcome of one accepted write. `sync` is present only on a `sync-join` delta — the landing * report the disclosure layer reads (design/182 §8.1: quarantine/fence details ride the PutResult). */ export interface PutResult { rev: number; sync?: RuleSyncLandingReport; } /** * What one `apply` answers. `conflict` is the durable partition's compare-and-set losing; `refused` is * the SESSION partition's epoch fence speaking (design/382 §4.3, carried through the unified store's * routing writer): a `redemption-add` whose scope names a session that has ended lands nothing — the * redemption reports the member `refused`, never a silent success and never a resurrected row. A * durable backend never answers `refused` (it holds no session partition); the member exists on the * union so the ONE write path the consent protocol takes has one result vocabulary. */ export type RuleWriteOutcome = PutResult | { conflict: true; rev: number; } | { refused: "session_ended"; rev: number; }; /** * What a caller may conclude from one {@link RuleWriteOutcome} — the arms told apart ONCE, here. * * `unreadable` is the arm the union cannot spell: an `apply` answer is DATA (a foreign backend, a * deserialized reply, a twin compiled against another generation), so "it was not a conflict and not a * refusal" is not a proof that it landed. */ export type RuleWriteReading = { kind: "landed"; rev: number; sync?: RuleSyncLandingReport; /** The landing stands; its report does not. */ reportUnreadable?: string; } | { kind: "conflict"; rev: number; } | { kind: "refused"; refused: string; } /** The store ANSWERED, and nothing in the answer says what happened. Never a landing, never a * confirmed nothing: the write may have committed and lost its acknowledgement, so the reading a * caller renders from this arm is the indeterminate one and the reconciliation is `list()`. */ | { kind: "unreadable"; why: string; }; /** * A revision is a COUNT the store advances, so this is the whole of what one may be. Zero is a revision: * a session-scope add lands in a partition that has none of its own and the landing reports the durable * revision unchanged, which is `0` for a durable bucket nothing has written yet. * * Exported because the acceptance kit judges the same thing about a backend's answer, and two copies of * "what a revision is" is exactly the drift the contract-sentence discipline exists to stop. */ export declare function isRuleRevision(rev: unknown): rev is number; export declare function readRuleWriteOutcome(res: RuleWriteOutcome): RuleWriteReading; /** Authorization accompanying an add: the redemption that produced it. Carrying the record id makes the * add's logical operation identity checkable at the backend, not just at the caller. A v1 caller * carries `principal`; the local-owner path (design/182 §4.5) carries `owner` instead — never both * disagreeing, never a sentinel string. */ export interface RedemptionAuthorization { recordId: string; principal?: string; owner?: RuleOwner; } /** The add half of the write union: one redeemed approval becoming one dot on one (rule, scope). */ export interface RuleAddDelta { kind: "redemption-add"; /** The identity's behavior member (a redemption lands one dot on one (behavior, text, scope)). */ behavior: RuleBehavior; rule: string; scope: RuleScope; tool: PersistedRule["tool"]; match: PersistedRule["match"]; command: string; add: RuleAdd; redemption: RedemptionAuthorization; } /** * The TIGHTENING add — the true dual of {@link RuleDeleteDelta}, and the reason it is a separate arm * rather than a `redemption-add` with the authorization left out: a delta named for a redemption that * carries no redemption is a name that lies. This arm carries NO authorization block at all, exactly as * `tighten-delete` does, because it needs none: a host may narrow on a person's behalf, it may not * widen — so `behavior` excludes `allow` IN THE TYPE and the widening direction cannot reach this door. * Minting a standing approval still takes a person's consent record, which is what `redemption-add` is. * * The QUARANTINE fence ({@link assertRedemptionNotQuarantined}) applies to `redemption-add` only, and * not because it was forgotten here: the fence exists to stop a REPLAYED approval record from reviving * an add its dot was quarantined for. A tightening add has no record and is never replayed — its dot is * minted once, at the call — so there is no replay for the fence to catch. */ export interface RuleTightenAddDelta { kind: "tighten-add"; /** The tightening members only. `allow` is not in the type: see this interface's doc. */ behavior: Exclude<RuleBehavior, "allow">; rule: string; scope: RuleScope; tool: PersistedRule["tool"]; match: PersistedRule["match"]; command: string; add: RuleAdd; } /** The delete half: appending one tombstone, and nothing else. */ export interface RuleDeleteDelta { kind: "tighten-delete"; tombstone: RuleTombstone; } /** * design/182 §8.1 — one whole sync round landing as ONE delta. * * "Partial landing" is unspellable at the write layer: either the whole join lands or none of it does, * and replaying the same join is idempotent. The delta cannot express "remove a local row" through the * join (a join only ever unions) — that is what the explicit `quarantine` member is for, so a * server-rejected or validator-rejected LOCAL row moves out of the live view in the SAME optimistic * write as the join, never silently and never in a second racing write. */ export interface RuleSyncJoinDelta { kind: "sync-join"; /** The validated inbound state (the client's §4.3 partitioning already applied). */ inbound: RuleSyncState; /** The server's collected (already-recycled) frontier. Absent in v2.0 — the fence arms stay dormant. */ gcFrontier?: RuleSyncFrontier; /** The observation vector to persist on a CLEAN landing (absent ⇒ the vector does not advance). */ observedVector?: RuleSyncFrontier; /** * Explicit local-row quarantine instructions. Each dot must name an add present in the current raw * state (an already-quarantined or unknown dot is an idempotent no-op); an instruction may never touch * a tombstone. Quarantined rows keep their bytes, leave the live view and the sync state, and are * disclosed — they can re-enter only through a NEW consent (a new ask, a new record, a new dot). */ quarantine?: Array<{ behavior: RuleBehavior; rule: string; scope: RuleScope; dots: RuleDot[]; reason: RuleQuarantineReason; }>; } /** * What a write may say. Authorization discriminates on the DELTA SHAPE, not on a full snapshot: only the * add arm can introduce a dot, and the delete arm carries a tombstone and no adds. A backend additionally * REFUSES at runtime any delete that would introduce a new add dot — structure and runtime check together, * so "pick the delete arm and carry across an add" is neither expressible nor accepted. The `sync-join` arm's * safety does not rest on hiding the type (ruled 2026-08-10: the backend contract IS exported for * out-of-repo store twins) — it rests on every inbound record inside it passing the single validator * again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the * calling layer, and closes identically for every caller. */ export type RuleWriteDelta = RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta | RuleSyncJoinDelta; /** * The refusal every backend owes a delta whose `kind` is outside {@link RuleWriteDelta} — minted here so * the two bundled backends (and an out-of-repo twin) answer an unnamed operation with ONE sentence * instead of each guessing. * * The parameter is `never` on purpose: the only way to reach it is from a `default` arm the type system * has already proved unreachable, so the factory doubles as the compile-time fence (a fifth arm added to * the union stops satisfying `never` and reds at every backend's switch, naming the missing word). The * runtime half is not decoration — a delta arrives as DATA (a foreign caller, a replayed record, a * deserialized join), and the alternative to throwing is folding an unnamed kind as whichever arm * an if/else chain happens to end with: a write nobody defined, landed under a name nobody checked. */ export declare function unknownRuleWriteDeltaError(delta: never): Error; /** * The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the * types. The consent boundary is unchanged by the export: the ENGINE reaches a writer only through the * consent protocol's redemption (and the sync client's join) — a property of the engine's wiring — * and a deployment always owned its own storage bytes, so type visibility grants nothing new. */ export interface PermissionRuleWriter { /** Mint the next dot for this replica. Counters need only be unique and monotonic, so a dot minted for * an operation that then fails is simply never used. */ nextDot(): Promise<RuleDot>; apply(delta: RuleWriteDelta, opts: { expectedRev: number; }): Promise<RuleWriteOutcome>; /** * design/182 §4.2 — the RAW state a sync round exchanges: adds with tombstoned dots NOT pre-filtered * (list() filters; a join must not), plus the replica identity, the minted-counter high water and the * persisted observation vector. Core-private like the rest of the writer: the raw view rides the sync * client, never a host API. */ readRaw(): Promise<RawRuleSyncState>; } /** The writer's raw view of one store — everything one sync round needs to know about this replica. */ export interface RawRuleSyncState { /** This replica's actor id — the wire `replica` field. A replica identity, NEVER a person identity. */ actor: string; /** The highest counter this replica has minted — the own-actor forgery fence's local truth. */ counter: number; rev: number; rules: PersistedRule[]; tombstones: RuleTombstone[]; /** The persisted observation vector (absent = never completed a clean round). */ observedVector?: RuleSyncFrontier; quarantined?: QuarantinedRuleAdd[]; } /** * The handle a writable backend hangs its write face on (exported with the backend contract, ruled 2026-08-10; * the consent boundary lives in the engine's wiring, not in this key's visibility). */ export declare const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter"; /** A partition that also carries the backend write face. */ export interface WritableDurableRulePartition extends DurableRulePartition { readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter; } /** The writer of a store or partition, or `undefined` when the backend is read-only from the engine's * side. Duck-typed on the handle, so it answers for the unified `PermissionRuleStore` (whose writer * routes by scope) exactly as for a bare durable partition. */ export declare function writerOf(store: DurableRulePartition | import("./permission-rule-provider.js").PermissionRuleStore): PermissionRuleWriter | undefined; /** Do two scopes name the same place? Three members (design/382 §4.1): global, project-by-root, * session-by-sessionId. */ export declare function sameScope(a: RuleScope, b: RuleScope): boolean; /** Do two rows/tombstones/quarantine rows name the SAME logical rule — the (behavior, text, scope) * triple? Every identity comparison in this module goes through this one predicate: a comparison * that forgot the behavior member would let a delete aimed at an allow take the same-text deny. */ export declare function sameRuleIdentity(a: { behavior: RuleBehavior; rule: string; scope: RuleScope; }, b: { behavior: RuleBehavior; rule: string; scope: RuleScope; }): boolean; /** * Apply tombstones to a raw persisted set: an add survives iff its dot appears in no tombstone for the * same (behavior, rule, scope). The criterion is dot IDENTITY — single dots across replicas carry no order to * compare — and an add the deleting replica never observed survives (add-wins), because behind every add * there is one real human approval. */ export declare function applyTombstones(rules: readonly PersistedRule[], tombstones: readonly RuleTombstone[]): PersistedRule[]; /** Fold one delta into a raw rule set, returning the new set. Pure — the backends share it so the file and * in-memory forms cannot drift in what a delta MEANS. */ export declare function foldDelta(rules: readonly PersistedRule[], delta: RuleAddDelta | RuleTightenAddDelta): PersistedRule[]; /** Every add dot present in a rule set. */ export declare function addDotsOf(rules: readonly PersistedRule[]): RuleDot[]; /** * The runtime half of the delete-arm authorization, shared by every backend so the two cannot drift on * what a delta MEANS. * * The union already makes "pick the delete arm and also add something" unspellable in TypeScript, and a * union does not constrain a JavaScript caller's payload — so the check is executed, not merely intended. * Every backend calls this before appending a tombstone. */ export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): void; /** * design/382 §4.3 — the DURABLE-scope gate of the two direct write arms, shared by every backend * (the `assertDeleteDeltaCarriesNoAdd` doctrine: the check is executed, not merely intended, because a * union does not constrain a JavaScript caller's payload). * * A `redemption-add` whose scope — or a `tighten-delete` whose tombstone's scope — is a session scope * refuses LOUDLY with the typed code `unsupported.session_scope_store`: a session authorization's home * is the session's own overlay, and a session TOMBSTONE is equally unspellable here — session rows are * not individually deletable (they die with their session, design/382 §10), so a tombstone naming one * is either a foreign writer's fabrication or a caller wiring fault, and both deserve the loud arm. * The third arm (`sync-join`) is NOT gated here: its session rows are per-row DROPPED AND DISCLOSED by * {@link screenRuleSyncState} (a peer's damage must not veto a whole round the way a local caller's * bug must), and a session-scoped QUARANTINE instruction throws inside {@link applySyncJoin} itself. * * The session member is the LOUD, typed arm; the rest of the face is executed too. A scope that is * neither member in well-formed shape — `{kind:"project", root:""}` above all, which `pathWithinRoot` * turns into base `/` so the row crosses every project boundary — is a caller wiring fault, refused * with the untyped loud arm the sibling {@link assertDeleteDeltaCarriesNoAdd} uses (no closed-set code * names it: it is nobody's supported request). The sync arm already refuses the same shape inbound * ({@link applySyncJoin} runs {@link isValidDurableScope} on every joined row), so this closes the * matching direct-write door rather than inventing a new rule. * * Exported with the backend contract for the same OPEN-SET duty {@link isValidDurableScope} names: * any lane — host-side import doors included — that writes rows into a persisted store carries the * durable two-member face. */ export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta): void; /** * The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones. * The live view is always DERIVED (`applyTombstones`); the join below never deletes anything. * The server-side twin MUST use the same join (consume the export, or pass * `permissionRuleSyncContract` against its own reimplementation). */ export interface RuleSyncState { rules: PersistedRule[]; tombstones: RuleTombstone[]; } /** * An actor → counter vector. A dot is at-or-below a frontier ⟺ `dot.counter <= frontier[dot.actor]`. * Used both as the client's observation vector and as the server's collected-frontier watermark — * two quantities with strictly separated jobs (§5.1): only the COLLECTED watermark (monotone, * never lowered by a registering or returning replica) may drive the fence arm. * * SQL twin note (server half): persist this as a JSON blob column — the vector is read and written * whole, never queried per-actor; a row-per-actor table would invite partial updates the semantics * cannot express. Epoch-millisecond columns around it follow the `_ms` suffix convention; the OCC * counter column is named `rev` (never `version` — that word is reserved for format/schema versions). */ export type RuleSyncFrontier = Record<string, number>; /** One quarantined row: bytes preserved, out of the live view and out of sync, disclosed. * SQL twin note: `add` (dot + provenance) is one immutable value minted at approval time — persist it * as a JSON blob; `rule`/`scope`/`reason`/`atMs` are query axes and belong in structured columns * (`at_ms` on the SQL side). */ export interface QuarantinedRuleAdd { behavior: RuleBehavior; rule: string; scope: RuleScope; add: RuleAdd; reason: RuleQuarantineReason; atMs: number; } /** One dropped/quarantined disclosure line. `reason` is a closed, typed code (RULE_SYNC_DROP_CODES in * governance-codes.ts) — "silently dropped" must be machine-checkable, so free text is not a member. */ export interface RuleSyncDrop { behavior: RuleBehavior; rule: string; scope: RuleScope; dot: RuleDot; reason: RuleSyncDropReason; } /** What one landed sync-join reports back for disclosure (rides {@link PutResult}). */ export interface RuleSyncLandingReport { /** Inbound records the backend refused (the peer still holds them — no data loss on this side). */ droppedInbound: RuleSyncDrop[]; /** Local rows moved into the quarantine area by this landing (instruction, fence, or validator). */ quarantined: RuleSyncDrop[]; /** Tombstones (with their covered adds) recycled below the frontier. Hygiene, not semantics. */ collectedTombstones: number; /** Inbound adds withheld this round because an inbound tombstone was rejected (§4.3 tighten-only: * a dropped tombstone may have covered them — they re-arrive once the peer's state is clean). */ withheldAdds: number; } /** * design/382 §4.3 — the CONSENT face's scope validity: the union's full three members, their shapes * executed (a project scope with an empty root would admit every cwd, wider than global, through * `pathWithinRoot`'s base construction; an empty sessionId would be a session nobody can name). * Read by the consent protocol's entries (prepare's explicit-scope gate, the record integrity walk), * and exported for the server's consent-projection twin. NEVER by a durable entrance — that face is * {@link isValidDurableScope}, and keeping the two as separate named tables is the design's point: * a session row leaking toward the store must meet an explicit refusal at every door, not a shared * table someone widened for the other face's sake. */ export declare function isValidConsentScope(scope: RuleScope): boolean; /** * design/382 §4.3 — the DURABLE face's scope validity: the two members a persisted row may carry, * `{global, project}`. A session scope is a WELL-FORMED consent scope that is structurally not a * durable one — session authorizations live in the session's own overlay and die with it; letting one * into the store (or the sync wire, or the at-rest bytes) would give it exactly the afterlife the * dimension is defined not to have. Exported for the server's rule-sync validator (same face, same * two members) and for any host-side lane that writes rows into a persisted store — the OPEN-SET * duty (design/382 §10): every such entrance, present or future, carries this face. */ export declare function isValidDurableScope(scope: RuleScope): boolean; /** * A PROJECT scope's root is a complete absolute path — `/…`, a drive-rooted `X:\…` / `X:/…`, a UNC `\host\…` — the one * predicate every scope face reads (the consent face, the durable face, the sync wire's pick). It is what a project row's * path patterns anchor at (its `/x` and relative words), so a root that is not one (`C:`, a relative spelling, empty) * would let the row apply (containment is a prefix test) while its anchor reads nothing: such a row is refused at every * write, dropped at every read, and disclosed. */ export declare function isProjectScopeRoot(root: unknown): root is string; /** Is `dot` at or below the frontier? Absent actor ⇒ not below (a frontier never covers what it never saw). */ export declare function dotAtOrBelowFrontier(dot: RuleDot, frontier: RuleSyncFrontier): boolean; /** * design/182 §5.1 — the state vector: per actor, the max counter over every add dot, every tombstone's * `deletedBy` and every tombstone's `removedDots`. "How far has this state seen that actor mint." */ export declare function ruleSyncVector(state: RuleSyncState): RuleSyncFrontier; /** Pointwise max of two frontiers — how an observation vector advances (monotone, never down). */ export declare function joinFrontiers(a: RuleSyncFrontier, b: RuleSyncFrontier): RuleSyncFrontier; /** * design/182 §3.1 pre-normalization — re-project the denormalized `tool`/`match`/`command` fields of one * persisted rule from its `rule` TEXT (the single source of truth) through the one shared validator. * * A record whose text the validator refuses, or whose stored fields disagree with the re-projection, is * damage or forgery — it is REFUSED (fail-closed), never repaired toward either side: picking a side * would break the join's commutativity, and "take the wider" would widen a matching surface nobody * approved. Returns the normalized record or the refusal reason. */ export declare function normalizePersistedRule(r: PersistedRule): { rule: PersistedRule; } | { reject: Extract<RuleSyncDropReason, "invalid_rule_text" | "metadata_mismatch">; }; /** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why. * * design/382 §4.3 — this screen carries the SESSION member of the durable face on every JOIN leg * (inbound state, local state, both sides of the pure join): a rule or tombstone row carrying a * session scope is dropped and reported (`session_scope_not_durable`), never landed and never allowed * to veto the round the way a malformed tombstone does. Dropping a session TOMBSTONE cannot widen: * tombstones remove adds only under the SAME (rule, scope), and every session-scope add is dropped by * this same screen — the #176 "its cover is not load-bearing" argument, one scope over. * * The face's OTHER member — a durable scope that is not well-formed, `{kind:"project", root:""}` * above all — is executed on the INBOUND legs by {@link applySyncJoin} (its tombstone pre-loop and its * rule arm both run {@link isValidDurableScope}) and at the direct write arms by * {@link assertWriteDeltaScopeDurable}, not here. Stated so the boundary is readable: a LOCAL row of * that shape — reachable only by editing the at-rest bytes, or from a store written before the write * arm executed that axis — passes this screen and stays live. */ export declare function screenRuleSyncState(state: RuleSyncState): { state: RuleSyncState; rejected: RuleSyncDrop[]; }; /** * design/182 §3.1 — the pure state join. Idempotent, commutative, associative; both sides monotone * (a join only ever unions, it never deletes). The output is CANONICALLY ORDERED so equal states are * deep-equal, which is what lets the contract suite assert the three laws structurally. * * - rules: grouped by (behavior, rule, scope); a group's adds are the dot-identity union (provenance rides each * add; two payloads under ONE dot — unreachable in a healthy protocol — resolve by canonical-json * byte order, determinism only, detection belongs to the screening layer); * - tombstones: grouped by (behavior, rule, scope, deletedBy); same identity with differing `removedDots` takes * the UNION (any one-sided pick breaks commutativity and can drop an observed removal — the union is * the only convergent answer, and it only ever tightens); * - both inputs pass {@link screenRuleSyncState} first (refused records are dropped deterministically; * callers wanting the refusal DETAILS run the screen themselves — this function stays a pure join). */ export declare function joinRuleStates(a: RuleSyncState, b: RuleSyncState): RuleSyncState; /** * design/182 §5.1/§5.2 — the paired-collection arm: a tombstone is collectable ⟺ its `removedDots` are * ALL at-or-below the frontier AND its `deletedBy` is too; collection removes the tombstone together * with the adds it covers, atomically (collect only the tombstone and the next join resurrects the add; * collect only the add and the leftover tombstone is idle but harmless — so it is always the pair). */ export declare function collectBelowFrontier(state: RuleSyncState, frontier: RuleSyncFrontier): { state: RuleSyncState; collectedTombstones: number; }; /** The current raw facts a sync-join folds into — what {@link applySyncJoin} takes and returns. */ export interface RuleSyncJoinBase { actor: string; counter: number; rules: PersistedRule[]; tombstones: RuleTombstone[]; quarantined: QuarantinedRuleAdd[]; observedVector?: RuleSyncFrontier; } /** * design/182 §8.1 — the ONE landing pipeline every backend shares (the `foldDelta` doctrine: the file * and in-memory forms must not drift on what a sync-join MEANS). Pure; the backend persists the result * in a single optimistic write. Steps: * * ① screen the inbound state at the backend (the single validator's fifth door closes HERE — the * backend does not trust the calling layer): text/metadata refusals drop the inbound record; * the own-actor forgery fence (M14) drops any inbound add or tombstone claiming THIS replica's * actor with a counter above what this replica has minted; an inbound tombstone with an empty * `removedDots` is a protocol violation and THROWS (the client's tighten-only partitioning must * have withheld the round's adds — a backend landing them anyway would loosen); * ② join (unions only, never deletes); * ③ with a `gcFrontier`: paired collection, then the fence arm — a live LOCAL add at-or-below the * collected frontier and ABSENT from the inbound state is the residue of an already-recycled * tombstone: moved to quarantine (never silently dropped), disclosed; * ④ execute the explicit quarantine instructions (idempotent per dot; touching a tombstone throws); * ⑤ screen LOCAL rows too — a locally damaged row (file edit) moves to quarantine rather than * riding every future round; * ⑥ persist the observation vector the delta carries (the client only sends one on a clean round). */ export declare function applySyncJoin(cur: RuleSyncJoinBase, delta: RuleSyncJoinDelta, nowMs: number): { next: RuleSyncJoinBase; report: RuleSyncLandingReport; }; /** * The redemption-side quarantine fence, shared by every backend (design/182 §5.2 review round 2): * a quarantined row's contract is "re-entry only through a NEW consent — a new ask, a new record, a * NEW dot". Replaying an already-redeemed approval record re-submits its RECORDED dot; once that * add has been quarantined, folding the replay back into `rules` would resurrect it without anyone * consenting again (and for `below_gc_frontier`, resurrect an approval whose deletion tombstone was * already recycled). Any quarantined row sharing the delta's dot — under ANY (rule, scope) — refuses * the write loudly: dots are minted once, so a replayed dot that quarantine pinned anywhere is the * resurrection shape regardless of which tuple it claims now. */ export declare function assertRedemptionNotQuarantined(quarantined: readonly QuarantinedRuleAdd[], delta: RuleAddDelta): void; /** Integrity fingerprint over a store's own facts. Corruption detection, not tamper-proofing. */ export declare function ruleStoreChecksum(payload: unknown): Promise<string>; /** * What a removal may say about the rule identity it just tombstoned. THREE words, not a boolean, for the * same reason {@link RuleAddLanding} has `committed: "no" | "unknown"`: the answer comes from a read-back, * and a read-back can fail. `yes` and `no` are things the store said; `unknown` is the honest word for a * look that did not happen — reporting `no` there would render "we could not see it" as "it is not there", * which is the one sentence this domain refuses to say anywhere else ("we cannot see the rules" is never * "there are none"). */ export declare const REMOVAL_LIVENESS_WORDS: readonly ["yes", "no", "unknown"]; export type RemovalLiveness = (typeof REMOVAL_LIVENESS_WORDS)[number]; /** * What a terminal write FAILURE may claim about the bytes — the failure half of the honesty * {@link RemovalLiveness} keeps on the success half, spelled ONCE and read by every write entry here. * * `"no"` = the store ITSELF answered: a face that cannot write at all, a target the type face refuses, * a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that lost * every attempt. Each of those is the store saying nothing landed, so saying it is a fact. * `"unknown"` = the apply THREW, which proves nothing either way (a backend can commit and then lose * its acknowledgement), or the read-back that would have settled it could not be taken. * * The word exists because the caller's two branches — retry, or reconcile with `list()` — are not * derivable from the failure PROSE. A consumer reading the strength out of the sentence is parsing a * message written for a person, and every such reader has to be updated by hand when the sentence is. */ export declare const RULE_WRITE_COMMITTED_WORDS: readonly ["no", "unknown"]; export type RuleWriteCommitted = (typeof RULE_WRITE_COMMITTED_WORDS)[number]; /** * The result of a removal. * * `conflict` is not a member: an OCC conflict is retried internally to a terminal state, so a caller never * has to write the retry loop that must not re-snapshot. */ export type RemoveResult = /** The tombstone landed and none of the observed dots survive. `stillLive` reports whether an approval * that landed DURING this call keeps the rule alive anyway (add-wins) — a real outcome with a name, * not an anomaly — or {@link RemovalLiveness} `unknown` when the read-back that answers it could not be * taken. A consumer reading `unknown` as "removed" has re-introduced the very over-claim the third word * exists to prevent: the tombstone landed, the rule's liveness is UNOBSERVED, reconcile with `list()`. */ { status: "removed"; rev: number; stillLive: RemovalLiveness; } /** Nothing to remove — including a repeated delete. No empty tombstone is minted. `stillLive` is the * SAME read-back the `removed` arm reports, taken at the same point in the call ({@link * readRuleLiveness}): "there was nothing to remove" is a statement about the snapshot this call read, * and an approval landing after it leaves the rule standing. Both 200-shaped arms therefore carry the * word, from one source. It is a LATER OBSERVATION, not an atomic absence: `rev` is the revision the * decision was taken at, the word is what a second look saw, and the store may change again right * after — `yes` beside a `no-op` is the honest pair, not a contradiction. */ | { status: "no-op"; rev: number; stillLive: RemovalLiveness; } /** Terminal failure, in one of two strengths — word for word the dual of {@link AddResult}'s, and * `committed` is the word for it ({@link RuleWriteCommitted}), so the strength is READ, not parsed out * of the sentence. When the STORE answered every attempt (the compare-and-set simply lost), nothing * landed: `committed: "no"`, and the read-back confirms it in the text. When the apply THREW, a missing * tombstone cannot tell a write that never landed from one whose evidence a paired collection erased * (`collectBelowFrontier` recycles a tombstone together with the adds it covered), so that outcome is * `committed: "unknown"`, reported INDETERMINATE, and the caller reconciles with `list()` rather than * retrying blind. When the read-back itself failed, the word is the one the apply earned — a throw * leaves it `unknown`, an answered compare-and-set stays `no`, because an unreadable store cannot * un-answer what the store already said. */ | { status: "failed"; error: string; committed: RuleWriteCommitted; }; /** * Remove a persisted rule. * * Removal is the TIGHTENING direction, so it needs no approval-record ceremony: a host may narrow on a * user's behalf, it may not widen. The asymmetry is the point — minting takes a human's click, revoking * does not. */ export declare function removePersistedRule(opts: { /** The identity's behavior member — `removePersistedRule({ behavior: "allow", … })` for a standing * approval, `"deny"`/`"ask"` for a standing refusal/question. Required: the same text can stand in * more than one behavior, and a removal that named only the text would delete one nobody asked about. */ behavior: RuleBehavior; rule: string; scope: RuleScope; /** Whose bucket. A bare string stays the principal shorthand (unchanged callers); the structural * {@link RuleOwner} form adds the local-owner bucket (downstream request, 2026-08-10 — design/182 §4.5's * `forLocalOwner()` face existed, but removal could not name it, so a local-owner rule was * unrevokable through this entry). Same observed-remove/add-wins/stillLive semantics either way. */ principal: string | RuleOwner; provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider; }): Promise<RemoveResult>; /** * What the ONE add-write path answered. `landed` carries the revision the add committed at; every other * terminal state is a single refusal string, which each door renders in its own vocabulary (the consent * protocol says `refused`, the record-less entry says `failed`) — one mechanism, two spellings, never * two mechanisms. */ export type RuleAddLanding = { landed: true; rev: number; } /** `committed` is what the caller may CLAIM afterwards. `"no"` means the store itself answered — a * read that failed before any write, a refused preflight, an epoch seal, or a compare-and-set that * lost every attempt: each of those is the store saying nothing landed. `"unknown"` means the apply * THREW, which proves nothing either way: a backend can commit and then lose its acknowledgement. */ | { landed: false; reason: string; committed: RuleWriteCommitted; }; /** * Land ONE add — the terminal write both doors take (the consent protocol's redemption and the * record-less tightening entry below). Optimistic: every attempt re-reads the store, anchors the * compare-and-set to that revision, and retries a conflict without rebuilding the add (the dot is the * add's identity and is minted once, by the caller, so a retry can never mint a second). * * The scope routing is the writer's ({@link writerOf}): a session-scope add lands in the session * partition, everything else in the durable one — one path, so the two doors cannot disagree about * where a rule goes. */ export declare function landRuleAdd(args: { store: import("./permission-rule-provider.js").PermissionRuleStore; writer: PermissionRuleWriter; /** Either add arm, whole. The kinds are dispatched EXHAUSTIVELY below, so a third add arm cannot be * added to the union without this function being told what it means. */ delta: RuleAddDelta | RuleTightenAddDelta; /** Judged on EVERY attempt's snapshot — the very revision the write below is anchored to, so a fact * that changed in between conflicts the write and is re-judged. A returned string refuses. */ preflight?: (snapshot: import("./permission-rule-provider.js").EffectivePermissionRules) => Promise<string | undefined>; }): Promise<RuleAddLanding>; /** * The result of an add — the dual of {@link RemoveResult}, word for word where the two mean the same * thing. `conflict` is not a member for the same reason: the retry loop is inside. */ export type AddResult = /** The add landed; `dot` is its audit identity (the same value a removal's tombstone will name). * Deliberately WITHOUT a `stillLive` twin of {@link RemoveResult}: a removal's twin reports the * semilattice's own add-wins resolution, a structural outcome of the write; a fresh dot has no such * resolution to report, and a removal that lands afterwards is an ordinary later event `list()` * answers. */ { status: "added"; rev: number; dot: RuleDot; } /** An equivalent rule is already live in this bucket — the instruction is already in effect, and no * second dot is minted. Repeating the call is a no-op, exactly as repeating a delete is. */ | { status: "no-op"; rev: number; } /** Terminal failure, in one of two strengths the `error` text distinguishes. When the STORE answered * (a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that * lost every attempt) nothing landed and the text says so plainly. When the apply THREW, the write * may have committed and lost its acknowledgement: the read-back asks the raw face, where an add * survives the tombstone a concurrent removal laid on it — but a sync collection can erase even * that, so a dot it cannot find is reported INDETERMINATE rather than as a confirmed nothing, and * the caller reconciles with `list()` instead of retrying blind (a blind retry mints a fresh dot and * can reinstate a restriction somebody had just removed). `committed` ({@link RuleWriteCommitted}) is * which of the two this is, as a WORD — the landing's own answer, passed through rather than re-derived, * so this entry and the consent protocol's door cannot disagree about a write they both took. */ | { status: "failed"; error: string; committed: RuleWriteCommitted; }; /** * Add a persisted rule in ONE step, with no approval record. * * The dual of {@link removePersistedRule}, and bounded by the same asymmetry: a host may narrow on a * person's behalf, it may not widen. So this entry writes the TIGHTENING direction only — `deny` and * `ask` — and `allow` is not spellable here at all (minting a standing approval still takes a person's * consent record: prepare → confirm → redeem). A JS caller that spells it anyway meets a loud refusal. * * The provenance is MINTED HERE as `user`: this entry takes no origin argument, so a host cannot claim * a rule came from an import or a starter set. Before it existed, the only way to land a rule was to * borrow the approval-record legs, which stamped the borrowed record's kind onto a write nobody had * approved — a second, unverified story about where a rule came from. */ export declare function addPersistedRule(opts: { /** The identity's behavior member. `allow` is deliberately not in the type — see the entry's doc. */ behavior: Exclude<RuleBehavior, "allow">; rule: string; scope: RuleScope; /** Whose bucket — the same two spellings {@link removePersistedRule} takes: a bare string is the * principal shorthand, the structural {@link RuleOwner} adds the local-owner bucket. */ principal: string | RuleOwner; provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider; }): Promise<AddResult>; export declare function errText(err: unknown): string; /** A durable partition that always reports zero rules — what an unauthenticated principal resolves to. */ export declare const EMPTY_DURABLE_RULE_PARTITION: DurableRulePartition; /** * In-memory backend — the reference implementation of the delta semantics, and the one the tests drive. * `process-local` by declaration: it makes no durability promise it cannot keep. */ export declare class InMemoryDurableRulePartition implements WritableDurableRulePartition { private readonly actor; private readonly now; readonly durability: StoreDurability; readonly fidelity: StoreFidelity; private rules; private tombstones; private quarantinedRows; private observedVector; private rev; private counter; constructor(actor?: string, now?: () => number); list(): Promise<StoredRules>; quarantined(): Promise<QuarantinedRuleAdd[]>; readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter; }