@sema-agent/core 7.8.0 → 7.9.1

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 (103) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/core/ask-origin.d.ts +16 -1
  4. package/dist/core/ask-origin.js +5 -1
  5. package/dist/core/checkpoint-store.d.ts +36 -35
  6. package/dist/core/effective-path-target.d.ts +43 -0
  7. package/dist/core/effective-path-target.js +56 -0
  8. package/dist/core/engine-notice.d.ts +8 -0
  9. package/dist/core/fs-write-gate-policy.js +2 -1
  10. package/dist/core/gate-lanes.d.ts +1 -0
  11. package/dist/core/gate-lanes.js +60 -28
  12. package/dist/core/gate-outcome.d.ts +8 -4
  13. package/dist/core/gate-outcome.js +2 -1
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +2 -0
  16. package/dist/core/hooks.d.ts +66 -32
  17. package/dist/core/mcp-injection-drop.d.ts +74 -0
  18. package/dist/core/mcp-injection-drop.js +27 -0
  19. package/dist/core/permission-rule-consent.d.ts +29 -36
  20. package/dist/core/permission-rule-consent.js +110 -60
  21. package/dist/core/permission-rule-model.d.ts +311 -96
  22. package/dist/core/permission-rule-model.js +309 -50
  23. package/dist/core/permission-rule-org.d.ts +19 -12
  24. package/dist/core/permission-rule-org.js +13 -16
  25. package/dist/core/permission-rule-provider.d.ts +10 -6
  26. package/dist/core/permission-rule-provider.js +12 -8
  27. package/dist/core/permission-rule-session.d.ts +7 -6
  28. package/dist/core/permission-rule-session.js +29 -10
  29. package/dist/core/permission-rule-store.d.ts +40 -18
  30. package/dist/core/permission-rule-store.js +68 -42
  31. package/dist/core/permission-rule-sync.d.ts +9 -3
  32. package/dist/core/permission-rule-sync.js +29 -25
  33. package/dist/core/permission-rule-syntax.d.ts +30 -0
  34. package/dist/core/permission-rule-syntax.js +44 -0
  35. package/dist/core/permission-rules.d.ts +56 -33
  36. package/dist/core/permission-rules.js +88 -55
  37. package/dist/core/persisted-rule-arms.d.ts +61 -0
  38. package/dist/core/persisted-rule-arms.js +50 -0
  39. package/dist/core/runner/active-skill-scope.js +2 -1
  40. package/dist/core/runner/contracts.d.ts +21 -2
  41. package/dist/core/runner/permission-rule-lanes.d.ts +42 -27
  42. package/dist/core/runner/permission-rule-lanes.js +25 -21
  43. package/dist/core/runner/prepare-gate-stations.js +1 -1
  44. package/dist/core/runner/prepare-safety-scan.js +8 -2
  45. package/dist/core/runner/prepare-task.js +1 -1
  46. package/dist/core/runner/resume-admission.d.ts +53 -0
  47. package/dist/core/runner/resume-admission.js +83 -0
  48. package/dist/core/runner/resume-apply.d.ts +50 -0
  49. package/dist/core/runner/resume-apply.js +184 -0
  50. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  51. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  52. package/dist/core/runner/resume-claim.d.ts +32 -0
  53. package/dist/core/runner/resume-claim.js +27 -0
  54. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  55. package/dist/core/runner/resume-internals-and-config.js +50 -0
  56. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  57. package/dist/core/runner/resume-policy-outcome.js +127 -0
  58. package/dist/core/runner/resume-preflight.d.ts +40 -0
  59. package/dist/core/runner/resume-preflight.js +122 -0
  60. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  61. package/dist/core/runner/resume-review-outcome.js +88 -0
  62. package/dist/core/runner/run-harness-handlers.js +1 -1
  63. package/dist/core/runner/runtask.d.ts +20 -0
  64. package/dist/core/runner/runtask.js +85 -734
  65. package/dist/core/runner/session-rule-policy.js +2 -1
  66. package/dist/core/runner/tool-end-body.d.ts +12 -5
  67. package/dist/core/runner/tool-end-body.js +5 -5
  68. package/dist/core/runner/tool-face-overlay.js +22 -3
  69. package/dist/core/runner/tool-output-projection.d.ts +9 -6
  70. package/dist/core/runner/tool-output-projection.js +1 -18
  71. package/dist/core/runner/turn-attachments.d.ts +2 -2
  72. package/dist/core/sensitive-path-policy.js +5 -3
  73. package/dist/core/shell-lexer.d.ts +47 -0
  74. package/dist/core/shell-lexer.js +478 -0
  75. package/dist/core/shell-scan.d.ts +60 -0
  76. package/dist/core/shell-scan.js +183 -0
  77. package/dist/core/shell-wrapper-table.d.ts +297 -0
  78. package/dist/core/shell-wrapper-table.js +58 -0
  79. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  80. package/dist/core/tool-catalog-entries.js +6 -6
  81. package/dist/core/tool-face.d.ts +80 -4
  82. package/dist/core/tool-face.js +10 -0
  83. package/dist/core/tool-policy.d.ts +1 -6
  84. package/dist/core/tool-registry.d.ts +8 -11
  85. package/dist/core/tool-registry.js +5 -2
  86. package/dist/core/tool-roster.d.ts +20 -2
  87. package/dist/core/tool-roster.js +36 -8
  88. package/dist/index.d.ts +7 -4
  89. package/dist/index.js +6 -3
  90. package/dist/stores/file/checkpoint-store.js +1 -1
  91. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  92. package/dist/stores/file/permission-rule-store.js +64 -16
  93. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  94. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  95. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  96. package/dist/tools/fs/fs-search-tools.js +17 -9
  97. package/dist/tools/fs/index.js +1 -1
  98. package/dist/tools/fs/notebook.d.ts +1 -1
  99. package/dist/tools/fs/safety.d.ts +1 -1
  100. package/dist/tools/fs/search.d.ts +0 -8
  101. package/dist/tools/fs/search.js +0 -23
  102. package/package.json +1 -1
  103. package/test/export-surface.snapshot.json +99 -11
package/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.9.1 — 2026-09-08
4
+
5
+ ### Wire (additive) — `mcp.injection_dropped`: the request-lane MCP drop reaches the user (L-167; @server @cli @client-core)
6
+ - **One mint, host facts.** A host that drops an entry of the user's own request-lane MCP injection (a `.mcp.json` row) now hands the FACTS to core — `mcpInjectionDroppedNotice({ sessionId, server, reason, field? })` — and core composes the notice: code `mcp.injection_dropped`, audience `user`, one sentence per reason naming the recovery. The host spells no code, audience or text (`@contract mcp.injection_dropped.single_mint`); a fact outside the vocabulary (an unlisted reason, an empty session or server name) is refused at the mint with a `TypeError`, never shaped into a notice that routes nowhere.
7
+ - **Closed set** `McpInjectionDropReason` = `malformed_entry` / `name_reserved_by_deployment` / `gate_closed` / `over_cap` (`MCP_INJECTION_DROP_REASONS`, registered in CLOSED-SETS with its text table's fence); `detail: { sessionId, server, reason, field? }` — `sessionId` is REQUIRED (the routing half of a user row) and lifted onto the typed key by the one delivery throat; `field` present only for a non-empty host-supplied key. Dedup unit is the host's: per leg per server name.
8
+ - **Consumers:** `ENGINE_NOTICE_CODES` +1 (`mcp.injection_dropped`, `NOTICE_AUDIENCE` row `user`) — a downstream audience mirror diffing against the catalog gains one row; server's `engine_notice` whitelist +1; cli renders one user-level transcript line off `detail.reason`. Exports +5 (`MCP_INJECTION_DROP_REASONS`, `MCP_INJECTION_DROP_TEXT`, `mcpInjectionDroppedNotice`, `McpInjectionDropReason`, `McpInjectionDropFacts`).
9
+
10
+ ### Narrowing — a path rule is judged against the path the tool really opens (#635)
11
+ - **One effective-target resolver, shared with tool execution.** `ToolPathTarget` gains three resolution members — `base` (`"cwd" | "root"`, absent ⇒ `cwd`), `absent` (`"none" | "base"`, absent ⇒ `none`) and `patternParam` (the parameter whose ABSOLUTE glob pattern carries its own base directory) — declared by Read/Edit/Write/NotebookEdit (`cwd`/`none`) and Grep/Glob (`root`/`base`; Glob + `pattern`). `effectivePathTargetOf` (new export, with `pathTargetBaseOf`, `pathTargetSlotOnly` and the closed sets `TOOL_PATH_BASES` / `TOOL_PATH_ABSENCES`) is the one reader: both permission-rule lanes judge a call with it, and the four canonicalizing write fences plus the two search tools share the BASE SELECTION (and, for Glob, the absolute-pattern split) before applying their own identity to it — lexical-normal for a rule, canonical for a tool. One declaration, one selection, so a fence and the tool it fences can no longer resolve one call to two files. **Three shapes now REACH the rules that name them** (each was silently unreachable before): `Grep(//proj/**)` vs a Grep with the path omitted (the tool searches the root); `Glob(//proj/secret/**)` vs `{pattern:"/proj/secret/**"}` (the absolute pattern is the base); and a relative Grep `path`, which resolves against the ROOT as the tool does rather than against the live `cd`. The file tools are byte-unchanged (their declared `cwd` base is the old guess). One honesty change: a relative spelling with NO usable base is now "no verdict" instead of being fabricated into `/<spelling>`.
12
+ - **Wire (additive, @server @cli @client-core @web-admin):** `ToolRosterEntry.pathTarget` carries optional `base` / `absent` / `patternParam` (present only when the definition declares them), and so do `ToolCallFace.pathTarget` and the parked row's face — without them the gate's stamped face loses the facts that say which file a call is about. A mirrored row schema with `additionalProperties: false` must admit the three keys. `ToolPathTarget` / `ToolCallFace.pathTarget` / `ToolCallLike.face.pathTarget` / the checkpoint parked-face row now all name ONE shape: `ToolCallPathTarget` is the READER type (both spellings satisfy it) and `ToolRosterPathTarget` its NORMALIZED spelling (`aliases` and `skillScopeEligible` always present, as the mint has always filled them) — both new exports; the call face and the parked row keep the normalized one, so a consumer already reading `face.pathTarget.aliases` compiles unchanged. `@server` `pathBases` is unchanged.
13
+ - **The declaration door** (`toolFaceProblem`, caller faces and MCP `toolFaces` alike) refuses a `base` or `absent` word outside its closed set, a `patternParam` that is not a top-level schema property, a `patternParam` declared while `absent` is not `"base"` (it could never be read), **`base: "root"` on a face whose `access` is not `"read"`** — two write guards not taught by this change (the session-transcript directory guard, which is handed no root at all, and the frozen-spec deny) resolve a write target against the call's working directory alone, so a write face declaring a root base would be judged on two different files; refused until #638 threads the base through both, with no built-in affected (the three write tools declare `base: "cwd"`, the two search tools are reads) — and **`absent: "base"` on a face whose `access` is not `"read"`** — that word says "with no path, the call is about the whole base directory", which is a search SCOPE; a write with no path names no file, and a write face declaring it would be judged on its base by the rule lanes and on nothing by the containment fences (a writer could then evade the sensitive-path guard by omitting its path). A refused face does not mount, as before.
14
+ - **An EMPTY path slot is absent on both sides.** Grep and Glob read their scope argument with the engine's own slot reader (first NON-EMPTY string under the declared param) instead of `!== undefined`. Behavior change, small and in the closing direction: `Glob({pattern:"/proj/secret/**", path:""})` now decomposes the absolute pattern exactly as an omitted `path` does (it used to fold `""` to the root and match the absolute pattern there, while every fence judged the pattern's own base — one call, two directories, which is how a root deny could be walked past). `Grep` with `path:""` still searches the whole root, as it did.
15
+ - **A win-family spelling lands where the env puts it.** `canonicalizeTarget` never joins a spelling it calls absolute (a drive letter, a UNC root) to the base a tool hands it — it passes the spelling through and the execution env resolves it against its own working directory. So the resolver does not apply the DECLARED base to such a spelling either; it uses the tracked cwd with the root as the floor, which is what the tool's env will do. Without this, `Grep({path:"C:/secret.txt"})` under root `/` with a live `cd` to `/proj` was judged `/C:/secret.txt` while the tool read `/proj/C:/secret.txt`, and `Grep(//proj/**)` — a deny that fired in 7.9.0 — missed. The source-side fix is #637 (the canonicalizer's absoluteness is family-blind); this branch retires with it.
16
+ - **The protective floor hands over the slot, not the resolution.** A call whose face declares no path target of its own is judged against the catalog's declaration for the NAME (design/388 B6) — a floor whose whole justification is "naming a slot can only tighten a deny". Naming a slot does; claiming how a FOREIGN tool of the same name resolves that slot does not, so the floor now supplies `param`/`aliases`/`access` and lets the resolution members default (`pathTargetSlotOnly`, new export). Net effect versus 7.9.0 for a call that carries a face: none — this keeps the floor where it was while the declarations moved. A BARE request (no face at all — a policy invoked outside a Runner) does read the catalog whole, resolution members included, which is the point: there the catalog IS the tool being judged.
17
+
18
+ ### Narrowing — the tightening (deny/ask) reach reads a real shell lexer (#631)
19
+ - **Reach.** `programRunReachOf(rule, command)` answers the closed set `ProgramRunReach` = `reached` / `not_reached` / `unreadable` (registered in CLOSED-SETS); `ruleReachesProgramRun` is its boolean face (unchanged signature, `reached` only). The command is read by `src/core/shell-lexer.ts` (no grammar library): quotes and escapes, every expansion form, the full redirection operator set (IO_NUMBER, `&>`/`&>>`, `N>&M-`, `>&-`, `<>`, `<<<`, here-documents whose bodies are skipped), every connector (`&` and a newline bound segments), subshell/group boundaries, leading keywords (`if rm -r x; then …` runs `rm`), leading assignments, and the closed wrapper table `SHELL_WRAPPER_TABLE` (`command`/`builtin`/`exec`/`env`/`sudo`/`doas`/`pkexec`/`nohup`/`time`/`nice`/`timeout`/`stdbuf`/`xargs`/`watch`/`ionice`/`setsid`/`taskset`/`chrt`/`strace`/`ltrace`/`flock`/`script`/`unshare`/`nsenter`/`noglob`/`nocorrect` — upstream's three strips as one table). Peeling is ADDITIVE (`ShellSegment.peeled`): `sudo rm -r x` is a run of `sudo …` and of `rm -r x`, so a deny on either reaches, and a rule body is never peeled (`Bash(sudo rm -r x)` denies exactly that). A command/process substitution anywhere in a segment (`$(…)`, backticks, `<(…)`, inside `${…}`/`$((…))`, a redirection operand, an unquoted here-document body) marks the segment `peelUnreadable`: the runs it spells are still compared (`echo "$(rm -r x)"` reaches `Bash(echo:*)`), and every other deny/ask reads `unreadable` — the commands INSIDE a substitution are deliberately not read (an approval prompt, never a guessed boundary). Words are compared argv-wise with quotes removed on BOTH sides: `rm "-r" x`, `sudo rm -r x`, `command rm -r x`, `rm -r x; echo "$HOME"`, `rm -r x &> log`, `rm -r x 2>&-`, `npm test & curl x`, `do curl x; done`, `timeout -- 5 rm -r x` now REACH a `Bash(rm -r:*)` / `Bash(curl:*)` deny or ask (7.9.0: silence on every one of them). A `#` comment and a quoted-delimiter here-document body are not runs (7.9.0 judged `echo ok # ; curl x` as a curl run — a limitation, not over-approximation). A leading `~` is an expansion of one word (an exact deny on the expanded path asks; a prefix deny reaches). Module split beside the lexer: `shell-scan.ts` (the byte-walking scanners) and `shell-wrapper-table.ts` (the table).
20
+ - **Unreadable is a fail-closed ask, never silence — and only when a standing deny/ask rule could not be judged** (zero tightening rules ⇒ the lane stays silent, as before). Unreadable = an expansion where the rule reads a word (`rm $X` vs `Bash(rm -r:*)`; `"$@"` / `"${a[@]…}"` after an exact body — they may be zero words), an unterminated quote/substitution, a syntax error (`> ;`, a dangling `&&`), a `for`/`case`/`function` header, a wrapper handed its command as one string (`env -S`, `flock -c`, `script -c`) or to a shell (`watch`, `script`), a wrapper option outside the row's CLOSED option sets (short or long — the table names every option it reads; an unknown one asks rather than guessing its arity), an expanding option value or operand, a positional that does not match (`taskset zz`), more than 8 wrapper layers, a here-document delimiter spelled with an expansion, a command over 10 000 characters (`MAX_SHELL_READ_CHARS`, upstream's own bound). Per lane: the PERSISTED store answers `{ unreadable: true, reason }` — the same shape as an unreadable store (origin `rule_store_unavailable`, real-approval, the classifier never answers; the message says the reason instead of "the store could not be read"); the ORG verdict carries `unreadable: <reason>` beside the first rule it could not judge (`behavior: "ask"`, origin `org_rule`, message names rule + reason); the rule DSL mints `{ action: "ask", matchedAskRule, requiresRealApproval: true }`. Precedence: a deny that cannot be judged outranks an ask that can; within one behavior a reach settles it. **No new AskOrigin word** — `rule_store_unavailable`'s meaning is widened to "the person's deny/ask rows cannot be enforced for this call" (store unreadable OR call unreadable against them; JSDoc amended).
21
+ - **Wire (additive):** `PersistedRuleUnreadable.reason?: string`; `OrgGateVerdict.verdict.unreadable?: string` (only beside `behavior: "ask"`; both screened by the normalizers, a non-string dropped); `PersistedRuleVerdict.unreadable?: string`; `orgRuleVerdictFor` returns `OrgRuleVerdict` (the same two members plus the optional third). New exports: `programRunReachOf`, `PROGRAM_RUN_REACHES`, `ProgramRunReach`, `ProgramRunReachOutcome`, `readShellCommand`, `isFullyReadable`, `MAX_SHELL_READ_CHARS`, `SHELL_WRAPPER_TABLE`, `ShellCommandShape`/`ShellSegment`/`ShellWord`/`ShellWrapperName`, `OrgRuleVerdict`. Retired (internal, never exported): `stripRedirections`, `stripFdDuplications`, `ruleLaneSegmentsOf`, `ruleAdmitsProgramRun`.
22
+ - **The allow side is byte-untouched:** `ruleAdmitsCommand` keeps its whole-string refusal of every shell construct (`rm -r x > f` is admitted by nothing; a quoted spelling is not an allow's equal) — and the deny now reads the quoted spelling, so an exact allow row minted for `rm "-r" x` no longer outruns a `Bash(rm -r:*)` deny.
23
+ - **Merged-tree round (four `not_reached` blind spots, each now `unreadable` or reached at all three lanes, the classifier never consulted):** ① an extended glob's INTERIOR is read for substitutions (`echo @($(rm -r x))` — the pattern's interior runs a program). ② a clustered `xargs -tI R` records the replacement token by the resolved letter (`-tI R rm -R x` no longer reads `rm -R x` as literal). ③ **executors are rows of the same closed table**: `viaShell` now means "evaluates its operands as shell input" (a command string, a script file, an expression) and covers `eval`, `source`, `.`, `trap`, `let`, `sh`/`bash`/`zsh`/`dash`/`ksh`/`fish`/`csh`/`tcsh` beside `watch`/`script` — the string is not split and judged (upstream splits on whitespace; the strict word stands), so `bash -c 'rm -r x'`, `eval 'rm -r x'`, `bash cleanup.sh`, `sudo bash -c …` ask once when a deny/ask rule stands (7.9.0: silence); a new row member `executes` names the words after which a NON-wrapper runs a command anywhere in its argv — `find` with `-exec`/`-execdir`/`-ok`/`-okdir` (such a word, or any expansion among `find`'s arguments, asks; `find . -name '*.ts'` stays silent). The executor's own run is still read (`Bash(eval:*)`, `Bash(find:*)` reach). ④ indirect arithmetic through a parameter SUBSCRIPT (`${arr[a]}`, `${arr[$i]}`, `${#arr[n]}` — bash evaluates an indexed subscript recursively, so a stored `arr[$(…)]` runs; `[@]`/`[*]`/a number name nothing) and through the `(( … ))` command (read as the arithmetic command — a run-less piece — instead of two boundaries around a phantom program) are unreadable, the same law as `$((a))`. `isFullyReadable` (exported; not read by the offer lane, which mints from its own reader) answers false for every shape above. **The round's two adversarial passes (all findings adopted):** a `$…` inside arithmetic or a subscript is a name too (`(( $1 ))`, `${arr[$1]}`); the arithmetic command is read after `time`/`time -p`/`{`/an adjacent `!`, and its interior is judged safe only when it is digits, operators and spaces — `((:);)` (nested subshells bash re-reads after the arithmetic parse fails) asks instead of vanishing; an `xargs -I` token carrying an expansion stops the peel; `$[…]` (legacy arithmetic) and a slice offset/length naming a variable (`${x:a}`, `${x:0:a}`) are hidden runs like a subscript; **builtins whose operands NAME variables** (`VARIABLE_OPERAND_BUILTINS` in the table module: `printf`, `unset`, `read`, `mapfile`/`readarray`, `declare`/`typeset`/`local`/`export`/`readonly`, `test`/`[`/`[[`) mark a hidden run when an operand spells an array element with a non-numeric subscript (`printf -v 'arr[a]'`, `-v'arr[a]'`, `unset arr[a]`, `declare arr[a]=1`, `[[ -v arr[a] ]]`) or an integer-attribute assignment (`declare -i x=a`) — judged at every peel candidate (`command printf -v …`); `compgen`/`complete` with `-C`/`-F` join the `executes` rows; an unclosed `((` is an unreadable tail in linear time. Not judged (documented, deliberate — the price would be a prompt on every `export FOO="$BAR"` / `[[ $n -gt 0 ]]`): an operand that is WHOLLY an expansion to one of those builtins, and an arithmetic comparison's operands.
24
+ - **@cli** (card copy): an ask of origin `rule_store_unavailable` may now be "your command could not be read against your deny/ask rules (<reason>)", not only "the rule store could not be read" — the decision's `message` carries the reason; a card that hard-codes store wording for the origin should read the message. An org ask's message has a second form (`… may reach this call, which could not be read against it (<reason>)`). **@server**: zero required changes (both new members are optional and screened; `OrgGateVerdict` producers may add `unreadable` on an ask verdict).
25
+
26
+ ## 7.9.0 — 2026-09-08
27
+
28
+ 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.
29
+
30
+ ### BREAKING — content-form deny/ask rules get their engine lane (#625; cli B-043 root cause)
31
+ - **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.
32
+ - **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`.
33
+ - **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`.
34
+ - **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.
35
+ - **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).
36
+ - **@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.
37
+
38
+ ### Fixes over the merged tree (main-tree adversarial round over #625 × 393 S3)
39
+ - **#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.
40
+ - **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.
41
+ - **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).
42
+
43
+ ### Wire (additive — new frames start being pushed; the vocabulary shipped in 7.8.0)
44
+ - `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).
45
+ - `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.
46
+ - 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.
47
+
48
+ ### Narrowing
49
+ - **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.
50
+
51
+ ### Internal (byte-invariant on the runtime — design/393 S3)
52
+ - `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.
53
+ - `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.
54
+
55
+ ### Pins
56
+ - `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.
57
+
58
+ ### Gates (#627 — server [6626] door request)
59
+ - **A `checkpoint.unsupported_version` refusal always names its arm (#627).** The word rides an OPTIONAL
60
+ `detail.reason`, so nothing red when a throw site omitted it — and the file ledger's replay refusal
61
+ (`FileCheckpointStore`, an event kind written by a newer worker) had omitted it since it was written. It
62
+ now carries `reason: "ledger_event_unknown"`, the tenth member of the closed set: the one arm that is NOT
63
+ row-scoped — no row under that data root is readable by this binary, so the host rolls the binary forward
64
+ rather than routing one token to another worker. **@server**: additive word on an optional member; a
65
+ consumer holding a total table over `reason` gains a row.
66
+ - The word list leaves the constructor's inline type for a named closed set, `CheckpointErrorReason`
67
+ (exported from `src/core/checkpoint-store.ts`; the public export surface is unchanged), registered in
68
+ `docs/CLOSED-SETS.md` with a new **mint-obligation** leg in `gate:closed-sets`: every
69
+ `new CheckpointError("checkpoint.unsupported_version", …)` under `src/` must pass a detail object literal
70
+ whose `reason` is spelled AT the site (a literal, or a conditional whose branches are) and is a member of
71
+ the set — a missing detail, a missing/shorthand/computed word, or a word outside the set reds with
72
+ `file:line`, and a per-row population floor reds when the walker stops seeing the sites at all. The
73
+ registry's two G8 floors were re-measured in the same pass (rows 8 → 10, switches 1 → 3; both had drifted
74
+ below the census, which is the blindness a floor exists to make loud).
75
+
76
+
3
77
  ## 7.8.0 — 2026-09-08
4
78
 
5
79
  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 +103,7 @@ One roster instead of 29 name tables. `ToolSpec` already described a tool comple
29
103
  - 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
104
  - 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
105
 
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)
106
+ ### 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
107
  - **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
108
  - `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
109
  - `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,14 @@ 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), OR could not read the CALL against the person's standing
46
+ * deny/ask rows (the tightening lexer's `unreadable` word: an expansion where a rule reads a word, an
47
+ * unterminated quote, a syntax error — the answer's `reason`): the person's own deny/ask rows cannot
48
+ * be enforced for this call, so it fails closed the way an unreadable org snapshot does — an allow
49
+ * tightens to an ask a person must clear, and no automatic lane may answer ("does one of my rules
50
+ * forbid this?" is unanswered). One word for both, deliberately: the AUTHORITY semantics (who may
51
+ * answer, what clears it, what the evidence records) are identical, and only the mechanism differs.
44
52
  * - `hook` — a deployment's PreToolUse screening face raised or co-raised the ask; the word is
45
53
  * engine-stamped at the fold and is the deployment's own per-call adjudication.
46
54
  * - `ask_rule` — an EXPLICIT `ask` permission rule matched: a person's standing "ask me each time",
@@ -61,7 +69,7 @@ import type { PermissionResult } from "./tool-policy.js";
61
69
  * observer or an approval card can say WHICH engine layer raised the question, which one word
62
70
  * could not.
63
71
  */
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"];
72
+ 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
73
  export type AskOrigin = (typeof ASK_ORIGINS)[number];
66
74
  /** Whether a value is a member of the closed origin set. Exported because the word crosses process
67
75
  * boundaries: a host validating one it received must not hand-roll the set. */
@@ -87,6 +95,9 @@ export interface AskOriginFacts {
87
95
  * read-back off the decision (a policy can put `decisionReason: "org_rule"` on its own ask; the
88
96
  * gate's cell cannot be written by a policy). */
89
97
  readonly org: "rule" | "unavailable" | undefined;
98
+ /** The gate's own record of its persisted-rule lane's READ: `"unavailable"` when the wired store could
99
+ * not be read for this call (the lane's deny/ask rows are unenforceable). Written only by the gate. */
100
+ readonly ruleStore?: "unavailable" | undefined;
90
101
  /** Which of the gate's OWN post-fold tightens minted the standing ask, if one did — again the gate's
91
102
  * record of its own layer (a tighten replaces an `allow` wholesale, so at most one fires per pass),
92
103
  * never a read-back of `decisionReason: "safety"` off a decision a policy could have composed. */
@@ -127,6 +138,7 @@ export declare const ORIGIN_IMPLIES_REAL_APPROVAL: {
127
138
  readonly unresolvable: false;
128
139
  readonly org_unavailable: "at_derivation";
129
140
  readonly org_rule: "at_derivation";
141
+ readonly rule_store_unavailable: "at_derivation";
130
142
  readonly hook: false;
131
143
  readonly ask_rule: false;
132
144
  readonly denial_limit_fallback: "at_mint";
@@ -148,6 +160,8 @@ export type RealApprovalTableCoversEveryOrigin = AssertAllKeysHandled<Exclude<As
148
160
  * - `org_unavailable` / `org_rule`: judgment may clear a `requiresRealApproval` bit a POLICY minted,
149
161
  * but not the org's — while adjudication is unavailable the org's answer is unknown, and an org
150
162
  * ask carries the org's own "a person nods" semantic.
163
+ * - `rule_store_unavailable`: the person's own deny/ask rows are unreadable — whether one forbids this
164
+ * call is unknown, and a classifier verdict would answer that question in the person's stead.
151
165
  * - `hook`: the deployment's own per-call adjudication; the classifier is its delegate for
152
166
  * hesitation, not an override for its screening face.
153
167
  * - `ask_rule`: a person's standing "ask me each time" — the classifier answering it would decide
@@ -169,6 +183,7 @@ declare const CLASSIFIER_MAY_ANSWER: {
169
183
  readonly unresolvable: false;
170
184
  readonly org_unavailable: false;
171
185
  readonly org_rule: false;
186
+ readonly rule_store_unavailable: false;
172
187
  readonly hook: false;
173
188
  readonly ask_rule: false;
174
189
  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,
@@ -843,15 +843,10 @@ export type PendingAction = {
843
843
  * display `family` + declared `pathTarget` — off the same roster row at the same mint. The pre-CAS resume-edit
844
844
  * recheck has no roster yet; it rebuilds the call face from these (`parkedCallFaceOf`) and judges the edit as
845
845
  * the live gate judged the original (merge-seam H1: without them a caller slot fell to the catalog's slot for
846
- * the NAME). Any member absent ⇒ pre-388 row ⇒ the recheck falls to the catalog, never a widened grant.
846
+ * the NAME). Any member absent ⇒ pre-388 row ⇒ the recheck falls to the catalog, never a widened grant — including #635's resolution members, which a row written before them simply lacks (they then read as their documented defaults).
847
847
  */
848
848
  family?: import("./tool-face.js").ToolFamily;
849
- pathTarget?: {
850
- param: string;
851
- aliases: string[];
852
- access: "read" | "create" | "edit";
853
- skillScopeEligible: boolean;
854
- };
849
+ pathTarget?: import("./tool-face.js").ToolRosterPathTarget;
855
850
  /**
856
851
  * #438 (additive; no checkpoint-version bump — the `reminderMark` precedent: an optional field an
857
852
  * older reader ignores. Unlike that precedent seat — which resume DOES read, through its
@@ -1967,6 +1962,30 @@ export interface CheckpointSummary {
1967
1962
  * {@link ResourceLedger.spentMicroUsd}. Pure; reads no clock/random.
1968
1963
  */
1969
1964
  export declare function summarizeCheckpoint(cp: Checkpoint): CheckpointSummary;
1965
+ /**
1966
+ * WHICH arm of a multi-arm {@link CheckpointError} `code` fired (requested 2026-08-10: a deployment retry
1967
+ * policy needs to tell "a newer worker can redeem this row" from "this row is damaged/caller-declared and
1968
+ * no worker ever will" — blanket-retriable and gate-shape heuristics were both refuted downstream, so the
1969
+ * throw site carries the fact it always knew).
1970
+ *
1971
+ * A CLOSED SET with one home, registered in `docs/CLOSED-SETS.md`. It is carried in an OPTIONAL member, so
1972
+ * the compiler is silent when a throw site omits it, and a site that omits it hands the host a refusal it
1973
+ * cannot classify — the registry's mint-obligation leg is what makes the omission loud (every
1974
+ * `checkpoint.unsupported_version` construction site must spell one of these words AT the site).
1975
+ *
1976
+ * · `unsupported_version` arms: `"version_newer"` (the row's format is newer than this worker's —
1977
+ * retryable on a newer worker), `"env_factory_missing"` (retryable on a factory-wired worker),
1978
+ * `"governed_unwired"` (retryable on an org-wired worker), `"origin_missing"` (a pending approval row
1979
+ * minted before the ask's origin was persisted on the park — terminal for the row's bytes; the host
1980
+ * decides the orphaned card itself, no worker version redeems it), `"ledger_event_unknown"` (a durable
1981
+ * LEDGER carries an event kind this binary does not know, so the replay is refused rather than served
1982
+ * incomplete — the one arm that is not row-scoped: NO row in that data directory is readable here, so
1983
+ * the host rolls the binary forward instead of routing one token elsewhere).
1984
+ * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`, `"real_approval_forged"`,
1985
+ * `"read_face_damaged"`, `"read_face_forged"`, `"constraint_chain_missing"` (all terminal for the row's
1986
+ * current bytes — no worker version redeems a damaged row).
1987
+ */
1988
+ 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
1989
  /** A typed checkpoint-store error so callers branch on `code` (mirrors `SessionError`). */
1971
1990
  export declare class CheckpointError extends Error {
1972
1991
  readonly code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found"
@@ -2142,20 +2161,11 @@ export declare class CheckpointError extends Error {
2142
2161
  * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
2143
2162
  * every other code. */
2144
2163
  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";
2164
+ /** WHICH arm fired, where one `code` covers several the word list and what each word means to a
2165
+ * host live at {@link CheckpointErrorReason}, its one home. Additive and optional like `field`
2166
+ * (`code` remains the only REQUIRED discriminant), which is exactly why the obligation to SPELL it
2167
+ * is a gate rather than a type: see the closed set's own doc. */
2168
+ reason?: CheckpointErrorReason;
2159
2169
  } | undefined;
2160
2170
  constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found"
2161
2171
  /** `runner.resume` was handed an {@link ResumeOutcome} whose `gate` arm does not match the
@@ -2330,20 +2340,11 @@ export declare class CheckpointError extends Error {
2330
2340
  * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
2331
2341
  * every other code. */
2332
2342
  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";
2343
+ /** WHICH arm fired, where one `code` covers several the word list and what each word means to a
2344
+ * host live at {@link CheckpointErrorReason}, its one home. Additive and optional like `field`
2345
+ * (`code` remains the only REQUIRED discriminant), which is exactly why the obligation to SPELL it
2346
+ * is a gate rather than a type: see the closed set's own doc. */
2347
+ reason?: CheckpointErrorReason;
2347
2348
  } | undefined);
2348
2349
  }
2349
2350
  /**
@@ -0,0 +1,43 @@
1
+ import type { ToolCallPathTarget } from "./tool-face.js";
2
+ import { type ToolCallLike } from "./tool-registry.js";
3
+ /** The bases of ONE CALL a target resolves against: the task root, and the live tracked working directory
4
+ * (absent when no tracker moved or the caller keeps none). Facts of the call, never of the tool. */
5
+ export interface ToolPathBases {
6
+ readonly root?: string | undefined;
7
+ readonly cwd?: string | undefined;
8
+ }
9
+ /**
10
+ * WHICH base this target's relative spellings resolve against, as SPELLED by the caller (no normalization:
11
+ * the tool hands this straight to `canonicalizeTarget`, which resolves win-form and symlinked spellings the
12
+ * fences must not pre-fold). `undefined` ⇒ the deployment gave no such base, so a relative spelling in this
13
+ * slot names nothing this reader can locate.
14
+ */
15
+ export declare function pathTargetBaseOf(pt: Pick<ToolCallPathTarget, "base"> | undefined, bases: ToolPathBases): string | undefined;
16
+ /**
17
+ * design/116 批④ #8 (CC 2.1.198 RWp, bundle :332987) — decompose an ABSOLUTE glob pattern into a base
18
+ * directory + relative pattern: cut at the last separator before the first glob metachar (`* ? [ {`); a
19
+ * pattern with no metachar splits dirname/basename. `undefined` for a relative pattern (the caller keeps it
20
+ * as-is) or when no usable base can be derived.
21
+ *
22
+ * Lives HERE rather than beside the search engine because two readers need the identical cut: Glob's own
23
+ * scope resolution and the path fences that must know which directory an absolute pattern really searches.
24
+ */
25
+ export declare function splitAbsoluteGlobPattern(pattern: string): {
26
+ baseDir: string;
27
+ relativePattern: string;
28
+ } | undefined;
29
+ /**
30
+ * The absolute, LEXICAL-NORMAL path this call is about under ONE reading of its path slot, or `undefined`
31
+ * when that reading locates none. `pt` is the reading (design/388 B6/B17: the PROTECTIVE target for a
32
+ * tightening fence, the DECLARED target for a grant) — this function never picks between the two.
33
+ *
34
+ * The order is the tool's own:
35
+ * ① a spelling in the slot wins — absolute as spelled, relative against {@link pathTargetBaseOf}'s base;
36
+ * ② otherwise, when the declaration says an absent slot means `none`, the call is about no path;
37
+ * ③ otherwise a `patternParam`'s ABSOLUTE pattern supplies its own base directory;
38
+ * ④ otherwise the base itself (the tool searches all of it).
39
+ *
40
+ * A base the deployment did not supply yields `undefined`: a relative spelling with nothing to resolve
41
+ * against names no file, and claiming it names `/<spelling>` was a fabrication no fence should judge.
42
+ */
43
+ export declare function effectivePathTargetOf(call: ToolCallLike, pt: ToolCallPathTarget | undefined, bases: ToolPathBases): string | undefined;
@@ -0,0 +1,56 @@
1
+ import { isAbsolutePathForm } from "../tools/fs/safety.js";
2
+ import { lexicalNormalAbsolutePathOf } from "./permission-rule-model.js";
3
+ import { pathTargetValue } from "./tool-registry.js";
4
+ export function pathTargetBaseOf(pt, bases) {
5
+ return (pt?.base ?? "cwd") === "root" ? bases.root : (bases.cwd ?? bases.root);
6
+ }
7
+ export function splitAbsoluteGlobPattern(pattern) {
8
+ if (!isAbsolutePathForm(pattern))
9
+ return undefined;
10
+ const m = /[*?[{]/.exec(pattern);
11
+ if (!m) {
12
+ const i = Math.max(pattern.lastIndexOf("/"), pattern.lastIndexOf("\\"));
13
+ if (i <= 0)
14
+ return undefined;
15
+ return { baseDir: pattern.slice(0, i), relativePattern: pattern.slice(i + 1) };
16
+ }
17
+ const prefix = pattern.slice(0, m.index);
18
+ const cut = Math.max(prefix.lastIndexOf("/"), prefix.lastIndexOf("\\"));
19
+ if (cut === -1)
20
+ return undefined;
21
+ let baseDir = pattern.slice(0, cut);
22
+ if (baseDir === "" && cut === 0)
23
+ baseDir = "/";
24
+ if (/^[A-Za-z]:$/.test(baseDir))
25
+ baseDir = baseDir + pattern[cut];
26
+ if (!baseDir)
27
+ return undefined;
28
+ return { baseDir, relativePattern: pattern.slice(cut + 1) };
29
+ }
30
+ function under(base, rest) {
31
+ return `${base === "/" ? "" : base}/${rest}`;
32
+ }
33
+ export function effectivePathTargetOf(call, pt, bases) {
34
+ if (pt === undefined)
35
+ return undefined;
36
+ const declaredBase = pathTargetBaseOf(pt, bases);
37
+ const spelled = pathTargetValue(call.args, pt);
38
+ if (spelled !== undefined)
39
+ return resolveSpelling(spelled, declaredBase, bases);
40
+ if ((pt.absent ?? "none") === "none")
41
+ return undefined;
42
+ if (pt.patternParam !== undefined) {
43
+ const pattern = pathTargetValue(call.args, { param: pt.patternParam });
44
+ const split = pattern === undefined ? undefined : splitAbsoluteGlobPattern(pattern);
45
+ if (split !== undefined)
46
+ return resolveSpelling(split.baseDir, declaredBase, bases);
47
+ }
48
+ return declaredBase === undefined ? undefined : lexicalNormalAbsolutePathOf(declaredBase);
49
+ }
50
+ function resolveSpelling(spelled, declaredBase, bases) {
51
+ if (spelled.startsWith("/"))
52
+ return lexicalNormalAbsolutePathOf(spelled);
53
+ const landsOn = isAbsolutePathForm(spelled) ? pathTargetBaseOf(undefined, bases) : declaredBase;
54
+ const normalBase = landsOn === undefined ? undefined : lexicalNormalAbsolutePathOf(landsOn);
55
+ return normalBase === undefined ? undefined : lexicalNormalAbsolutePathOf(under(normalBase, spelled));
56
+ }
@@ -61,6 +61,14 @@ export interface EngineNotice {
61
61
  * its explicit {@link NOTICE_AUDIENCE} row (#433 made the registry total over the catalog —
62
62
  * no engine-minted code is audience-defaulted any more). The refusal itself
63
63
  * (`mcp.server_revoked`) is a tool RESULT code, not a notice.
64
+ * - `"mcp.injection_dropped"` (L-167) — an entry of the user's own request-lane MCP injection
65
+ * was NOT mounted for the leg. The HOST owns the drop sites and supplies the facts; CORE composes
66
+ * the notice ({@link import("./mcp-injection-drop.js").mcpInjectionDroppedNotice}, the one mint).
67
+ * `detail: { sessionId, server, reason, field? }` — `reason` is the closed set
68
+ * `malformed_entry` / `name_reserved_by_deployment` / `gate_closed` / `over_cap`
69
+ * (`MCP_INJECTION_DROP_REASONS`, CLOSED-SETS), `field` names the bad key for `malformed_entry`
70
+ * when known. `"user"` audience; `sessionId` REQUIRED at the mint (a user row with no session
71
+ * routes nowhere — refused, never fabricated). Dedup unit: per leg per server name, the host's.
64
72
  * - `"config.models_swapped"` — `Runner.swapModels` replaced the model catalog generation
65
73
  * (zero-restart model switching). `detail: { models, tiers, occurrenceId }` — key COUNTS only,
66
74
  * never the
@@ -1,6 +1,7 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../tools/fs/safety.js";
2
2
  import { isWithin } from "./runner/session-rule-policy.js";
3
3
  import { declaredPathTargetOf, isProtectedWrite } from "./tool-registry.js";
4
+ import { pathTargetBaseOf } from "./effective-path-target.js";
4
5
  const ask = (message) => ({ action: "ask", message, decisionReason: "rule" });
5
6
  export function createFsWriteGatePolicy(opts) {
6
7
  const { env, rootPath, defaultWrite } = opts;
@@ -26,7 +27,7 @@ export function createFsWriteGatePolicy(opts) {
26
27
  if (path === undefined) {
27
28
  return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${declared === undefined ? " (the tool declares no path target)" : ""}${advisory}`);
28
29
  }
29
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
30
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
30
31
  if (!canon.ok) {
31
32
  return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})${advisory}`);
32
33
  }
@@ -45,6 +45,7 @@ export declare function normalizeOrgGateVerdict(answer: unknown, unreadable: str
45
45
  export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
46
46
  hit?: PersistedRuleHit;
47
47
  unreadable?: true;
48
+ reason?: string;
48
49
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
49
50
  };
50
51
  /**