@sema-agent/core 7.8.0 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/core/ask-origin.d.ts +12 -1
  4. package/dist/core/ask-origin.js +5 -1
  5. package/dist/core/checkpoint-store.d.ts +34 -28
  6. package/dist/core/gate-lanes.js +49 -23
  7. package/dist/core/gate-outcome.d.ts +8 -4
  8. package/dist/core/gate-outcome.js +2 -1
  9. package/dist/core/hooks.d.ts +56 -31
  10. package/dist/core/permission-rule-consent.d.ts +29 -36
  11. package/dist/core/permission-rule-consent.js +110 -60
  12. package/dist/core/permission-rule-model.d.ts +254 -55
  13. package/dist/core/permission-rule-model.js +323 -43
  14. package/dist/core/permission-rule-org.d.ts +9 -8
  15. package/dist/core/permission-rule-org.js +6 -15
  16. package/dist/core/permission-rule-provider.d.ts +10 -6
  17. package/dist/core/permission-rule-provider.js +12 -8
  18. package/dist/core/permission-rule-session.d.ts +7 -6
  19. package/dist/core/permission-rule-session.js +29 -10
  20. package/dist/core/permission-rule-store.d.ts +40 -18
  21. package/dist/core/permission-rule-store.js +68 -42
  22. package/dist/core/permission-rule-sync.d.ts +9 -3
  23. package/dist/core/permission-rule-sync.js +29 -25
  24. package/dist/core/permission-rule-syntax.d.ts +30 -0
  25. package/dist/core/permission-rule-syntax.js +44 -0
  26. package/dist/core/permission-rules.d.ts +55 -33
  27. package/dist/core/permission-rules.js +65 -55
  28. package/dist/core/persisted-rule-arms.d.ts +56 -0
  29. package/dist/core/persisted-rule-arms.js +48 -0
  30. package/dist/core/runner/contracts.d.ts +21 -2
  31. package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
  32. package/dist/core/runner/permission-rule-lanes.js +27 -21
  33. package/dist/core/runner/prepare-gate-stations.js +1 -1
  34. package/dist/core/runner/prepare-safety-scan.js +8 -2
  35. package/dist/core/runner/prepare-task.js +1 -1
  36. package/dist/core/runner/resume-admission.d.ts +53 -0
  37. package/dist/core/runner/resume-admission.js +83 -0
  38. package/dist/core/runner/resume-apply.d.ts +50 -0
  39. package/dist/core/runner/resume-apply.js +184 -0
  40. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  41. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  42. package/dist/core/runner/resume-claim.d.ts +32 -0
  43. package/dist/core/runner/resume-claim.js +27 -0
  44. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  45. package/dist/core/runner/resume-internals-and-config.js +50 -0
  46. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  47. package/dist/core/runner/resume-policy-outcome.js +127 -0
  48. package/dist/core/runner/resume-preflight.d.ts +40 -0
  49. package/dist/core/runner/resume-preflight.js +122 -0
  50. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  51. package/dist/core/runner/resume-review-outcome.js +88 -0
  52. package/dist/core/runner/run-harness-handlers.js +1 -1
  53. package/dist/core/runner/runtask.d.ts +20 -0
  54. package/dist/core/runner/runtask.js +85 -734
  55. package/dist/core/runner/tool-end-body.d.ts +12 -5
  56. package/dist/core/runner/tool-end-body.js +5 -5
  57. package/dist/core/runner/tool-output-projection.d.ts +9 -6
  58. package/dist/core/runner/tool-output-projection.js +1 -18
  59. package/dist/core/runner/turn-attachments.d.ts +2 -2
  60. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  61. package/dist/core/tool-roster.d.ts +9 -0
  62. package/dist/core/tool-roster.js +15 -5
  63. package/dist/index.d.ts +2 -2
  64. package/dist/index.js +2 -2
  65. package/dist/stores/file/checkpoint-store.js +1 -1
  66. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  67. package/dist/stores/file/permission-rule-store.js +64 -16
  68. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  69. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  70. package/dist/tools/fs/notebook.d.ts +1 -1
  71. package/dist/tools/fs/safety.d.ts +1 -1
  72. package/package.json +1 -1
  73. package/test/export-surface.snapshot.json +43 -11
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.9.0 — 2026-09-08
4
+
5
+ The resume decision ladder leaves runtask.ts, and the two `runtask.ts` seams design/388 slice 1 shipped unwired in 7.8.0 are wired.
6
+
7
+ ### BREAKING — content-form deny/ask rules get their engine lane (#625; cli B-043 root cause)
8
+ - **One three-state rule model.** A persisted rule row carries `behavior: "deny" | "ask" | "allow"` (closed set `RULE_BEHAVIORS`, one precedence table `RULE_BEHAVIOR_PRECEDENCE`: deny > ask > allow, read by every adjudication). Identity is the (behavior, text, scope) triple. Renames: `PersistedAllowRule`→`PersistedRule`, `ParsedAllowRule`→`ParsedRule`, `StoredAllowRules`→`StoredRules`; `parseAllowRuleText`→`parseRuleText(text, behavior)` (no default), `formatAllowRuleText`→`formatRuleText`. Removed exports: those five names. Same-name reshapes: `PersistedRuleHit.behavior`; `ToolGateInput.persistedRules.admits`→`adjudicate` (the answer is a verdict; a read failure MUST answer `{ unreadable: true }`); `RuleTombstone` / `RuleAddDelta` / `QuarantinedRuleAdd` / `RuleSyncDrop` / `RuleSyncJoinDelta.quarantine[]` / `SessionRuleAdd` / `RemovedPermissionRule` / `RuleCandidate` / `RedeemResult` / `RedeemedBatchMember` carry `behavior`; `removePersistedRule({ behavior })`; `RuleApprovalRecord.schema` 3→4; `ImportPreview.uncovered.denyAskBuckets` removed; `PermissionRulePolicyOptions.bashPrefixLane` removed, `pathBases` added; `PersistedRuleTool` is `string` (tools derive from the catalog's path targets, `ruleToolGrammarOf`); `PersistedRuleMatch` +`path`; `directoryRuleAdmits` no longer reads the tool.
9
+ - **Store rows (@server two stores + contract kit):** every row / tombstone / quarantine / dropped entry carries `behavior` (required on the wire: a row without it is not shapeable and is skipped with a warning; a tombstone without it withholds this round's add). File store schema 3→4 (`.rule-schema-4`; a schema-3 bucket migrates once with `behavior: "allow"` on every row and tombstone; a behavior word outside the set is left for a person, refused whole). Sync contract kit +3 cases. `RuleSyncResponseBody.dropped[].behavior` required; `PermissionRuleSyncResult.resurrected[].behavior`.
10
+ - **Closed sets (@cli @client-core @server readers):** `DeniedBy` +`persisted_rule` (a person's own standing deny row); `AskOrigin` +`rule_store_unavailable` (a wired persisted-rule store could not be read: the ask is fail-closed, the classifier does not answer it, real-approval derived); `PermissionRuleIssue.code` −`unsupported.bash_prefix_rules_store` +`invalid.command_rule` / `unsupported.path_base`.
11
+ - **The lane.** The persisted-rule lane reads the store once per decision (not only on a surviving ask) and adjudicates: a deny row refuses outright (`deniedBy: "persisted_rule"`, a veto row in the station table); an ask row asks with `matchedAskRule` (origin `ask_rule`, a person's standing "ask me each time" that the classifier never answers, by design); an allow row is the prior predicate. The edit round re-adjudicates. Path family: `Edit(~/.sema/settings.json)` / `Write(//tmp/secret/**)` deny/ask rules judge the call's path target (deny/ask read the protective slot, allow the declared slot: the 7.8.0 two-reader rule); pattern grammar `//abs`, `~/`, `/root-relative`, cwd-relative, `*` within a segment, `**` across (narrower than CC's gitignore semantics: `?` / `[]` / `{}` / `!` refused, disclosed). Precedence across authorities: org deny > org ask > personal deny > personal ask > personal allow > bare ask.
12
+ - **The compiler** (`createPermissionRulePolicy`) no longer refuses a Bash prefix deny/ask (`Bash(rm -r:*)` in `permissions.deny` / `permissions.ask` compiles; the retired re-coding never appears, tripwire pinned); an allow in content form still refuses `unsupported.bash_prefix`. Redirections are stripped on the tighten side before matching (`> file`, `2>`, `>|`, fd duplications; the remaining forms are #631).
13
+ - **@cli:** the shell's escort of content-form ask/deny rules into bare tool-name asks (B-043's amplification) retires whole; `permissions.deny` / `permissions.ask` content rules go up the wire as written. Judgement: `permissions.ask: ["Bash(rm -r:*)"]` + auto ⇒ `date` zero cards and zero classifier calls, `rm -r x` a real card of origin `ask_rule`; deny likewise.
14
+
15
+ ### Fixes over the merged tree (main-tree adversarial round over #625 × 393 S3)
16
+ - **#632 — a deny row imported while a call was PARKED now vetoes the resumed execution.** The resumed execution boundary re-adjudicates the person's persisted-rule lane in the same posture as the deny-narrowing layers: a matching deny row (or an ask row, or an unreadable store — the durable path cannot re-ask) ends the call with `deniedBy: "persisted_rule"`, the approval is spent, the call is re-issued. `Prepared.persistedRuleLane` (internal seat) carries the lane to the driver. Pin: park → import deny → resume ⇒ zero executions; control without the row executes once.
17
+ - **Schema-4 rule buckets are strict on every row's `behavior`.** A correctly checksummed schema-4 file carrying a rule, tombstone or quarantined row whose behavior word is outside deny/ask/allow is refused WHOLE (`list()` rejects, the lane reads `unreadable` ⇒ the ask carries `rule_store_unavailable`), never read as "no rule" beside a live allow row.
18
+ - **Known limits, disclosed (tickets, not this release):** #631 — the tighten-side command reader is a hand-written stripper: `command rm -r x`, `rm "-r" x`, `rm -r x; echo "$HOME"` (expansion makes segmentation fail) do not reach a `Bash(rm -r:*)` deny/ask; direction = a real shell lexer with wrappers, and an unreadable command asks instead of no-match. #635 — the path lane reads the call's path slot, not the search tools' effective target: `Grep(//proj/**)` with the path omitted (tool default = root), `Glob` with an absolute pattern, and a relative Grep path resolved against liveCwd where the tool resolves against root, are not reached; direction = one effective-target resolver shared with tool execution. Both are under-blocking relative to the rule's intent, not wider than 7.8.0 (which had no engine-side deny at all).
19
+
20
+ ### Wire (additive — new frames start being pushed; the vocabulary shipped in 7.8.0)
21
+ - `tool_roster_delta` frames are now PUSHED. The driver subscribes to the leg's roster-delta seat at the point it pushes `wiring_manifest` (the roster on the manifest is the baseline a delta diffs against; a delta the seat buffered before the subscription flushes first, in publish order) and forwards each run-time roster change (the RefreshMcpTools seam) as its own identity-minted frame: `delta.fromDigest` = the digest the consumer holds, `delta.roster` the whole post-change roster, `delta.summary` the added/removed/changed names. One refresh that changes the roster ⇒ exactly one frame; a refresh that changes nothing publishes none. A consumer that adopted 7.8.0's tolerant default (adopt the carried roster, record a skew when `fromDigest` mismatches) needs nothing new. @server (live projection of the roster: the frame is on the wire now), @cli (the two frames `wiring_manifest.manifest.tools` / `tool_roster_delta` parse as already claimed).
22
+ - `tool_end.structured` is projected through the LEG's card projector (`Prepared.structuredProjector`: the engine card set ∪ the caller rows' declared `renderHints.resultCards`) on every arm that mints a tool_end body — the live loop, the resumed batch's executed and refused arms, the deferred-sibling close, the two orphan reconciles. Effect on the wire: a caller tool's DECLARED card now rides its frame (7.8.0 dropped it — only engine cards passed); an undeclared `details.type` is still dropped and is now announced ONCE per leg as `config.tool_card_undeclared` (audience operator), which 7.8.0 minted at the seat but never reached from the loop.
23
+ - 7.8.0's "Not wired" disclosure retires whole: the roster delta frame, the structured projector and the manifest-side subscription are wired; the `resume` identity arm was already wired in 7.8.0.
24
+
25
+ ### Narrowing
26
+ - **design/388 §2.2 ④ — a caller's `renderHints.resultCards` must live under the tool's OWN card namespace** (the row's `cardId`: `caller:<contractId>` — one mint, `rosterCardId`, read by the row and by the prepare door). A declaration naming another contract's namespace, an engine card (`builtin:*` or a bare engine card type) or a bare name is refused at prepare, `config.tool_face_invalid`. Load-bearing now that the leg projector admits every DECLARED card onto the wire (a 7.8.0 tree accepted the declaration and dropped the card at projection; the S3 tree would have shipped it as a foreign card). @cli @server: a caller tool that declared a foreign card namespace now fails prepare instead of mounting.
27
+
28
+ ### Internal (byte-invariant on the runtime — design/393 S3)
29
+ - `Runner.resumeStream`'s pre-CAS ladder (E1–E10 of the decomposition map, 1 918 lines) moved verbatim into eight `resume-*.ts` lanes (layer 3), each behind ONE Input: admission (+ wake shaping), review-outcome (+ gate match, park twins), policy-outcome, checkpoint-screen (+ abort / handle / constraint rungs), internals-and-config, apply (+ settlement mint), preflight, claim. E11 (the post-claim hand-off) stays the driver's. The four async rungs enter their successor in their own last continuation (a `next` seat — the tool gate's fold→lanes shape), and the internals-and-config rung hands the ladder's one window await back as a started promise the driver awaits where the ladder did, so no host read of the ladder moved off its tick. runtask.ts 9 186 → 7 470 lines; dist `runtask.js` −730 / +50 with every moved line accounted for.
30
+ - `toolEndBodyFrom` / `reconciledToolEndBody` (internal machinery, not on the public surface) take the projector as a required parameter; the module-level `structuredFrom` is gone — `createStructuredProjector()` with no options is the engine set alone (the fixture form). `ToolRosterDeltaSeat.unsubscribe()` (additive) closes the lane with the leg.
31
+
32
+ ### Pins
33
+ - `test/design393-s3-resume-lanes.test.ts` (WIRED / Input shape / await boundaries — five in-body mutations red), `test/design393-s3-388-seams.test.ts` (the two seams end to end on a real stdio MCP server and a caller tool with a declared card — three mutations red), the run-lane residents pins, G5's generic-Input pin.
34
+
35
+ ### Gates (#627 — server [6626] door request)
36
+ - **A `checkpoint.unsupported_version` refusal always names its arm (#627).** The word rides an OPTIONAL
37
+ `detail.reason`, so nothing red when a throw site omitted it — and the file ledger's replay refusal
38
+ (`FileCheckpointStore`, an event kind written by a newer worker) had omitted it since it was written. It
39
+ now carries `reason: "ledger_event_unknown"`, the tenth member of the closed set: the one arm that is NOT
40
+ row-scoped — no row under that data root is readable by this binary, so the host rolls the binary forward
41
+ rather than routing one token to another worker. **@server**: additive word on an optional member; a
42
+ consumer holding a total table over `reason` gains a row.
43
+ - The word list leaves the constructor's inline type for a named closed set, `CheckpointErrorReason`
44
+ (exported from `src/core/checkpoint-store.ts`; the public export surface is unchanged), registered in
45
+ `docs/CLOSED-SETS.md` with a new **mint-obligation** leg in `gate:closed-sets`: every
46
+ `new CheckpointError("checkpoint.unsupported_version", …)` under `src/` must pass a detail object literal
47
+ whose `reason` is spelled AT the site (a literal, or a conditional whose branches are) and is a member of
48
+ the set — a missing detail, a missing/shorthand/computed word, or a word outside the set reds with
49
+ `file:line`, and a per-row population floor reds when the walker stops seeing the sites at all. The
50
+ registry's two G8 floors were re-measured in the same pass (rows 8 → 10, switches 1 → 3; both had drifted
51
+ below the census, which is the blindness a floor exists to make loud).
52
+
53
+
3
54
  ## 7.8.0 — 2026-09-08
4
55
 
5
56
  One roster instead of 29 name tables. `ToolSpec` already described a tool completely; the engine, the server and the shells still re-asserted each tool's identity and axes by NAME in hand-kept tables (a retired name lived on as a key in five of them). This version mints ONE effect roster per prepared leg from the mounted instances, derives every remaining table from a static catalog, and closes the mount array behind a builder.
@@ -29,7 +80,7 @@ One roster instead of 29 name tables. `ToolSpec` already described a tool comple
29
80
  - A deferred tool's placeholder carries the real tool's `aliases` (dispatch by alias resolves to the same slot before and after activation, the roster's name→row resolution agrees with the dispatcher's — wire name first, then alias, in mount order — and a transcript call under an alias activates the canonical entry on replay). The MCP `toolFaces` overlay binds to the RAW accepted tool name (`Tool.mcpRemoteName`, a typed carry the materializer stamps): two raw spellings folding onto one minted name cannot make a face land on the other tool — the unaccepted spelling is announced undeclared. `TOOL_KEY_MAX_CHARS` / `TOOL_KEYS_MAX`; `ToolCallLike` / `callPathTargetOf` / `isPathConfinableWrite` / `fileOpAccessOf` (the compaction summary records file ops for the FILE families only — a search tool's `path` is a root, not a file read).
30
81
  - The roster carries every name the model can call: the ToolSearch instance the deferred lane appends to the harness list rides the roster's ENGINE LANE (`RosterBuilder.mountEngineLane`) as the trailing row, minted after the disclosure lane (which reads the mount array and never writes it) — the prompt manifest's tool rows are the mount array; the roster is that plus the engine lane. The effect map follows the instance the harness dispatches to (a later same-name mount's declared effect is the name's truth). `gate:tool-catalog` rule ②b: a resolvable `defineTool({ name })` with no catalog row is red (per-file `HOST_FACTORY_DEFINITIONS` exemption list, empty today).
31
82
 
32
- ### Not wired in 7.8.0 — three runtask.ts seams deferred to design/393 S3 (the seats are minted; the driver does not yet consume them)
83
+ ### Not wired in 7.8.0 — three runtask.ts seams deferred to design/393 S3 (the seats are minted; the driver does not yet consume them) — RETIRED in 7.9.0: all three are wired there (see that section)
33
84
  - **Disclosure**: the three items below are the driver-side half of this slice. In 7.8.0 the roster, its delta seat and the structured projector all exist and are pinned at the seat/function level, but runtask.ts (design/393's domain, being decomposed in parallel) does not yet call them: no `tool_roster_delta` frame is pushed and `tool_end` still projects through the module-level card set. A consumer must not rely on `tool_roster_delta` frames until the version that lands 393 S3. (The `resume` identity arm — `judgeParkedToolIdentity` minting `resume.tool_contract_mismatch` — IS wired in 7.8.0: the merge-seam adversarial round showed a same-name replacement executing under a different face once the rechecks read faces, so it did not wait for S3.)
34
85
  - `tool_end`: the body projection reads `prepared.structuredProjector` (the per-leg card set + the once-per-leg announce) in place of the module-level `structuredFrom` (tool-end-body.ts:77 via runtask).
35
86
  - `wiring_manifest`: beside the manifest push (runtask.ts ≈:4149) subscribe `prepared.toolRosterDeltas.subscribe((delta) => queue.push({ type: "tool_roster_delta", delta, ...ident() }))`.
@@ -248,7 +248,7 @@ export declare function classifySubagentError(terminal: TerminalCause): {
248
248
  * delegation's landing report from `structured` and MUST NOT scrape the model-facing text. The
249
249
  * `async_launched` receipt honored it (`type:"agent"`, on the allowlist); the **completed** delivery
250
250
  * did not: its `details` was `{...child}` — a bare `TaskResult` spread with no `type` key at all — so
251
- * `structuredFrom`'s allowlist dropped it every time. The one card the contract names by name was the
251
+ * the card projector's allowlist dropped it every time. The one card the contract names by name was the
252
252
  * one card that could never arrive, and the only way to get the child's report was the thing the
253
253
  * contract forbids.
254
254
  *
@@ -41,6 +41,10 @@ import type { PermissionResult } from "./tool-policy.js";
41
41
  * lane may answer it).
42
42
  * - `org_rule` — an organization policy rule asked (or stamped an existing ask): "no configuration
43
43
  * clears this, a person nods".
44
+ * - `rule_store_unavailable` — the deployment wired a persisted-rule store and could not READ it for
45
+ * this call (a read failure, a timeout): the person's own deny/ask rows cannot be enforced, so the
46
+ * call fails closed the way an unreadable org snapshot does — an allow tightens to an ask a person
47
+ * must clear, and no automatic lane may answer ("does one of my rules forbid this?" is unanswered).
44
48
  * - `hook` — a deployment's PreToolUse screening face raised or co-raised the ask; the word is
45
49
  * engine-stamped at the fold and is the deployment's own per-call adjudication.
46
50
  * - `ask_rule` — an EXPLICIT `ask` permission rule matched: a person's standing "ask me each time",
@@ -61,7 +65,7 @@ import type { PermissionResult } from "./tool-policy.js";
61
65
  * observer or an approval card can say WHICH engine layer raised the question, which one word
62
66
  * could not.
63
67
  */
64
- export declare const ASK_ORIGINS: readonly ["content_question", "unresolvable", "org_unavailable", "org_rule", "hook", "ask_rule", "denial_limit_fallback", "shell_gate_tighten", "safety_tighten", "policy"];
68
+ export declare const ASK_ORIGINS: readonly ["content_question", "unresolvable", "org_unavailable", "org_rule", "rule_store_unavailable", "hook", "ask_rule", "denial_limit_fallback", "shell_gate_tighten", "safety_tighten", "policy"];
65
69
  export type AskOrigin = (typeof ASK_ORIGINS)[number];
66
70
  /** Whether a value is a member of the closed origin set. Exported because the word crosses process
67
71
  * boundaries: a host validating one it received must not hand-roll the set. */
@@ -87,6 +91,9 @@ export interface AskOriginFacts {
87
91
  * read-back off the decision (a policy can put `decisionReason: "org_rule"` on its own ask; the
88
92
  * gate's cell cannot be written by a policy). */
89
93
  readonly org: "rule" | "unavailable" | undefined;
94
+ /** The gate's own record of its persisted-rule lane's READ: `"unavailable"` when the wired store could
95
+ * not be read for this call (the lane's deny/ask rows are unenforceable). Written only by the gate. */
96
+ readonly ruleStore?: "unavailable" | undefined;
90
97
  /** Which of the gate's OWN post-fold tightens minted the standing ask, if one did — again the gate's
91
98
  * record of its own layer (a tighten replaces an `allow` wholesale, so at most one fires per pass),
92
99
  * never a read-back of `decisionReason: "safety"` off a decision a policy could have composed. */
@@ -127,6 +134,7 @@ export declare const ORIGIN_IMPLIES_REAL_APPROVAL: {
127
134
  readonly unresolvable: false;
128
135
  readonly org_unavailable: "at_derivation";
129
136
  readonly org_rule: "at_derivation";
137
+ readonly rule_store_unavailable: "at_derivation";
130
138
  readonly hook: false;
131
139
  readonly ask_rule: false;
132
140
  readonly denial_limit_fallback: "at_mint";
@@ -148,6 +156,8 @@ export type RealApprovalTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<As
148
156
  * - `org_unavailable` / `org_rule`: judgment may clear a `requiresRealApproval` bit a POLICY minted,
149
157
  * but not the org's — while adjudication is unavailable the org's answer is unknown, and an org
150
158
  * ask carries the org's own "a person nods" semantic.
159
+ * - `rule_store_unavailable`: the person's own deny/ask rows are unreadable — whether one forbids this
160
+ * call is unknown, and a classifier verdict would answer that question in the person's stead.
151
161
  * - `hook`: the deployment's own per-call adjudication; the classifier is its delegate for
152
162
  * hesitation, not an override for its screening face.
153
163
  * - `ask_rule`: a person's standing "ask me each time" — the classifier answering it would decide
@@ -169,6 +179,7 @@ declare const CLASSIFIER_MAY_ANSWER: {
169
179
  readonly unresolvable: false;
170
180
  readonly org_unavailable: false;
171
181
  readonly org_rule: false;
182
+ readonly rule_store_unavailable: false;
172
183
  readonly hook: false;
173
184
  readonly ask_rule: false;
174
185
  readonly denial_limit_fallback: false;
@@ -1,4 +1,4 @@
1
- export const ASK_ORIGINS = ["content_question", "unresolvable", "org_unavailable", "org_rule", "hook", "ask_rule", "denial_limit_fallback", "shell_gate_tighten", "safety_tighten", "policy"];
1
+ export const ASK_ORIGINS = ["content_question", "unresolvable", "org_unavailable", "org_rule", "rule_store_unavailable", "hook", "ask_rule", "denial_limit_fallback", "shell_gate_tighten", "safety_tighten", "policy"];
2
2
  const ASK_ORIGIN_SET = new Set(ASK_ORIGINS);
3
3
  export function isAskOrigin(v) {
4
4
  return ASK_ORIGIN_SET.has(v);
@@ -19,6 +19,8 @@ function originLadder(decision, facts) {
19
19
  return "org_unavailable";
20
20
  if (facts.org === "rule")
21
21
  return "org_rule";
22
+ if (facts.ruleStore === "unavailable")
23
+ return "rule_store_unavailable";
22
24
  if (decision.decisionReason === "hook")
23
25
  return "hook";
24
26
  if (decision.matchedAskRule !== undefined)
@@ -36,6 +38,7 @@ export const ORIGIN_IMPLIES_REAL_APPROVAL = {
36
38
  unresolvable: false,
37
39
  org_unavailable: "at_derivation",
38
40
  org_rule: "at_derivation",
41
+ rule_store_unavailable: "at_derivation",
39
42
  hook: false,
40
43
  ask_rule: false,
41
44
  denial_limit_fallback: "at_mint",
@@ -48,6 +51,7 @@ const CLASSIFIER_MAY_ANSWER = {
48
51
  unresolvable: false,
49
52
  org_unavailable: false,
50
53
  org_rule: false,
54
+ rule_store_unavailable: false,
51
55
  hook: false,
52
56
  ask_rule: false,
53
57
  denial_limit_fallback: false,
@@ -1967,6 +1967,30 @@ export interface CheckpointSummary {
1967
1967
  * {@link ResourceLedger.spentMicroUsd}. Pure; reads no clock/random.
1968
1968
  */
1969
1969
  export declare function summarizeCheckpoint(cp: Checkpoint): CheckpointSummary;
1970
+ /**
1971
+ * WHICH arm of a multi-arm {@link CheckpointError} `code` fired (requested 2026-08-10: a deployment retry
1972
+ * policy needs to tell "a newer worker can redeem this row" from "this row is damaged/caller-declared and
1973
+ * no worker ever will" — blanket-retriable and gate-shape heuristics were both refuted downstream, so the
1974
+ * throw site carries the fact it always knew).
1975
+ *
1976
+ * A CLOSED SET with one home, registered in `docs/CLOSED-SETS.md`. It is carried in an OPTIONAL member, so
1977
+ * the compiler is silent when a throw site omits it, and a site that omits it hands the host a refusal it
1978
+ * cannot classify — the registry's mint-obligation leg is what makes the omission loud (every
1979
+ * `checkpoint.unsupported_version` construction site must spell one of these words AT the site).
1980
+ *
1981
+ * · `unsupported_version` arms: `"version_newer"` (the row's format is newer than this worker's —
1982
+ * retryable on a newer worker), `"env_factory_missing"` (retryable on a factory-wired worker),
1983
+ * `"governed_unwired"` (retryable on an org-wired worker), `"origin_missing"` (a pending approval row
1984
+ * minted before the ask's origin was persisted on the park — terminal for the row's bytes; the host
1985
+ * decides the orphaned card itself, no worker version redeems it), `"ledger_event_unknown"` (a durable
1986
+ * LEDGER carries an event kind this binary does not know, so the replay is refused rather than served
1987
+ * incomplete — the one arm that is not row-scoped: NO row in that data directory is readable here, so
1988
+ * the host rolls the binary forward instead of routing one token elsewhere).
1989
+ * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`, `"real_approval_forged"`,
1990
+ * `"read_face_damaged"`, `"read_face_forged"`, `"constraint_chain_missing"` (all terminal for the row's
1991
+ * current bytes — no worker version redeems a damaged row).
1992
+ */
1993
+ export type CheckpointErrorReason = "version_newer" | "env_factory_missing" | "governed_unwired" | "origin_missing" | "ledger_event_unknown" | "real_approval_damaged" | "real_approval_forged" | "read_face_damaged" | "read_face_forged" | "constraint_chain_missing";
1970
1994
  /** A typed checkpoint-store error so callers branch on `code` (mirrors `SessionError`). */
1971
1995
  export declare class CheckpointError extends Error {
1972
1996
  readonly code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found"
@@ -2142,20 +2166,11 @@ export declare class CheckpointError extends Error {
2142
2166
  * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
2143
2167
  * every other code. */
2144
2168
  retryAfterMs?: number;
2145
- /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
2146
- * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
2147
- * damaged/caller-declared and no worker ever will" blanket-retriable and gate-shape heuristics were
2148
- * both refuted downstream, so the throw site carries the fact it always knew). Closed set;
2149
- * additive and optional like `field` — `code` remains the only REQUIRED discriminant.
2150
- * · `unsupported_version` arms: `"version_newer"` (retryable on a newer worker),
2151
- * `"env_factory_missing"` (retryable on a factory-wired worker), `"governed_unwired"`
2152
- * (retryable on an org-wired worker), `"origin_missing"` (a pending approval row minted
2153
- * before the ask's origin was persisted on the park — terminal for the row's bytes; the host
2154
- * decides the orphaned card itself, no worker version redeems it).
2155
- * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`,
2156
- * `"real_approval_forged"`, `"constraint_chain_missing"` (all terminal for the row's
2157
- * current bytes — no worker version redeems a damaged row). */
2158
- reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "origin_missing" | "real_approval_damaged" | "real_approval_forged" | "read_face_damaged" | "read_face_forged" | "constraint_chain_missing";
2169
+ /** WHICH arm fired, where one `code` covers several the word list and what each word means to a
2170
+ * host live at {@link CheckpointErrorReason}, its one home. Additive and optional like `field`
2171
+ * (`code` remains the only REQUIRED discriminant), which is exactly why the obligation to SPELL it
2172
+ * is a gate rather than a type: see the closed set's own doc. */
2173
+ reason?: CheckpointErrorReason;
2159
2174
  } | undefined;
2160
2175
  constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found"
2161
2176
  /** `runner.resume` was handed an {@link ResumeOutcome} whose `gate` arm does not match the
@@ -2330,20 +2345,11 @@ export declare class CheckpointError extends Error {
2330
2345
  * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
2331
2346
  * every other code. */
2332
2347
  retryAfterMs?: number;
2333
- /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
2334
- * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
2335
- * damaged/caller-declared and no worker ever will" blanket-retriable and gate-shape heuristics were
2336
- * both refuted downstream, so the throw site carries the fact it always knew). Closed set;
2337
- * additive and optional like `field` — `code` remains the only REQUIRED discriminant.
2338
- * · `unsupported_version` arms: `"version_newer"` (retryable on a newer worker),
2339
- * `"env_factory_missing"` (retryable on a factory-wired worker), `"governed_unwired"`
2340
- * (retryable on an org-wired worker), `"origin_missing"` (a pending approval row minted
2341
- * before the ask's origin was persisted on the park — terminal for the row's bytes; the host
2342
- * decides the orphaned card itself, no worker version redeems it).
2343
- * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`,
2344
- * `"real_approval_forged"`, `"constraint_chain_missing"` (all terminal for the row's
2345
- * current bytes — no worker version redeems a damaged row). */
2346
- reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "origin_missing" | "real_approval_damaged" | "real_approval_forged" | "read_face_damaged" | "read_face_forged" | "constraint_chain_missing";
2348
+ /** WHICH arm fired, where one `code` covers several the word list and what each word means to a
2349
+ * host live at {@link CheckpointErrorReason}, its one home. Additive and optional like `field`
2350
+ * (`code` remains the only REQUIRED discriminant), which is exactly why the obligation to SPELL it
2351
+ * is a gate rather than a type: see the closed set's own doc. */
2352
+ reason?: CheckpointErrorReason;
2347
2353
  } | undefined);
2348
2354
  }
2349
2355
  /**
@@ -2,7 +2,9 @@ import { decisionText, describeThrown } from "./tool-policy.js";
2
2
  import { askOriginOf, classifierMayAnswer } from "./ask-origin.js";
3
3
  import { denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  import { inlineUntrusted } from "./untrusted-text.js";
5
+ import { isRuleBehavior } from "./permission-rule-model.js";
5
6
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
+ import { applyPersistedTightening, disclosedRuleSet } from "./persisted-rule-arms.js";
6
8
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
7
9
  import { exitGate, traceHookCrash } from "./runner/gate-exit.js";
8
10
  function isPlainOwnRecord(x) {
@@ -89,8 +91,6 @@ function copiedUsableDots(dots) {
89
91
  return usable ? { dots: copied } : {};
90
92
  }
91
93
  export function normalizePersistedRuleHit(hit) {
92
- if (typeof hit === "string")
93
- return { hit: { rules: [{ rule: hit }] } };
94
94
  if (!isPlainOwnRecord(hit))
95
95
  return {};
96
96
  if (ownDataValue(hit, "unreadable") === true)
@@ -132,7 +132,10 @@ export function normalizePersistedRuleHit(hit) {
132
132
  return {};
133
133
  if (!members.every((m) => typeof m.rule === "string"))
134
134
  return {};
135
- return { hit: { rules: members.map((m) => ({ rule: m.rule, ...copiedUsableDots(m.dots) })) } };
135
+ const behavior = ownDataValue(hit, "behavior");
136
+ if (!isRuleBehavior(behavior))
137
+ return {};
138
+ return { hit: { behavior, rules: members.map((m) => ({ rule: m.rule, ...copiedUsableDots(m.dots) })) } };
136
139
  }
137
140
  export function persistedRuleMandateOf(marks) {
138
141
  return marks.probeMandated === true
@@ -153,10 +156,11 @@ function parkWith(suspendAsk, parkArgs, carry) {
153
156
  export async function runGateLanes(pass) {
154
157
  const { input, toolName, toolCallId, callSignal, ledger, notifier, screening, adjudicate, resolveAsk, suspendAsk } = pass;
155
158
  let orgRealApprovalRequired = false;
156
- const askOriginFacts = (org) => ({
159
+ const askOriginFacts = (org, ruleStore) => ({
157
160
  contentQuestion: toolName === ASK_USER_QUESTION_TOOL_NAME,
158
161
  markedUnresolvable: input.isMarkedUnresolvable?.(input.event.toolCallId) === true,
159
162
  org,
163
+ ...(ruleStore !== undefined ? { ruleStore } : {}),
160
164
  tightened: pass.tightenedBy,
161
165
  });
162
166
  let orgTightenCount = 0;
@@ -240,6 +244,24 @@ export async function runGateLanes(pass) {
240
244
  };
241
245
  const orgAnswer = await applyOrgLayer(pass.decision, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput);
242
246
  pass.decision = orgAnswer.decision;
247
+ const readPersistedRules = async (req) => {
248
+ let pendingHit;
249
+ try {
250
+ pendingHit = Promise.resolve(input.persistedRules?.adjudicate(req));
251
+ }
252
+ catch {
253
+ return { unreadable: true };
254
+ }
255
+ return await settleOrgVerdictWithin(pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true })), { unreadable: true }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
256
+ };
257
+ const laneReq = { ...pass.req, ...(pass.policyRewrite !== undefined ? { args: pass.policyRewrite } : {}) };
258
+ const laneAnswer = input.persistedRules !== undefined && pass.req.toolName !== ASK_USER_QUESTION_TOOL_NAME ? await readPersistedRules(laneReq) : undefined;
259
+ {
260
+ const tightened = applyPersistedTightening(pass.decision, laneAnswer);
261
+ pass.decision = tightened.decision;
262
+ if (tightened.tightened === "deny")
263
+ pass.deniedBy = "persisted_rule";
264
+ }
243
265
  if (pass.decision.action === "ask") {
244
266
  if (pass.policyRewrite !== undefined) {
245
267
  pass.currentInput = pass.policyRewrite;
@@ -252,6 +274,8 @@ export async function runGateLanes(pass) {
252
274
  probeMandated: pass.decision.action === "ask" && pass.decision.probeMandated === true,
253
275
  });
254
276
  let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
277
+ if (laneAnswer?.unreadable === true)
278
+ personalEvidence = { dotsAbsent: "unavailable" };
255
279
  let laneCoverage;
256
280
  if (input.persistedRules &&
257
281
  !orgRealApprovalRequired &&
@@ -260,24 +284,10 @@ export async function runGateLanes(pass) {
260
284
  pass.decision.decisionReason !== "hook" &&
261
285
  pass.req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
262
286
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
263
- const answer = await (async () => {
264
- let pendingHit;
265
- try {
266
- pendingHit = Promise.resolve(input.persistedRules?.admits(pass.req));
267
- }
268
- catch {
269
- return { unreadable: true };
270
- }
271
- return await settleOrgVerdictWithin(pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true })), { unreadable: true }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
272
- })();
273
- const hitRules = answer.hit?.rules;
287
+ const answer = laneAnswer ?? {};
288
+ const hitRules = answer.hit?.behavior === "allow" ? answer.hit.rules : undefined;
274
289
  laneCoverage = answer.coverage;
275
290
  personalEvidence = { dotsAbsent: answer.unreadable === true ? "unavailable" : "no_match" };
276
- const disclosedRuleSet = (rules) => {
277
- const shown = rules.slice(0, 5).map((r) => inlineUntrusted(r.rule, 200));
278
- const rest = rules.length - shown.length;
279
- return rest > 0 ? `${shown.join(", ")} and ${rest} more` : shown.join(", ");
280
- };
281
291
  const unionDotsOf = (rules) => {
282
292
  const seen = new Set();
283
293
  const union = [];
@@ -294,6 +304,8 @@ export async function runGateLanes(pass) {
294
304
  }
295
305
  return { dots: union };
296
306
  };
307
+ if (answer.hit?.behavior === "ask")
308
+ personalEvidence = unionDotsOf(answer.hit.rules);
297
309
  if (hitRules !== undefined && persistedRuleMandate === undefined && pass.decision.matchedAskRule === undefined) {
298
310
  pass.decision = {
299
311
  action: "allow",
@@ -324,7 +336,7 @@ export async function runGateLanes(pass) {
324
336
  };
325
337
  }
326
338
  }
327
- const originFacts = askOriginFacts(orgAnswer.org);
339
+ const originFacts = askOriginFacts(orgAnswer.org, laneAnswer?.unreadable === true ? "unavailable" : undefined);
328
340
  if (pass.decision.action === "ask") {
329
341
  const stamped = { ...pass.decision, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}) };
330
342
  pass.decision = { ...stamped, origin: askOriginOf(stamped, originFacts) };
@@ -374,6 +386,7 @@ export async function runGateLanes(pass) {
374
386
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true &&
375
387
  pass.decision.requiresRealApproval !== true &&
376
388
  pass.decision.decisionReason !== "hook" &&
389
+ pass.decision.origin !== "ask_rule" &&
377
390
  pass.hookAsk === undefined &&
378
391
  input.egress !== true &&
379
392
  input.peerMessage !== true &&
@@ -543,7 +556,7 @@ export async function runGateLanes(pass) {
543
556
  }
544
557
  }
545
558
  }
546
- if (!adjudicate && input.orgRules === undefined)
559
+ if (!adjudicate && input.orgRules === undefined && input.persistedRules === undefined)
547
560
  break;
548
561
  let recheck = { action: "allow" };
549
562
  if (adjudicate) {
@@ -576,6 +589,19 @@ export async function runGateLanes(pass) {
576
589
  editDenied = recheck;
577
590
  break;
578
591
  }
592
+ let editRuleStore;
593
+ if (input.persistedRules !== undefined && toolName !== ASK_USER_QUESTION_TOOL_NAME) {
594
+ const editLane = await readPersistedRules({ toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) });
595
+ editRuleStore = editLane.unreadable === true ? "unavailable" : undefined;
596
+ const editTightened = applyPersistedTightening(recheck, editLane);
597
+ recheck = editTightened.decision;
598
+ if (editTightened.tightened === "deny")
599
+ pass.deniedBy = "persisted_rule";
600
+ if (recheck.action === "deny") {
601
+ editDenied = recheck;
602
+ break;
603
+ }
604
+ }
579
605
  if (recheck.action === "allow") {
580
606
  if (recheck.updatedInput !== undefined) {
581
607
  editArgs = recheck.updatedInput;
@@ -588,7 +614,7 @@ export async function runGateLanes(pass) {
588
614
  editRewrittenSinceHuman = true;
589
615
  }
590
616
  const editAskSnapshot = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
591
- const editAskDecision = { ...editAskSnapshot, origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org)) };
617
+ const editAskDecision = { ...editAskSnapshot, origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org, editRuleStore)) };
592
618
  const editAskReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
593
619
  const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
594
620
  ledger.fromResolved(rr, editAskDecision, toolName);
@@ -129,18 +129,22 @@ export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude
129
129
  * - `ask_resolution` — the ask's own settlement is the refusal (a person said no, a window elapsed,
130
130
  * nobody could be asked…); the detail is on {@link GateOutcome.settlement}.
131
131
  */
132
- export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
132
+ export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
133
133
  export type DeniedBy = (typeof DENIED_BY_VALUES)[number];
134
134
  /** Membership test for {@link DeniedBy}. */
135
135
  export declare function isDeniedBy(v: unknown): v is DeniedBy;
136
136
  /** Which layers may VETO an approval a person already gave (invariant I3: a `human_allowed` settlement
137
- * beside a `denied` disposition names one of these). The approval-edit re-screen (hook), the policy and
138
- * org re-checks, and the write-protection re-judge run AFTER the person's yes; the classifier, plan mode
139
- * and compliance run before any ask exists, and `ask_resolution` IS the settlement, not a veto of it. */
137
+ * beside a `denied` disposition names one of these). The approval-edit re-screen (hook), the policy, org
138
+ * and persisted-rule re-checks, and the write-protection re-judge run AFTER the person's yes; the
139
+ * classifier, plan mode and compliance run before any ask exists, and `ask_resolution` IS the
140
+ * settlement, not a veto of it. `persisted_rule` = a person's own standing DENY row (their settings'
141
+ * deny list, imported) — the personal-store sibling of `org`, and like it re-judged on an edited
142
+ * command. */
140
143
  export declare const DENIED_BY_MAY_VETO: {
141
144
  readonly policy: true;
142
145
  readonly hook: true;
143
146
  readonly org: true;
147
+ readonly persisted_rule: true;
144
148
  readonly classifier: false;
145
149
  readonly plan_mode: false;
146
150
  readonly compliance: false;
@@ -31,7 +31,7 @@ export const SETTLEMENT_IS_REFUSAL = {
31
31
  blanket_allow_refused: true,
32
32
  task_aborted: true,
33
33
  };
34
- export const DENIED_BY_VALUES = ["policy", "hook", "org", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
34
+ export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
35
35
  const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
36
36
  export function isDeniedBy(v) {
37
37
  return DENIED_BY_SET.has(v);
@@ -40,6 +40,7 @@ export const DENIED_BY_MAY_VETO = {
40
40
  policy: true,
41
41
  hook: true,
42
42
  org: true,
43
+ persisted_rule: true,
43
44
  classifier: false,
44
45
  plan_mode: false,
45
46
  compliance: false,