@sema-agent/core
Version:
Stateless, task-oriented AI agent core
678 lines (677 loc) • 41.2 kB
TypeScript
/**
* design/179 §4/§7/§13 — the ONE consent protocol every rule takes to enter the store through the engine.
*
* Single card, settings import and starter batch all reduce to the same three steps:
*
* durable approval record (pending) → authenticated confirmation transfer (approved) → principal-bound redemption (redeemed)
*
* and only the third step reaches the core-private writer. A host holding a record id cannot redeem it
* while it is pending, cannot redeem another principal's record, and cannot redeem free text — the
* redemption face takes a ticket and nothing else, so there is no field to write a rule of one's own into.
*
* The record is DURABLE, not an in-memory ticket: a safety ask can park, and the approval that redeems it
* may arrive in another process at another time. An in-memory ticket loses exactly that case.
*
* ## Crash order, and why replay is safe
*
* The record moves to `redeemed` — carrying the dot it minted — BEFORE the writer applies. A crash in
* between leaves a redeemed record whose rule is not in the store; replaying the redemption re-applies the
* SAME delta with the SAME dot, which the store folds idempotently. One redemption mints exactly one dot,
* for as many replays as it takes. Deliberately re-adding a deleted rule is a different act: a new ask, a
* new record, a new dot.
*
* ## The boundary this does NOT claim
*
* The confirmation transfer is an in-process call. A deployment must reach it only from its authenticated
* approval channel, and a deployment that shares filesystem credentials with the user can circumvent all of
* this by editing a file backend's file. That is the settings-file trust model, stated rather than
* defended against: for a file backend, host = user, no more and no less.
*/
import { type EditedRuleBreadthWarning, type UncoveredSegmentDetail, type RuleBehavior, type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
import type { RuleOwner } from "./permission-rule-store.js";
import { type PermissionRuleStoreProvider } from "./permission-rule-provider.js";
/** One candidate rule inside an approval record: what it says (`behavior`), the exact text and where it
* would apply. A card mints `allow` candidates only (a card is one person's yes); the settings import
* carries all three behaviors, each entry under the list it was read from. */
export interface RuleCandidate {
behavior: RuleBehavior;
rule: string;
scope: RuleScope;
}
/** What the approval record is for — it decides the provenance the minted adds carry. */
export type RuleApprovalKind = "card" | "import" | "starter";
/**
* design/375 §4.1 — one "don't ask again" OPTION as the record stores it: references into the
* record's `candidates` by index, never rule text of its own. The display triple (rule, match,
* command) is deliberately NOT here — it is derived at projection time from the referenced
* candidate's text ({@link ruleOffersOfRecord}), so there is no stored copy to drift from the rule
* that would actually persist.
*
* · `kind: "single"` — one candidate covering the whole command.
* · `kind: "batch"` — a CONJUNCTION batch: confirming it is one yes to ALL of `candidates` at
* once, with no per-member sub-selection. On a CARD record the batch carries two MINT-TIME
* facts that cannot be re-derived from candidate texts and therefore must be stored:
* `segments` (each member's source segment, index-aligned with `candidates`),
* `uncoveredSegments` (the honest surplus count the wire offer disclosed, judged on the
* prepare-time coverage snapshot) and — since `schema: 3` (design/382 §3.5) — `uncoveredDetail`,
* one reason row per counted segment in segment order: WHY each stayed uncovered is a mint-time
* fact that cannot be re-derived from candidate texts, so it must be stored or a crash/reload
* rebuild would render a card that says less than the first render did (record-is-what-you-see).
* REQUIRED on a card batch, all three: "optional, validated when present" is the
* silent-absence-passes shape this record form already refused once, and it is not re-imported
* for the new seat. On an import/starter record there is no compound command
* and no coverage snapshot, so all three fields must be ABSENT — honest absence, never an
* empty-string stand-in. The read-side integrity checks refuse either direction's violation.
*
* Cardinality: a batch references 1..N candidates where N is the record's own candidate count.
* The wire offer's 1..5 is the CARD-lane minting cap — a card record inherits it by being minted
* there — while an import batch of twenty is a legal record; the two contracts are deliberately
* separate and neither speaks in the other's name.
*/
export type RuleOffer2 = {
readonly kind: "single";
readonly candidate: number;
} | {
readonly kind: "batch";
readonly candidates: readonly number[];
readonly segments?: readonly string[];
readonly uncoveredSegments?: number;
readonly uncoveredDetail?: readonly UncoveredSegmentDetail[];
};
/** A durable record of one pending/settled human decision about minting rules.
*
* Identity (design/182 §4.5, additive): a v1 record carries `principal` and nothing else — it stays
* valid forever and projects at runtime to a principal-kind {@link RuleOwner} (zero migration; pending
* and redeemed-but-unlanded records redeem across the upgrade unchanged). Only the local-owner path
* mints records with the `owner` field and NO principal. A record carrying both, disagreeing, is
* contradictory and unredeemable. */
export interface RuleApprovalRecord {
id: string;
principal?: string;
/** Structural owner (additive). Absent on every record a v1 tree minted. */
owner?: RuleOwner;
/**
* design/375 §4.5 — the record-form version stamp. A stored row WITHOUT it — or carrying an older
* number — predates this form; such a row confirms and redeems nothing (refused loudly,
* `record_schema_stale`) and is never read through a compatibility shim: pending approvals are
* short-lived, and re-triggering the command draws a fresh card. Stores hand pre-form rows back
* as {@link StaleRuleApprovalRecord}. `3` (design/382 §3.5, B8/B7): the card batch's
* `uncoveredDetail` seat became REQUIRED and `edited.warnings` became storable — a `schema: 2`
* row is read back stale exactly like the pre-stamp rows before it (the short-lived-pending
* bargain, unchanged). `4`: every candidate carries its `behavior` (the settings import now carries
* the deny/ask lists too) — a `schema: 3` row is stale by the same equality gate.
*/
schema: 4;
kind: RuleApprovalKind;
state: "pending" | "approved" | "redeemed";
candidates: RuleCandidate[];
/**
* The card's OPTION structure over `candidates` (design/375 §4.1). The flat candidate list stays
* the redemption/replay anchor — tickets, `redeemedDots` and the store writes all key on
* candidate indices, unchanged — while confirmation keys on OFFER indices into this array.
* Import/starter records are the whole-list normalization: one batch offer over every candidate,
* so all three entries speak one protocol. Offers are pairwise DISJOINT in membership (an
* unchosen offer's members must stay unredeemable, which overlapping offers could not
* guarantee); the mint sites satisfy that by construction and the read side refuses violations.
*/
offers: RuleOffer2[];
createdAt: string;
/** The ask this record was drawn from, for reconciliation. Advisory metadata; never adjudication input.
* `boundInputHash` is ALSO the card-edit binding anchor: an edited-candidate confirmation must echo
* it back, so a record minted without one refuses edits (there is nothing to bind the edit to). */
toolCallId?: string;
boundInputHash?: string;
/**
* The adjudicated command a CARD record was drawn for — the same post-rewrite bytes the ask carried
* (the form `boundInputHash` digests). Additive: absent on batch records and on every card a prior
* version minted. It is the edit gate's coverage input ("the edited rule must still admit THIS
* command"), so a record without one refuses edited candidates rather than guessing; the engine-
* candidate paths never read it.
*/
command?: string;
/** Monotonic revision of THIS record, bumped by every accepted transition. The compare-and-set key:
* comparing state alone cannot separate two different writes that both leave the state unchanged. */
rev: number;
/**
* Which OFFER the person actually chose, recorded by the confirmation transfer (design/375 §4.3;
* takes the seat the per-candidate selection held before offers existed).
*
* A card offers alternatives of different breadth and a human picks ONE. Without this, confirming
* the card would authorize every option on it: a host could redeem the narrow ticket, then later
* the wider one, and the second rule would exist without anyone ever having agreed to it.
* Choosing a BATCH offer is a conjunction — one yes to ALL of its member candidates at once —
* so every member becomes redeemable and every non-member stays unredeemable forever. Absent
* exactly while the record is pending.
*/
selectedOffer?: number;
/**
* The person-EDITED candidate this record carries, if any — full provenance for the one candidate
* whose text was authored at the card rather than derived by the engine. `index` names the appended
* row in `candidates` (whose `rule` holds the CANONICAL spelling); `text` keeps the raw input bytes
* exactly as submitted (the idempotency primary key — a client retrying a lost response resends the
* same bytes); `at` is when the edit landed. Present ⇒ the record's chosen offer is the single
* offer minted over this candidate (the append, the offer and the choice are one CAS write).
* Absent on every card settled through an engine candidate.
*
* `warnings` (design/382 §3.3-5) — the BREADTH warning codes the text gates raised on this edit,
* written in the same CAS that settles it: "the width was named before the yes" becomes a fact the
* record itself carries, auditable after any crash or upgrade. Optional; present only when a
* warning was raised, and when present it is an array from the closed code set (the read-side
* integrity gate refuses anything else). Codes only — the sentences are derivable from the
* canonical rule text and are deliberately not stored (no second copy to drift).
*/
edited?: {
index: number;
text: string;
at: string;
warnings?: readonly EditedRuleBreadthWarning["code"][];
};
/** Dots already minted for this record, keyed by candidate index — the replay anchor. */
redeemedDots?: Record<number, RuleDot>;
}
/**
* design/375 §4.5 — the minimal ENVELOPE a store hands back for a durable row that predates the
* CURRENT (`schema: 4`) record form. A typed read cannot honestly return such a row as a
* {@link RuleApprovalRecord}, and dropping it at the store layer would misreport "stale" as
* corruption — so the store contract is this variant instead: `staleSchema: true` plus the
* IDENTITY fields, which are spelled the same way in every version and therefore safe to read.
* The identity fields exist so the owner gate can still run, and it runs FIRST: a non-owner
* asking about a stale row is answered exactly like a non-owner asking about any row (existence
* is never confirmed); the stale disclosure is for the record's own owner only. Nothing else
* about the row is surfaced.
*/
export interface StaleRuleApprovalRecord {
readonly staleSchema: true;
readonly id: string;
readonly principal?: string;
readonly owner?: RuleOwner;
}
/**
* The durable home of approval records. A separate record type on purpose: it is not a checkpoint row and
* must not evolve with one.
*/
export interface RuleApprovalRecordStore {
/** A row whose `schema` is missing or unknown MUST come back as {@link StaleRuleApprovalRecord}
* (identity fields only) — never thrown away as a parse failure, never widened into a full
* record the current form cannot vouch for. */
get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
/**
* Compare-and-set on the record's REVISION, not on its state.
*
* State alone is not a compare-and-set key: a batch's second candidate transitions redeemed → redeemed,
* so two concurrent retries would both find the state they expected, both mint a dot, and both write —
* one silently overwriting the other's `redeemedDots`, which is how one approval becomes two adds and a
* deleted rule comes back to life. `next.rev` must be `expectRev + 1`.
*/
cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
create(record: RuleApprovalRecord): Promise<void>;
}
/** Everything the consent lane needs wired. */
export interface RuleConsentDeps {
provider: PermissionRuleStoreProvider;
approvals: RuleApprovalRecordStore;
/** Injectable clock/id for deterministic tests; defaults are the real ones. */
now?: () => Date;
newId?: () => string;
/**
* Deployment lever for the card-edit face: whether `confirmRuleApproval` accepts a FRESH
* `editedCandidate`. Absent or `false` = OFF (the default — the edit face widens what a fabricated
* confirmation could mint, from "one of the engine's bounded candidates" to "any same-head rule
* passing the coverage gate", so it is opt-in). Any other non-boolean value is a configuration
* mistake and refuses LOUDLY at the read — never silently mapped to a default. Replaying an edit a
* record already settled is a pure record read and does not consult this switch: the minting already
* happened, and withholding the receipt helps no one.
*/
cardEdits?: boolean;
/**
* design/382 §2.4-6 — the deployment's sensitive-read negative control for the directoryRead MINT
* floor, same-source with its compiled read-deny judge (`(d) => readDeny.matchPath(d) !== null` is
* the canonical wiring). Consulted only when a card would PROPOSE a directory authorization from a
* cd segment — never by the explicit-consent doors (hand-written, import), which carry their own
* preview and validation. Absent ⇒ the engine's built-in default deny table stands in (refusal-only).
*/
deniesDirectoryRead?: (directory: string) => boolean;
}
/** In-memory approval records — the test backend and the reference CAS semantics, the stale-row
* envelope read included. */
export declare class InMemoryRuleApprovalRecordStore implements RuleApprovalRecordStore {
private readonly rows;
get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
create(record: RuleApprovalRecord): Promise<void>;
cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
/** Test/inspection helper — how many records exist. The principal negative pins read this. */
size(): number;
}
/**
* design/375 §4.1 — project a record's stored offers into the wire {@link RuleOffer} shape: the
* AUTHORITATIVE card render source (record-is-what-you-see; the ask-time projection is advisory
* and may be coverage-narrower). The display triple (rule, match, command) is derived HERE, at
* projection time, by parsing each referenced candidate's stored text — never stored beside it,
* never accepted from a caller — so what a card shows is definitionally the rule that would
* persist; there is no second copy to corrupt. A batch offer's `segment`s and
* `uncoveredSegments` come from the mint-time facts the record carries.
*
* Card records only, in effect: an import/starter record's whole-list batch carries no segment
* metadata (its person-facing form is the import/starter PREVIEW, not an offer card), and asking
* for its card projection throws. A candidate text that no longer parses throws too — that is
* store damage on a row the validator once accepted, and rendering a wrong rule under a "don't
* ask again" label is the one failure a refusal always beats.
*/
export declare function ruleOffersOfRecord(rec: Pick<RuleApprovalRecord, "candidates" | "offers">): readonly RuleOffer[];
/** An opaque, single-purpose redemption ticket. Its only content is which record and which candidate. */
export type RuleTicket = string;
export declare function mintRuleTicket(recordId: string, candidateIndex: number): RuleTicket;
/**
* Create the pending record behind ONE approval card, and the tickets its options would redeem.
*
* The candidates are DERIVED here, from the call, by the same function that produced the options shown on
* the card — they are not an argument. That is the difference between the stated invariant and a wish:
* while this took a candidate list, a surface could display an approval for `git status` and build a
* record containing `Bash(rm:*)`, and every later step would faithfully honor the record. There is now no
* field in which to write a rule of one's own, on this entry as on the redemption entry.
*
* `toolCallId` and `boundInputHash` are recorded so an auditor can tie the record back to the exact ask,
* and a command the lane cannot speak for yields no record and no tickets at all.
*
* What this does NOT claim: that the command named here is the command that was displayed. A surface that
* shows one thing and says another is the same-layer trust §5 already states plainly — in an in-process
* deployment the host IS the user. What it closes is the gap where the rule TEXT never came from the
* engine at all.
*/
export declare function prepareCardApproval(opts: {
principal?: string;
/** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
owner?: RuleOwner;
/** The tool whose ask this card belongs to. Only the shell tool the rule lane speaks for and the name tools (the skill-activation
* card) are accepted. */
toolName: string;
/** The command as adjudicated — the same bytes the ask carried; for a name tool (`Skill`), the name its call carried (`args.skill`). */
command: string;
/** The ask's call id and argument digest, recorded for reconciliation. */
toolCallId?: string;
boundInputHash?: string;
/**
* Where a redeemed rule would apply — any of the three consent dimensions (design/382 §4.1).
*
* design/382 §4.4 (#490②, BREAKING B2) — the DEFAULT is no longer global:
* · present ⇒ used as given (the explicit channel; all three members legal, garbage refused loudly);
* · absent with `cwd` present ⇒ `{ kind: "project", root: cwd }`. Root = the adjudicated call's
* cwd is a deliberately NARROW default — the engine is a library and repository semantics are
* host business; a host wanting the true project root passes an explicit scope. The narrow cost
* is more asks (a nested-directory rule does not cover siblings), the safe direction;
* · both absent ⇒ a LOUD refusal (`config.missing_scope`): silent-global was the #490② defect,
* silent-project has no root to anchor, and "where does this consent land" is not guessable.
*/
scope?: RuleScope;
/**
* design/382 §4.3 — the SESSION IDENTITY of the adjudicated call, threaded by the caller from the
* original call context exactly like `cwd` (never inferred from the process). It is the coverage
* read's third eligibility axis: with it, a standing session rule of that session counts as
* coverage; without it, no session rule covers anything (fail-closed). Independent of `scope` on
* purpose — `scope` names where a NEW consent would land, this names where the CALL is running.
*/
sessionId?: string;
/**
* The working directory of the ADJUDICATED CALL — the same task root the gate's lane judged
* with — threaded by the caller from the original call context. Never inferred from `scope`
* (a rule's landing scope is not where the call ran; nested project roots make the two
* coverage answers diverge) and never from the process. Absent, a project-scope rule covers
* nothing (the shared predicate's fail-closed arm), so segments lean UNCOVERED and the batch
* leans wide — the over-offer direction, which redemption's per-rule validation keeps safe.
*/
cwd?: string;
/**
* design/382 §2.4 (adversarial-review r2) — the EXECUTION working directory of the adjudicated
* call: the live tracked cwd the shell will resolve a relative `cd ./x` against, threaded by the
* caller exactly like `cwd` (the runner's ask-projection seat threads its `handsCwdRef.current`;
* a host preparing cards for its own calls threads whatever base those calls execute under).
* ONLY the relative-cd resolution reads it; scope eligibility and the derived project scope stay
* on `cwd`. Absent ⇒ `cwd` is the base (the single-value deployment, where the two coincide).
* Present and DIFFERENT, it is what keeps this durable consent path and the advisory ask-time
* projection minting the SAME directory member — two faces reading one world.
*/
execCwd?: string;
deps: RuleConsentDeps;
}): Promise<{
approvalId: string;
offers: readonly RuleOffer[];
tickets: RuleTicket[];
candidates: RuleCandidate[];
} | undefined>;
/**
* The authenticated confirmation transfer: pending → approved.
*
* A deployment must reach this ONLY from the channel that actually carries a human decision — the same
* channel an approval card's yes travels. Nothing below this line can tell an authentic confirmation from
* a fabricated one, which is why the boundary is stated here rather than checked here.
*/
export declare function confirmRuleApproval(opts: {
approvalId: string;
principal?: string;
/** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
owner?: RuleOwner;
/**
* The index of the OFFER the person chose — into the record's `offers`, the same order
* `prepareCardApproval` returned them (design/375 §4.3). A card presents alternatives of
* different breadth, so "they said yes" is not an answer on its own — "they said yes to THIS
* one" is. A single offer authorizes its one candidate; a BATCH offer is a conjunction, one yes
* to ALL of its member candidates at once, with no per-member sub-selection. May be omitted
* exactly when the record carries ONE offer (a single-element choice space has no ambiguity —
* the import/starter whole-list batch lands here); a multi-offer record with no selection
* refuses `selection_missing`. Mutually exclusive with `editedCandidate`.
*/
selectedOffer?: number;
/**
* The person-EDITED rule text for this card, travelling on the SAME authenticated confirmation
* channel as a choice among the engine's candidates (never the un-authenticated prepare entry, which
* keeps refusing caller candidates). `text` is the rule as authored; `boundInputHash` must echo the
* record's own bound-input digest — the submitter's proof of "I am editing the card that showed THIS
* command", an in-process mis-binding fence (a caller holding only a leaked approvalId cannot spell
* it), not a cryptographic one. The engine validates the text through the one shared validator,
* requires it to still ADMIT the adjudicated command, appends it as a new candidate and binds the
* selection to it, returning the minted ticket. Mutually exclusive with `selectedOffer`.
*/
editedCandidate?: {
text: string;
boundInputHash: string;
};
deps: RuleConsentDeps;
}): Promise<ConfirmResult>;
/** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
* The two `record_*` members past `record_not_found` are the durable-row gates (design/375 §4.5/§4.7),
* disclosed to the record's own owner only — a non-owner gets `record_not_found` for every row:
* - `"record_schema_stale"` — the record predates the current (`schema: 4`) form; the decision it carried
* is void, and the recovery path is re-triggering the command for a fresh card (no
* compatibility read, no migration — `detail.message` says so);
* - `"record_malformed"` — the record's offer structure failed the read-side integrity checks
* (store damage); `detail.message` names the breakage.
* The three `edit_*` members are the card-edit face's own refusals:
* - `"edit_disabled"` — the deployment has not opted into card edits (`RuleConsentDeps.cardEdits`);
* - `"edit_binding_mismatch"` — the confirmation does not echo the record's bound-input digest
* (missing echo, a record minted without one, or a different card). Refused BEFORE anything else,
* settled replays included, and never returns a minted ticket;
* - `"edit_rejected"` — the edited text failed a gate (validator refusal, coverage, record shape);
* `detail` carries the specifics. */
export type ConfirmRefusalReason = "record_not_found" | "record_schema_stale" | "record_malformed" | "selection_missing" | "selection_invalid" | "selection_mismatch" | "not_pending" | "conflict" | "edit_disabled" | "edit_binding_mismatch" | "edit_rejected";
/** The confirmation outcome: landed, or refused with a named reason.
*
* `mintedCandidate` (additive) is present exactly when an EDITED candidate settled this confirmation —
* fresh mint and idempotent replay alike (a client retrying a lost response gets the same index, the
* same canonical rule text and the same deterministically re-minted ticket, never a push toward a
* second card). `rule` is the CANONICAL spelling, which may differ from the submitted bytes (spelling
* normalization); a surface echoes it back so the person sees the form that will actually persist.
*
* `detail` (additive, refusal arm) rides `edit_rejected`: `code` is the shared validator's refusal
* code when the validator is what refused, absent when another gate did; `message` always says why. */
export type ConfirmResult = {
ok: true;
mintedCandidate?: {
index: number;
rule: string;
ticket: RuleTicket;
};
} | {
ok: false;
reason: ConfirmRefusalReason;
detail?: {
code?: RuleRejectCode;
message: string;
};
};
/**
* What the TEXT×COMMAND gates answered. `canonicalRule` is the spelling that would actually persist,
* which may differ from the submitted bytes (spelling normalization). On the refusal arm, `code` is the
* shared validator's own refusal code when the validator is what refused, and is ABSENT when the
* coverage gate refused — the same presence rule the confirmation's `edit_rejected` detail carries,
* because they are the same values from the same body.
*/
export type EditedRuleTextPrecheck = {
ok: true;
canonicalRule: string;
/**
* design/382 §3.3-5 — BREADTH notes about the accepted rule (additive; absent = nothing to
* say). A warning is never a refusal: the muscle-memory wide forms are legal (§3.4), and this
* seat is what makes accepting one an informed act. Rendering them beside the input line is
* the surface's duty (the engine cannot force a display); what the ENGINE guarantees is the
* durable half — a confirmation through the edit arm writes the hit codes into the record
* (`edited.warnings`), so "the width was named before the yes" is an auditable fact. The
* confirmation result deliberately does NOT repeat them (one fact, one loud place).
*/
warnings?: readonly EditedRuleBreadthWarning[];
} | {
ok: false;
code?: RuleRejectCode;
message: string;
};
/**
* Would this edited rule text pass the card-edit face's text gates for this command? A pure function,
* for a surface that wants to answer while the person is still typing instead of after a round trip.
*
* It is the SAME body the confirmation arm runs (`checkEditedRuleText`), which is the whole point: a
* boundary that re-implements the check — or calls `parseRuleText` directly — installs a SECOND,
* STRICTER judge that refuses spellings the edit face accepts (the muscle-memory `Bash(adb *)` form is
* exactly such a case: the validator alone refuses it as a wildcard; the edit face normalizes it first).
*
* TWO THINGS IT DOES NOT SAY, and a surface's wording has to respect both:
* · `ok` means SUBMITTABLE, not "the confirmation will succeed". The record-level gates — the binding
* echo, the record's owner and state, scope inheritance, the deployment switch — are not inputs here
* and are re-decided by `confirmRuleApproval` against the record.
* · `canonicalRule` may differ from the bytes passed in (spelling normalization). It is the spelling
* that would persist, so an inline reviewer should show THAT form rather than echoing the input.
*
* The two arguments are different kinds of thing, and are treated differently on purpose: `text` is a
* PERSON's input, so every malformed spelling of it is an answer (a refusal), never a throw. `command`
* is the CALLER's context — the adjudicated command the card was drawn for — and an absent or non-string
* one is a caller bug: answering "your rule does not admit the empty command" would read to the person
* as a verdict on what they typed, so it refuses loudly instead of misattributing the fault.
*
* ONE RECORD SHAPE A CALLER MUST NOT ASK ABOUT: a card carrying no adjudicated command at all (minted
* before the field existed, or by a store that dropped it). That is a RECORD-level refusal, and this
* function is the text half — passing its absent command here is asking a question with no answer, and
* it throws rather than inventing one. A host holding such a record already knows the outcome without
* asking: `confirmRuleApproval` refuses the edit with a message that says coverage cannot be verified,
* so the surface's move is to not offer the edit box at all.
*/
export declare function precheckEditedRuleText(text: string, command: string): EditedRuleTextPrecheck;
/** What a redemption produced. `alreadyRedeemed` marks the replay path — the same dot, no second rule.
*
* `rev` is the DURABLE store's revision. A session-scope redemption (design/382 §4.3) lands in the
* session overlay and does not move it: its `rev` is a best-effort read of the store's current
* revision (0 when the store could not be read — the landing is in the overlay either way, and the
* durable revision is reporting, not the landing's identity). */
export type RedeemResult = {
status: "redeemed";
behavior: RuleBehavior;
rule: string;
scope: RuleScope;
dot: RuleDot;
rev: number;
alreadyRedeemed: boolean;
} | {
status: "refused";
reason: string;
};
/**
* Redeem one ticket into one persisted rule.
*
* Takes a ticket and a verified principal, and nothing else. There is deliberately no field carrying rule
* text: what gets persisted is what the engine put in the record when it drew the card, so a host cannot
* turn a click on `git status` into a rule for something else.
*/
export declare function redeemRuleTicket(opts: {
ticket: RuleTicket;
principal?: string;
owner?: RuleOwner;
deps: RuleConsentDeps;
}): Promise<RedeemResult>;
/** The three user-editable settings layers this version reads. */
export type ImportedSettingsLayer = "userSettings" | "projectSettings" | "localSettings";
/** One layer to read. `root` is the canonical directory a project-layer rule is scoped to — taken from
* where the FILE actually lives, since the local layer is not always at a repository root. */
export interface CcImportLayer {
layer: ImportedSettingsLayer;
path: string;
root: string;
readFile: (path: string) => Promise<string | undefined>;
}
/**
* What the import WOULD do, shown before anyone confirms.
*
* `uncovered` is a fixed-key record, not a list: everything this version does not import is named in the
* TYPE, so an empty list, a missing member or a duplicate is not expressible. A preview that reported only
* what it read would be claiming completeness it does not have.
*/
export interface ImportPreview {
candidates: RuleCandidate[];
skipped: Array<{
rule: string;
reason: string;
}>;
/**
* ALWAYS EMPTY in this version — a compatibility seat, kept on the wire shape so an existing reader
* keeps finding the key (removing a wire field would break readers for no gain).
*
* The seat once carried the entries whose MATCH FORM was rewritten on the way in (the space-star
* suggestion form of the other product translated to this lane's colon-star form, one row per
* translated candidate with its `scope`). That translation is retired: the space-star wildcard is
* now a first-class match form of this lane's own grammar, so an entry's match form imports as the
* file states it — `Bash(npm run *)` lands as `Bash(npm run *)`, never as a colon-star — and nothing
* is ever pushed here. What `candidates` carries is the grammar's CANONICAL re-projection of each
* entry (the parser folds spacing: `Bash(npm<TAB>run *)` lands as `Bash(npm run *)`); that is the
* ordinary normalization every rule text in this lane undergoes, the same one the store applies,
* and not a translation this seat reports. An entry whose star placement has no rule form is
* refused into `skipped` under `unsupported.wildcard`, named as the file writes it. A reader
* rendering a "rewritten spellings" column from this seat renders an empty column, correctly.
*/
translated: Array<{
from: string;
to: string;
scope: RuleScope;
}>;
layers: Array<{
path: string;
layer: ImportedSettingsLayer;
found: boolean;
}>;
/** The two SETTINGS LAYERS this version does not read at all. The three permission lists of the layers it
* does read — allow, deny AND ask — are all imported (each candidate carries its `behavior`), so there
* is no longer a "left in place" count beside them: an entry that did not become a candidate is in
* `skipped` with its reason. */
uncovered: {
flagSettings: "not-imported-v1";
policySettings: "not-imported-v1";
};
}
/**
* One member row of a batch redemption's FULL table (design/375 §4.6) — what actually happened to
* each candidate of the chosen offer, keyed by its ORIGINAL candidate index. `candidateIndex` is
* the member's identity: the same rule text can be two candidates in two scopes with two different
* outcomes, so the bare text is not a key. A successful row always carries its `dot` and the
* independent `alreadyRedeemed` axis (the single-leg replay marker: a dot-recorded-but-unlanded
* retry is `alreadyRedeemed: true` AND `persisted`; a rule that pre-existed in the store is
* `false` AND `deduped` — the two axes answer different questions and neither implies the other).
* A refused row always carries its `reason`. `deduped` counts as LANDED: an equivalent rule
* already standing means the consent is already in effect.
*
* design/382 §4.3 — on a SESSION-scope member the two landed words mean the same thing about a
* different home: landed in / already in the session's OVERLAY (a session rule's home store), never
* the persisted store. The words state that the landing happened; DURABILITY is what the member's own
* `scope` says, so a consumer never has to guess it off the status (deliberately no fourth status
* value — the closed set names landing outcomes, not storage classes).
*/
export type RedeemedBatchMember = {
readonly candidateIndex: number;
readonly behavior: RuleBehavior;
readonly rule: string;
readonly scope: RuleScope;
readonly status: "persisted" | "deduped";
readonly alreadyRedeemed: boolean;
readonly dot: RuleDot;
} | {
readonly candidateIndex: number;
readonly behavior: RuleBehavior;
readonly rule: string;
readonly scope: RuleScope;
readonly status: "refused";
readonly reason: string;
};
/**
* Read the THREE permission lists (allow, deny, ask) of the user-editable settings layers and produce a
* preview plus a PENDING approval record. Nothing is stored until someone confirms that record and the
* batch is redeemed. A preview with zero importable candidates returns NO `approvalId` — there is nothing
* to confirm, and the absence says so (design/375 §4.1).
*
* Every list is read through the ONE validator under its own behavior: a deny/ask entry takes the
* tightening floor (`Bash(node:*)` as a deny is a legitimately wide tightening and imports), an allow
* entry the allow floor. A content-form deny/ask so imported is the person's own standing instruction
* and adjudicates in the persisted-rule lane beside their allows — deny > ask > allow — instead of
* being escorted by a shell into a bare tool-name ask that widens `Bash(rm -r:*)` into "every Bash call
* asks". An entry that does not import (a spelling with no rule form, a tool the lane does not speak
* for, a non-string) is DISCLOSED in `skipped` with its reason and stays in the settings file.
*/
export declare function prepareCcImport(opts: {
layers: CcImportLayer[];
principal?: string;
owner?: RuleOwner;
deps: RuleConsentDeps;
}): Promise<{
preview: ImportPreview;
approvalId?: string;
}>;
/**
* The factory-reviewed starter set, offered when there is nothing to import.
*
* Every entry is an EXACT read-only command whose behavior does not depend on repository or environment
* configuration. The version-control family is deliberately absent: repository configuration can make a
* subcommand invoke an external helper, which is the same reason this repository already declined a
* subcommand allowlist for it. Nothing here takes effect until someone confirms the batch.
*/
export declare const STARTER_RULES: readonly string[];
/** Prepare the starter batch as a pending approval record. Nothing takes effect without confirmation;
* an unconfirmed deployment simply starts with an empty rule set. */
export declare function prepareStarterBatch(opts: {
principal?: string;
owner?: RuleOwner;
deps: RuleConsentDeps;
}): Promise<{
preview: RuleCandidate[];
approvalId: string;
}>;
/**
* Redeem the CHOSEN offer of a confirmed record, member by member — reporting what actually landed
* rather than what was previewed (dedup, concurrency and redemption-time validation can all move a
* member between the two moments).
*
* design/375 §4.1/§4.6 — this is the one-yes walk for every record kind: an import/starter record's
* whole-list batch, a card's chosen batch (each per-segment rule lands as its own candidate), or a
* card's chosen single (a one-member walk). Members OUTSIDE the chosen offer are simply not walked
* — and stay individually unredeemable through the ticket entry, same guarantee, one predicate.
*
* The walk contract, spelled out because each clause is load-bearing:
* · every member goes through the SAME single-ticket redemption leg — dot CAS'd into the record
* BEFORE the store write (a crash in between leaves a replayable intent), concurrent-loser
* dot reuse included; the walk adds no concurrency primitive of its own;
* · a refused member does NOT fold the batch: "all or nothing" is the CONFIRMATION's semantics
* (one yes covered the whole offer), not the landing's transaction shape — the walk continues
* and the member's row says refused, with the reason;
* · resuming a partial walk IS re-walking: already-landed members answer `alreadyRedeemed: true`
* and reuse their recorded dots (two concurrent walkers converge on one dot set, zero double
* mints), unlanded members mint on;
* · the returned table is COMPLETE — exactly one row per member of the chosen offer, in the
* offer's member order, no extras — so a caller's aggregate is an honest projection: all rows
* landed (`persisted` or `deduped`, both count as the consent being in effect) is full
* success, and ANY refused row must project to a non-success aggregate, never to a silent
* partial one.
*/
export declare function redeemRuleBatch(opts: {
approvalId: string;
principal?: string;
owner?: RuleOwner;
deps: RuleConsentDeps;
}): Promise<{
members: readonly RedeemedBatchMember[];
rev: number;
} | {
status: "refused";
reason: string;
}>;