UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

677 lines (676 loc) • 41.1 kB
/** * 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 is accepted. */ toolName: string; /** The command as adjudicated — the same bytes the ask carried. */ 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; }>;