@sema-agent/core
Version:
Stateless, task-oriented AI agent core
785 lines (784 loc) • 47.5 kB
TypeScript
/**
* 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;
/** 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;
}