@sema-agent/core 7.22.0 → 7.23.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 (60) hide show
  1. package/CHANGELOG.md +210 -1
  2. package/dist/core/auto-compaction.d.ts +2 -2
  3. package/dist/core/checkpoint-store.d.ts +1 -5
  4. package/dist/core/context-edit.js +1 -1
  5. package/dist/core/engine-notice.d.ts +13 -0
  6. package/dist/core/gate-bounded-await.d.ts +14 -4
  7. package/dist/core/gate-bounded-await.js +1 -1
  8. package/dist/core/gate-crash-trace.d.ts +7 -0
  9. package/dist/core/gate-crash-trace.js +3 -0
  10. package/dist/core/gate-fold.js +5 -4
  11. package/dist/core/gate-lanes.js +2 -1
  12. package/dist/core/governance-codes.d.ts +1 -1
  13. package/dist/core/governance-codes.js +2 -0
  14. package/dist/core/hooks.js +2 -1
  15. package/dist/core/park-selfcheck.js +1 -1
  16. package/dist/core/path-identity.d.ts +11 -4
  17. package/dist/core/path-identity.js +5 -2
  18. package/dist/core/read-root-candidate.js +11 -1
  19. package/dist/core/runner/assemble-result.d.ts +2 -7
  20. package/dist/core/runner/assemble-result.js +25 -8
  21. package/dist/core/runner/contracts.d.ts +15 -15
  22. package/dist/core/runner/gate-exit.d.ts +0 -5
  23. package/dist/core/runner/gate-exit.js +0 -3
  24. package/dist/core/runner/inherited-ask-grants.d.ts +2 -7
  25. package/dist/core/runner/inherited-ask-grants.js +1 -0
  26. package/dist/core/runner/prepare-ask-lane.js +1 -0
  27. package/dist/core/runner/prepare-context-lane.d.ts +43 -1
  28. package/dist/core/runner/prepare-context-lane.js +25 -4
  29. package/dist/core/runner/prepare-task.js +2 -2
  30. package/dist/core/runner/prepare-turn-wiring.js +2 -2
  31. package/dist/core/runner/run-harness-handlers.js +4 -2
  32. package/dist/core/runner/run-recovery-lanes.js +6 -3
  33. package/dist/core/runner/run-telemetry-and-budget-seats.js +11 -1
  34. package/dist/core/runner/write-protection-station.d.ts +7 -25
  35. package/dist/core/runner/write-protection-station.js +18 -0
  36. package/dist/core/task-registry.d.ts +0 -6
  37. package/dist/core/task-result.d.ts +28 -10
  38. package/dist/core/types.d.ts +1 -1
  39. package/dist/core/write-protect.d.ts +64 -12
  40. package/dist/core/write-protect.js +87 -34
  41. package/dist/engine/execution-env/node-execution-env.d.ts +19 -2
  42. package/dist/engine/execution-env/node-execution-env.js +5 -1
  43. package/dist/index.d.ts +1 -1
  44. package/dist/tools/fs/bash-lexer.d.ts +12 -5
  45. package/dist/tools/fs/bash-lexer.js +5 -2
  46. package/dist/tools/fs/bash-program-position.d.ts +18 -0
  47. package/dist/tools/fs/bash-program-position.js +13 -1
  48. package/dist/tools/fs/bash-readonly-classifier.d.ts +10 -3
  49. package/dist/tools/fs/bash-readonly-classifier.js +70 -41
  50. package/dist/tools/fs/fs-bash.d.ts +22 -0
  51. package/dist/tools/fs/fs-bash.js +19 -12
  52. package/dist/tools/fs/fs-read.js +2 -1
  53. package/dist/tools/fs/fs-shared.d.ts +33 -7
  54. package/dist/tools/fs/fs-shared.js +12 -1
  55. package/dist/tools/fs/safety.d.ts +37 -0
  56. package/dist/tools/fs/safety.js +31 -3
  57. package/package.json +2 -2
  58. package/test/export-surface.snapshot.json +3 -1
  59. package/wire-changes/core@7.23.0.json +316 -0
  60. package/wire-changes/core@7.23.1.json +205 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,214 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.23.1 — 2026-09-18
4
+
5
+ ### Read boundary — a `$` inside SINGLE QUOTES no longer refuses the whole command (#921, ruling C-R67 ③; behaviour face, both seats; @cli @server @client-core re-pin)
6
+ - **Before.** The compound classifier's whole-string gate scanned for `$` character by character with no idea of quoting, so the most ordinary lines there are carried a card no rule could clear: `awk '{print $1}' f`, `awk -F: '{print $1}' f`, `awk '{print $NF}' f`, `grep 'x$' f`, `sed -n 's/$/x/p' f`. One character decided it — `awk '{print}' f`, the same invocation, was auto-allowed. Inside single quotes the character is a field reference or an end-of-line anchor; bash expands nothing there.
7
+ - **After.** ONE sieve, two passes in a load-bearing order. Everything whose ABSENCE makes quoting exact — escapes, subshells, line breaks, and redirection outside the tolerate mode — is still refused QUOTE-BLIND; only then, with a backslash guaranteed gone, is the lexer's existing quote mask built and `$` refused wherever bash would expand it (unquoted, or inside double quotes). Rules: −1 (the tolerate arm and the default arm were two refusal paths and are now one, with the sentence as the parameter). `quoteMask` reports WHICH quote holds each character; `quoted` is a view of that, minted in the same step.
8
+ - **A BACKTICK is not read through the mask, and the line between the two is measured.** Quoting stops the FIRST expansion, not a later one: `let` and `declare -i` evaluate their argument as an ARITHMETIC expression, and that evaluation performs command substitution on the text it is handed, single quotes included. Against real bash, `let 'a[0`<command>`]'` and `declare -i 'a=b[0$(<command>)]'` RUN the command; `let 'a=$x'` and `let 'a[0$x]'` hand the evaluator the literal characters `$x`, which is a syntax error and runs nothing, and a value arriving through `${x}` is expanded but never re-substituted. So the two spellings that can EXECUTE — `` ` `` and `$(` — stay refused wherever they stand (`(` being in the blind class is what keeps the second one out), which is the same fact the whitelist already states about an assignment word whose value spells a substitution literally. `$` alone cannot execute, so it is the only character this mask decides.
9
+ - **Exactness, since the two characters are the ones that could break the mask.** The mask and bash agree left to right until something stops a quote character from being one. Two things can: a backslash, refused before the mask is built, and the `$'…'` / `$"…"` forms, whose `$` stands OUTSIDE the region it opens — where the mask still reports it unquoted and the gate refuses the string. Either the string is refused or the mask is exact; there is no third case. An unbalanced mask is not used at all.
10
+ - **Reach.** Ten measured shapes stop being whole-refused, and each then answers exactly as the same command without the character. Two directions, both pinned: `awk '{print $1}' f` / `grep 'x$' f` / `cat '$F'` go to zero asks on the default/off and bypass tiers, while `awk '{print $1}' /etc/passwd` and `grep 'x$' <deny row>` STAY mandated — and now name the file they read instead of refusing the string. `awk "{print $1}" f` (double quotes, a real expansion), `cat $F`, `cat $'a\tb'`, `cat \`echo f\``, `echo '\`id\`'` (single-quoted backtick — see above) and every unbalanced-quote spelling are unchanged. `cat '$HOME/.ssh/<key>'` goes from one mandated ask to zero: bash hands `cat` the literal directory name `$HOME`, so the command cannot reach the key — and a deployment whose deny table covers that literal path still gets its refusal (`test/backlog714-home-expansion-precise.test.ts` re-pinned).
11
+ - **The rule lane too.** The segmenter is shared with the persisted permission-rule lane, where a whole-string refusal put the command outside the lane entirely and no stored rule of EITHER polarity could speak about it. These commands are now nameable: a deny rule reaches them, and so does an allow rule.
12
+ - **Not fixed, and now visible rather than assumed gone.** (i) An interpreter's inline program that opens with a slash-addressed pattern is still read as an absolute path: `awk '/error/' app.log` reports a phantom out-of-root read of `/error`, and `perl -pe '/x/' f` of `/x`. sed's positional script got a first-operand row in #892; the interpreter family has not. Pinned in `test/backlog791-interpreter-inline-program.test.ts` G-9. (ii) `trap` / `alias` / `history -s` / `git -c 'core.pager=…'` hand a LATER evaluation a command line and are not in the shell re-entry class, so the boundary walks their argument as an ordinary in-root word and vouches it. That hole is PRE-EXISTING — `trap 'cat /etc/passwd' EXIT` measured vouched on 66b7e7c5 too — and this change adds the `$` spelling (`trap 'cat $F' EXIT`) to the same already-open door; no capability is new, since anyone who can write the one can write the other. Both ticketed, pinned in `test/backlog921-single-quoted-expansion.test.ts` ④.
13
+ - **headless.** Both tiers, permissive direction: a headless run that refused these awk/grep/sed lines for want of an approver now executes them. Nothing is newly refused. Pins: `test/backlog921-single-quoted-expansion.test.ts`.
14
+
15
+ ### Read boundary — a verb's FIRST OPERAND can be a slot its grammar fills, not a file: `sed -n '/error/p' app.log` stops asking (#892; behaviour face, both seats; @cli @server @client-core re-pin)
16
+ - **Before.** Only grep's PATTERN slot was modelled, by a hand-written predicate and a hand-written branch in each of the two walks that needed it. sed's SCRIPT slot was modelled for the `-e` spelling and not for the positional one, so `sed -n '/error/p' app.log` — a script opening with an address regexp, spelled with slashes — was read as the absolute path `/error/p`, found outside the roots, and carried a MANDATED card on an everyday log-reading line. The `-e` spelling of the same script was already exempt: one invocation, two readings.
17
+ - **After.** `VerbOptionSlots.firstOperand` is a declaration row (grep's `pattern`, sed's `script`) read with the same `OptionValueKind` vocabulary the option slots already use, and ONE reader (`suppliesFirstOperandSlot`) answers "did an option supply this slot instead" for both walks. Rules: −1 (the verb-named branch and its private predicate are gone; a verb with no row keeps an ordinary first operand). The slot's value is judged exactly as a declared option value is — a kind that cannot name a file yields no candidate, one that can is a file as before.
18
+ - **Reach — a NARROWING, listed per spelling.** Six shapes lose a mandated card on the default/off and bypass tiers (and take an ordinary ask on the classify tier instead): `sed -n '/error/p' <log>`, `sed -n '/x/r <out-of-root>' f` (see Stated), and the four spellings in which a path-shaped token IS sed's script — `sed <path>`, `sed <deny row>`, `sed -l <path>`, `sed -l <deny row>`. The last four are not a file under EITHER implementation: the token is the script where `-l` takes no value and `-l`'s length argument where it does, so the verb-blind reading that recorded it as a read was an over-approximation the row now retires (`test/backlog764-verb-option-slots.test.ts` G-2b re-pinned; the one spelling whose token really is an input, `sed -n -e 1p -l <file>`, still mandates and still catches the arity mutation). The narrowing is exactly one operand wide: `sed -f prog.sed <path>`, `sed p <path>`, `sed -n p /etc/passwd` and `grep pat /etc/passwd` all still mandate, and no other verb grew a row. Pins: `test/backlog892-first-operand-slot.test.ts`.
19
+ - **Stated (ruled reach, C-R67 ②).** `sed -n '/x/r <out-of-root>' f` was mandated only because its script's first character was `/` and the operand walk read the whole script as an absolute path — an accident, not a promise: `sed -n 'p;r <out-of-root>' f` was never caught, and `sed -n '1r <out-of-root>' f` has been the ruled, disclosed reach of the friction floor since 7.22.0. Removing the misreading removes the accident with it, so that spelling now answers like its siblings. The script slot's named files do not reach the read boundary by ruling; the floor for what they open is the execution environment's sandbox.
20
+ - **headless.** Both tiers, permissive direction and narrow: a headless run that refused `sed -n '/error/p' <log>` for want of an approver now executes it. Nothing is newly refused.
21
+
22
+ ### Read boundary — a decline is read as evidence by the SEAT that declares it; the classify tier trades the mandate for an ordinary ask (#891, ruling C-R67 ①; behaviour face, classify tier only; @cli @server @client-core re-pin)
23
+ - **Before.** The shell scan declines two ways — it cannot name the program bash will run (`env -- ls`, `nice -x 5 cat f`, `xargs -0 wc -l`, `sudo -u root cat f`), or it read the program and the operands arrive on a STREAM it never saw (`find … | xargs wc -l`, `git ls-files | xargs grep -l TODO`, `ls | xargs cat`). Both refused the command outright on BOTH seats, so under `shellGate: "classify"` these everyday pipelines carried a card no allow rule and no auto classifier could retire. Neither decline names a word: they say only that the scan could not tell.
24
+ - **After.** Which decline a seat reads as read evidence is a TABLE the seat declares against (`SHELL_SCAN_DECLINES`, two members; `BOUNDARY_SEAT_DECLINE_EVIDENCE` keeps both, `CLASSIFY_SEAT_DECLINE_EVIDENCE` keeps neither). The question is asked ONCE for the whole command, ahead of every per-verb exit, so nothing downstream stands in front of the table — the blanket refusal of a listed reader whose operands the whitelist does not enumerate included. Rules: −4 (four per-segment refusals fold into that one pass), +1 (the pass). No third verdict state: both seats still raise an ask on the same shapes.
25
+ - **Reach — default and bypass tiers: none, measured byte for byte.** The read-boundary seat is what those tiers mount ALONE, where "not mandated" is VOUCHED, so it keeps both members: all 78 rows of the differential corpus answer identically, `echo /etc/passwd | xargs cat` included (the closed P0 stays closed). Under `shellGate: "classify"` 17 of those rows move from a mandated card to an ordinary one.
26
+ - **What the release does NOT buy, and it is one rule.** The boundary's objection over an operand the command NAMES still mandates, on BOTH halves of that judgement — the deny table and the roots. It is asked by the face that already answered the containment half (`classifyOutOfRootReadGate`), which now reports the deny finding too: ONE extraction of what the operands are, two findings over it, computed in a second pass with the deny judge present so the containment answer stays deny-free and keeps its `paths` contract. So `cat <deny row> && ls | xargs wc -l` answers exactly as its twin `cat /etc/passwd && ls | xargs wc -l`, and so do `ls | xargs wc -l; cat <deny row>`, `echo x | xargs cat <deny row>`, `ls | xargs grep -l TODO <deny row>`, the attached and separated payload spellings (`grep -f<deny row>`), a `NAME=value` operand (`dd if=<deny row>`) and a read standing before a directory change. Because the extraction is the face's own, no read is invented either: a sed SCRIPT is still not a path.
27
+ - **🔔 This reaches the DEFAULT/OFF and BYPASS tiers too, and it is the only place in this batch where they move.** That face reads a segment under a control word VERB-BLIND, so it was already mandating `! echo /etc/passwd` on every tier while `! echo <deny row>` was vouched — the same asymmetry, one layer up, and one its own header claims to cover. Giving it the deny half closes that as well: 252 rows of a 39,930-row control-word corpus move, every one V → M (a tightening; zero M → V), every one carrying a deny-listed operand, under `!` / `if` / `while` / `time` / `command` / `env` / `xargs` and the unreadable-launcher heads. A deployment with no deny table sees nothing (the face never reports the half its boundary cannot answer); the plain spelling is untouched, since a program declared to read no path still names no read.
28
+ - **Accepted reach, stated rather than mitigated.** On the classify tier, once that ordinary ask is retired by a rule or the auto classifier, a read HIDDEN inside such a command is not asked again: a stream is re-split by its consumer (`echo <deny row> | xargs cat` — the path is in `echo`'s words, and what `xargs` hands `cat` is a stream nobody read), whitespace is re-split with it (`echo " /etc/passwd" | xargs cat`), an option the walk could not read may move the working directory first (`env -q cd ..; strings ../secret`), text reaching a shell through stdin is a command line nobody parsed (`echo "cat /etc/passwd" | env -- sh`), and a stdin literal (`env -- ls < /etc/passwd`) or a substitution body (`env -- ls $(cat /etc/passwd)`) behind a decline is not walked. An operand the scan could not READ at all — an unexpanded glob — is not an objection either, which is why `find . -name '*.ts' | xargs wc -l` still drops. Each is pinned at that answer in `test/backlog891-seat-keyed-declines.test.ts`. What such a command may read is floored by the execution environment's sandbox.
29
+ - **headless.** Classify tier only, and in the permissive direction: a headless run (no approver) that refused these commands on a mandated card now refuses them on an ordinary one — and where a stored allow rule covers the command, it now RUNS instead of being refused. Default/off and bypass tiers are unchanged, so a deployment on those tiers sees no lane change.
30
+ - **@cli @server @client-core.** Re-pin any probe that expects `mandated` for these shapes under `shellGate: "classify"`; the default tier's expectations are unchanged. The PUBLIC export face is unchanged (`handoff:diff` 66b7e7c5..HEAD = +0/−0 over all three changes in this batch): the new names — `SHELL_SCAN_DECLINES`, `ShellScanDecline`, `programPositionRunsShell`, `shellScanDeclineOf`, `BOUNDARY_SEAT_DECLINE_EVIDENCE`, `CLASSIFY_SEAT_DECLINE_EVIDENCE` — are module exports inside `src/tools/fs`, not re-exported from `src/index.ts`, and `refusedCommandMayRead`'s new required fourth argument is likewise internal.
31
+
32
+ ### Gates — the debts gate reds text that spills past the seventh cell (`gate:debts`; no product code)
33
+ - A ledger row with one bare `|` inside a cell and no closing pipe still splits into nine segments, so the column-count check passed while the tail after the last pipe sat in no cell at all — unread by the gate's positional readers and dropped by the renderer (found by the cli debt survey: five rows whose open remainders lived in that tail; one server row measured the same way today). A row must open and close with a pipe; a non-empty first or last segment is red and names the row. Negative control in `test/gate-negative-controls.test.ts`.
34
+
35
+ ### Loading — every `src/tools/fs` module is a safe first entry; the gate's crash tracer leaves the record floor (#918; no behaviour change)
36
+ - **Before.** A host that imported `dist/tools/fs/bash-readonly-classifier.js` BEFORE `dist/tools/fs/fs-bash.js` (the cli's danger-matrix harness) got `ReferenceError: Cannot access 'NOT_AUTO_ALLOWED' before initialization` on 7.21.1, 7.22.0 and 7.23.0 alike. The runtime cycle: `tools/fs/safety` → `core/gate-bounded-await` → `runner/gate-exit` (one VALUE import, the crash tracer) → `tool-policy` → `tools/fs/index` → `fs-bash` → the classifier still mid-evaluation, whose re-export of the phrase constant fs-bash's top-level table read had not been reached. A bare import of fs-bash never showed it: ESM evaluation order is the entry's.
37
+ - **After.** `traceHookCrash` lives in `src/core/gate-crash-trace.ts` (two type-only edges, one call); `gate-bounded-await` has no runtime edge into `runner/`; `fs-bash` reads `NOT_AUTO_ALLOWED` from its declaring module (`bash-program-position`) and the classifier's dead re-export is retired. The built dist has no path from the classifier back to fs-bash; the cli's import order loads. Pins (`test/backlog918-module-entry-order.test.ts`): the cli order in a fresh process, every `src/tools/fs` module as a first entry (one fresh `node --import tsx` process each), and no runtime `runner/` import in the bounded-await helper.
38
+ - **Reach.** No wire, no export change (`traceHookCrash` was never public), no behaviour; a host importing engine modules directly in any order now loads them.
39
+
40
+ ### Write protection — the NTFS data-stream strip is the reference fold's `:.*$` pattern; a stream name carrying a line terminator is a STATED miss (#912, ruling C-R66 (b))
41
+ - **Before.** The win32 reading cut the stream suffix by index (`indexOf(":")`), one notch stricter than the reference: `name:stream\nrest` was stripped to `name`, where the reference's regexp `.` stops at the terminator and names nothing.
42
+ - **After.** The cut is the reference's `/:.*$/`. `\.bashrc:evil` still names `.bashrc`; `\.bashrc:evil\nrest` (and `\r`, U+2028, U+2029) no longer does — the reference misses that spelling the same way, and alignment was the ruling. Pin `test/backlog841-invisible-and-stream-fold.test.ts` ⑥ holds the aligned form.
43
+ - **Reach.** One fewer question on exactly that control-character spelling, win32 readings only; no wire key, no closed set, no export change. Headless: that spelling's overwrite goes from refused to executed under the write-protection table (the opt-in deny set and the data-root passthrough are untouched).
44
+
45
+ ## 7.23.0 — 2026-09-18
46
+
47
+ ### Gates — every script's CLI entry guard folded into ONE helper; the roster runner no longer reads silence as green (#915; `gate:all` and its 21 guarded children; no product code)
48
+ - **Before.** Twenty-one scripts under `scripts/` each hand-rolled "am I the entry point?" in one of four forms, and each form was blind to one spelling of the same file: a hand-built `file://` string vs the percent-encoded module URL (non-ASCII checkout paths — `gate:memory-ga` and `gate:child-clamp-seats` printed 0 bytes and exited 0 on the main tree, so the local RC runs of 7.21.0 and 7.22.0 carried a PASS for two gates that never ran; test [7475] G6, sema-internal#48), a raw `argv[1]` vs the resolved module path (`/tmp` → `/private/tmp`, any symlinked checkout — **`gate:all` itself listed nothing and exited 0 through the alias**, and so did `verify-layering` / `verify-error-surface`; codex round 2 reproduced), and a one-sided realpath vs `--preserve-symlinks-main`. A false guard was never an error: the script exported, exited 0, and `gate:all` — which checked the exit status only — printed PASS.
49
+ - **After.** `scripts/cli-entry.mjs` `isCliEntry(import.meta.url)` is the one guard (both sides real-path'd, false on throw) and all 21 scripts import it. `gate:all` reads a child's exit 0 with nothing on either stream as **SILENT**, prints it as such and counts it as a failure (`childVerdict`): every gate speaks on green. Pins (`test/backlog915-gate-entry-guard.test.ts`): no script compares `import.meta.url` with `argv[1]` on its own; ≥21 users of the helper; both gates and `gate:all --list` speak through a symlink under a non-ASCII directory with and without `--preserve-symlinks-main`; importing a guarded script stays quiet; silence ≠ pass.
50
+ - **Reach.** No wire, no product behaviour. A gate that never ran can no longer show as green in `gate:all`; scripts invoked through a symlinked checkout now run their CLI bodies.
51
+ ### Fixed — `covers: "exact"` reaches the CARD (#913; user-visible half of #862b/#714; @cli @server @client-core re-pin; headless lanes: no change)
52
+ - **Before.** The default-tier read-boundary seat minted `readRootCandidate.covers: "exact"` for a single-file candidate (7.22.0, #714/#862b), and the card path rebuilt the offer key by key (`gate-fold` → `normalizeReadRootCandidate`), returning only `{ dir, clearsThisAsk }`. No card on the 7.22.0 artifact ever carried the member; a consumer that expanded the offer to `<dir>/**` granted nothing the boundary honours, so the ask kept coming back. The black-box round that judged this face PASS read the PROBE's return value, not the folded decision.
53
+ - **After.** The validator carries `covers` on its ONE spelling and refuses the WHOLE candidate on any other value — the same posture `clearsThisAsk !== true` already takes (a repaired offer is a different offer, never shown). The folded ask (`probeReadRootCandidate`) and the ask request (`readRootCandidate`) now carry `covers: "exact"` exactly where the seat minted it. Rules: 0 added (one spelling law now covers both members).
54
+ - **Reach.** @cli: the grant offer on a single-file card now says `covers: "exact"` — accept it by adding THAT exact spelling, not the directory (7.22.0 pins that expect the key absent flip). @server: 7.85.0's narrow reader already forwards the member; 7.84.0 swallowed it. @client-core: reader admits the optional member. Headless lanes: unchanged (the offer is card text; no ask is added or removed).
55
+ - **Stated.** On 7.22.0 the member was absent on every card: a deployment on that artifact cannot clear a single-file ask by directory grant. Pins: `test/backlog913-covers-on-the-card.test.ts` (validator, folded card, real seat end to end; mutation = the old return, red).
56
+
57
+ ### Fixed — assembling a failure terminal survives any thrown value (#886; behaviour-face text change on `terminal.message` for NON-Error rejections; @server @cli @client-core)
58
+ - **Before.** The failure terminal rendered a non-`Error` rejection with `String(value)`. `String` is a PARTIAL function: it raises on a value with a null prototype (nothing to call) and on one whose `toString` / `Symbol.toPrimitive` throws. So the one step whose job is to DISCLOSE the failure could destroy the disclosure — the run threw, the report of the throw threw, and no `TaskResult` was assembled at all. The sibling read, `errorCodeOf`'s `code`/`cause` walk, had the same hole through a throwing accessor.
59
+ - **After.** The non-`Error` arm renders through the engine's existing total describer (`describeRejectedValue`, reused — no second renderer), which never lets the value's own formatting hooks write the sentence and guards every read it makes. The code walk carries ONE guard over the whole walk: an unreadable link ends it and the answer is whatever was already read. An unreadable code is reported as NO code, never a fabricated one.
60
+ - **射程 / consumers.** `Error` rejections — essentially all production traffic — are byte-identical (`err.message` verbatim, code unchanged). For a NON-`Error` rejection `terminal.message` reads differently: an object now describes its shape and own keys instead of `[object Object]`, and a thrown string is quoted. It was always free text and never a discriminant; a consumer matching it exactly for a non-Error throw should read `terminal.code`. Sheet: `docs/wire-consumption/unreleased-886.md`.
61
+ - **headless.** No lane change: nothing is newly refused or newly allowed. A headless run that previously produced NO result for these two rejection classes now produces an ordinary `failed` terminal with a `limits.*` / `conflict` / carried code — strictly more reporting, never less.
62
+
63
+ ### Fixed — the repetition sample is scrubbed where it becomes a disclosure (#898; value change on `repetitionEvents[].segment` and the `repetition.detected` trace frame; @server @cli @client-core)
64
+ - **Before.** The degenerate-repetition detector keeps a ≤120-char verbatim slice of the looped text so the spare/cut allowance can be judged from real samples. It left the engine scrubbed by nobody, through TWO doors: the result face (`stats.mechanisms.repetitionEvents[].segment`) and the `repetition.detected` trace frame. A model that loops reproduces whatever it was looping ON — credentials included. A deployment scrubbing at its own mouth covered the result face only; the trace door had nothing in front of it.
65
+ - **After.** One `redactSecrets` call sits ABOVE both doors, so the two cannot disagree about what a reader may see and a third door added later inherits the scrub instead of re-deciding it. Only the text changes: `rule` / `period` / `reps` / `action` / `turn` and the cut/spared accounting are untouched, so the sample stays useful for the judgement it exists for.
66
+ - **CC anchor.** UNANCHORED, not a divergence: 2.1.259 has no output-repetition detector and no repetition-sample disclosure at all (its `degenerate` hits are MCP OAuth grant state and a job-label validator; its one `repetition` hit is a bug-report category string). This is a sema mechanism (design/39), so the posture is sema's to set — nothing is registered in the divergence book.
67
+ - **射程 / consumers.** Byte-unchanged for every sample with no credential shape. A host that already scrubs this at its mouth keeps working — the two become defence in depth. Sheet: `docs/wire-consumption/unreleased-898.md`.
68
+ - **headless.** No lane change: nothing is newly refused or allowed, and no ask/approval path is touched.
69
+
70
+ ### Additive — a human-review ledger row names the call it gated (#897; optional key `gates[].toolCallId`; @server S-394 @cli @client-core)
71
+ - **Before.** `stats.humanReview.gates[]` rows carried `kind` / `waitMs` / `decision` / `toolName` / `toolArg` but no identity, and a headless deployment's own permission-denial ledger is legitimately a DIFFERENT LENGTH from this one: a run pushes rows from FOUR mints (a synchronous ask, an inherited-grant reuse, a durable resume, the park probe) and only the first passes through the host's `onAsk`. Reconciling by count therefore cannot work, and there was no key to join on instead.
72
+ - **After.** Every mint carries `toolCallId` on the row it pushes, from its own view of the call — the synchronous ask from the request, the grant reuse from the grant's own key, the durable resume from the checkpoint's `pendingAction`. It is never re-derived downstream at the aggregator: a re-derivation would have to guess which of several rows a call produced, which is the ambiguity this closes.
73
+ - **Absence has one reading:** the mint had no call id to give — a durable pause that gates no tool call (`resource_limit` / `review` / `plan_review`), or a row from an engine older than this one. Never a placeholder. (The two review-gate rows in the timing suite are the standing negative control: they still carry no tool fields at all, correctly.)
74
+ - **Also closed:** the durable-resume row now carries `toolArg` too. The comment there recorded "the input isn't threaded onto the persisted gate — a documented follow-on"; it is threaded onto the row's PENDING ACTION, which is where the mint reads it from.
75
+ - **Shape.** The row had SIX structurally-identical inline copies (`task-result.ts`, `contracts.ts` ×2, `inherited-ask-grants.ts`, `assemble-result.ts`, `prepare-task.ts`) and a SEVENTH — the persisted checkpoint's — that had silently drifted NARROWER (`{kind, waitMs, decision?}`, no tool fields at all) while the three persist sites spread the full live rows into it: the type said storage loses the identity while the bytes carried it. All seven now reference one declaration, `HumanReviewGateRow`. No checkpoint-version bump (additive optional fields, the `reminderMark` / `hasBidiControls` precedent).
76
+ - **射程 / consumers.** Additive; `src/index.ts` is untouched (the row type is named internally, not exported — say the word if a downstream would rather import the name). A consumer reconciling its own ledger should join on `toolCallId` and read an absent one as "not joinable", never as "no call". Sheet: `docs/wire-consumption/unreleased-897.md`.
77
+ - **headless.** No lane change: nothing is newly asked, refused or allowed. The headless operator gains the key that makes its `permission_denials` ledger joinable against the engine's — the counts stay legitimately unequal, and that is now reconcilable instead of merely puzzling.
78
+
79
+ ### Fixed — a Read body taken off the wire retires the entry that dedups against it (#894; user-visible tool-face behaviour; @cli @server @client-core)
80
+ - **Before.** After the request-layer stale-tool-result pass replaced an old `Read` result's content with a cleared marker, the read-state entry survived — so re-reading the same unchanged file answered `[<path>: unchanged since you last read it …; content omitted to save context]` while the only copy on the wire was that marker. The model's escape was to shell out and `cat` the file. Reported from a deployment running a 40 000-token window.
81
+ - **What the evidence actually said.** The three COMPACTION lanes were never the problem: all three wire `onApplied` and clear the state correctly (measured — with compaction on, the re-read transmits the body). The uncovered mechanism is not a compaction lane at all; it is the micro-compact pass, which takes content off the wire with no summary, no compaction event and nothing changed in the session. The contract sentence that said "every compaction lane" was TRUE — its SCOPE was wrong, and it has been widened to say so explicitly rather than left as a true sentence that reads like a wider promise.
82
+ - **After.** ONE retirement function serves both mechanisms (`applyContextShrinkToReadFileState`, a discriminated shrink) rather than a second retirement written next to the second mechanism. Retirement is keyed by the TRANSMISSION, not by path: a `ReadEntry` records which tool call carried its body, so clearing an older transmission never retires an entry a later Read re-established. The retirement sits beside the clear's ledger write and shares its posture — an entry in that ledger means the marker is repainted on every later request build, so the content is gone for good.
83
+ - **射程 / consumers.** A host or probe that pinned "a second identical Read always dedups" over a long session will now see a full body where it saw a stub; the correct reading was always "dedups while the body is still visible". The structured sidecar is unchanged (`details.type === "file_unchanged"`) — it simply fires less often. An entry restored from a checkpoint written before this version carries no transmission id and is therefore never retired by a clear (it keeps the old behaviour; a real Read re-establishes it). Sheet: `docs/wire-consumption/unreleased-894.md`.
84
+ - **headless.** Strictly more content delivered, never less, and no ask/approval lane is touched: a headless run that previously had to spend a `Bash`/`cat` call (itself subject to the read boundary, and refused outright on a headless lane where the boundary asks) now gets the body from `Read` directly.
85
+
86
+ ### Test-only — the live honest-miss probe judges the ANSWER, not the whole reply (#899; no product code)
87
+ - **Before.** The `live-real-model` honest-miss leg asserted `not.toContain("ZEPHYR-9142")` over the model's entire reply. A model that answered `NOT-IN-MEMORY` correctly and then explained what memory DOES hold — naming the planted codename entry as a side note — was judged red for behaving correctly. Measured on the 7.22.0 release candidate: three chained-run reds against two isolated greens on the same build.
88
+ - **After.** The negative half reads the answer: an unrelated entry named on the line that carries the verdict token is being offered as the answer (red); named on any other line it is a side note (green). Same class as the other probes whose assertions judged a correct model reply red.
89
+ - **Where.** `test/backlog899-answer-segment.ts` holds the predicate so a deterministic pin (`test/backlog899-answer-segment.test.ts`, six cases, no model call) tests it; the live leg asserts with the same predicate.
90
+
91
+ ### Fix — the symlink chain anchors a relative target with ONE separator, so a link under a root stops reporting a doubled spelling (#893; `canonicalChain` seam; @server adapters)
92
+ - **Before.** The walk welded a relative link target onto its link's directory with a separator of its own: `` `${dirname(cur)}${sep}${target}` ``. A link that lives directly under a root has `/` for a directory and already carries that separator, so `/tmp -> private/tmp` was reported as `//private/tmp`. Two independent implementations of the same seam answered differently for the same filesystem — a remote adapter that anchors through its own platform join answered `/private/tmp` — and one seam with two answers is the defect, not the spelling.
93
+ - **After.** One helper states the anchor: exactly one separator between the link's directory and its target, none added when the directory already ends in the platform's own. It stays UNFOLDED — `path.resolve`/`path.join` would fix the separator and fold `..` with it, and folding `..` across a symlinked component is precisely what this anchor exists to prevent. A trailing backslash counts as a terminator only where the platform's separator IS one: on a POSIX tree `/a/b\` is a directory whose NAME ends in a backslash.
94
+ - **Reach (fewer asks, never more).** The judges that walk this chain — the write-protection judge and the read deny set — match each hop against guarded rows and containment roots that are written with single separators. A doubled hop matched **nothing**, so the correction can only make a hop match a row it should always have matched: on a host whose root holds relative symlinks (macOS `/tmp`, `/var`, `/etc`), a chain that passes through one now reaches the judge as an ordinary path. No hop stops matching. Nothing becomes silent.
95
+ - **Headless / no-approval lanes**: a chain hop through a root-relative symlink that lands on a protected row now refuses there instead of passing unnoticed — the same direction as every other hop, and confined to hosts whose root actually holds such a symlink.
96
+ - **@server**: any remote `ExecutionEnv` adapter that implements `canonicalChain` should answer the same way — one separator, `..` unfolded. Core's own answer was the outlier, not the adapters'. (How many adapters implement it is a fact about the server tree and is not counted from here.)
97
+ - Pins: `test/backlog893-chain-root-anchor.test.ts` (6 cases: the helper per family, `..` survival, the two separators, a real-filesystem walk, the live root-symlink reading where the host has one, and the structural pin that exists because that last one is host-conditional).
98
+
99
+ ### Fix — a trailing separator run stops minting a second key for one path (#872; three families, one fold point; @cli @server re-pin)
100
+ - **Before.** `<dir>/adir`, `<dir>/adir/` and `<dir>/adir//` name one path. The arm that keys an EXISTING path folded them already, as a side effect of asking `realpath`; the arm that keys a path that does not exist YET rejoined the caller's own tail verbatim, so one not-yet-created path came back under two and three keys (measured: three distinct keys). A byte-wise reader — the per-file read state, the write gate's per-key ledger — treats two keys as two files, so a `Read <dir>/f` followed by an `Edit <dir>/f/` was refused for want of a prior read. The POSIX, drive and UNC shapes had it alike.
101
+ - **After.** One statement of the rule, at the ONE place a key is minted for a path the probe just found absent — so the entry spelling and every symlink hop target reach it alike. The question "may this run come off?" is asked in the module's own vocabulary (is anything left BELOW the anchor), so the three families need no arm each.
102
+ - **The fold runs on the ANSWER, at the canonicalizer's single exit, and never on a spelling about to be probed.** Two things break if it moves earlier, and adversarial rounds found both. (i) A trailing separator ASSERTS a directory and the platform enforces it: `<existing regular file>/` answers `not_directory`, which this tree reports as a refusal — fold before the probe and that refusal is spent, so the slash-suffixed spelling of a file resolves to the file and overwrites it. (ii) The ancestor walk chooses which ancestor to canonicalize by SPLITTING the spelling it was handed: strip the leaf's separator first and the leaf is skipped as though it had been resolved, so `<root>/link/` — a link onto a directory OUTSIDE the root — comes back as the in-root spelling instead of the destination, which is a containment key naming a file that is not there. Both are pinned as negative controls.
103
+ - **An ANCHOR keeps the separator the platform writes on a root** (`/`, `C:\`, `C:/`, `\\host\share\`): that run is the root's own spelling, not a tail nobody wrote, and stripping it would rename the spelling to nothing, to a DRIVE-RELATIVE location, or to a share root written unlike every other root — the last of which the previous window deliberately made uniform.
104
+ - **Interior runs are untouched and stay a stated limit** (`<dir>/missing//new.txt`): the ancestor walk folds them by probing where it can, and a run in the middle of a name is the platform's to read. Unchanged from 7.21.0, on both the UNC and the drive shape.
105
+ - **⚠️ ONE WIDENING, stated.** For a path that does not exist, `<dir>/newfile/` now keys as `<dir>/newfile`, and a write under it creates a regular file — where the platform answered `ENOENT` for the slash-suffixed spelling (measured). **No guard is bypassed**: the key is judged by containment, the sensitive-path list and the deny set exactly as `<dir>/newfile` is, which is a spelling the caller may always write directly. What is lost is POSIX's "a trailing separator means a directory" for a target that does not exist yet. The alternative — refusing a directory-asserting spelling whose target would be a file — is a narrowing nobody has asked for and is left as a follow-up rather than decided here.
106
+ - **Otherwise strictly fewer keys.** A spelling that used to mint its own now mints the one its separator-free twin already did; no directory changes side (containment compares identities, which folded these already).
107
+ - **Headless / no-approval lanes**: no change of verdict on any existing path; the read-then-write pair spelled inconsistently stops being refused for want of a prior read, and the one widening above applies there too.
108
+ - **Cost.** Both matches on this path are scans now, not regexes. `/[\\/]+$/` against a spelling with a long separator run before a non-separator is quadratic (measured: ~5s for 32k separators, on the caller's own thread); and the anchor guard asks `pathIdentityOf`, whose UNC arm stripped each segment's trailing dot/space run with `/[. ]+$/` — a second quadratic match (~1.2s for 32k dots) that **every containment check already reached**, not only this fold. A path spelling is unbounded input; both are backward scans, pinned by shape at the helper AND at the call site.
109
+ - Pins: `test/backlog872-trailing-separator-one-key.test.ts` (9 cases: the three spellings of an absent path, the negative control that `a` and `a/b` still differ and that interior runs are untouched, the drive/UNC families on the helper, the anchor rule, the hop arm, the relative twin, the directory-assertion refusal, the scan's shape, and the skip-the-leaf negative control).
110
+
111
+ ### Narrowing — every symlink hop passes the Win32 namespace screen the entry spelling already passed (#871; @cli re-pin on POSIX trees)
112
+ - **Before.** The screen ran once, on the spelling the model wrote. A link TARGET is a spelling this function has never seen, and it goes on to be probed exactly like one the model wrote — but it was carried straight to the environment. So a link whose target read `\\.\pipe\p` had a raw device object probed (opening one can hang the process), and an extended-length target (`\\?\C:\x`) was carried forward verbatim instead of being folded to the DOS path it names. The verdict was already fail-closed — `isBlockedDevicePath` catches the device case on the terminal key — so what was open was the PROBE in between.
113
+ - **After.** Each hop target passes the same `win32NamespaceScreen`, with the same family and the same refusal word, before anything is joined or probed. The screen normalizes a spelling and never admits one, so an extended-length hop is FOLDED and followed rather than refused, and the refusal that does happen is lexical: measured, zero environment calls name the target.
114
+ - **⚠️ This is a NARROWING on POSIX trees, and it is the throat's own.** A backslash is an ordinary name byte on POSIX, so a file whose name literally begins `\\.\ ` or `\\host\` is a legal file there — and reached through a link target spelled bare, it is now refused (`device_namespace` / `unc_on_posix_tree`). That is exactly what the same spelling already got when the MODEL wrote it (measured, pinned as the consistency case): the entry screen has refused those two shapes on POSIX trees since 7.18.0. The screen is anchored at the start of the spelling, so a name with a directory in front of it (`<dir>/\\.\pipe\p`) is not screened and still resolves, on the entry side and on a hop alike. One spelling, one word, wherever it is read.
115
+ - **The screen may REFUSE on any tree; it may only REWRITE on the tree whose namespace it is.** On a POSIX tree `\\?\C:\real\x` is an ordinary (if strange) FILENAME — backslashes are name bytes there — and mapping it to `C:\real\x` would name a different file. A hop target is therefore joined as the caller wrote it on a POSIX tree, and as the screen mapped it on a win tree, where the mapping is the point. **Stated, not fixed here: the ENTRY throat does rewrite that spelling on a POSIX tree** (measured), which is a rename of the same kind; it predates this change, it is shared by every lexical site, and it is left as a follow-up rather than changed from a symlink-hop ticket.
116
+ - **Cost and SCOPE, stated.** One lexical screen per hop — no filesystem call, no device probe; the probe it removes is the expensive one. The scope: this walk is only reached when `canonicalPath` could not answer, so a chain the environment's own `realpath` resolves is never read hop by hop and its intermediate targets are not screened — that call follows the links itself, before anything lexical can look at them. Screening those would mean reading every link before every canonicalization: a different design with a different cost, left as a follow-up rather than claimed here.
117
+ - **Headless / no-approval lanes**: a chain whose hop is a namespace spelling now refuses instead of probing; on a lane with no approver a refusal is the outcome either way, so what changes is that the raw device object is not touched on the way there.
118
+ - **@cli / @server**: a pin that expects a symlink whose target is spelled `\\.\…` or `\\host\share\…` to resolve on a POSIX tree now expects a refusal carrying the namespace word. No win-family behaviour changes except the extended-length FOLD, which is a widening there.
119
+ - Pins: `test/backlog871-hop-namespace-screen.test.ts` (6 cases: the device hop with its probe count, the model-spelled twin as the consistency control, the extended-length fold, the POSIX word plus the anchored-screen asymmetry, the ordinary-hop negative control on both families, and the refuse-but-do-not-rewrite rule).
120
+
121
+ ### Test only — live evidence for the write-protection ask sentence (#842)
122
+
123
+ The table judge's ask message IS the `askMessage` an auto-mode classifier is handed, so the target/chain
124
+ sentences added when the judge gained its resolved-target view changed what a real model is asked — and
125
+ nobody had ever put the spellings in front of one. New `test/live-write-protect-ask.test.ts` (the four live
126
+ env vars, run through `scripts/run-live-gate.mjs`) classifies ONE fixed covered write under all three
127
+ minted sentences, read off the mint rather than re-spelled, and asserts (a) each yields a parseable ruling
128
+ and (b) the three agree. A disagreement is the disclosure #842 asked for, not a test bug, and the failure
129
+ message names which sentence moved. Measured 3/3 green on `deepseek-v4-flash`. No product change.
130
+
131
+ ### Additive — an operator notice for a write floor running on half a judgment (#869; notice code +1; @client-core mirror, @server @cli forwarders unchanged)
132
+
133
+ #868 turned "the environment could not resolve this target" from a refusal into an absent view. The refusal
134
+ was also the only way a deployment learned that its environment cannot answer path questions, so this code
135
+ replaces it as the signal. `EngineNotice.code = "config.write_protection_unresolved"`, audience `operator`,
136
+ minted at the leg's write-protection mount (`src/core/runner/write-protection-station.ts`, the same seat
137
+ that owns the sibling `config.write_protection_target_view_absent`). `detail: { reason, cause, sessionId,
138
+ runId }` with `reason` ∈ `"unresolved-symlink"` / `"unresolvable-target"` and `cause` the environment's own
139
+ sentence. ONCE PER LEG PER REASON, carrying no call identity — the fact is about the ENVIRONMENT, not about
140
+ a write, and one broken environment spells the same failure differently for every path it is asked about.
141
+ Announce, never narrow: no verdict moves because of the notice. `ENGINE_NOTICE_CODES` gains one member.
142
+ NOT covered by this code: a judgment the gate's bounded wait gave up on, which is still reported on the
143
+ hook-error lane (`onHookError`) — that one is the gate's fact, not the environment's.
144
+
145
+ ### Widening — a write-protection view that cannot answer no longer votes, on either arm (#868; @server headless range below, @cli, @client-core)
146
+
147
+ The always-on write-protection judge was one grade stricter than the reference implementation whenever a
148
+ target could not be RESOLVED: the allow arm refused the write outright, and the standing-ask arm marked the
149
+ ask with a word (`write_protection_unresolved`) no automatic lane may answer. The reference builds its
150
+ variant set by guarding every filesystem primitive of its walk and asking its table about the variants it
151
+ ended up with — a resolution that fails contributes nothing and the lexical variants still judge. Both arms
152
+ now follow one law: **a view that cannot answer does not vote**, and the judgment is what the views that did
153
+ answer said. The LEXICAL view always answers; the ENVIRONMENT views (the resolved real target, the leaf's
154
+ symlink chain) are independent contributions, and each of the ways one can fail — the canonicalizer refusing
155
+ the spelling, a link the tree cannot follow, a namespace it declines, an environment call that throws —
156
+ makes that view absent rather than refusing the write.
157
+
158
+ RANGE, as a conjunction of three conditions: the call is a covered write (`Write`/`Edit`/`NotebookEdit`
159
+ face), its SPELLING misses every row under every reading, AND the environment fails to resolve it. All three
160
+ ⇒ what was a hard refusal is now judged by the spelling alone. Any one absent ⇒ nothing moved: a spelling
161
+ that hits a row still asks; a resolvable environment is byte-for-byte unchanged. **Headless / no-approver
162
+ lanes (@server, deployment operators): a covered write that the environment cannot resolve now EXECUTES
163
+ where it was previously refused.** That is the reference's own posture for a default-ON friction floor —
164
+ refusing every write an environment cannot resolve refuses most writes on exactly the deployments whose
165
+ environments answer least — and it is deliberately NOT extended to the opt-in sensitive-path DENY set,
166
+ whose unresolvable target still refuses (`write_protection.judges_the_real_target` now states where the two
167
+ part and why).
168
+
169
+ What still refuses: the judgment that never happened at all. The gate's bounded wait elapsing or the task
170
+ ending mints `unresolvable`, which denies on both arms and keeps `write_protection_unresolved` reachable —
171
+ a wait that produced nothing has no lexical answer to stand on either.
172
+
173
+ New compile seat `WriteProtectionCompileOptions.onViewAbsent?: (absence: WriteProtectionViewAbsence) => void`
174
+ (additive; absent ⇒ the absence is silent), fired at most once per judgment on the hit arm as well as the
175
+ clear one. New exported type `WriteProtectionViewAbsence { cause, unresolvedSymlink }`.
176
+
177
+ ### No behaviour change — the write-protection arm's two asymmetries with its deny-set sibling, stated (#839)
178
+
179
+ Two places where the always-on write-protection table and the opt-in sensitive-path deny set answer
180
+ differently were carrying their difference implicitly. Neither moves a verdict; both now say what they are.
181
+ (a) WHAT THE ARM WRITES: the post-fold tighten was gated on an enumeration of the case it skipped ("not
182
+ over a standing ask") and now states the law positively and totally — it replaces a surviving `allow` and
183
+ nothing else, every other standing decision keeping its own action, message, provenance and disclosures
184
+ while the judge's finding is still RECORDED as the mandate the rule and classifier lanes read. The
185
+ alternative reading (compose under the gate's `deny > ask > allow` order, so the judge's refusal outranks a
186
+ standing ask, matching the sibling guard) was implemented, measured and refused: it retires the only
187
+ reachable home of the `write_protection_unresolved` mandate word, which exists for exactly that ask. The
188
+ asymmetry is intended — the sibling is a POLICY and composes under the fold; this is a post-fold TIGHTEN.
189
+ (b) WHO IS JUDGED: membership in this table is a predicate over the CALL (a face declaring a non-read path
190
+ target) with deliberately no name-list seat beside it, unlike `createSensitivePathPolicy({ tools })`. New
191
+ contract sentence `write_protection.membership_is_the_call` carries the argument: a name list on a
192
+ default-ON floor narrows that floor one tool name at a time and silently, while this layer's narrowing seat
193
+ is the whole-table replacement, which says what it removes. No seat was added.
194
+
195
+ ### Narrowing — the write-protection segment fold reads an INVISIBLE component and an NTFS data-stream suffix (#841)
196
+
197
+ The always-on write-protection table judged path components literally apart from its case fold, so two
198
+ spellings that name a protected component on a real filesystem were cleared: one carrying an invisible
199
+ format character (`.ss<U+200C>h/authorized_keys`, a right-to-left override in front of `.bashrc`) and one
200
+ hiding the component behind an NTFS data-stream suffix (`.bashrc:evil`, which names `.bashrc` on win32).
201
+ The reference implementation's own segment fold strips both; sema now does too, on the two axes they
202
+ actually belong to — the invisible strip is on the ONE segment fold and therefore applies to every reading
203
+ and to BOTH sides of the comparison (a deployment row carrying one folds onto the plain spelling, and two
204
+ rows differing only by an invisible are one row), while the data-stream strip belongs to the win32
205
+ NAMESPACE reading alone, because on POSIX `a:b` is a legal filename that keeps its own identity. A strip
206
+ that would EMPTY a component keeps the component. NARROWING, one extra question at most: these spellings
207
+ move from `allow` to the table's `ask` (headless: from executing to refused, as every table hit is there).
208
+ The data-root passthrough is deliberately untouched — its comparison is byte-exact, and there an added
209
+ match would be a guard bypassed rather than a spare question. `foldWriteProtectCase` is renamed
210
+ `foldWriteProtectSegment` (module-internal; not part of the package's public export surface).
211
+
3
212
  ## 7.22.0 — 2026-09-18
4
213
 
5
214
  ### Additive — an in-session RE-DIAL for one named MCP server, on the run handle (#857; no model face; @server S-381 @client-core CC-30 @cli L-276)
@@ -84,7 +293,7 @@
84
293
  ### Widening (mandated ask → plain ask / allow), one family half — an interpreter's inline program and a verb's script slot DEMOTE the segment instead of refusing the whole command (#862 · #791 · #769; both faces; @cli @server re-pin; headless lanes: fewer refusals)
85
294
  - **Before.** The ARGUMENT-AS-PROGRAM family had four members and one standing: shell re-entry (`sh -c …`), argv re-entry (`xargs cat`), a verb's SCRIPT SLOT (`sed -n '1r /etc/hosts' f`) and an INTERPRETER's inline program (`awk '{print}' f`, `perl -pe … f`, `jq -r .name pkg.json`) each refused the command WHOLE, which the read boundary reads as "nothing was judged" and both probe seats read as a MANDATE — no standing allow rule retires it, the auto-mode classifier may not answer it, and a headless lane refuses it. Measured on this tree: `jq -r .name package.json` was four-cell mandated while the engine's own read-only table (`#619`, the upstream transcription) judges it READ-ONLY; `awk '{print}' data.txt`, `perl -pe 's/a/b/' file.txt`, `sed -n '1r /etc/hosts' f` likewise.
86
295
  - **After — one standing for the inline spelling and the FILE spelling.** The family's two TABLE-DRIVEN members demote the segment (never auto-allowed, the sentence unchanged character for character) and the walk goes on judging every path the command NAMES. The re-entry members are untouched: their unread text is a command line this walk MODELS, so nothing was judged and the mandate stands. The line between them is what the walk could have done about the text, and it is the line the friction-floor contract already drew for a program FILE — `awk -f prog.awk`, `jq -f filter.jq`, `python3 script.py` are judged as the in-root operands they are with the program inside them never parsed. An inline program is the same question at a different spelling; it now gets the same answer. The refused-whole word scan drops the same check for the same reason (`echo x > o; awk 'NR==1' f` is exempt where `awk -f prog.awk f` always was), and the `bash_readonly` face — which has no approval channel — still REFUSES, because its rule for every demotion is refusal.
87
- - **Reach.** Classify doctrine (every permission mode but bypass): `awk '{print}' f`, `perl -pe 's/a/b/' f`, `node -e …`, `python3 -c …`, `sed -n '1r /x' f`, `sed 's/a/b/w out' f` ask ONCE and a standing `Bash(…)` rule now clears them. Default/off doctrine (the read-boundary seat alone, which bypass installs): they are VOUCHED — the same verdict that seat already gave `awk -f prog.awk`. `jq` reaches zero asks end to end, because the read-only leg it was blocked from now clears it. **Headless / no-approver lanes: these commands are executed rather than refused under the off doctrine, and are one clearable ask under classify.** **Accepted, and stated rather than implied:** a file named INSIDE the unread program text is not judged by the boundary — `sed -n '1r /etc/hosts' f` reads `/etc/hosts` at zero asks under the off doctrine, and under classify after one approval or one stored rule. That is the floor's stated reach (`@contract shell.read_boundary.friction_floor`: it is not a defence against a command written to defeat it), it is what the same spelling in a SCRIPT FILE always did, and the containment that holds it is the `ExecutionEnv` the command runs in. **Unchanged:** an out-of-root OPERAND, a deny-listed operand, or an unresolvable word beside an inline program still mandates on both faces (pinned); a path-prefixed interpreter (`/usr/bin/perl -e …`) still mandates on the program's own out-of-root path.
296
+ - **Reach.** Classify doctrine (every permission mode but bypass): `awk '{print}' f`, `perl -pe 's/a/b/' f`, `node -e …`, `python3 -c …`, `sed -n '1r /x' f`, `sed 's/a/b/w out' f` ask ONCE and a standing `Bash(…)` rule now clears them. Default/off doctrine (the read-boundary seat alone, which bypass installs): they are VOUCHED — the same verdict that seat already gave `awk -f prog.awk`. `jq` reaches zero asks end to end, because the read-only leg it was blocked from now clears it. **Headless / no-approver lanes: these commands are executed rather than refused under the off doctrine, and are one clearable ask under classify.** **Erratum (2026-09-18, post-tag, docs only):** the inline spellings named here are the ones whose program text carries no `(` / `)` / `$` — `python3 -c 'print(1)'` and `node -e 'console.log(1)'` are UNCHANGED and still mandated, refused by the generic punctuation gate exactly as #791 stated since 7.17.7 (a lexical floor cannot tell `print(1)` from `open(...)`); `python3 -c 'x=1'`, `node -e 1`, `perl -e 'print 1'`, `awk 'BEGIN{print 1}'` are the demoted half, measured on the release tree. **Accepted, and stated rather than implied:** a file named INSIDE the unread program text is not judged by the boundary — `sed -n '1r /etc/hosts' f` reads `/etc/hosts` at zero asks under the off doctrine, and under classify after one approval or one stored rule. That is the floor's stated reach (`@contract shell.read_boundary.friction_floor`: it is not a defence against a command written to defeat it), it is what the same spelling in a SCRIPT FILE always did, and the containment that holds it is the `ExecutionEnv` the command runs in. **Unchanged:** an out-of-root OPERAND, a deny-listed operand, or an unresolvable word beside an inline program still mandates on both faces (pinned); a path-prefixed interpreter (`/usr/bin/perl -e …`) still mandates on the program's own out-of-root path.
88
297
  - Pins: `test/backlog791-interpreter-inline-program.test.ts` (G-1/G-1c/G-2/G-3/G-6, the arm's sentence and the seat split pinned apart), `test/backlog769-sed-script-slot.test.ts` (G-1/G-2/G-3), `test/backlog696-*` and `test/backlog854-*` G6/G6b re-pinned to the new reading.
89
298
 
90
299
  ### Widening (mandated ask → the segment's own plain ask), one word shape — a `NAME=value` ARGUMENT's value is judged only where the program's grammar says it opens one (#862 · #860; both faces and the refused-whole word scan; @cli @server re-pin; headless lanes: fewer refusals)
@@ -330,7 +330,7 @@ export interface MaybeCompactOptions {
330
330
  /**
331
331
  * Fires ONCE per LANDED compaction (after `appendCompaction` committed the new baseline), with the
332
332
  * files that were attached WHOLE (untruncated) into the summary. The Runner wires this to the read-state
333
- * invalidation (`applyCompactionToReadFileState`): the summary just replaced the history the read-dedup
333
+ * invalidation (`applyContextShrinkToReadFileState`): the summary just replaced the history the read-dedup
334
334
  * stubs point at, so stale entries must drop and the re-attached content re-registers. Kept OUTSIDE
335
335
  * `workingFileAttachments` deliberately — the invalidation is owed even when attachments are disabled
336
336
  * (an empty array then). Best-effort: a throwing callback must not fail the landed compaction.
@@ -340,7 +340,7 @@ export interface MaybeCompactOptions {
340
340
  * survive the compaction's clear even though they are NOT in `attachedComplete` — every file
341
341
  * `maybeCompact` deliberately skipped re-attaching, for EITHER exclusion reason (kept-tail-visible,
342
342
  * or matches a declared instruction source). Without this the read-state clear (see
343
- * `applyCompactionToReadFileState`) would wipe them anyway, defeating both the read-before-edit gate
343
+ * `applyContextShrinkToReadFileState`) would wipe them anyway, defeating both the read-before-edit gate
344
344
  * and the next read's dedup for exactly the files this optimization was supposed to leave untouched.
345
345
  * (The instruction-source half of this was missing in an earlier revision: `instructionSources` and
346
346
  * `seededFiles` are independent `ProjectMemoryLoad` fields — a path can be declared in the former
@@ -1677,11 +1677,7 @@ export interface CheckpointRow {
1677
1677
  humanReview?: {
1678
1678
  count: number;
1679
1679
  totalWaitMs: number;
1680
- gates: Array<{
1681
- kind: string;
1682
- waitMs: number;
1683
- decision?: string;
1684
- }>;
1680
+ gates: import("./task-result.js").HumanReviewGateRow[];
1685
1681
  };
1686
1682
  /**
1687
1683
  * design/80 D-1 (persist-winner): the winning resume binding, recorded by {@link CheckpointStore.resolve}
@@ -238,7 +238,7 @@ export function planRejectionClears(messages, opts) {
238
238
  const cleared = clearable.map(({ m, at }) => {
239
239
  const source = at.occurrence !== undefined && isToolResult(at.occurrence) ? at.occurrence : m;
240
240
  const clearedMsg = clearOneResult(source, opts.offload);
241
- return { key: at.key, groupCount: at.groupCount, marker: clearedMsg.content[0]?.text ?? CC_CLEARED_MARKER, fp: at.fp };
241
+ return { key: at.key, groupCount: at.groupCount, marker: clearedMsg.content[0]?.text ?? CC_CLEARED_MARKER, fp: at.fp, toolCallId: m.toolCallId };
242
242
  });
243
243
  return { cleared, tokensSavedEstimate };
244
244
  }
@@ -34,6 +34,19 @@ export interface EngineNotice {
34
34
  * prepared leg, never per judgment and never re-fired for a mid-run roster delta; absent ⇒ the judge
35
35
  * has its target view, or no covered write tool was mounted AT PREPARE, or there is no judge —
36
36
  * `wiringManifest.writeProtection` reports the judge's reading either way.
37
+ * - `"config.write_protection_unresolved"` (#869) — this leg's write-protection judge HAS a target view
38
+ * and the environment behind it could not answer: it refused a spelling, could not follow a link, or
39
+ * threw. That view then does not vote and the write is judged by its SPELLING alone (the judge's
40
+ * absence law), so the disclosure is the only place a deployment learns that its write floor is
41
+ * running on half a judgment. Announce, never narrow — no verdict moves because of this notice; the
42
+ * verdict already moved when the environment failed to answer. Audience `operator`.
43
+ * `detail: { reason, cause, sessionId, runId }` — `reason` is `"unresolved-symlink"` (a link whose
44
+ * real target is unknowable) or `"unresolvable-target"` (everything else: a refused spelling, an
45
+ * errored or throwing probe), and `cause` is the environment's own first sentence for that reason.
46
+ * ONCE PER LEG PER `reason` — the fact is about the ENVIRONMENT, not about a write, so it carries no
47
+ * call identity and a second failing write of the same shape adds nothing. Absent ⇒ every judgment
48
+ * this leg made had the views it was compiled for, OR the judge never finished at all (a judgment the
49
+ * gate's bounded wait gave up on is reported on the hook-error lane instead, `onHookError`).
37
50
  * - `"config.tool_card_undeclared"` (design/388 L4) — a tool minted a `details.type` card its definition
38
51
  * (or its caller declaration's `renderHints.resultCards`) never declared; the card was dropped from
39
52
  * `tool_end.structured`; once per leg per type; `detail: { type, sessionId, runId }`.
@@ -1,4 +1,4 @@
1
- import { traceHookCrash } from "./runner/gate-exit.js";
1
+ import { traceHookCrash } from "./gate-crash-trace.js";
2
2
  import type { WriteProtectionVerdict } from "./write-protect.js";
3
3
  /**
4
4
  * design/77 §4: resolve `p`, but REJECT (so the caller catches and fails closed) if it hasn't settled within
@@ -36,8 +36,18 @@ export declare function createGateDeadline(input: {
36
36
  approvalTimeoutMs?: number;
37
37
  }, notifier: Parameters<typeof traceHookCrash>[2]): () => number;
38
38
  /**
39
- * The write-protection judge's wait, at both of its call sites. An elapsed or aborted judgment is the
40
- * judge's own `unresolvable` verdict — which the fold denies and the approved-edit arm denies — because a
41
- * wait that did not finish IS a target nobody could resolve, and an unjudged target is never vouched.
39
+ * The write-protection judge's wait, at both of its call sites — and the ONE minter of the `unresolvable`
40
+ * verdict. An elapsed or aborted judgment is not "a target the environment could not resolve" (the judge
41
+ * answers that one itself, by dropping the view that could not contribute and letting the lexical half
42
+ * stand); it is NO judgment at all — not even the lexical half's answer came back — and there is nothing
43
+ * for the fold or the approved-edit arm to act on but their own fail-closed arm. A wait that produced
44
+ * nothing knows nothing about what it was waiting for, which is why `unresolvedSymlink` is `false` here
45
+ * and not a guess.
46
+ *
47
+ * KNOWN, AND NARROWER THAN IT LOOKS: the lexical half is computed synchronously at the top of `judge`, so
48
+ * a spelling HIT is already on a settled promise before this wait begins and cannot elapse; what this arm
49
+ * refuses is a spelling MISS whose environment half never came back. Closing even that would mean handing
50
+ * the deadline INTO the judge so the missing half could be dropped there too — a seat change on a public
51
+ * type, deliberately not taken here.
42
52
  */
43
53
  export declare function awaitWriteProtectionJudgment(judged: Promise<WriteProtectionVerdict>, ms: number, signal?: AbortSignal, onFailure?: (err: unknown) => void): Promise<WriteProtectionVerdict>;
@@ -1,4 +1,4 @@
1
- import { traceHookCrash } from "./runner/gate-exit.js";
1
+ import { traceHookCrash } from "./gate-crash-trace.js";
2
2
  export function gateWaitFailureOf(err) {
3
3
  const mark = err?.gateWait;
4
4
  return mark === "timeout" || mark === "aborted" ? mark : undefined;
@@ -0,0 +1,7 @@
1
+ import type { ToolGateInput } from "./hooks.js";
2
+ import type { SafeNotifier } from "./safe-notify.js";
3
+ /** Hand the crash itself to the deployment's trace lane (`onError(phase:"hook")`), never letting a
4
+ * throwing sink alter the fail-closed outcome — the same swallow posture as every other observer here.
5
+ * Shared by the gate's dispatch (the phase-1 crash arm, the seat-bound refusal), the reversibility probe's
6
+ * two failure arms, the approved-edit re-screen and the bounded-await deadline. */
7
+ export declare function traceHookCrash(input: ToolGateInput, err: unknown, notifier: SafeNotifier): void;
@@ -0,0 +1,3 @@
1
+ export function traceHookCrash(input, err, notifier) {
2
+ notifier.notify(() => input.onHookError?.(err), "toolGate.onHookError");
3
+ }
@@ -1,7 +1,8 @@
1
1
  import { inlineUntrusted } from "./untrusted-text.js";
2
2
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
3
3
  import { normalizeReadRootCandidate } from "./read-root-candidate.js";
4
- import { traceHookCrash } from "./runner/gate-exit.js";
4
+ import {} from "./runner/gate-exit.js";
5
+ import { traceHookCrash } from "./gate-crash-trace.js";
5
6
  import { writeProtectionMandated, writeProtectionTightenDecision, writeProtectionUnresolved } from "./write-protect.js";
6
7
  import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
7
8
  export async function runGateFold(pass) {
@@ -110,8 +111,8 @@ export async function runGateFold(pass) {
110
111
  };
111
112
  pass.tightenedBy = "safety";
112
113
  }
113
- const writeProtectionStandingAsk = pass.decision.action === "ask";
114
- if ((pass.decision.action === "allow" || writeProtectionStandingAsk) && input.writeProtectionCheck !== undefined) {
114
+ const writeProtectionStanding = pass.decision.action;
115
+ if ((writeProtectionStanding === "allow" || writeProtectionStanding === "ask") && input.writeProtectionCheck !== undefined) {
115
116
  const judged = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face, input.trackedCwd?.(), pass.callSignal ?? input.abortSignal);
116
117
  const verdict = judged === null
117
118
  ? undefined
@@ -126,7 +127,7 @@ export async function runGateFold(pass) {
126
127
  if (verdict !== undefined && writeProtectionUnresolved(verdict))
127
128
  pass.writeProtectionUnresolved = true;
128
129
  const decision = verdict === undefined ? undefined : writeProtectionTightenDecision(toolName, verdict);
129
- if (decision !== undefined && !writeProtectionStandingAsk) {
130
+ if (decision !== undefined && writeProtectionStanding === "allow") {
130
131
  pass.decision = decision;
131
132
  if (decision.action === "ask")
132
133
  pass.tightenedBy = "safety";
@@ -9,7 +9,8 @@ import { readOnlyShellVerdict } from "./read-only-shell.js";
9
9
  import { COMMAND_RULE_TOOL } from "./permission-rule-model.js";
10
10
  import { catalogRuleFaceOf } from "./tool-registry.js";
11
11
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
12
- import { exitGate, traceHookCrash } from "./runner/gate-exit.js";
12
+ import { exitGate } from "./runner/gate-exit.js";
13
+ import { traceHookCrash } from "./gate-crash-trace.js";
13
14
  import { createGateDeadline, awaitWriteProtectionJudgment } from "./gate-bounded-await.js";
14
15
  function isPlainOwnRecord(x) {
15
16
  try {
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
119
119
  * the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
120
120
  * so the catalog is not written a second time here and the two cannot drift.
121
121
  */
122
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
122
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
123
123
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
124
124
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
125
125
  * sink is a supported shape, and narrowing that field would break it. */
@@ -114,6 +114,7 @@ export const ENGINE_NOTICE_CODES = [
114
114
  "config.artifact_host_invalid",
115
115
  "config.task_root_not_canonical",
116
116
  "config.write_protection_target_view_absent",
117
+ "config.write_protection_unresolved",
117
118
  "config.execution_env_capability_invalid",
118
119
  "config.tool_card_undeclared",
119
120
  "config.tool_face_undeclared",
@@ -204,6 +205,7 @@ const NOTICE_AUDIENCE_TABLE = {
204
205
  "config.artifact_host_invalid": "operator",
205
206
  "config.task_root_not_canonical": "operator",
206
207
  "config.write_protection_target_view_absent": "operator",
208
+ "config.write_protection_unresolved": "operator",
207
209
  "config.execution_env_capability_invalid": "operator",
208
210
  "config.tool_card_undeclared": "operator",
209
211
  "config.tool_face_undeclared": "operator",
@@ -1,5 +1,6 @@
1
1
  import { decisionText, refuseOutOfContractDecision } from "./tool-policy.js";
2
- import { createSettlementLedger, engineFailClosedExit, hookDenyExit, traceHookCrash } from "./runner/gate-exit.js";
2
+ import { createSettlementLedger, engineFailClosedExit, hookDenyExit } from "./runner/gate-exit.js";
3
+ import { traceHookCrash } from "./gate-crash-trace.js";
3
4
  import { runGateFold } from "./gate-fold.js";
4
5
  import { runGateLanes } from "./gate-lanes.js";
5
6
  export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
@@ -86,7 +86,7 @@ function syntheticCheckpoint(scope) {
86
86
  suspendCount: 1,
87
87
  suspendedAt: Date.now(),
88
88
  resourceLedger: { spentMicroUsd: 0, spentTokens: 0, spentTurns: 0, sliceCount: 1 },
89
- humanReview: { count: 1, totalWaitMs: 0, gates: [{ kind: "human", waitMs: 0, decision: "approve" }] },
89
+ humanReview: { count: 1, totalWaitMs: 0, gates: [{ kind: "human", waitMs: 0, decision: "approve", toolName: "SelfCheck", toolCallId: `${scope}:selfcheck-call`, toolArg: "park self-check probe" }] },
90
90
  sourceTaskId: `${scope}:task`,
91
91
  principal: `${scope}:principal`,
92
92
  durableApproval: { scope, ttlMs: PARK_SELFCHECK_ROW_TTL_MS },
@@ -3,11 +3,18 @@ import { type PathFamily } from "../tools/fs/safety.js";
3
3
  /**
4
4
  * Every spelling the leaf symlink chain of `path` passes through, or `[]`.
5
5
  *
6
- * `[]` has ONE reading — nothing to judge beyond the spelling and the terminal — reached three ways that are
7
- * deliberately the same word: no `canonicalChain` declared, `ok: false`, or an empty chain. An unanswerable
8
- * chain is never a refusal: the terminal's own canonicalization already refuses what it cannot resolve, and a
6
+ * `[]` has ONE reading for the VERDICT — nothing to judge beyond the spelling and the terminal — reached four
7
+ * ways that are deliberately the same word: no `canonicalChain` declared, a refused namespace spelling, an
8
+ * empty chain, and the environment answering `ok: false`. An unanswerable chain is never a refusal: this
9
+ * reader's callers are path guards whose own absence law says a view that cannot answer does not vote, and a
9
10
  * second refusal law over one target would be a second rule. The spelling is anchored as the canonicalizer
10
11
  * anchors it (`baseCwd`). A refused namespace spelling and a win-form UNC spelling are not walked (the reference
11
12
  * CLI's variant set stops at a network spelling); the share is recognized by the throat's own `isUncSpelling`.
13
+ *
14
+ * `onUnanswerable` separates ONE of those four from the rest for a caller that DISCLOSES its absent views: the
15
+ * environment was ASKED and could not answer (`ok: false`), which is a fact about that environment. The other
16
+ * three are not failures — nothing was asked, or the answer was genuinely "no hops" — so they stay silent. The
17
+ * verdict is `[]` either way; this sink changes nothing about what is judged, only what is said out loud. A
18
+ * throwing sink is the caller's bug and is not caught here.
12
19
  */
13
- export declare function symlinkChainHops(env: ExecutionEnv, path: string, signal: AbortSignal | undefined, family: PathFamily, baseCwd?: string): Promise<readonly string[]>;
20
+ export declare function symlinkChainHops(env: ExecutionEnv, path: string, signal: AbortSignal | undefined, family: PathFamily, baseCwd?: string, onUnanswerable?: (cause: string) => void): Promise<readonly string[]>;
@@ -1,5 +1,5 @@
1
1
  import { isAbsoluteForFamily, isUncSpelling, joinForFamily, win32NamespaceScreen } from "../tools/fs/safety.js";
2
- export async function symlinkChainHops(env, path, signal, family, baseCwd) {
2
+ export async function symlinkChainHops(env, path, signal, family, baseCwd, onUnanswerable) {
3
3
  if (typeof env.canonicalChain !== "function")
4
4
  return [];
5
5
  const screened = win32NamespaceScreen(path, family);
@@ -10,5 +10,8 @@ export async function symlinkChainHops(env, path, signal, family, baseCwd) {
10
10
  return [];
11
11
  const target = baseCwd !== undefined && baseCwd.length > 0 && !isAbsoluteForFamily(family, spelled) ? joinForFamily(family, baseCwd, spelled) : spelled;
12
12
  const chain = await env.canonicalChain(target, signal);
13
- return chain.ok ? chain.value.hops : [];
13
+ if (chain.ok)
14
+ return chain.value.hops;
15
+ onUnanswerable?.(chain.error.message);
16
+ return [];
14
17
  }
@@ -20,5 +20,15 @@ export function normalizeReadRootCandidate(value) {
20
20
  return undefined;
21
21
  if (!win32NamespaceScreen(dir, pathFamilyOf({ root: dir })).ok)
22
22
  return undefined;
23
- return { dir, clearsThisAsk: true };
23
+ let covers;
24
+ try {
25
+ const d = Object.getOwnPropertyDescriptor(value, "covers");
26
+ covers = d === undefined ? "absent" : "value" in d && d.value === "exact" ? "exact" : "refuse";
27
+ }
28
+ catch {
29
+ covers = "refuse";
30
+ }
31
+ if (covers === "refuse")
32
+ return undefined;
33
+ return covers === "exact" ? { dir, clearsThisAsk: true, covers: "exact" } : { dir, clearsThisAsk: true };
24
34
  }