@sema-agent/core 7.51.1 → 7.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +550 -2
- package/dist/agents/agent-transcript-tool.d.ts +1 -1
- package/dist/agents/cascade.js +4 -0
- package/dist/agents/cumulative-stats.d.ts +3 -0
- package/dist/agents/cumulative-stats.js +3 -0
- package/dist/agents/list-agents-tool.d.ts +1 -1
- package/dist/agents/read-inbox-tool.d.ts +1 -1
- package/dist/agents/repair-loop.js +4 -0
- package/dist/agents/send-message-tool.d.ts +11 -16
- package/dist/agents/send-message-tool.js +11 -13
- package/dist/agents/subagent.d.ts +5 -0
- package/dist/agents/subagent.js +7 -4
- package/dist/agents/tool-filter.d.ts +40 -13
- package/dist/agents/tool-filter.js +28 -14
- package/dist/bench/metrics.d.ts +1 -0
- package/dist/brain/anthropic.js +16 -12
- package/dist/brain/model-presets.d.ts +4 -5
- package/dist/brain/model-presets.js +4 -4
- package/dist/brain/open-responses.js +16 -12
- package/dist/brain/openai.js +16 -12
- package/dist/brain/request-params.d.ts +12 -33
- package/dist/brain/request-params.js +5 -2
- package/dist/brain/request-route.d.ts +9 -8
- package/dist/brain/request-route.js +1 -0
- package/dist/brain/route-adjudicator.d.ts +117 -47
- package/dist/brain/route-adjudicator.js +171 -42
- package/dist/brain/route-conformance.js +2 -2
- package/dist/brain/status-sink.d.ts +18 -0
- package/dist/brain/status-sink.js +7 -0
- package/dist/core/allow-entry.d.ts +42 -8
- package/dist/core/allow-entry.js +24 -17
- package/dist/core/auto-compaction.d.ts +23 -20
- package/dist/core/auto-compaction.js +37 -16
- package/dist/core/auto-promote.d.ts +2 -1
- package/dist/core/auto-promote.js +2 -2
- package/dist/core/caller-tool-name.d.ts +9 -0
- package/dist/core/caller-tool-name.js +13 -0
- package/dist/core/checkpoint-requirements.d.ts +7 -1
- package/dist/core/checkpoint-requirements.js +2 -0
- package/dist/core/checkpoint-store.d.ts +36 -12
- package/dist/core/checkpoint-store.js +1 -1
- package/dist/core/child-clamp-seats.d.ts +6 -0
- package/dist/core/child-clamp-seats.js +2 -1
- package/dist/core/credential-vocabulary.d.ts +33 -8
- package/dist/core/credential-vocabulary.js +32 -7
- package/dist/core/decision-receipt.d.ts +2 -2
- package/dist/core/delegation-frames.d.ts +9 -2
- package/dist/core/delegation-name.d.ts +18 -0
- package/dist/core/delegation-name.js +47 -0
- package/dist/core/engine-capabilities.d.ts +8 -1
- package/dist/core/engine-capabilities.js +1 -1
- package/dist/core/engine-notice.d.ts +36 -19
- package/dist/core/frontmatter-fence.d.ts +10 -0
- package/dist/core/frontmatter-fence.js +6 -0
- package/dist/core/gate-fold.js +5 -23
- package/dist/core/gate-lanes.js +356 -270
- package/dist/core/governance-codes.d.ts +9 -4
- package/dist/core/governance-codes.js +9 -3
- package/dist/core/hooks.d.ts +276 -734
- package/dist/core/hooks.js +97 -192
- package/dist/{tools/fs → core}/image-admission.d.ts +21 -3
- package/dist/{tools/fs → core}/image-admission.js +24 -6
- package/dist/core/locked-config.d.ts +26 -5
- package/dist/core/locked-config.js +31 -6
- package/dist/core/mcp-connection-pool.d.ts +4 -1
- package/dist/core/mcp-connection-pool.js +7 -2
- package/dist/core/mcp-probe.d.ts +12 -9
- package/dist/core/mcp-probe.js +2 -2
- package/dist/core/mcp-server-spec.d.ts +53 -7
- package/dist/core/mcp-server-spec.js +84 -1
- package/dist/core/mcp.d.ts +11 -7
- package/dist/core/mcp.js +27 -9
- package/dist/core/memory-engine/data-plane.js +5 -1
- package/dist/core/memory-engine/distiller.js +16 -2
- package/dist/core/memory-engine/engine.d.ts +8 -11
- package/dist/core/memory-engine/engine.js +9 -5
- package/dist/core/memory-engine/frontmatter.d.ts +6 -0
- package/dist/core/memory-engine/frontmatter.js +19 -16
- package/dist/core/memory.d.ts +2 -2
- package/dist/core/mode-policy.d.ts +4 -0
- package/dist/core/mode-policy.js +7 -5
- package/dist/core/model-seat.d.ts +4 -1
- package/dist/core/mods/dollar.d.ts +109 -0
- package/dist/core/mods/dollar.js +154 -0
- package/dist/core/mods/host.d.ts +23 -0
- package/dist/core/mods/host.js +21 -0
- package/dist/core/mods/kernel/budget.d.ts +43 -0
- package/dist/core/mods/kernel/budget.js +83 -0
- package/dist/core/mods/kernel/dispatch.d.ts +100 -0
- package/dist/core/mods/kernel/dispatch.js +701 -0
- package/dist/core/mods/kernel/events.d.ts +242 -0
- package/dist/core/mods/kernel/events.js +210 -0
- package/dist/core/mods/kernel/index.d.ts +6 -0
- package/dist/core/mods/kernel/index.js +5 -0
- package/dist/core/mods/kernel/receive.d.ts +65 -0
- package/dist/core/mods/kernel/receive.js +123 -0
- package/dist/core/mods/kernel/registration.d.ts +131 -0
- package/dist/core/mods/kernel/registration.js +102 -0
- package/dist/core/mods/kernel/site-rules.d.ts +84 -0
- package/dist/core/mods/kernel/site-rules.js +104 -0
- package/dist/core/mods/stations.d.ts +350 -0
- package/dist/core/mods/stations.js +444 -0
- package/dist/core/own-property-receipt.js +11 -23
- package/dist/core/parent-spec-seat.d.ts +18 -7
- package/dist/core/parent-spec-seat.js +6 -2
- package/dist/core/path-identity.js +2 -1
- package/dist/core/permission-rule-syntax.d.ts +15 -0
- package/dist/core/permission-rule-syntax.js +24 -16
- package/dist/core/permission-rules.d.ts +7 -1
- package/dist/core/permission-rules.js +26 -15
- package/dist/core/prompt-section-seat.d.ts +15 -27
- package/dist/core/prompt-section-seat.js +73 -40
- package/dist/core/protocol-http-auth.d.ts +3 -1
- package/dist/core/protocol-http-auth.js +2 -0
- package/dist/core/reminder-mint.d.ts +2 -2
- package/dist/core/remote-env.d.ts +24 -12
- package/dist/core/remote-env.js +1 -0
- package/dist/core/resume-startup-cause.d.ts +16 -1
- package/dist/core/resume-startup-cause.js +4 -1
- package/dist/core/retired-keys.d.ts +4 -0
- package/dist/core/retired-keys.js +3 -0
- package/dist/core/runner/announce-once-ledger.d.ts +31 -1
- package/dist/core/runner/announce-once-ledger.js +22 -0
- package/dist/core/runner/assemble-result.d.ts +9 -0
- package/dist/core/runner/assemble-result.js +3 -1
- package/dist/core/runner/call-hook-stations.d.ts +23 -27
- package/dist/core/runner/call-hook-stations.js +58 -42
- package/dist/core/runner/caller-stop-gate.d.ts +25 -0
- package/dist/core/runner/caller-stop-gate.js +7 -0
- package/dist/core/runner/compaction-seams.d.ts +8 -22
- package/dist/core/runner/compaction-seams.js +28 -53
- package/dist/core/runner/construction-doors.js +11 -1
- package/dist/core/runner/contracts.d.ts +85 -83
- package/dist/core/runner/credential-origin-seat.d.ts +2 -0
- package/dist/core/runner/credential-origin-seat.js +19 -0
- package/dist/core/runner/extra-body-dropped-seat.d.ts +2 -6
- package/dist/core/runner/gate-exit.d.ts +47 -36
- package/dist/core/runner/gate-exit.js +8 -2
- package/dist/core/runner/image.js +1 -2
- package/dist/core/runner/inline-image.js +1 -1
- package/dist/core/runner/notice-seat-input.d.ts +11 -0
- package/dist/core/runner/notice-seat-input.js +1 -0
- package/dist/core/runner/person-path-rows.js +1 -0
- package/dist/core/runner/prepare-acquire-reconcile.js +5 -1
- package/dist/core/runner/prepare-ask-lane.d.ts +5 -7
- package/dist/core/runner/prepare-ask-lane.js +6 -15
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
- package/dist/core/runner/prepare-caps-and-workflow.js +25 -8
- package/dist/core/runner/prepare-config-doors.d.ts +5 -0
- package/dist/core/runner/prepare-config-doors.js +9 -0
- package/dist/core/runner/prepare-defer-classify.js +3 -4
- package/dist/core/runner/prepare-gate-stations.d.ts +8 -11
- package/dist/core/runner/prepare-gate-stations.js +11 -10
- package/dist/core/runner/prepare-inherited-gate.d.ts +8 -12
- package/dist/core/runner/prepare-inherited-gate.js +8 -21
- package/dist/core/runner/prepare-memory-engine-session.js +1 -1
- package/dist/core/runner/prepare-park-ask.js +10 -14
- package/dist/core/runner/prepare-policy-chain.d.ts +18 -16
- package/dist/core/runner/prepare-policy-chain.js +98 -101
- package/dist/core/runner/prepare-project-context.js +7 -10
- package/dist/core/runner/prepare-prompt-assembly.d.ts +45 -40
- package/dist/core/runner/prepare-prompt-assembly.js +134 -101
- package/dist/core/runner/prepare-prompt-inputs.d.ts +18 -19
- package/dist/core/runner/prepare-prompt-inputs.js +5 -7
- package/dist/core/runner/prepare-protocol-tools.d.ts +6 -4
- package/dist/core/runner/prepare-protocol-tools.js +2 -2
- package/dist/core/runner/prepare-safety-scan.js +6 -10
- package/dist/core/runner/prepare-suspend-saga.d.ts +6 -0
- package/dist/core/runner/prepare-suspend-saga.js +7 -5
- package/dist/core/runner/prepare-task.js +33 -15
- package/dist/core/runner/prepare-turn-wiring.d.ts +13 -15
- package/dist/core/runner/prepare-turn-wiring.js +29 -27
- package/dist/core/runner/prepare-wiring-manifest.d.ts +5 -6
- package/dist/core/runner/prepare-wiring-manifest.js +7 -7
- package/dist/core/runner/resume-apply.d.ts +5 -2
- package/dist/core/runner/resume-apply.js +24 -42
- package/dist/core/runner/resume-checkpoint-screen.d.ts +1 -0
- package/dist/core/runner/resume-checkpoint-screen.js +9 -1
- package/dist/core/runner/resume-preflight.js +38 -16
- package/dist/core/runner/retired-runner-deps.js +3 -1
- package/dist/core/runner/run-brain-sinks.d.ts +3 -2
- package/dist/core/runner/run-brain-sinks.js +5 -1
- package/dist/core/runner/run-compaction-machinery.d.ts +7 -1
- package/dist/core/runner/run-compaction-machinery.js +7 -33
- package/dist/core/runner/run-harness-handlers.js +4 -2
- package/dist/core/runner/run-identity-wiring.d.ts +3 -1
- package/dist/core/runner/run-identity-wiring.js +3 -2
- package/dist/core/runner/run-leg.js +62 -84
- package/dist/core/runner/run-notification-lane.js +1 -0
- package/dist/core/runner/run-recovery-lanes.d.ts +3 -0
- package/dist/core/runner/run-recovery-lanes.js +11 -10
- package/dist/core/runner/run-settle-and-teardown.js +9 -28
- package/dist/core/runner/run-stop-and-final-verify.js +18 -25
- package/dist/core/runner/run-terminal-adoption.d.ts +3 -0
- package/dist/core/runner/run-terminal-adoption.js +33 -22
- package/dist/core/runner/run-tool-mount-facts.js +5 -3
- package/dist/core/runner/run-turn-boundary.js +23 -34
- package/dist/core/runner/runtask.d.ts +6 -7
- package/dist/core/runner/runtask.js +14 -72
- package/dist/core/runner/session-rule-policy.d.ts +6 -4
- package/dist/core/runner/session-rule-policy.js +17 -10
- package/dist/core/runner/side-call-account.d.ts +68 -0
- package/dist/core/runner/side-call-account.js +61 -0
- package/dist/core/runner/stream-halt-verbs.js +12 -17
- package/dist/core/runner/stream-lifecycle-verbs.js +8 -5
- package/dist/core/runner/stream-steer-verb.d.ts +1 -1
- package/dist/core/runner/stream-steer-verb.js +20 -38
- package/dist/core/runner/sub-agent-deny-lists.d.ts +121 -22
- package/dist/core/runner/sub-agent-deny-lists.js +228 -71
- package/dist/core/runner/tool-end-body.d.ts +5 -6
- package/dist/core/runner/tool-end-body.js +6 -4
- package/dist/core/runner/tool-not-executed.d.ts +15 -0
- package/dist/core/runner/tool-not-executed.js +30 -0
- package/dist/core/runner/tool-occurrence.d.ts +8 -7
- package/dist/core/runner/tool-occurrence.js +5 -5
- package/dist/core/runner/tool-result-media-seat.d.ts +1 -1
- package/dist/core/runner-deps.d.ts +20 -28
- package/dist/core/safety-merge-corpus.js +18 -2
- package/dist/core/secret-env.d.ts +93 -20
- package/dist/core/secret-env.js +192 -38
- package/dist/core/session-policy-store.d.ts +93 -17
- package/dist/core/session-policy-store.js +178 -25
- package/dist/core/session-reconcile.d.ts +4 -0
- package/dist/core/session-reconcile.js +2 -1
- package/dist/core/shell-read-silence.js +3 -32
- package/dist/core/side-query.d.ts +5 -3
- package/dist/core/skill-grant.d.ts +3 -2
- package/dist/core/skill-grant.js +17 -9
- package/dist/core/skills-directory.d.ts +31 -11
- package/dist/core/skills-directory.js +655 -104
- package/dist/core/task-event.d.ts +13 -3
- package/dist/core/task-result.d.ts +8 -0
- package/dist/core/task-spec.d.ts +20 -19
- package/dist/core/task-stream.d.ts +14 -4
- package/dist/core/tighten-task-spec.d.ts +4 -3
- package/dist/core/tighten-task-spec.js +2 -4
- package/dist/core/tool-check-reading.d.ts +74 -0
- package/dist/core/tool-check-reading.js +80 -0
- package/dist/core/tool-policy.d.ts +59 -18
- package/dist/core/tool-policy.js +77 -12
- package/dist/core/tool-registry.d.ts +20 -0
- package/dist/core/tool-registry.js +4 -0
- package/dist/core/tool-spec.d.ts +10 -6
- package/dist/core/tools.js +2 -0
- package/dist/core/trace.d.ts +51 -32
- package/dist/core/types.d.ts +2 -1
- package/dist/core/types.js +1 -0
- package/dist/core/unc-spelling.d.ts +10 -0
- package/dist/core/unc-spelling.js +3 -0
- package/dist/core/untrusted-egress.d.ts +8 -0
- package/dist/core/untrusted-egress.js +4 -1
- package/dist/core/user-attachment.d.ts +12 -2
- package/dist/core/user-attachment.js +17 -2
- package/dist/core/wiring-manifest.d.ts +14 -13
- package/dist/core/wiring-manifest.js +2 -2
- package/dist/engine/compaction/utils.js +1 -2
- package/dist/engine/execution-env/node-execution-env.d.ts +4 -3
- package/dist/engine/execution-env/symlink-chain.js +1 -1
- package/dist/engine/harness/agent-harness.d.ts +1 -1
- package/dist/engine/harness/agent-harness.js +3 -3
- package/dist/engine/harness/types.d.ts +17 -16
- package/dist/engine/harness/types.js +7 -1
- package/dist/engine/llm/types.d.ts +12 -7
- package/dist/engine/loop/agent-loop.js +1 -1
- package/dist/engine/loop/types.d.ts +1 -1
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -2
- package/dist/engine/session/import-validate.js +3 -10
- package/dist/engine/session/session.d.ts +4 -4
- package/dist/engine/session/session.js +4 -4
- package/dist/index.d.ts +24 -17
- package/dist/index.js +15 -12
- package/dist/internal/harness.d.ts +1 -1
- package/dist/internal/harness.js +1 -1
- package/dist/orchestration/run-spec.d.ts +4 -6
- package/dist/orchestration/run-spec.js +39 -63
- package/dist/orchestration/run-workflow-tool.js +1 -2
- package/dist/orchestration/workflow.d.ts +11 -3
- package/dist/orchestration/workflow.js +11 -2
- package/dist/prompt-assembly/artifact-store.d.ts +37 -56
- package/dist/prompt-assembly/artifact-store.js +31 -49
- package/dist/prompt-assembly/artifact.d.ts +89 -76
- package/dist/prompt-assembly/artifact.js +167 -146
- package/dist/prompt-assembly/assemble.d.ts +34 -29
- package/dist/prompt-assembly/assemble.js +33 -220
- package/dist/prompt-assembly/bands.d.ts +20 -0
- package/dist/prompt-assembly/bands.js +18 -0
- package/dist/prompt-assembly/catalog/constitution.d.ts +23 -0
- package/dist/prompt-assembly/catalog/constitution.js +69 -0
- package/dist/prompt-assembly/catalog/core-behavior.d.ts +9 -0
- package/dist/prompt-assembly/catalog/core-behavior.js +85 -0
- package/dist/prompt-assembly/catalog/index.d.ts +11 -0
- package/dist/prompt-assembly/catalog/index.js +59 -0
- package/dist/prompt-assembly/catalog/modes.d.ts +15 -0
- package/dist/prompt-assembly/catalog/modes.js +137 -0
- package/dist/prompt-assembly/catalog/role.d.ts +10 -0
- package/dist/prompt-assembly/catalog/role.js +157 -0
- package/dist/prompt-assembly/catalog/schema.d.ts +124 -0
- package/dist/prompt-assembly/catalog/schema.js +25 -0
- package/dist/prompt-assembly/catalog/sections.d.ts +9 -0
- package/dist/prompt-assembly/catalog/sections.js +98 -0
- package/dist/prompt-assembly/catalog/team.d.ts +4 -0
- package/dist/prompt-assembly/catalog/team.js +258 -0
- package/dist/prompt-assembly/composer.d.ts +24 -28
- package/dist/prompt-assembly/composer.js +124 -46
- package/dist/prompt-assembly/epoch.d.ts +89 -79
- package/dist/prompt-assembly/epoch.js +115 -142
- package/dist/prompt-assembly/explain.d.ts +58 -34
- package/dist/prompt-assembly/explain.js +21 -24
- package/dist/prompt-assembly/overlay.d.ts +127 -0
- package/dist/prompt-assembly/overlay.js +301 -0
- package/dist/prompt-assembly/renderers.d.ts +21 -0
- package/dist/prompt-assembly/renderers.js +39 -0
- package/dist/prompt-assembly/section-usage.d.ts +3 -3
- package/dist/prompt-assembly/section-usage.js +1 -1
- package/dist/prompt-assembly/select.d.ts +56 -0
- package/dist/prompt-assembly/select.js +69 -0
- package/dist/prompt-assembly/sugar.d.ts +31 -0
- package/dist/prompt-assembly/sugar.js +20 -0
- package/dist/prompt-assembly/turn-snapshot.d.ts +3 -2
- package/dist/prompt-assembly/types.d.ts +45 -148
- package/dist/prompts/coordinator.d.ts +2 -21
- package/dist/prompts/coordinator.js +3 -255
- package/dist/prompts/cowork.d.ts +27 -16
- package/dist/prompts/cowork.js +2 -23
- package/dist/prompts/default.d.ts +38 -500
- package/dist/prompts/default.js +20 -285
- package/dist/prompts/supervisor.d.ts +6 -71
- package/dist/prompts/supervisor.js +6 -117
- package/dist/stores/file/session-policy-store.d.ts +3 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +6 -5
- package/dist/tools/fs/dir-entries.d.ts +4 -2
- package/dist/tools/fs/dir-entries.js +16 -4
- package/dist/tools/fs/fs-bash.d.ts +1 -1
- package/dist/tools/fs/fs-bash.js +22 -11
- package/dist/tools/fs/fs-pdf.js +17 -13
- package/dist/tools/fs/fs-read.js +4 -3
- package/dist/tools/fs/fs-shared.d.ts +1 -10
- package/dist/tools/fs/fs-shared.js +3 -4
- package/dist/tools/fs/fs-write.js +2 -1
- package/dist/tools/fs/notebook.js +1 -1
- package/dist/tools/fs/pdf.d.ts +20 -10
- package/dist/tools/fs/pdf.js +19 -10
- package/dist/tools/fs/program-read-model.d.ts +68 -8
- package/dist/tools/fs/program-read-model.js +112 -33
- package/dist/tools/fs/program-read-model.rows.js +30 -10
- package/dist/tools/fs/read-deny-home.d.ts +25 -9
- package/dist/tools/fs/read-deny-home.js +28 -27
- package/dist/tools/fs/read-deny.d.ts +41 -11
- package/dist/tools/fs/read-deny.js +51 -5
- package/dist/tools/fs/safety.d.ts +4 -26
- package/dist/tools/fs/safety.js +10 -32
- package/dist/tools/fs/search.js +15 -10
- package/dist/tools/fs/shell-read-argv.d.ts +3 -2
- package/dist/tools/fs/shell-read-argv.js +118 -38
- package/dist/tools/fs/shell-read-establish.d.ts +5 -5
- package/dist/tools/fs/shell-read-establish.js +11 -61
- package/dist/tools/fs/shell-read-identity.js +6 -3
- package/dist/tools/fs/shell-read-judge.d.ts +13 -15
- package/dist/tools/fs/shell-read-judge.js +18 -4
- package/dist/tools/fs/shell-read-plan.d.ts +2 -2
- package/dist/tools/web.js +1 -1
- package/package.json +13 -1
- package/test/export-surface.snapshot.json +256 -99
- package/wire-changes/core@7.51.1.json +5 -5
- package/wire-changes/core@7.51.2.json +293 -0
- package/wire-changes/core@7.52.0.json +5859 -0
- package/dist/core/hook-seats.d.ts +0 -66
- package/dist/core/hook-seats.js +0 -37
- package/dist/prompt-assembly/packs/sema-default.d.ts +0 -2
- package/dist/prompt-assembly/packs/sema-default.js +0 -147
- package/dist/prompts/simple-sections.d.ts +0 -58
- package/dist/prompts/simple-sections.js +0 -43
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,553 @@
|
|
|
1
|
+
## 7.52.0 — 2026-10-17
|
|
2
|
+
|
|
3
|
+
### Another spelling of a mounted tool's name in a tool list refuses the run before it starts: a deny list that names a case twin or a retired name hid nothing, and the tool it was written for ran (#1930, #1932, #1935, #1937, #1947, #1951, #1953; P1; security — fail-open; behavior narrowing, the reversal of one arm of the 7.51.0 "kept and told" entry — open to veto; **hard-breaking**: a run whose agent definition, delegation tool, `TaskSpec` or policy name lists (a permission rule's tool name included), or a session-rule write, or a host-written workflow `toolCeiling`, name a tool by another spelling of a mounted tool's name is refused where 7.51.x started it, and so is a run whose delegation tool is mounted under another name than the one it declares (`{ ...tool, name: "Delegate" }` on a tool built as `createSubagentTool({ name: "FENCE" })`); codes: `config.agent.unknown_tool` and `config.tool_face_control` gain the `detail.reason` value `near_miss`, `config.tool_face_control` also gains `renamed_mount`, `invalid_record` and `registry_occupied`; `checkpoint.config_refused` carries `config.tool_face_control` as `detail.code` (the row's recorded tool names and a renamed delegation mount, refused before the claim — they were `resume.parent_constraint_mismatch` for the names, in this unreleased window), `config.legacy_tool_name` answers a case twin; closed sets: `REQUIREMENT_WORDS` +1 (`own_tool_lists`; 28 → 29 — an older worker refuses a row carrying it as `version_newer`, the row stays pending), `CHECKPOINT_CONFIG_REFUSAL_CODES` +1 (`config.tool_face_control`; 8 → 9), `CheckpointErrorReason` +7 (`not_a_list`, `not_a_string`, `unknown_name`, `unsupported_pattern`, `invalid_record`, `renamed_mount`, `registry_occupied`; 18 → 25); `detail.reason` is an open string on those codes (new values: `near_miss`, `renamed_mount`, `invalid_record`); exports: none; members: `WorkflowInternals` +1 optional `toolCeiling` (the launching run's ceiling, on the trusted fourth argument of `startWorkflow` / `runWorkflow`; `RunWorkflowOptions.toolCeiling` stays, now the host-written form that is judged); `CheckpointState` +1 (`ownToolLists`, requirement word `own_tool_lists`: optional `{ toolName, definition?, lists }` — the delegation tool that wrote a leg's own lists, the definition it was spawned as and the digest of exactly the lists it wrote, which the record holds for; absent on a row parked before it); `RunInternals` −1 +1 (`ownToolListsWhere` → `ownToolLists`: the engine-minted string label of whose lists a leg's own are becomes the structure `{ toolName, definition?, lists }`; the sentence is minted where it is used)) (@cli @server @client-core @test)
|
|
4
|
+
|
|
5
|
+
- **Before (stage `575ee3f41`, real Runner lanes, a real `/bin/sh` — the marker of an `echo` came back in the model's tool result).** An agent definition's `denyTools: ["BASH"]` (the file key `disallowedTools`), `["bash"]`, the run's own `excludeTools: ["BASH"]`, a workflow agentType's `denyTools: ["BASH"]` and a run's `toolPolicy` deny of `BASH` each started the run, and the child's (or the run's) `Bash` executed — ran 1, no line to the user. The two list judgments kept the entry as written and told the operator one line (`onError` phase `config`; since 7.51.0, reversing 7.49.0 – 7.50.2, which refused it); the policy name audit called a case twin "unmatched" and only advised. A kept entry is matched by exact name, so a denied twin hides nothing. The allowed side read the same way, in the other direction: `allowTools: ["read"]` (or `["bash"]` on an agent) mounted nothing the entry was written for — the run started with no `Bash`, no line to the user.
|
|
6
|
+
- **Now.** One rule: a name the one name reading holds to be another spelling of a tool the run carries — the reading gives the spelling to write (`currentName`: a case twin `BASH` / `bAsh`, a retired name with a successor `bash` / `read_file` / `KillShell`, a protocol peer the mint spells otherwise `mcp__github.com__x` → `mcp__github_com__x`; a rule form of one, `BASH(rm:*)`, included) — reaches no tool and refuses the run before any turn, on either side of a list — on the universe of the list (one sentence below). Three lanes, existing codes: an agent definition's or a delegation tool's lists → `config.agent.unknown_tool`; the run's own `allowTools` / `excludeTools` → `config.tool_face_control`; both with `detail.reason: "near_miss"` and a sentence naming both spellings (`… declares tool "BASH" (near_miss: the mounted spelling is "Bash") in its denied tools, a spelling that reaches no tool a run mounts — it hides nothing, so the tool it was written for stays available to the agent. Write the name as the tool is mounted.`); a policy's name lists (a workflow agentType's lists, a run's `toolPolicy`, an ancestor's, a session's stored rows) → `config.legacy_tool_name`, the code the audit already answers a retired or unmintable name with (`tool policy deny-list entry "BASH" differs from the mounted tool "Bash" only in case … Write "Bash".`). The reading is the entry lists' own (`allow-entry.ts`, now exported as `nearMissSpellingOf` inside the package): an engine tool's exact name and a protocol tool's are never a near miss, a name the roster carries is not one (a caller tool `BASH` beside `Bash`; a deployment's own `Task`). A name nothing answers to and that has no other spelling (`NotMounted`, the typo `Bsh`, a retired name with no successor) stays kept and told to the operator once — a tool of that name may be mounted later; no user-facing line is added for it. Rules: the list judgment gains no criterion of its own — it reads the `currentName` the entry reader already gives (+1 refusal arm, one sentence); the policy audit calls the same reading (+1 arm); the case comparison against a roster stays in one place. The universe, one sentence: a root-facing list (the run's own `allowTools` / `excludeTools`, a policy's names, a session-rule write) is read against the run's roster, the caller's tools, the catalog and the exact names of the pools its reachable delegation tools declare and can list, and another spelling of a name in it is always refused; a child-facing list (a definition's, a delegation tool's) is read against the pool that delegation tool declares and the catalog and — only while that pool cannot be listed — keeps and tells such a name; a layer a run inherits is not asked again. An exact name in a root-facing universe is the caller's own, never a twin (a pool tool `FENCE` beside the caller's `Fence`, named by a `toolPolicy` or a workflow agentType's deny, starts the run); a delegation tool the run's own selector takes out (`excludeTools: ["Agent"]`) counts in no universe, and an unlistable pool (an `extraTools` factory with no declared `extraToolNames`) clears no name. The pool is read in one place (`delegationPoolReading`, after the selector), the root-facing names in one (`rootFacingToolNames`, which the session-rules write gate calls too). The spawned child is handed the lists its spawning run judged, with the evidence of that judgment, and a ceiling it inherits was asked by the run that wrote it: neither is asked again of the child's own poorer universe (the child that did refused a parent naming its own `read` / `GREP` / `Task` tool, and a resumed row's ceiling was asked after the claim — #1935). The persistent rule layer (`permissions.deny` / `ask` rules, `Bash(*)` form included) reaches the same policy-name audit by its name sets, so `BASH(*)`, `BASH` and `BASH(touch:*)` refuse the run too (`config.legacy_tool_name`), while `Bash(*)` still denies the call and a rule naming nothing is still an advisory. A session-rule write (`toolDeny` / `toolAllow`, operator writes included) holding a case twin of an engine tool is refused at the write like a retired name (`inert_entry`, `detail.reason: "near_miss"`). The near-miss sentence says whose spelling it asks for: `the mounted spelling is` for the roster, `a delegation pool carries` for a pool-only tool, `the engine's tool catalog names` for a tool only the catalog carries. (#1937) Three places the rule did not reach the whole way, closed: (a) a child-facing entry the spawning run kept because its delegation tool's pool cannot be listed is no longer waved through the spawned child — the child's lists carry the stamp "judged against a pool that can be listed" only for a pool that can (a delegation tool with an `extraTools` factory and no `extraToolNames` stamps nothing), so the child asks the entry of its own universe — its roster, the catalog, the pools it can list, and the delegation tool that spawned it under its exact mounted name (a name of its lineage, mounted at its depth or not; an exact hit of it comes before any other reading of the same string, so the built-in Explore / Plan's own denied `agent` and a host's `denyTools: ["Task"]` start the child, #1942) — and refuses another spelling of a tool it carries (`config.tool_face_control`, `near_miss`, before any model turn; the spawning run's one line is not told a second time, a name nothing answers to stays kept), and the one line says what happens next (the pool this delegation tool hands its child cannot be listed; the spawned child judges the entry against its own roster: another spelling of a tool it carries refuses the child, an exact name takes effect, a name nothing answers to stays in force for a tool mounted later); the row a parked child leaves records who wrote its lists (`CheckpointState.ownToolLists`), so a bare `resumeStream` asks the same universe — a row parked before the member is judged as before (KL-1930-c); and the same rule closes the other way: a delegation tool named `Task` whose child list holds `TASK` (a case twin of the tool's own name) started its child in 7.51.x and now refuses it before the first turn, as it already did at the spawning run and at any depth where the child mounts the nested copy — the tool's name is a name of the child's lineage, so its twin is a twin; (b) the policy name audit reads the root-facing universe (roster, the caller's tools, the catalog, the listable pools' names) instead of the roster alone, so a session's stored `toolDeny: ["FENCE"]`, a run's `toolPolicy` deny, a workflow agentType's lists and a permission rule refuse a twin of a tool only a listable pool or only the catalog carries (`WEBFETCH` beside the catalog's `WebFetch`) — the same configuration `excludeTools` already refused; (c) a ceiling the host writes (`RunWorkflowOptions.toolCeiling`) is asked of every workflow agent's own tools (the catalog, its caller tools, the listable pools of the delegation tools its own lists leave) before the agent launches, on both spawn legs, and a near miss in it refuses that agent (`config.tool_face_control`, `near_miss`; the first `ctx.agent()` rejects with the sentence) — the ceiling of the prepared run that launched the workflow rides the new `WorkflowInternals.toolCeiling` (the `run_workflow` tool's wiring) and is not asked again; given both, both layers bound every agent (the launching run's first) and only the host-written one is judged. Rules: the stamp's meaning narrows (+0), the audit's universe is the existing one (+0 functions), one function (`judgeHostToolCeiling`) asks the existing list judgment of the host's ceiling, and one in-memory flag on the list judgment's input (`toldUpstream`: a delegation lane inside a prepared run spawned the leg and told what it kept — so the refusal is asked and nothing is told twice) is not persisted. (#1947) A fourth place, (d): a delegation tool is mounted under the name it declares. A tool built as `createSubagentTool({ name: "FENCE" })` and mounted renamed (`{ ...tool, name: "Delegate" }`) answered to two names — the declared one in its definitions' lists, the copy a child is handed and its prompt text, the mounted one in the roster, the pool reading and the model's call — so a child definition's `denyTools: ["FENCE"]` was read as the exact name of the child's lineage and no longer as a twin of the `Fence` a factory injects, and that `Fence` ran (the same lanes before this rule: ran 1; with `TaskSpec.agents` set the per-task rebuild handed back a copy under the declared name and the rename vanished unseen — the root's call of `Delegate` came back "Tool Delegate not found"). Now the factory binds the declared name to its `execute` — the one function every working copy of the tool keeps (the nested copy a child is handed is built at the same place); `defineTool` binds a product's wrapper to the same name — and a mount whose `name` differs refuses the run at prepare, however the copy was made (a spread, an `Object.create` copy, a copy with any member set to `undefined` or destructured away, a Proxy that hides members, a shell copying only name / description / parameters / execute, a `defineTool` product, the result of the tool's own `withAgents`) — `config.tool_face_control`, `detail.reason: "renamed_mount"`, the sentence naming both names and the two ways out (mount it under its declared name, or declare the name you mount it under) — before any model turn, whether or not the model would delegate. Both rosters the run holds are read — the one the caller mounted and the one the per-task rebuild handed back (a function the tool supplies, which may itself rename) — so the sentence is the same with or without `TaskSpec.agents`; and a product renamed at mount is refused like the spec form. A closure, a bound copy (`Function.prototype.bind`) or a Proxy around `execute` is another function and is not followed (KL-1947-b). The table of declared names is never replaced by an empty one: a slot of `globalThis` that holds something other than the engine's table refuses (`config.tool_face_control`, `registry_occupied` — building a delegation tool throws, a roster is refused at prepare and before the resume claim). (#1951) A resume asks the roster it is handed once more before the claim (the resume screen, the same sentence, `CheckpointError` `checkpoint.config_refused` with `detail.code` `config.tool_face_control` and `detail.reason` `renamed_mount` — the code a fresh run reports — through the channel the row's own recorded names now use too): the checkpoint stays `pending` whatever the store, so the corrected retry resumes the same work — where the refusal at prepare alone, after the claim, left a row `resolved` on a store without the optional `reopen` (the retry was `checkpoint.already_resolved`); the rebuilt roster is still read only at prepare (KL-1951-a). Rules: +1 (one tool, one name: mounted name = declared name), −1 (the two-name reading of one tool); the pool reading still reads the mounted name, which is now the declared one; the check has one sentence (`renamedDelegationMountSentence`) asked at two stations that do not overlap in what they read — the resume screen before the claim (the roster as handed) and the config door at prepare (the roster as mounted and as rebuilt; a resume's mounted roster has already passed). Two more places, closed with it — the record of who wrote a leg's own lists (`ownToolLists`): (e) it holds only for the lists it wrote. The record carries `lists`, the digest (`toolListsDigest`, the one the judgment evidence uses, but always recorded) of exactly the lists the delegation tool handed the child, and a prepare keeps it only while its own lists digest to that value (`boundOwnToolLists`, the same shape as `boundToolListsJudgment`). A bare resume of a parked child whose host hands the table the tool wrote keeps the record (the leg starts; a refusal names the tool's list); one whose host hands another table — an entry added, `excludeTools: ["agent"]` — asks it as its own `TaskSpec` seat: the sentence names `TaskSpec.excludeTools` (`BASH` beside `Bash`: refused, `near_miss`), and `agent` is a twin of the catalog's `Agent` like any other name (refused), not the lineage's exact name. (f) a live seat that is not a record — `RunInternals.ownToolLists` set by a JavaScript host to a string, a record with no `lists`, or an empty `definition` / `lists` — refuses the leg (`config.tool_face_control`, `detail.reason: "invalid_record"`, before any model turn), in the sentence a row's damaged record gets before the claim, naming the seat (a bare `TypeError` otherwise). The shape check is one for the row and the seat: a tool name the caller-name contract accepts, a present `definition` and a `lists` that are non-empty strings. Rules: the record's author and its binding are one rule (a record holds for the lists it wrote); the live seat and the row share the one shape check.
|
|
7
|
+
- **Against Claude Code 2.1.296.** `cli296.js:237460` refuses a rule whose tool name has no `_` and a non-upper-case initial (`Tool names must start with uppercase`, suggestion `Use "Bash"`) and nothing there refuses `BASH`; that a `BASH` rule then matches nothing is inferred from the exact-string matching (black-box reading, not run in this car), so the rule layer refusing the case twin is a stricter deviation. Not otherwise re-read against `cli296.js` in this entry. By the 7.51.0 entry's reading the reference matches an agent's list by exact name and never fails a run on a name nothing answers, so a case twin starts the run there and hides nothing; refusing it is a deliberate, stricter deviation (a deny that cannot bind is a fail-open) — the register's row `agent-deny-entry-near-miss-refuses-run` (7.45.0, the agent-definition lane) is extended to this four-lane form.
|
|
8
|
+
- **Who breaks.** A deployment or a user's agent file whose `denyTools` / `allowTools` (`disallowedTools` / `tools` in a `.claude/agents/*.md`), `TaskSpec.excludeTools` / `allowTools` or policy lists spell a mounted tool another way is refused at the first run, naming both spellings, whether or not the agent is used that run (the 7.49.0 – 7.50.2 shape, for this one class of name only; an agent file naming a server nobody declared still starts) — those lists never bound the tool they named (a denied twin ran it; an allowed one granted nothing). A session-rule write holding a case twin of an engine tool (`BASH`, `READ`; `toolDeny` / `toolAllow`, operator writes included) is refused at the write (`SessionPolicyError` `inert_entry`, `detail.reason: "near_miss"`, the way out named: declare the deployment's own tool in the store's `mountedToolNames`), as a retired name already is. A stored row that already holds one (written by an earlier release) refuses every run of its session under `config.legacy_tool_name` and a non-operator write cannot take it out (`loosen_forbidden`); an operator rewrite clears it (probed in the D pins). A permission rule naming a case twin (`BASH(*)`) refuses the run the same way. A run whose delegation tool has an `extraTools` factory and no declared `extraToolNames`, and whose own `allowTools` / `excludeTools` or policy names spell another spelling of a tool the run carries (`excludeTools: ["BASH"]`, a deny `FENCE` beside `Fence`), is refused — 7.51.x started it and told one line, and the twin hid nothing (the run's own `Bash` ran): declare `extraToolNames` or spell the name exactly (KL-1935-b). @cli: the shell-side workaround for the silent deny (L-1535) retires on arrival. @server: its acceptance-door copy of the list judgment (`judgeDeniedToolNames`) must refuse the same spellings or relay the engine's `config.agent.unknown_tool` / `near_miss`. @client-core: render `detail.reason: "near_miss"` beside `"unknown_name"`. @test: the pins below are the black-box set. A delegation tool with an `extraTools` factory and no declared `extraToolNames` keeps the entry at the spawning run for the definitions' and delegation tools' lists (kept and told once, KL-1930-c) because its pool cannot be listed there — and the spawned child, whose universe is the real one (its roster, the catalog, the pools it can list, and the delegation tool that spawned it), refuses another spelling of a tool it carries before it starts (#1937; the first cut let the child through and its twin hid nothing): declare the roster to have the refusal at the spawning run. A policy deny, a session's stored `toolDeny` or a workflow agentType's list that names a twin of a tool only a listable delegation pool or only the catalog carries (`FENCE` beside the pool's `Fence`, `WEBFETCH`) now refuses the run before it starts (`config.legacy_tool_name`) where it was advised and the pool's tool ran. A host that starts a workflow itself with `RunWorkflowOptions.toolCeiling` naming a twin of a tool its agents carry gets the first `ctx.agent()` rejected; a deployment that builds its own `run_workflow` wiring passes the launching run's selector layers as `WorkflowInternals.toolCeiling` (the fourth argument), as the tool now does — layers passed there are the launching run's and are not asked again (@server if it mints its own wiring). A row an earlier release parked with another spelling in its ceiling (`deny: ["BASH"]`) resumes and applies it as spelled (KL-1935-a). A host that set `RunInternals.ownToolListsWhere` itself (an engine-minted seat; none is known) fails to compile — write `ownToolLists`. A host that renames a delegation tool at mount (`{ ...tool, name }`, or a wrapper that sets `name` on the factory's product) is refused at the first run, naming the declared and the mounted name (7.51.x started it): mount the tool under its declared name, or declare the name you mount it under with `createSubagentTool({ name })` — agent files, `denyTools` and the model's calls then speak the one name. A tool the factory did not make is not read, nor is a delegation tool whose `execute` the host wrapped in a closure, a bound copy or a Proxy of its own and stripped of its delegation members, or whose table the host removed (KL-1947-b). A row an earlier release parked under a renamed mount resumes once the resume hands the tool under its declared name: a resume that still hands it renamed is refused before the claim (`checkpoint.config_refused`, `detail.code` `config.tool_face_control`, `detail.reason` `renamed_mount`, the sentence naming both names) and the row stays `pending` on any store; a resume that hands another table than the child was spawned with (the recorded lists no longer hold) is refused at prepare, after the claim — a store with `reopen` puts the row back to `pending`, one without leaves it `resolved`: re-supply the table the child was spawned with, and have the store implement `reopen`. Nothing sets `RunInternals.ownToolLists` but the delegation lane: a host that does writes `lists` (the digest of the lists it wrote) or leaves the seat out; a malformed seat refuses the leg.
|
|
9
|
+
- **Pins.** `test/backlog1930-near-miss-refuses.test.ts` (91 cells, real Runner over `/bin/sh`: R the refusal per source × side and per spelling, the run's own lists, a workflow agentType and a `toolPolicy`, each ran 0 with no model turn; C controls — a real name hides the tool, nothing answers ⇒ one account, a name the roster carries, a policy deny of the real name, a workflow deny of the real name, two caller tools that differ only in case with one excluded and both named; K the limits as they stand — a typo, a retired name with no successor, the unlistable pool, a retired-name caller tool excluded and denied, an inherited parent policy; S one reading: no second case comparison in the two stations; and 28 for #1932 — A a pool tool `FENCE` beside the caller's `Fence` under a `toolPolicy` / workflow agentType deny, requireApproval and allow lists, a declared-only pool tool, an unlistable pool kept and told, the no-pool control still a twin; B the run's own lists under an unlistable pool, a declared roster without the name, a declared roster with it, a static pool with and without it; C a list judged upstream is not judged again in the spawned child (re-expressed by #1935); D the session-rule write gate and a stored twin row; E the carrier named in the sentence; F permission rules; K6 a delegation tool's own list is read against its own pool; and 15 for #1935 — R a root-facing list refuses another spelling whatever a delegation tool injects (an excluded `Agent`, an unlistable pool, a policy deny, a permission rule), only a reachable, listable pool clears a name; D a child-facing list is read against the declared pool, reachable delegation tools only, each tool its own pool, a per-task definition against every reachable tool's pool, the delegation tool's own name — and only that name, not an alias set on the mounted copy — carried by its children's pool (D4 – D6); P a layer a run inherits is not asked again (a parent naming its own `read` / `GREP` / `Task` tool; an ancestor's stored session rules); S2 one root-facing function; and 15 for #1937 — A the main form per source (a delegation tool's / a definition's / a task definition's `denyTools: ["FENCE"]` beside the pool [Fence] behind a factory with no roster: told once at the spawning run, the spawned child refused before any model turn, Fence ran 0), the declared-roster control (clean at the spawning run, `FENCE` hidden, `Fence` runs), the allowed side, two layers (maxDepth 2: judged at the leaf, not at the middle layer; the declared roster leaves the leaf unasked), the flag's unit cell (`toldUpstream` / `judgedUpstream`), a delegation tool run with no prepared selector behind it (nobody told: the child tells, and still refuses a twin); B the policy audit's universe (a session's stored rule, a workflow agentType's list, a catalog-only twin by `toolPolicy` / permission rule / `excludeTools`, the exact names still start); D a host-written workflow ceiling (refused per agent on both spawn legs; the launching run's rides the trusted fourth argument; a caller tool named `read` clears it; the exact name still hides; one absence test — a `null` trusted ceiling is no ceiling handed down; a delegation tool the agent's own `excludeTools` takes out counts in no universe; both channels given — both layers bound the agent, the host's is judged, the launching run's layer also removes a delegation tool from the reach)); and 14 for #1942 — E the main form (the built-in Explore and Plan × a delegation tool named `agent` or `Task` × a factory with no roster × depth 1: the child starts, nothing told), the controls (no factory, depth 2, the default name, a non-twin name), a host's own definition and tool-level list (deny and allow, the catalog's own spelling still an exact name), a real twin of the tool's name (`AGENT`, `TASK`) still refused before the child's first turn on both sides, the retired spelling `Task` as the tool's own name, a parked unstamped child resumed bare (it starts), an old-shape row (refused as before), six damaged records (refused before the claim: by type, and a tool name no caller tool can carry), the kept-entry sentence of an unlistable pool and the listable pool's unchanged). Re-expressed (old → new meaning, #1942): `backlog1377` ⑤ and `backlog1432` ④ (the unlistable pool's kept-entry sentence says what the spawned child will do with the entry); the cells that passed `ownWhere` to the list judgment pass `ownBy` (`backlog1875`, `backlog1886`, `backlog1910`, `backlog1930` A5); `scripts/verify-phase-api.mjs` floor 1083 → 1084 (the suspend-saga phase takes the leg's own-lists record); `module-size-ratchet` three rows (+2 / +3 / +3, each with its reason). `test/backlog1910-persisted-selector-canonical.test.ts` +5 cells (U9 – U13: a restored or live ceiling holding another spelling of a tool is applied as written, the row consumed by a leg that ran, the spelling read-back still before the claim). Re-expressed (old → new meaning, #1935): `backlog1930` K3 / A6 / B1 / B2 (kept and told → refused; the root-facing form), C1 (the child refused → the child starts, told once at the parent), C3 (refused at the child → refused at the root), K6 (refused at the child → refused at the root), E2 (the sentence's carrier follows the child's universe); `backlog1377` ③ (carrier), ⑥ (the pool carries `bash`; the root no longer clears a child's list), ⑪ (the count comment); `backlog1432` ① (carrier); `backlog1736` U4 / U6 and `backlog560` ⑤ and `prepare-listings-transcript` AGENTS AUDIT (the delegation tool's pool carries the tool the definition names); `scripts/verify-phase-api.mjs` floor 1082 → 1083 (the policy-chain phase takes the leg's selector). Re-expressed (old → new meaning, #1937): `backlog1930` K3 and C1 ×3 (the child starts and its twin hides nothing → the spawned child is refused against its own roster, its tools never run), C2 (now across the three sources: kept at the spawning run, told once, the child starts), K5 (advised at the parent and the child's Fence ran → refused at the parent for a listable pool; the unlistable half pins KL-1930-e), S1 / S2 (the policy audit asks the root-facing universe and calls `rootFacingToolNames`); `backlog1377` ⑪ (the judge-call count 8 → 10: the host-written ceiling is asked by the same judge, once per side). Re-expressed (old → new meaning, #1930): `test/backlog1377-subagent-deny-names-one-judgment.test.ts` ① `edit` (×3 sources), ② `bash` and `mcp__github.com__*` (×3), ③ the near miss's account (×3) — kept and told → refused (`config.agent.unknown_tool`, `detail.reason: "near_miss"`, the sentence naming the mounted spelling), ⑧ the second delegation tool's `edit` → refused beside a `NoSuchTool` that is still told (⑤, the unlistable pool, is unchanged: a near miss there is still told); `test/backlog1432-deny-universe.test.ts` ① `bash` / `BASH` (×3 sources) — told, run starts, `Bash` stays → refused, no child runs (④, a factory with no declaration, unchanged); `test/per-task-agents.test.ts` [886]①c (`bash` told → `NoSuchTool` told, `bash` refused); `test/prepare-listings-transcript.test.ts` AGENTS AUDIT (`bash` told → refused beside `BASH` and an allowed `bash`). Mutations, red cells: the list judgment's refusal removed → R1–R4, K3 (declared) and the re-expressed cells; the audit's removed → R5, R6; the judgment asking its own case fold → S1 and the cells whose spelling is no case twin; the refusal widened to every stale entry → K2; the complete-universe gate dropped → K3; the run's own lists judged against the incomplete universe → K3 (per fixture: `.gate-logs/car-1930/mutations.md`). #1935 mutations, red cells per fixture (`.gate-logs/car-1935/mutations.md`): completeness counting every delegation tool → R4, D1; the root-facing judgment or the audit waiting for completeness → R2, R3, B1, B2, C3, K3, A6; the child asking inherited layers again → P1 ×3, U9, U11, U13; a child-facing list read against the spawning run's roster → C1 ×3, D1, D2, E2, K3, K6; the upstream evidence not honored → C1, C2, D2, K1 – K3 and six #1736 cells; the audit without the pools' exact names → A1 – A3, A5; the ancestor rows asked for a twin → P2; the write gate off the catalog → D1 (session), S2; per-tool pools read as their union → D3, D4; the delegation tool's own name left out of its children's pool → D5; the mounted copy's caller-set aliases carried → D6. #1942 mutations, red cells per fixture (`.gate-logs/car-1942/mutations.md`): the spawning tool's name left out of the child's universe → E1, E3, E4, E5, E7 and the seven E6 cells that park first; the record read from the live seat only → E5; the row stamped without it → E5; the row-shape refusal off → the six damaged-record cells; the record not handed to the pre-claim read-back → the same six; the recorded name not judged by the caller-name contract → the protocol-spelling and wildcard cells; the listable pool's sentence for an unlistable pool → E8 and the `backlog1377` ⑤ / `backlog1432` ④ cells; the child's label not minted from the structure → E4. #1947 F (26 cells in the same file, real Runner, scripted model): F1 the main form (declared `FENCE`, mounted `Delegate`, a factory injecting `Fence`, a definition's `denyTools: ["FENCE"]` ⇒ refused at prepare, zero model turns, `Fence` ran 0, the sentence names both names), F2 the same form under its declared name starts and `Fence` runs once, F3 the default name `Agent` and a custom declared name `Delegate` start, F4 renamed with a root that only speaks is refused all the same, F5 the nested copy at depth 2 is named as the tool declares (the grandchild starts), F6 renamed with `TaskSpec.agents` is refused before the rebuild (the same sentence) and the unrenamed control starts, F7 the default name under another and a case-only rename are refused, F8 a renamed copy that inherits every member (`Object.create`) is refused too, F9 a tool the factory did not make is not read, F10 the declared name is bound to the factory's `execute` and follows its copies, products and shells (a host closure or a bound copy is not followed; no `ToolSpec` member carries the name), F11 one station and one call site, F12 a `defineTool` product renamed at mount is refused and an unrenamed one starts, F13 a `withAgents` that hands back a renamed copy is refused, F14 a parked row resumed with a renamed mount on a store without `reopen` is refused before the claim (the row stays pending, the corrected retry completes), F15 the same on a store with `reopen` (same refusal, same pending row, `reopen` never asked) and the unrenamed control resumes, F16 the limit as it stands (a renamed copy from the tool's own `withAgents` is read only at prepare, after the claim), F17 the stations (the screen asks once, after the row's names; `runtask.ts` hands it `config.tools`), F18 – F22 a mount refused with the member set to `undefined` / destructured away / destructured then through `defineTool` / hidden by a Proxy / reduced to a shell of name, description, parameters, execute, F23 the tool's own `withAgents` returning such a stripped, renamed copy, F24 the limit as it stands (a host closure around `execute` is not followed; a bound copy is another function the same way — probed, not pinned), F25 the registry is one table per process (a second loaded copy of the module reads what the first bound), F26 an occupied slot refuses (construction throws, a run is refused at prepare with the arm named, nothing is swapped for an empty table). #1942 E gains E9 (a resumed leg whose host hands another table — `BASH` added — is refused as its own `TaskSpec.excludeTools`, stamped and unstamped), E10 (`excludeTools: ["agent"]` is refused, near_miss), E11 (the table the tool wrote, handed back, keeps the record: both resume), E12 (a live seat that is not a record: three shapes, `invalid_record`, zero model turns), E13 (the refusal is read before the acquire branches the session: a `resumeAt` run leaves the leaf where it was), E14 (a live record that does not hold for the lists handed spares nothing: the host's own lists are told, a record that holds still spares them), E15 (the contract as it stands: a table handed back in the order the engine wrote it keeps the record; reversed or with an entry repeated it is another table, read as the host's own `TaskSpec` seat), E16 (a row an older worker wrote — the evidence stamped with the digest of the written order, no record — resumed bare on a store without `reopen`: the stamp still matches and the leg starts) and four more damaged-record cells in E6 (an empty definition name, no digest, a non-string digest, an empty digest); E5 and E6 re-expressed (the record carries `lists`; the sentence for a value that is not a record names the digest; E6 now reads `checkpoint.config_refused` with `detail` `{ field: "inheritedGate", code: "config.tool_face_control", reason: "invalid_record" }`). Re-expressed with the channel: F14 / F15 / F17 (the renamed mount on a resume), `backlog1910` U1 – U8, U12, P1 – P3, the non-string member (code `resume.parent_constraint_mismatch` → `checkpoint.config_refused` + `detail`, the unit cells read the structured `{ message, reason }`), `backlog1623` ⑦. Mutations, red cells per fixture (`.gate-logs/car-1947/mutations.md`): the comparison dead → F1, F4, F6 – F9, F12, F13; inverted → every cell but F10 and F11; the seat not minted → F1, F4, F6 – F8, F10, F12, F13; the member left out of the closed set → F8, F10, F12 (and `tsc`); the call moved into the lists judgment → F6, F11; the call reading only the roster as mounted → F11, F13; a tool with no declared name read as a mismatch → F2, F3, F5, F6, F9, F12; a product not carrying the name → F12. The record: the bound check dropped → E9, E10, E14; inverted → E1 – E7, E9 – E11, E14, C2, F2, F6, F12; the live seat's shape check removed → E12, E13; the digest member not required → E6, E12, E13; an empty definition name not refused → E6, E12; the row stamp without the digest → E5, E9 – E11; the telling read from the raw seat → E14; the record bound after the acquire → E13. Member tables extended: `test/backlog126-definetool-axis-carry.test.ts` (carried: the one delegation seat a product keeps), `test/public-knob-liveness.test.ts`, the generated `ToolSpec` field reference (`docs/sdk/04-tools-and-mcp.md`).
|
|
10
|
+
- **Known limits.** KL-1930-a (a typo with no other spelling is kept and told), KL-1930-b (a retired name with no successor: kept in the lists, a hard failure in the policy audit — #1781), KL-1930-c (an unlistable pool keeps the definitions' and delegation tools' lists at the spawning run — kept and told once; the spawned child refuses the near miss against its own roster, #1937; the run's own lists and the policy names refuse at the run itself, KL-1935-b; a bare resume of such a child tells the kept entry once more, as it did when it was spawned — the resumed leg carries no mark that its spawning run told), KL-1930-d (a caller tool wearing a retired name, excluded and also denied by a policy: the audit's retired arm refuses, as before — the new case-twin arm clears a caller's own exact names), KL-1930-e (an inherited parent policy's names are not audited on the child's roster, as before — and the parent's audit cannot see an unlistable pool's tool; with a listable pool the parent now refuses the twin, #1937), KL-1935-a (a row an earlier release parked with another spelling in its ceiling is applied as spelled: the twin hides nothing), KL-1935-b (a root-facing list refuses another spelling even where an unlistable pool might hold the tool: declare `extraToolNames` or spell it exactly), KL-1937-a (closed in this release: a renamed delegation mount is refused at prepare — #1947, and with it #1939), KL-1947-b (a delegation tool whose `execute` is wrapped in a closure, a bound copy or a Proxy of the host's own and stripped of its delegation members is not found: deliberate laundering, the engine does not chase it), KL-1951-a (a renamed copy handed back by the tool's own `withAgents` is read only at prepare, after the resume claim — the mounted roster is read before it), KL-1937-b (an inherited ceiling naming another spelling of a tool only an unlistable pool holds — the spawning run could not see that tool, so the entry was kept as a name nothing answers to — is applied as spelled in the child, where the twin hides nothing and the tool runs; the pool-listable form refuses at the spawning run; #1941). KL-1932-a (a name cleared only by the spawning run's own tool was refused at the child) no longer exists: a delegation tool's own list is read against its own pool at the parent.
|
|
11
|
+
|
|
12
|
+
### The retired `hooks` seat is refused the way every retired seat is — one table row per owner, one door, one code per owner; `config.mods_invalid` is no longer minted for a retired key (#1923; P2; correction inside 7.52.0 of hard-breaking 1 of the `Hooks` entry below — the word that entry named never shipped; behavior change — open to veto; hard-breaking: none beyond that entry's; codes: `config.mods_invalid` no longer answers a `hooks` key; closed sets: none — `config.deps_retired_key`, `config.spec_retired_key` and `checkpoint.config_refused` are existing words; exports: none; members: none) (@server @cli @client-core @test)
|
|
13
|
+
|
|
14
|
+
- **Before (the tree this entry corrects, unreleased).** The retired `hooks` key had its own door beside the retired-key tables, and the two disagreed. `new Runner({ hooks })` constructed and the run failed `config.mods_invalid` at the first leg's prepare, while `new Runner({ promptProvider })` — a retired key in the table — threw `config.deps_retired_key` at once. A task spec carrying `hooks` failed `config.mods_invalid` where `promptProvider` failed `config.spec_retired_key`. A resume carrying `hooks` threw a bare `config.mods_invalid` (not a member of the resume's config-refusal set) where `promptProvider` was `checkpoint.config_refused` carrying `config.spec_retired_key`. Readings on the base: construction no-throw / `config.deps_retired_key`; spec `config.mods_invalid` / `config.spec_retired_key`; resume `config.mods_invalid` / `checkpoint.config_refused` + `config.spec_retired_key` (`hooks` / `promptProvider`).
|
|
15
|
+
- **Now.** `hooks` is one row in the `TaskSpec` retired-key table and one in the `RunnerDeps` one (one shared sentence naming `RunnerDeps.mods` / `TaskSpec.mods` and the `classic.*` events); the special door and its second reading before the resume's claim are deleted. All three forms answer the retired-key class's own words, identical to `promptProvider`'s: `new Runner({ hooks })` throws `config.deps_retired_key` at construction; a task spec — root or delegated child — fails the leg `config.spec_retired_key`; a resume is refused before the claim as `checkpoint.config_refused` carrying `config.spec_retired_key` (whatever the outcome kind; the row stays pending). An explicit `undefined` is absence, as for every retired key. `config.mods_invalid` keeps every other mint — a registration the kernel cannot take.
|
|
16
|
+
- **Against Claude Code 2.1.296.** Not a Claude Code behaviour: an engine-internal configuration refusal; the retired-key class is this engine's own.
|
|
17
|
+
- **Who breaks.** @server @cli @client-core: a host that still passes `hooks` is refused in this release either way (7.51.x ran it); only the word, and for the deps the moment, change — a reader that maps `config.mods_invalid` to "a `hooks` key is still wired" must read `config.deps_retired_key` (construction) / `config.spec_retired_key` (a leg) / `checkpoint.config_refused` + `detail.code` (a resume) instead, and `new Runner` now throws where the run used to fail. No closed set moves: the two retired-key words and the resume's refusal are words the shared door already throws (`config.spec_retired_key` is already a member of `CheckpointConfigRefusalCode`). @test: the pins below are the black-box set.
|
|
18
|
+
- **Pins.** `test/retired-hooks-key-one-door.test.ts` (10 cells: C construction + sentence; S root and child; R four outcome kinds pending + the deps half; P parity of `hooks` and `promptProvider` over construction / root / child / resume and `undefined` as absence; M a malformed registration is still `config.mods_invalid`, and no special `hooks` door is left in src). Re-expressed (old → new meaning): `test/mods-classic-stations.test.ts` three cells (`RunnerDeps.hooks`: run fails `config.mods_invalid` → `new Runner` throws `config.deps_retired_key`; `TaskSpec.hooks`: `config.mods_invalid` → `config.spec_retired_key`; resume: bare `config.mods_invalid` → `checkpoint.config_refused` + `detail.code` `config.spec_retired_key`, the row pending), `test/mods-classic-redo.test.ts` H1 (child leg `config.mods_invalid` → `config.spec_retired_key`) and H2 (pre-claim `config.mods_invalid` → `checkpoint.config_refused` + `config.spec_retired_key`). Mutations, red cells: `hooks` deleted from the deps table → C1, C2, P1, R2 and the stations deps cell; deleted from the spec table → S1, S2, R1, P1 and the stations spec and resume cells.
|
|
19
|
+
|
|
20
|
+
### A frontmatter fence is a whole line: an indented `---` no longer closes the block, and a block that opens and never closes is refused by name — one judge for the skills loader and the memory entry parser (#1841; behavior narrowing — open to veto; **deviation from Claude Code 2.1.296 runtime parse (stricter), aligned with its strict form `y6`**; closed sets: none; exports: none; members: `ParsedEntryFile.unclosed?: true`, additive and optional) (@cli @client-core @test)
|
|
21
|
+
|
|
22
|
+
- **Before.** Both parsers compared `line.trim()` to `---` for the opening and the closing fence, so an INDENTED ` ---` (a rule inside a YAML block scalar, a continuation of a value, a line the memory format carries verbatim in `extra`) closed the block early and every key after it fell silently into the body. A block closed only by `----` / `--- x` was a different silent shape: the skills loader reported "no frontmatter", and the memory parser read the file as a bare note.
|
|
23
|
+
- **Now.** One predicate (`isFrontmatterFence`, `src/core/frontmatter-fence.ts`, a leaf with no imports): a fence is a WHOLE line — `---` flush left, then spaces / tabs only, then at most one CR. The skills loader (`parseSkillFile`) and the memory entry parser (`locateFrontmatter`) judge the opening fence and the closing fence by it; `agent-definition.ts` parses no frontmatter, so there are two readers. A skill file keeps its tolerance of a leading byte-order mark (it was the trim that swallowed it; it is now stripped before the opening fence is judged); the memory opener now answers to the same predicate as its closer (CRLF-opened and `--- `-opened files parse; an indented first `---` still does not).
|
|
24
|
+
- **A block that opens and never closes is refused by name, never read as "no frontmatter" and never read loosely.** Reading it as bare text would drop what the block declared (a skill's `disallowed-tools` is a silent loosening; a memory file's marker is hidden and a fresh id is minted over the id it carries), and falling back to the loose reading would be a second reading of the same fact. Skills: the existing `no_frontmatter` warning, detail `SKILL.md opens with --- but is not closed by a whole-line ---`, the skill is not installed. Memory: `parseEntryFile` reports the fact (`unclosed: true` beside `hadFrontmatter: false`), and the harvest answers it with the existing rejection code `invalid` (the branch that already refuses an oversize file), reason `memory file opens with --- but is not closed by a whole-line --- …`, nothing minted, the file left on disk; the rejected file keeps its committed baseline (repairing the fence and editing on the same handle commits the edit); the same refusal stands at the other two admission seats, the remote data plane (`harvestFilesToPatches`: rejection `invalid`, no update over the committed entry, no minted id) and a held entry's release (disposed `discarded`, never committed as an all-body note). A COMMITTED entry whose closing fence is damaged this way is withheld from the read plane (it reads as id-less) until the author closes the block, and nothing is deleted. One sentence (`UNCLOSED_FRONTMATTER_REASON`) at both readers.
|
|
25
|
+
- **A tool list is read whole or the skill is refused.** In a skill, `allowed-tools` / `disallowed-tools` with an INLINE value (plain, quoted, a closed flow sequence) followed by indented lines nothing reads (`allowed-tools: Read` then ` ---` then ` * Bash`) used to copy the first value and drop the rest silently — a `disallowed-tools` that lost its tail is a looser skill. It was refused by #1841 (`no_frontmatter`, the detail names the key); a plain scalar is now read whole instead — its indented lines fold into the value, as YAML reads them (#1849), and of the entries they yield `---` is no name, so that skill is refused by name (#1862) — and a closed quoted value or a closed flow sequence followed by more indented lines is still refused. A block scalar or a multi-line flow sequence consumes its own lines and is copied as written (#1862: a block scalar's `---` line is refused the same way); a `*` that reaches a list that way is read by the grant table as it always was (a `*` on an allow list voids the list, on a deny list it reads as nothing by itself — KL-1303-c, KL-1372-a), never as every tool. In a memory file the same lines stay inside the block as verbatim `extra`, typed as nothing.
|
|
26
|
+
- **Against Claude Code 2.1.296** (`cli296.js`, byte offsets into that file). Its plain parse `Pv` (@11655075, `/^---\s*\n([\s\S]*?)---\s*\n?/`) closes at ANY `---` after the opener — an indented one, `----`, `a---b` — and CC itself names that reading a hazard (`the closing --- is ambiguous (a value containing "---"?)`, @11655668, inside its parse function `si` @11655185). Its strict whole-line forms are `y6` (@11655109, `/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/`, used by its ambiguity check and rewrite path), `nn` (@28467881) and `$g` (@39554364, `/^---[ \t]*$/m`). sema reads the strict form: stricter than CC's runtime parse, equal to CC's own strict form; the fence rows are run against both regexes in the pin. Reasons: CC lists the loose reading as a hazard; a `---` line inside a YAML block scalar is legitimate content and the loose reading loses data; one judge for both readers.
|
|
27
|
+
- **Who breaks.** A stored file that relied on an indented or glued closer. **Skills:** a `SKILL.md` closed by ` ---` / `----` / `--- x` stops loading — it was read wrongly before (closed early, or at the indented line) and is now announced `no_frontmatter` with the sentence above; a skill with a quoted or closed-flow tool list followed by indented lines stops loading likewise (it loaded with a shortened list before; a plain list followed by indented lines is read whole, #1849, and refused when an entry it yields is no name, #1862). **Memory:** a file closed that way stops parsing (no id read); its next harvest answers `invalid` instead of minting a second entry over it. Both are from silent-wrong to loud-refused; close the block with a line that is exactly `---`. cli / client-core: a shell that mirrors the reading follows the same predicate byte for byte (flush-left `---`, then `[ \t]*`, then an optional CR); test: the rows in the pin are the black-box set.
|
|
28
|
+
- **Pins.** `test/backlog1841-frontmatter-fence.test.ts` (97 cells): A the predicate table (19 rows) and its agreement with `y6`, with `Pv` recorded where it differs; B skills closing / opening rows, a block scalar carrying an indented `---`, trailing blanks, a lone CR on the last line, a byte-order mark (F1); C memory closing rows, the opener (F2: CRLF / trailing blank accepted, indented refused, a marker in a CRLF-opened file is SEEN), serialize → parse closure with fence look-alikes in `extra` (F3); G the unclosed fact at both readers and through a real harvest (five closers; a committed entry damaged, then repaired with and without an edit on the same handle), a held entry's release, and the remote data plane; H the tool-list continuation (both keys, three value shapes), the consumed block scalar / flow sequence, the grant table reading `*`, and the memory `tools:` lines kept as `extra`.
|
|
29
|
+
|
|
30
|
+
### A skill's tool lists take list values only: a YAML block sequence is read as the list it is, and a value the reader cannot read as a list refuses the skill — one reading for `allowed-tools` / `disallowed-tools`, wherever the value begins (#1845, #1849; P1; behavior change — a silent drop becomes a read or a loud refusal, open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @test)
|
|
31
|
+
|
|
32
|
+
- **Before.** The skills loader (`parseSkillFile`, `src/core/skills-directory.ts`) read a tool list only from an INLINE value (plain, comma, quoted, flow sequence) or a block scalar. A YAML block sequence — the key with no value, then ` - name` lines — was read as no list at all and the skill installed with no warning: `disallowed-tools:` / ` - Bash` installed a skill that denied nothing, `allowed-tools:` / ` - Read` one that pre-approved nothing. The fence rule above made the shape reachable for skills that used to be skipped as headerless (a `metadata:` block scalar carrying a `---` before `name`: the new reading installs it, the old one never did), so a newly installed skill could silently lose its declared refusals while the model kept the tool.
|
|
33
|
+
- **Now.** The two tool-list keys take LIST values only, and each is read whole or the skill is refused. Inline values and block scalars are read as before, except as the #1849 bullet below says. A block sequence is read as the list: an empty value (or one that is only a `#` comment) followed by `- name` lines under the key — indented, or flush left with it (both are YAML's block sequence) — all at the first line's indent, blank and comment lines allowed among them; quotes dropped, a trailing ` #` comment dropped, and each line's item cut into entries like every other value since #1862 (`- Read Grep` is two entries, as in Claude Code; this entry first read it as one, never split again — a rule spelling keeps its spaces and commas inside its parentheses either way); the sequence ends at the next flush-left key or the closing fence. Refused instead (`no_frontmatter`, the detail names the key and says why, never quotes the file): a mapping, a number or a boolean as the value, a flow-sequence or block-sequence item that is a number, a boolean, null, a mapping or a sequence (Claude Code drops such an item silently; a dropped deny entry is a looser skill), a line under the key that is not a `- name` entry, entries indented differently (an indented entry among flush-left ones, or the other way round), a dash line glued to its name (`-Read`), an entry that is YAML structure rather than a name (nested, mapping, flow, anchor, alias, tag, block scalar, an unterminated quote, an empty item), any flush-left line that is not a key or a dash line inside the sequence (the entries after it would be lost with it), and indented lines behind a value whose shape cannot run over lines (a quoted value, a closed flow sequence, a scalar a comment ended — the #1841 case for those shapes) — one sentence for all of them (`` `disallowed-tools` has a value the reader cannot read as a list (…) ``), one record per key. A key with no value (nothing under it but the next key, a comment or the closing fence) and an explicit `null` / `~` are YAML's null, and null is an empty list — as the key's absence, and as Claude Code reads it (C-R487): the skill installs with no list and nothing is announced; `[]` and `""` keep installing with no list too. A `*` entry is copied like any other and read by the grant table as it always was (KL-1303-c, KL-1372-a). Keys that are not tool lists keep skipping their indented bodies, a block sequence included. The two lists are kept as lists from the moment they are read (the loader no longer carries them as scalar text to a later split).
|
|
34
|
+
- **One reader of the value, wherever it begins — seven more shapes (#1849; P2; behavior change, open to veto).** Seven holes were separate readings or silent drops; all seven now go through the tool-list reader's one entry point. That reader was one only at its entry: inside it four tokenizers (the comment stripper, the flow-item cutter, the text splitter, the bracket counter) each kept their own rules — the source of the rows #1862 fixes; since #1862 one scanner reads every form.
|
|
35
|
+
- **Comments.** A `#` comment ends the value in every shape (YAML's rule: a `#` at the start of the text or after a space / tab, outside a quote; a quote opens only where a scalar begins). `allowed-tools: Read, Grep # never add Bash here` used to pre-approve Bash — the inline value kept its comment as entries while the block-sequence entry already dropped it; a multi-line flow sequence drops comments per line (a comment-only line adds nothing); a `#` glued to a name (`mcp__a#b`) or inside a quote is text. Superseded by #1862 and #1866: a ` #` inside a rule's parentheses is a comment as anywhere (as this entry first read it, and as YAML does; #1862 read it as the rule's text and #1866 withdrew that — an unquoted `Bash(echo a # b)` now refuses, its parenthesis left open, where this entry read `Bash(echo a`); a quote opens only where a YAML scalar starts, so a quote that follows a plain word is text and the entry that keeps it is refused, and a value that starts with a quote is ONE quoted scalar — the list of quoted names (`"Read", "Grep"`) this entry read is refused (text after a closing quote); a flow sequence (`["Read", "Bash(echo # x)"]`) is the spelling.
|
|
36
|
+
- **A value that begins on the next line.** `allowed-tools:` then ` Read` (or a quoted, flow or block-scalar value there) is the same value as `allowed-tools: Read`; the "empty value means a sequence head" arm is gone — only a dash line, or nothing at all, is a block sequence. A plain scalar folds its indented continuation lines into itself wherever it begins (`allowed-tools: Read,` then ` Grep` is `Read, Grep`; the #1841 case `allowed-tools: Read` then ` ---` then ` * Bash` read the four entries a block scalar of the same lines gives — since #1862 both refuse, `---` being no name). The scalar ends at the next flush-left line, at a comment line, or at a line a comment ended; a line of its own that is `key: value` is a mapping, not a list, and is refused.
|
|
37
|
+
- **A repeated key.** A tool-list key written twice is refused by name (YAML refuses a repeated key); the second list used to be dropped silently (the first occurrence won). Every other key keeps first-wins.
|
|
38
|
+
- **A stray line.** A flush-left line that is no key behind a tool list's value (`allowed-tools: Read` then `Bash`) is refused by name; it used to be skipped silently.
|
|
39
|
+
- **Quotes.** One decoder for every quoted scalar the loader reads (an inline value, a flow item, a block-sequence entry, `name`, `description`): `''` in a single quote; `\"`, `\\`, `\t`, `\n`, `\xNN`, `\uNNNN`, `\UNNNNNNNN` and YAML's other single-character escapes in a double quote; an unknown escape stays as written; a value is decoded once. The block-sequence refusal of entries with escapes is gone with it (they decode). A tool value whose quote does not close on its line was refused here; since #1862 a quoted scalar runs over lines until it closes, as YAML reads it, and one that never closes is refused. A decoded `\t` or `\_` inside an entry refuses the list since #1862 (no separator cuts them). `description: "say \"hi\""` is now `say "hi"` (it was `say \"hi\"`).
|
|
40
|
+
- **Null.** A key with no value (nothing under it but the next key, a comment or the closing fence) and an explicit `null` / `~` are YAML's null, and null is an empty list — the skill installs with no list and nothing is announced, as Claude Code reads it (C-R487; the first version of this entry refused a bare empty header, which is withdrawn).
|
|
41
|
+
- **Garbage.** A value that is a mapping (`{a: 1}`, `{}`, a `key: value` line under the key), a number or a boolean (`5`, `-1.5`, `0x1F`, `.inf`, `true`), one that starts with an anchor, alias, tag or other mark a plain scalar cannot start with (`&x [Bash, Write]` — the words would be copied as junk entries and the list lost; a lone `*` is still the wildcard entry), or a list item that is a number, a boolean, null, a mapping or a sequence (flow or block) is refused by name: Claude Code drops it silently, and a dropped deny entry is a looser skill. A quoted `"5"` or `'true'`, a word like `5x` or `1.2.3`, and several words (`5 6`) are strings and read as written.
|
|
42
|
+
- **Against Claude Code 2.1.296** (`cli296.js`; byte offsets are positions of the matched text, nothing was run). Its skill / plugin validator says `allowed-tools must be a string or array of strings` (@2068813) and describes the deny key as a `Comma-separated string or YAML list` (@3519629), and it reports `YAML frontmatter failed to parse` for a malformed block: CC reads a real YAML list, so a block sequence is valid input there. The validator reports a wrong-typed value and a non-string array item as errors and says nothing about a null (`h!==void 0&&h!==null`, @36154896); the neighbouring "At runtime this value is dropped" (@2068654) is the `description` message. The runtime reader of a skill's `allowed-tools` is `ib` (@18728638 calls it; @15787912), over `Ygt` (@15787564): absent / null is no tools, a falsy value is none, a string is one string, an array is its string items (a non-string item is dropped silently), any other type is an empty list; every string is then split on commas and spaces outside parentheses (`Lu`, @7089046) and a `*` collapses the list to `*` alone. So CC drops a bad value and keeps the skill; sema follows CC on a null (an empty list, C-R487) and is STRICTER than CC where the value is garbage — a mapping, a number, a boolean, a non-name item — which it refuses loudly (a divergence, open to veto); since #1862 also where CC keeps an entry that matches nothing (a tab, a no-break space or a leading quote in it, a `key:` item, an unclosed parenthesis) or reads past an unclosed `[` / quote, all refused loudly — "only on garbage" was the claim of this entry and is no longer the whole list. sema keeps its closed subset reader (no YAML dependency) and answers every list shape with a read or a refusal. One difference is left as it was: CC splits each ARRAY ELEMENT too (`Lu` runs over the array's strings), so a block-sequence entry `- Read Grep` is two names there and one entry here (an inline value and a flow sequence go through sema's text splitter, which cuts on commas and white space outside parentheses; CC's `Lu` cuts on commas and the space character only — a tab stays inside a name there); not changed in this entry — changed by #1862 (every item cut on commas and the space character; a tab inside an entry refused).
|
|
43
|
+
- **Who breaks.** A stored skill whose tool list was a block sequence: it now takes effect — a `disallowed-tools` block sequence now denies what it names (it denied nothing before), an `allowed-tools` one now pre-approves what it names, so a skill that looked inert starts asking for (or refusing) tools. A stored skill with a bare `allowed-tools:` or `disallowed-tools:` (an empty value, nothing under it) installs with no list, as it always did. A key with lines under it the reader cannot take as entries is refused, announced `no_frontmatter` with the key named. This narrows what loads (open to veto): sema refuses where Claude Code's runtime drops the value and keeps the file, only for a value that is garbage (a mapping, a number, a boolean, a non-name item) or lines nothing placed — chosen because a dropped `disallowed-tools` is a looser skill than the one its author wrote. #1849 narrows and widens further: a skill that repeats a tool-list key, leaves a flush-left line that is no key behind a value, leaves a tool value's quote open, writes a mapping, a number or a boolean as the value, or puts a number, a boolean, null, a mapping or a sequence among the items is now refused (all were installed, with a list dropped, cut short or holding junk entries); a `null` / `~` value installs with no list; a skill that wrote a `# comment` after a tool list no longer lists the words of the comment (an allow list no longer pre-approves a tool the comment mentions, a deny list no longer denies one); a plain value's ` #` inside parentheses ended the rule (#1862 read it as the rule's text; since #1866 it ends the value again and the parenthesis it leaves open refuses the skill); quote escapes in a tool value, a `name` or a `description` now decode; and the next-line and continuation-line shapes that were refused (or read as junk entries) are read — the quoted-token shapes this entry also read are refused since #1862. cli / client-core: a shell that previews a skill's lists from the same files must read a block sequence the same way (entries per line, same refusals); test: the cells in the pin are the black-box set.
|
|
44
|
+
- **Pins.** `test/backlog1845-tool-list-block-sequence.test.ts` (287 cells): A the shape that lost its deny list (a block scalar carrying `---` before `name`, then a block-sequence `disallowed-tools`) installs and puts Bash in the deny table, the `allowed-tools` twin, both keys, and block sequence = inline list at the grant table; B shapes read (quotes, four-space / tab indent, CRLF, comments and blanks, a header comment, rule spellings kept whole, a line is one entry, `*`); C thirty-five not-a-list shapes × both keys (each refused by name with a sibling skill still loading), both keys bad, a repeated bad key, the quoted-value continuation sentence, refusal text free of file content; D every pre-existing list shape and every non-tool key unchanged; E a `#` comment ends the value in every shape (and the grant table sees the list the author wrote); G a value on the next line, a folded plain scalar, mapping / comment-line / unclosed-quote refusals, eleven spellings of one list read alike; H nine repeated-key shapes × both keys; I eight value shapes × both keys followed by a flush-left stray line; J quote escapes in an inline quote, a flow sequence and a block sequence read alike, decoded once; K a key with no value, `null` or `~` installs with no list (ten shapes × both keys, no grant row, the other key untouched), a header followed by `- Read` is still the list, a mapping / number / boolean / indicator-led value / non-name item is refused (twenty-eight shapes × both keys) while what only looks like a number is a string; F the weakest spots each with its counterexample (which item shapes count as a name; an empty value is null — an empty list — and only a stray flush-left word after the header refuses, a flush-left sequence is the list; where the sequence ends; where a comment starts; where a multi-line plain value ends; what the repeated-key refusal does not touch). `test/backlog1841-frontmatter-fence.test.ts` H: an inline value with ` ---` / ` * Bash` below it is now one folded scalar (the entries a block scalar gives — re-expressed by #1862: both refuse on `---`); a quoted value or closed flow sequence with indented lines below it still refuses. `test/nuia-baseline.json`: `src/core/skills-directory.ts` leaves the ratchet (baseline 18, 26 counted on the stage tree, 0 now: every line and match-group read is a checked read); the #1845 pin file counts 0 too, so it needs no row. Two older pins that held a bare `allowed-tools:` to install with no member (`test/rb424-skills-loader-hardening.test.ts` MCPSK-1, `test/backlog1303b-loader-and-surface.test.ts` ①) were re-pointed at the explicit empties (`[]`, `""`) by the first version of this entry; the bare empty installs again (K), and both pins hold either way.
|
|
45
|
+
|
|
46
|
+
### A skill's tool-list value is read by ONE scanner: one cut into entries for every form, a quote only where a scalar starts, and a quote / `[` that does not close refuses the skill — in any key; the deny list's `disallowedTools` alias is read; a key is matched without the blanks before its colon (#1862, #1861, #1854, closed by #1866–#1869 in the next entry; P1; security; behavior change — a narrowing (more shapes refused loudly) and a widening (the alias, the spaced key, multi-line quoted values, block items cut), open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @test)
|
|
47
|
+
|
|
48
|
+
- **Before.** The tool-list reader of #1845 / #1849 was one only at its entry point: inside it a comment stripper, a flow-item cutter, a text splitter and a bracket counter each had their own idea of quotes, parentheses and whitespace (three definitions of whitespace, two indicator tables). Seven readings followed, each reproduced through `createSkillsFromDirectory`: `allowed-tools: [Read, Bash(gh pr comment --body "see #12")]` took ` #12")]` for a comment, never closed its sequence and swallowed the next line — the `disallowed-tools` key under it — as allow entries (deny: none, no warning); an unclosed `[` did the same with no parenthesis involved; `["Bash, Write"]` read `"Bash` / `Write"` while the inline `"Bash, Write"` read two names and the block item `- "Bash, Write"` one; a no-break space was text to the comment rule and a separator to the splitter (`Read,␣Grep␣#␣never␣Bash` with no-break spaces granted `#`, `never` and `Bash`); `[Read, WebFetch: Bash]` passed `WebFetch:` as a name; `[Bash, Write], Edit` read `[Bash` / `Write]` / `Edit`; `disallowed-tools : Bash` was no list; and another key's unclosed `[` (`argument-hint: [file`) swallowed the tool key under it.
|
|
49
|
+
- **Now.** ONE scanner (`scanValue` and its `EntryCutter`, `src/core/skills-directory.ts`). The forms differ only in how the value's lines are gathered — the value after the colon or on the next indented line, with the indented lines that continue it, up to the next flush-left line that is not blank, a comment or a `]` (so a flush-left `key:` line always starts a new key); each `- ` line; each line of a block scalar's text (a literal one's lines as written, a folded one's after YAML folds them — corrected by #1876: this said every line was cut on its own) — and every gathered text goes through the same state machine. A quote opens where a YAML scalar starts (the value, a flow item, a block item; anywhere else a quote character is text), may run over lines, and is decoded once; a `[` at the value's start opens the flow sequence; a `#` after a space or a tab, or at a line's start, is a comment — never inside a quote (inside a rule's parentheses too, since #1866: the first version of this entry read it as the rule's text). Every scalar is cut into entries at commas and the space character outside parentheses (a depth-counted zone: `Bash(echo $(date) now)` is one entry; a parenthesis the rule grammar escapes, `\(` / `\)`, counts for nothing, so `Read(docs/a\) Write b)` stays one entry and never grants `Write`; since #1866 `Bash(a)(b)` and an entry with a `)` and no `(` refuse, an entry's parentheses being one group that closes it — the first version installed `Bash(a)(b)` and read a lone `)` as text); a tab is no separator. Line breaks inside a scalar fold as YAML folds them, in a plain value and in a quoted one alike: the blanks around a break go, one break is a space, a blank line between is a line break of the text (so the entry holding it is refused), and an escaped blank (`\ `) before a break stays. An entry is a rule spelling or the list is unreadable — since #1866 judged by the rule syntax itself (`ruleSpellingOf`: an ASCII tool name as written, then at most one parenthesized rule), which refuses a tab, a no-break space, a line break or an invisible character in a name, a name led by a YAML indicator, and a trailing colon; the lone `*` is a spelling, copied like any entry, no collapse to `*` alone (the first version of this entry judged entries by a hand-written name test of its own). YAML's typing of a value or an item keeps its #1849 reading (a null value is an empty list; a number, a boolean or an item typed as null refuses). Text after a closing quote, `]` or comment refuses; so does a quote or `[` that does not close, in ANY key's value (`description: "…` never closed, `argument-hint: [file`), named in the `no_frontmatter` detail (`` `argument-hint` has a value the reader cannot read (its `[` does not close …) ``). A block scalar's lines are already scalar text: they are cut, and no quote or comment is read in them. A key is the text before its colon without the blanks in front of the colon, for every key; a flush-left line that is ONE quoted scalar and a colon is a key too (`"disallowed-tools": Bash`, decoded by the same decoder, as YAML reads it), and a U+2028 / U+2029 / lone CR on a key line no longer stops it being one. `disallowedTools` is the deny list where `disallowed-tools` is absent or null (#1854; since #1867 a null hyphen key — empty, `null`, `~`, a comment only — is the key's absence, so the alias is read; the first version of this entry let it shadow the alias); a hyphen key with a value (`[]` and `""` included) is the list and the alias is not looked at, except that an unclosed quote or `[` in it refuses as in any key. No alias for `allowed-tools`. The reader has fewer rules (not fewer lines — the module is longer, its JSDoc says more): four tokenizers become one scanner; the two indicator tables and the hand-written name test become the rule syntax's one test (#1866); blanks are read by one definition (`isBlank`, which every blank test and trim in the module calls).
|
|
50
|
+
- **Against Claude Code 2.1.296** (`cli296.js`, byte offsets, read not run). The runtime reader `Ygt` (@15787564) takes a string as one item and an array as its string items, then `Lu` (@7089046) cuts each item on `,` and the space character outside a parenthesis zone — a BOOLEAN zone (`(` opens it, `)` closes it) — and a `*` collapses the list; the frontmatter is YAML-parsed, re-parsed with leading tabs turned to spaces on failure (`tXt` @11656597), and dropped on a second failure. Aligned: the cut (commas and spaces, parentheses kept whole; flow items and block items cut alike, #1861); the deny alias (`jde` @16305645: `e["disallowed-tools"]??e.disallowedTools`, #1854 — aligned on a null hyphen key only since #1867: `??` falls through on YAML's null, and the first version of this entry did not); the spaced key (YAML trims a plain key); YAML's structure inside a rule's parentheses (a ` #` comment, a flow's `,` / `]`, a `: ` mapping — since #1866; the first version of this entry read a `#` there as the rule's text, KL-1862-a, deleted). Divergences (open to veto): the zone counts depth (`Bash(echo $(date) now)` is two entries there) and refuses parentheses left unmatched; `*` does not collapse (KL-1861-b); tab-indented block items are read, as CC's fallback reads them (KL-1861-a); each line of a literal block scalar (`|`) is cut on its own, where a folded one (`>`) is folded first as YAML folds it — `disallowed-tools: >` / ` Bash(echo` / ` a)` is `["Bash(echo a)"]` (KL-1862-b, corrected by #1876); and entries CC keeps to match nothing (no rule spelling: a tab or a no-break space in a name, a leading quote, a `key:` item, parentheses unmatched), and values CC's YAML rejects and then drops (an unclosed `[` / quote, text after a closed quote), are refused loudly here. An installed deny list therefore holds every entry YAML and the cut give its value: the #1866 pins mutate deny lists over 1500 generated skills and find none installed without an entry the mutation did not touch.
|
|
51
|
+
- **Who breaks.** A stored skill whose block items hold several words now has several entries (`- Read Grep` was one entry no rule reads; it now grants Read and Grep). A skill with a tab or a no-break space inside an entry — written, or decoded from `\t` / `\_` — is refused (it installed with an entry no rule reads). A list that relied on a quote after a plain word or after a closed quote (`Read, "Grep"`, `"Read", "Grep"`, `"*", mcp__*`) is refused: write a flow sequence (`[Read, "Grep"]`, `["*", mcp__*]`). An entry starting with a YAML indicator (`---`, `-x`, `"x`, `#x`, `*name`) and an inline `Read: x` are refused. A `#` inside a rule's parentheses stayed in the rule in the first version of this entry; #1866 withdrew that (an unquoted one now refuses — never cut short, never gaining an entry). A multi-line quoted tool value is read (it was refused); a blank line inside a multi-line plain or quoted value now refuses (#1849 read the words around it as entries; YAML reads a line break inside one entry — use `- ` lines or a flow sequence, both of which allow blank lines between items). A `disallowedTools`-only skill now denies what it names; a key written `disallowed-tools : X` or `"disallowed-tools": X` is now the list. An entry holding a zero-width space, a byte-order mark or a control character is refused. Any key whose quote or `[` never closes on its lines refuses the skill (it installed, a key below it possibly swallowed); `argument-hint: [a] [b]` keeps loading (KL-1849-b; since #1866 a `(` left open inside such a value no longer hides its `]`, the former KL-1862-c). Left to #1863: the other keys' values are still read by the line reader (KL-1849-b). cli / client-core: a shell that previews a skill's lists must cut block items like inline values and refuse the same shapes; test: the cells in the pins are the black-box set.
|
|
52
|
+
- **Pins.** `test/backlog1862-tool-list-scanner.test.ts`: R the seven review rows through the public loader (all RED on the pristine base); S the splitting table, 21 forms × both keys; P the parenthesis zone (nested, back to back, a `#` and quotes inside it, over a line break, escaped parentheses, unclosed in five forms × both keys); T tab, no-break space and invisible characters; L key lines over U+2028 / U+2029 / CR, blank-line folding; A the alias (alone, shadowed, shadowed but unclosed, an empty hyphen key, repeated, no allow alias); K the spaced key and the quoted key; W `*`; F flows (a quoted `]` / `#` over lines, comment lines, nesting, an empty quoted item, a line after `]`); Q where a quote opens, escaped blanks before a break; B block-scalar lines; O other keys; Z1 large values (200000 entries in one block item; a rule folded over 400000 lines; 300000 unterminated keys — linear time measured, not asserted); Z 3000 seeded generated skills (1000 clean spellings read exactly; 2000 with a mutated allow list refused, or holding only scanned names with the deny list beside it intact — moved to the #1866 pins and extended to deny lists, its oracle now the rule syntax). Cells whose meaning #1866 / #1867 / #1868 changed are re-expressed there (listed in the next entry). Re-expressed: `test/backlog1845-tool-list-block-sequence.test.ts` B, E, G (including a blank line inside a plain value, now refused), J, F1, F4; `test/backlog1841-frontmatter-fence.test.ts` H; `test/rb410-skills-directory-loader.test.ts` (whitespace split); `test/backlog1303b-loader-and-surface.test.ts` ① (the `"*"` entries move into a flow sequence). Mutations, each red on its cells: no quote state, no parenthesis zone, a boolean zone, `]` closing a quote, a key line swallowed, a cut on tab, a comment inside the zone, the no-break space as a blank, no indicator test.
|
|
53
|
+
|
|
54
|
+
### A skill's tool-list scanner reads YAML's structure first and judges every entry by the rule syntax: a parenthesis unmatched either way refuses, a comment and a flow item end inside a rule's parentheses too, a null `disallowed-tools` yields to its alias, a scalar key keeps its characters unless it is one quoted scalar, and an indented key under a plain value refuses (#1866, #1867, #1868, #1869; P1 / P1 / P2 / P2; security; behavior change — a narrowing (more shapes refused loudly; a tool name outside the rule syntax reads as nothing in every list; a session's rules whose `toolDeny` holds such an entry refuse every call) and a widening (the alias on a null key, a block item over lines, a flow value of another key no longer hidden by a `(`), open to veto; closed sets: none; exports: `createSessionRulePolicy`'s `onEarlierGrammar` hook gains the bucket and the row's grammar; members: `SESSION_RULES_GRAMMAR` 1 → 2) (@server @cli @client-core @test)
|
|
55
|
+
|
|
56
|
+
- **Before.** Four readings of the #1862 scanner were wrong, each reproduced through `createSkillsFromDirectory`. (#1866) The zone refused an unclosed `(` but read a `)` with no `(` as text, so `allowed-tools:` / ` - "Read(docs/legacy) Write notes.txt)"` installed `Read(docs/legacy)`, `Write` and `notes.txt)` — the author's one rule became an unrestricted `Write` (a deny list alike: `Bash, Write)` installed `Write)`); inside parentheses a flow's `,` / `]` and a `#` after a blank were text, so `disallowed-tools: [Read(./secrets, Write, Edit)]` installed ONE `Read` rule and denied neither `Write` nor `Edit`, and a comment holding a `)` reopened the cut (`allowed-tools: Bash(echo "(") # :) Write` granted `Write`); and the hand-written name test refused only control and format characters, so `Bash` followed by U+3164 (a filler) or U+FE0F (a variation selector) installed a deny row that matches nothing. (#1867) A null `disallowed-tools` (empty, `null`, `~`, a comment only) shadowed `disallowedTools: Bash` — no deny list, no warning. (#1868) `description: 'Don't use this for builds'` lost a character (`Don' use…`): the value was judged whole-quoted by its first and last character and the decoder dropped the character after the lone `'`. (#1869) `description: X.` then an indented ` disallowed-tools: Bash` (one space, two, or a tab) was skipped — no deny list, no warning.
|
|
57
|
+
- **Now.** YAML's structure is read first, as YAML reads it, inside a rule's parentheses too: a `#` after a space or a tab ends the value, a flow sequence's items end at `,` and `]`, and a `:` followed by a blank, a line break or the plain scalar's end is a mapping mark. The parenthesis zone works only inside one scalar's text: nothing cuts inside it (depth-counted; `\(` / `\)` count for nothing), and an entry's parentheses must be ONE group that closes the entry — unmatched either way, or followed by more text (`Bash(a)(b)`, two rules joined by a tab or a no-break space), the skill is refused with one clause (`an entry's parentheses are not one group that closes it …`). Every entry is then judged by the rule syntax's own test, `ruleSpellingOf` (`src/core/permission-rule-syntax.ts`), which is now the one home of the tool-name rule — `readRuleEntry`, the reader of every list (request / deployment `allowedTools`, a skill's lists, session rule buckets, sub-agent denied lists), asks it too, and its old name pattern in `allow-entry.ts` is gone: a tool name, AS WRITTEN (the lenient parser's trim no longer reads a byte-order mark, a no-break space or a tab before the `(` as nothing — `Read<BOM>(*)` read as the whole `Read` tool in every list), of ASCII letters, digits and `_ - . : *`, starting with a letter, a digit or `_` and not ending in `.` / `:` (or the lone `*`), then at most one parenthesized rule whose `(` closes at the entry's end. The name's characters are those the MCP / A2A mint makes (`normalizeNameSegment`, `[a-zA-Z0-9_-]`) plus `.`, `:` and `*`. An entry that is no rule spelling refuses the skill; the detail names the key and the entry's place (`entry 3 is no rule spelling (…)`). What a rule's parentheses hold — a `)` of a path included, which keeps a person's stored path rule readable in every list — is not judged by the syntax or the loader: the grant table reads it and announces what it cannot read. A null `disallowed-tools` (empty, `null`, `Null`, `NULL`, `~`, a comment only, a quoted key with nothing after it, `~` on the next line) is the key's absence and `disallowedTools` is read; a hyphen key with a value is the list (`[]` and `""` are values) and the alias is not read — only its quote or `[` left open refuses, as in any key. A scalar key's value (`name`, `description`, …) is decoded only when it is ONE quoted scalar — the scanner's own closing quote is the value's last character — and is kept exactly as written otherwise. A plain value of any key followed by an indented `key:` line (a bare or quoted key, its colon followed by a blank or nothing) refuses the skill naming both keys; a `:` glued to text (`https://…`) is no key. A block item runs over the lines indented deeper than its dash: a quoted item until it closes (`- "Read,` / ` Grep"` is two entries; it was refused with a clause that said its quote did not close), a plain one folded. The unclosed-flow clause no longer mentions a `(` the value may not have. Blanks are read by one definition in the module.
|
|
58
|
+
- **Against Claude Code 2.1.296** (`cli296.js`, byte offsets, read not run). `jde` (@16305645) reads `e["disallowed-tools"]??e.disallowedTools`: YAML's null falls through to the alias — now here too. `Lu` (@7089046) runs over each string YAML gives, so a `#`, a flow `,` / `]` and a `: ` inside parentheses are YAML's there as here; its zone is boolean and installs an entry holding an unmatched `)` (`Write)`), which here refuses (a divergence, open to veto, KL-1861-b / KL-1861-a / KL-1862-b list the others). CC's YAML rejects a plain value followed by an indented `key:` line and drops the frontmatter; here the skill is refused by name.
|
|
59
|
+
- **Who breaks.** A rule holding a ` #`, a `: ` or — in a flow sequence — a `,` / `]` inside its parentheses must be quoted whole (`["Bash(git status, git diff)"]`, `- "Bash(echo a # b)"`, `'Bash(echo a: b)'`); unquoted, the skill is refused (the first version of #1862 installed it, reading the parentheses' text past what YAML reads). An entry with a stray `)` (`Write)`, `notes.txt)`, a lone `)`), `Bash(a)(b)`, or two rules joined by a tab or a no-break space refuses. A tool name outside the rule syntax — a character outside ASCII letters, digits and `_ - . : *` (a filler, a variation selector, a look-alike letter, a backslash, a `#`, an apostrophe), a leading `-`, `.`, `:` or a `*` that is not the lone `*`, a trailing `.` or `:` — refuses the skill at load, and now reads as `grammar` in **every** list: the request's and the deployment's `allowedTools` (announced, grants nothing), a session's `toolAllow` / `toolDeny` (a write holding it is refused; a stored row holding it: see **Stored session deny rows** below), a sub-agent definition's denied list (`config.agent.unknown_tool` at start); a caller tool registered under such a name is refused at prepare (#1878, below). The same spellings in an allow list grant nothing now (`Read<BOM>(*)` granted the whole `Read` tool). An allow list's wildcard veto keeps its reading: what the lenient parser names `*` (`* (*)`, `*` then a tab or a byte-order mark before `(`) still voids the list, although the entry itself is now no rule spelling — a stricter entry reading never un-voids a list. A `disallowed-tools:` with no value beside `disallowedTools: X` now denies X (it denied nothing). A scalar value that starts and ends with a quote but is not one quoted scalar is kept as written, quotes included (`'Don't …'`; `description: "It's a "quoted" word"` was stripped of its outer quotes). A plain value followed by an indented `key:` line refuses (the line was skipped, the inner key lost). Widened: `argument-hint: [file (optional]` loads (it was refused; KL-1862-c deleted with KL-1862-a); a block item continued on deeper lines is read (`- Read` / ` more` was refused); an invisible character inside a rule's parentheses no longer refuses the skill at load — a control or format character there is refused by the rule's grammar at the grant table (an allow entry is announced, a deny entry keeps the skill from activating), and a tab, a no-break space or a filler there is read as the rule's text (KL-1866-c: `Bash(rm<NBSP>-rf:*)` denies only that spelling). Narrowed, documented: a multi-line flow whose items are flush left is refused (KL-1866-a). server: a session write and a deployment's `allowedTools` holding such a name now read it as `grammar`; cli / client-core: a shell that previews a skill's lists must read the same structure and judge entries by the same syntax; test: the cells in the pins are the black-box set.
|
|
60
|
+
- **Stored session deny rows.** A session's rules whose `toolDeny` holds an entry no rule grammar reads cannot be read, and refuse every call until they are rewritten — never read as fewer. "No rule grammar reads" is the entry reader's word `grammar` in the grammar the row carries: a name outside the rule syntax (`-deploy`, a name outside ASCII, `Bash` followed by a variation selector), a byte-order mark / no-break space / tab before the `(`, an empty entry. An entry the grammar reads that names nothing — a name no tool carries, `*`, a tool with no rule form (`Deploy(prod)`) — still refuses nothing, the rest of the bucket refusing as written. The rule grammar is 2 (`SESSION_RULES_GRAMMAR`, was 1): an entry this grammar reads as no rule in a row an earlier grammar wrote (stamp 0 or 1) is `earlier_grammar` in either bucket (grammar 0's own reading of its parenthesized and `*` allow entries is unchanged), announced as `config.session_rules_earlier_grammar` once per distinct set per layer and now per bucket, naming the session, the bucket, each entry with the reader's word (`toolDeny "-deploy" (grammar) was stored in rule grammar 1`), and the way out: an operator write of a new value without the stored row's rev, leaving the entry out — the write judge refuses an entry it cannot read, and handing the stored row back is refused `inert_entry` / `earlier_grammar`. Every refused call is told, with no stored text, `the session rules cannot be read as rules (they hold a toolDeny entry that rule grammar 2 reads as no rule) — every call is refused until they are rewritten`. In `toolAllow` the same entry admits nothing and the rest of the list is read as written — except that the move is an entry's reading, never the list's: an allow list a `*` voids, in any spelling the voiding test reads as `*` (`* (*)`, a tab or a byte-order mark before the `(`), stays voided in a row stamped 1 or 2; in a grammar-0 row such a spelling is one entry that reads as nothing, as a bare `*` is there (grammar 0 compared it as a name; 7.51.x and the first version of this entry voided the list — open to veto); a refusal under that allowlist ends `; the session's rules are stored in rule grammar 1, where 1 entry is no rule in this engine's rule grammar (2) and admits nothing`. The tighten comparison (`loosenReasons`) reads the same verdict: over such rules a non-operator write is `loosen_forbidden` (`the prior rules refuse every call — their toolDeny holds "-deploy" (grammar), which no grammar reads`), and such rules as the next value loosen nothing; the published safety-merge corpus moves with it (`inert-deny-entry-removed-safe` loses its empty entry; `unreadable-deny-prior-loosened` and `unreadable-deny-next-safe` added). Rules no store stamped (a plain rules object handed to `createSessionRulePolicy`) refuse every call the same way, with no earlier-grammar announcement (the run's policy audit names the entry). `createSessionRulePolicy`'s `onEarlierGrammar` is now `(entries, field, grammar)`: a callback that reads only `entries` still type-checks and now also hears `toolDeny` sets. A plugin's agent definition is read the other way (an unreadable entry disables that definition only): its user cannot edit it, while a session's rules are written by the session's own operator and can be rewritten. Who breaks: @server — a durable backend storing the judge's answer now stores `grammar: 2`, and an engine before this version reading that row refuses it (a grammar newer than it reads) and its layer refuses every call, so every replica reading the store moves to this version before any writes through it; a one-time stamp migration that reads only an unstamped row's `toolAllow` before stamping it `grammar: 2` (server's M17, `oldGrammarEntriesOf` — the premise "grammars 0 and 1 differ only in `toolAllow`" does not hold for grammar 2) leaves a row whose `toolDeny` holds such an entry refusing every call but takes away its `config.session_rules_earlier_grammar` announcement — ask `unreadableDenyEntries` (exported by #1876) before stamping, and leave such a row unstamped or name it; a stored row (stamp 0 or 1) whose `toolDeny` holds such an entry — on 7.51.x it refused what the lenient name read named (`Read<BOM>(*)` the whole `Read` tool, `-deploy` a tool of that literal name) — now refuses every call for its session until an operator rewrites it; center: a merger running the corpus follows `loosenReasons` (one vector changed, two added). Pins: `test/backlog1866-session-deny-fail-closed.test.ts` F1–F7 (14 cells; 13 RED on the pristine base, F3b green on both, F3a red there only for the missing `unreadableDenyEntries`, F2c only for its grammar-0 arm; F1d a row stamped 2 written past the judge: refused, no earlier-grammar advisory, the policy audit naming the entry); re-expressed `test/backlog1761-rule-row-grammar-version.test.ts` ④ (a later grammar is `SESSION_RULES_GRAMMAR + 1`, no longer the literal 2), `test/backlog1790-grammar0-star.test.ts` A2b (a padded `*` in a grammar-0 allow bucket: `grammar` → `earlier_grammar`), A3c / A6 (the clause and the advisory take the row's grammar and bucket), `test/backlog1866-scanner-closure.test.ts` R7 session reach (`grammar` → `earlier_grammar` at stamp 1, unreadable at both); `test/safety-merge-corpus.test.ts` through the corpus. `test/backlog1474-rule-entry-reader.test.ts` T1a / T1b (an empty or padded deny entry no longer leaves silently: its rules refuse every call, an operator removes it). Mutations, each red on its cells: no fail-closed branch (F1a / F1b / F1c / F1d / F5a), the stamp left at 1 (F1a / F1b / F2a / F4a / F5a / F6a / F7a, the #1866 R7 session reach), no earlier-grammar generalization (F1a / F1b / F1c / F2a / F2b / F4a, #1790 A2b, the #1866 R7 session reach), no tighten clause (F5a / F5b, #1474 T1a / T1b, both new corpus vectors), the earlier move un-voiding a list in a stamped row (F2c).
|
|
61
|
+
- **Pins.** `test/backlog1866-scanner-closure.test.ts` (64 cells; 51 RED on the pristine base — 3 of them on the refusal clause only, the base refusing in its own words — and 13 controls green on both): R1 X1–X10 (an unmatched `)` in a quoted block item, a plain value, a flow item, a plain block item, a deny list, a lone `)`; balanced nesting and a quoted balanced rule stay entries; `Bash(echo ")")` refuses where the reference's boolean zone keeps one entry, and its escaped spelling reads; an escaped `\)` grants no `Write`); R2 A1–A9 and M20 (the alias read under each null spelling of the hyphen key, the grant table denying Bash), a valued hyphen key (`Write`, `[]`, `""`) shadowing, a null hyphen key leaving the alias's own refusal standing, a shadowed garbage alias ignored and an unterminated one refusing; R3 F1 / F4 / B1 / B2 / B5 / B10, and `argument-hint: [file (optional]` loading; R4 B3 / B4 / B6 / B7 / C1 / C3 refused, never a gained entry, a quoted item keeping its `#`, a comment after a closed rule stripped; M a `: ` inside parentheses; R5 E1 / E2 kept as written, whole quotes still decoded; R6 D1–D3 (one space, two, a tab), a quoted indented key, a tool key's plain value, and two controls (a URL continuation, a nested mapping under an empty value); R7 a filler, a variation selector and a zero-width space refused by the entry's place, the syntax home's spelling table, the name read as written (a byte-order mark / no-break space / tab before `(`, in every list), parentheses' content left to the content grammar in every other list (a session's stored path deny holding a `)` keeps denying) while the loader's cut refuses it, the wildcard veto (`* (*)` still voids an allow list), control characters in a name and in parentheses, two rules glued by a no-break space / filler / tab, KL-1866-c pinned as it stands, the mint's names (a server and a tool named outside ASCII), and one reach pin each for an allow list, a session deny bucket and a sub-agent denied list; P3 a block item over lines, the unclosed-flow clause, flush-left flow items (KL-1866-a), a glued dash; Z 4500 seeded skills (1500 clean, 1500 with the allow list mutated, 1500 with the deny list mutated): clean ones read exactly, mutated ones refused or holding only rule spellings (the oracle is `ruleSpellingOf`), and no installed deny list lacking an entry that lies before the first mutation. Re-expressed (old → new meaning): `test/backlog1862-tool-list-scanner.test.ts` R1 (a ` #` in a flow item's parentheses: the rule's text → a comment, the unclosed `[` refuses; the quoted item reads), the S flow row (`[Bash(a, b) Read]` → `["Bash(a, b) Read"]`), P back to back (`Bash(a)(b)` one entry, `Read)` installed → both refused), P `#` in the zone (the rule's text → refused unless quoted), P escapes (`Bash(a\\) b)` two entries → refused), P unclosed (`[Bash(echo, Write]` the flow clause → the parentheses clause), T tab (`Bash(a\tb)` refused → read), A empty hyphen key (shadows the alias → the alias is read), O (`argument-hint: [file (optional]` refused → loads; a quoted description stripped of its outer quotes → kept as written), the clause strings, Z (moved here); `test/backlog1845-tool-list-block-sequence.test.ts` B (`Bash(echo "a: b")` and `mcp__a#b` installed → quoted / refused; a deeper line under an item refused → folded, its C row removed), J (`it's`, `aAA/b`, `a\db`, `a\xZZb` as names → inside a rule), K (`[Bash(a,5,b), Read]` read → refused, the quoted item reads), F1 (`- Bash(echo a #b)` read → refused), F4 (`mcp__a#b` → `Bash(a#b)`; `Bash(echo 'a #b') Read` read → refused), the clause strings; `test/backlog1841-frontmatter-fence.test.ts` H (the clause). Mutations, each red on its cells: the zone lifting comments (R4 ×4, #1862 R1 / P, #1845 F1 / F4); a flow `,` / `]` inside parentheses read as text (R3 ×6, #1862 P ×2 / O, #1845 K ×2); the cut accepting a `)` at depth 0 (R1 ×7, #1862 P ×2 — on the clause only: the name head refuses those entries too); the cut not refusing text after the closing `)` (glue, back to back, the parentheses-in-other-lists cell, the control-character cell, Z); the name head admitting `)` (the syntax home's cell); the syntax accepting a downgraded parse (the home's cell, #1327 ×4); the name read after the lenient trim, with no as-written check (the as-written cell); the wildcard veto back on the entry grammar (the veto cell); the loader skipping the syntax (61 cells); the head back to the old Unicode class (49 cells, the three reach pins among them); a null hyphen key shadowing the alias (R2 ×11, #1862 A); `unquote` by the first and last character (E1 / E2, #1862 O); no indented-key check (R6 ×4); no mapping mark (M, #1862 R5, #1845 B / C / G ×4 / K ×2); a block item read as one line (P3, #1845 B).
|
|
62
|
+
|
|
63
|
+
### Every reader of an entry list asks the one test of a rule spelling: the Skill card binds what the list reader reads, and a sub-agent's denied lists, the tripwire baseline, a name set and the rule DSL compiler read a tool name as written (#1875, #1870; security; behavior narrowing — open to veto; hard-breaking: none; closed sets: `PermissionRuleIssue.code` +1 `invalid.tool_name` (an exported type: an exhaustive switch over it gains a case), `DeniedEntryReading` +1 `unreadable` (engine-internal; its readers: `sub-agent-deny-lists.ts` — the list judgment, the workflow leg's reading, the layer screen — and the selector's reach in `tool-filter.ts`); exports: none; members: the Skill card's `boundGrants` lists gain one spelling family, `<reads as nothing (<word>): "<entry>">`) (@server @cli @client-core @test)
|
|
64
|
+
|
|
65
|
+
- **Before.** #1866 made `ruleSpellingOf` the test of a rule spelling and `readRuleEntry` asked it, but five other readers still decided by the lenient parser, which trims a name and reads a malformed parse as a whole-string name. So one spelling had up to three answers. The Skill card's binding spelled `Bash<TAB>(*)`, `Read<BOM>(*)` and `" Bash"` as `Bash` / `Read` and digested them as those tools, while activating the skill granted nothing for them. A parked card for `allowed-tools: ["Bash<TAB>(*)"]` therefore covered a resume whose definition had become `["Bash"]` — the run-wide `Bash` grant installed on an approval whose card granted nothing (it had covered both on 7.51.x, when both granted `Bash`). A sub-agent's denied entry named `Bash` / `Read` for those spellings and `" Bash"` / `-deploy` as written. The tripwire's drift baseline held `Bash` for `Bash(a)(b)` and `Bash<TAB>(*)`. A name set (`createAllowDenyPolicy`) kept `Bash<TAB>(*)`, `" Bash"` and `-deploy` raw — a deny that denies nothing. The rule DSL (`validatePermissionRules` / `createPermissionRulePolicy`) compiled `-deploy`, and `Bash` followed by U+FE0F / U+3164 / U+200B, clean — a deny or ask that never fires — while `" Bash"` and `Read<BOM>(*)` refused `Bash` / `Read` under the lenient reading.
|
|
66
|
+
- **Now.** Each reader asks the question it has, of one reader. The Skill card binds each list as the list reader reads it at activation (`compileAllowList`, before any roster). A rule keeps its canonical spelling (`Tool` / `Tool(content)`; the digest of a list whose every entry is a rule is byte for byte the 7.51.x one). An entry the reader reads as nothing is spelled `<reads as nothing (<word>): "<entry>">` — the reader's word, then the entry as the notice writes it, uncapped. Every entry of an allow list a `*` voids carries `wildcard_all`. A member that is not a string keeps its type label. Two lists now bind one digest only when the reader reads them alike, and a label written into a list is itself read as nothing and wrapped again. A sub-agent's denied entry the syntax cannot read is `unreadable`: the judgment before the run refuses it on either side (`config.agent.unknown_tool` / `config.tool_face_control`, the `grammar` sentence). That includes a blank, a tab, a byte-order mark, a no-break space, U+3000 or a filler between the name and the `(` (`Echo ()` was a rule form granting nothing on an allowed list and the whole `Echo` on a denied one), and a name ending in `.`. Every layer of the run's selector is read so at prepare, whoever judged it: a ceiling a checkpoint row restored, and the run's own lists carrying the spawning run's evidence. A readable `Tool(content)` still names `Tool` there and hides the whole tool (`Bash(a)(b)` hides `Bash`; its content's grammar is not the selector's — open to veto). The tripwire's baseline is the names the allowed-tools rows grant. A name set refuses an entry the syntax cannot read (`config.invalid_tool_name_set`, lesson "a spelling no tool name can have"). The rule DSL refuses a rule the syntax cannot read: an empty name and malformed parentheses keep `invalid.empty_tool` / `invalid.paren`, and a name outside the syntax's spelling is the new `invalid.tool_name`.
|
|
67
|
+
- **Against Claude Code 2.1.296.** No new divergence. The name test and its reading as written are #1866's. This entry extends it to the remaining readers of a list.
|
|
68
|
+
- **Who breaks.** @server @cli: a surface that renders `boundGrants` shows the new label family for an entry that reads as nothing (render it as "reads as nothing", never as a tool). A server keying a remembered answer on `boundGrantDigest` keeps every digest of a list of rules. A parked Skill card whose lists hold an entry that reads as nothing is refused at the resume's claim (`checkpoint.invalid_outcome`, "… no longer bind to the digest this Skill approval was asked under — its definition changed, or this engine reads one of its entries differently than the engine that approved it …"; the row stays pending) when the resume's definition reads differently. Before, it passed the claim when the lenient readings matched: #1372's `Probe ` case moves from the execution boundary to the claim. A Skill approval parked by 7.51.x on a skill whose lists hold an entry that reads as nothing (a `*` included: `["*"]`, `["Read", "*"]`, `["WebSearch(x)"]`, `["Deploy(prod)"]`, `["Bash(a)(b)"]`, `[" Read"]`, `["Read<TAB>(*)"]`, deny `["*"]`, deny `["Bash(a)(b)"]` — each of these digests differently from the 7.51.2 digest, and every list of rules alike) is refused the same way after the upgrade, its definition unchanged (the digest binds the reader's reading, and 7.51.x digested such an entry as a tool name): deny the row and re-activate the skill — the new card is approved again. A list whose every entry is a rule digests byte for byte as in 7.51.x, so its parked approvals resume. @server: after the upgrade, a parked delegated child whose row records a ceiling — or own lists judged upstream — holding such an entry (an earlier engine read `Read<TAB>(*)` as `Read`) resumes into a leg refused at its prepare: `config.tool_face_control`, naming the lists (`the lists of the run this leg was spawned under declares tool "Read\t(*)" (grammar) …`). The row stays pending; correct the list and resume again. A sub-agent definition, a delegation tool's lists or a run's own `allowTools` / `excludeTools` holding `Echo ()` or `Read<TAB>(*)` now refuse the run. `createAllowDenyPolicy` throws on `" Bash"`, `Bash<TAB>(*)`, `-deploy` and `mcp__peer__.` (it kept them). `createPermissionRulePolicy` throws on `" Bash"` / `Read<BOM>(*)` (they denied `Bash` / `Read`) and on `-deploy` / a name with an invisible character (they compiled and denied nothing). `-deploy` is no tool name in any list, a name set included. In a name set, `onInvalidName: "throw"` (the default) refuses the policy loudly; `"skip"` drops the entry and reports it through `onInvalidNameIssue`. On 7.51.x a deny naming a tool mounted as `-deploy` denied it by its raw name; in this version no caller tool mounts under such a name (#1878, the next entry), so a dropped entry frees no tool. `Tool()` / `Tool(*)` in a name set and on a sub-agent's allowed list: read as the bare `Tool` since #1880 (the r9 closure entry below).
|
|
69
|
+
- **Pins.** `test/backlog1875-one-entry-reader.test.ts` (60 cells; 46 RED on the pristine base d69d1ada8 — KL-1866-b's name-set cell among them, pinned as it stands — and 14 green on both — nine controls, the golden digests and ②c among them, and five cells where the base already answered the same way: `Bash(a)(b)` in the sub-agent / name-set / DSL readers and its parked card, and the `-deploy` tripwire cell). ① five spellings × five readers. ② the parked card refused at the claim and at the execution boundary, and on the real lane (park, swap the definition, resume as a person: refused before the claim, the row pending, `Park` never runs), with a control and ②c — the same lists and a roster that changed across the park, refused at the boundary. ③ the golden digests minted on the base, the label not forgeable, the `*`-voided list, the non-string label. ④ six separators between the name and `(` in a sub-agent's denied list, and ④b every selector layer screened whether or not judged upstream. ⑤ the rule DSL's name head, seven spellings and the old codes' controls. `test/backlog1623-tool-selector.test.ts` ⑦ an older engine's row whose ceiling holds `Read\t(*)`: the resumed leg refused, the row pending. Re-expressed (old → new meaning): `test/backlog1372-skill-deny-unreadable.test.ts` ④ (refused at the execution boundary → at the claim, the digests now differ); `test/backlog1377-subagent-deny-names-one-judgment.test.ts` ⑩ (`Edit `, ` Edit(src/**)`, `Bash(` read as a whole-string name → `unreadable`) and ⑭'s control; `test/backlog1736-unknown-tool-name-lazy.test.ts` U4 (`Echo ()` a rule form granting nothing / the whole `Echo` denied → refused `grammar` on either side; the rule-form cells use `Echo()`); `test/policy-name-audit.test.ts` (`mcp__peer____.` / `mcp__peer__.` left to the audit → refused by the screen; the audit cell reads `mcp__peer__a.b`). Mutations, one per reader put back on the lenient reading, each red on its cells: the binding (15 — ① ×5, ② ×6, ③ ×3, #1372 ④), the denied entry (18 — ① ×4, ④ ×8, #1736 U4, #1377 ⑩ ×3 and ⑭, #1623 ⑦), the tripwire baseline (4), the name set (5, the audit cell among them), the DSL's name head (11), the layer screen removed (3 — ④b ×2, #1623 ⑦).
|
|
70
|
+
|
|
71
|
+
### A caller tool's every name is one a rule can name: the mount refuses a name or alias no list could write, so an entry a name set drops can no longer free a mounted tool (#1878, closing KL-1866-b and the remaining half of #1389; P2; security — the upgrade fail-open of the previous entry closed at its root; behavior narrowing — open to veto; **hard-breaking**: a deployment that mounts a caller tool or alias named outside the rule syntax, or named `*`, is refused at prepare; closed sets: none — `config.tool_name_invalid` keeps its code and widens its meaning; exports: none; members: none) (@server @cli @client-core @test)
|
|
72
|
+
|
|
73
|
+
- **Before.** The mount refused a caller tool's name or alias only for `__`. Every list reads a tool name through the rule syntax's name head — ASCII letters, digits and `_ - . : *`, starting with a letter, a digit or `_`, not ending with `.` or `:` — so a tool mounted as `-deploy` (the provider pattern admits a leading `-`), `检索`, `deploy.` or `*` could be named by no list. With the previous entry, a name set in `onInvalidName: "skip"` mode dropped `deny: ["-deploy"]` and only reported it: a deployment that changed nothing could call a tool 7.51.x had denied. The same held through an alias (a rule naming an alias reaches the whole tool, so `deny: ["-x"]` on a tool `deploy` aliased `-x` was dropped) and for a name outside ASCII (`deny: ["检索"]`). A session-rule store's `mountedToolNames` was judged for `__` only.
|
|
74
|
+
- **Now.** Every name a caller tool answers to — its `name` and each of its `aliases` — keeps one contract, judged in one place (`callerToolNameProblem`): a name a rule can name (the rule syntax's own name head, and not the lone `*` — the wildcard reaches every tool and names none) and no `__`. One that breaks it refuses the run at prepare, before session acquire and before any turn: `failed`, `config.tool_name_invalid`, the sentence naming the name (or the alias and its tool) and the spelling a name may have. A session-rule store's `mountedToolNames` is judged by the same contract at construction. A protocol-minted name (`mcp__<server>__<tool>`, `a2a__<peer>__<skill>`, segments normalized to `[A-Za-z0-9_-]`) always keeps it. The name set's `skip` mode is unchanged; what it drops is now a spelling no mounted tool has. The selector's note that a tool named `*` cannot be the sole allowed tool is retired: no tool is named `*`.
|
|
75
|
+
- **Against Claude Code 2.1.296** (`cli296.js`, byte offsets, read not run). A name an extension registers — a mod's `command.register` and its neighbours — is judged by `plt=/^[a-zA-Z0-9_-]{1,64}$/` (@10663077), and a tool the meta-mcp bridge injects by the same pattern (@32097338): a leading `-` is admitted there, a `.`, a `:` and a `*` are refused, and a name holds at most 64 characters. The contract here is the rule syntax's name head (#1866), the reverse at both ends: a leading `-` is refused, a `.` / `:` / `*` inside a name is admitted, and the length bound is the roster's (128, `TOOL_WIRE_NAME_MAX_CHARS`). A divergence, open to veto (registered as `caller-tool-name-contract`): every name that mounts must be one every list can name, and the name head's `.` / `:` are the protocol mint's characters (#1866). Whether the contract should also hold to the providers' pattern is #1898 (unwindowed; such a name mounts and a provider that refuses it fails at the first model call).
|
|
76
|
+
- **Who breaks.** @server @cli @client-core: a deployment whose `spec.tools` holds a tool or alias named with a leading `-`, `.` or `:`, a trailing `.` or `:`, a character outside ASCII (a CJK name, a look-alike letter, a variation selector, a filler), a blank, the empty string or the lone `*` now fails at prepare with `config.tool_name_invalid`. Upgrade path: rename the tool or alias — and the list entries that name it — to the rule syntax's spelling; the model sees the new name. Before, such a tool mounted and no list could grant or refuse it by name. A run parked by 7.51.x whose frozen roster holds such a name (`-deploy`, `检索`, `deploy.`) is refused at the resume's prepare (`config.tool_name_invalid`) and its row stays pending; once the tool is renamed, the parked call names a tool the run no longer has and can never be entered again — the row's only ways out are a deny or a cancel. A session-rule store constructed with such a name in `mountedToolNames` throws at construction (`TypeError`, naming the option and the name). A host that reads `config.tool_name_invalid` as "the name carries `__`" now also receives it for these names (same code, wider meaning). @test: the pins below are the black-box set.
|
|
77
|
+
- **Pins.** `test/backlog1878-mount-name-contract.test.ts` (40 cells; 30 RED on both d69d1ada8 and bc868972d, 10 green on both — the controls and the `__` sentence). U the upgrade regression on a real run: `-deploy` with a skip-mode deny naming it, the same with no list, the alias `-x`; controls `deploy` denied by name and through its alias, and the harness's own call. M nine refused spellings, each as a name and as an alias; the `__` sentence naming every protocol prefix; six admitted names, each mounted, called, and refused by a name-set deny and by a rule deny. S the predicate equals the rule syntax's reading over 24 spellings; the caller judge equals it plus `__`; every protocol-minted name over 12 hostile segments × both namespaces keeps the contract. D the store's declaration (six refused with the mount's sentence, a control). Upgrade reading — a skip-mode deny naming the tool, the model calling it: d69d1ada8 denied it (ran 0); bc868972d ran it (ran 1, the entry dropped) for `-deploy`, for the alias `-x` (called by its name and by the alias) and for `检索`; now the run fails at prepare (ran 0). Re-expressed (old → new meaning): `test/tool-roster.test.ts` G5 (a caller tool named `*` mounted beside `excludeAllTools` → refused at prepare; the flag's cells ride a tool named `Probe`); `test/backlog1156-legacy-tool-name-session-rules.test.ts` structure (the two deployment-name doors called `carriesNamespaceSeparator` → each calls `callerToolNameProblem` once, which calls it once); `test/backlog1875-one-entry-reader.test.ts` the KL-1866-b cell's title (the name set's reading unchanged; such a tool no longer mounts). Mutations, each red on its cells: the mount judging `__` only (21 — U1–U3, M ×18); the predicate on a second pattern without the trailing rule (6 — M `.` / `:` ×4, S1, D `deploy.`); the predicate on the provider pattern `^[A-Za-z0-9_-]{1,64}$` (10 — U1–U3, M `-deploy` ×2, M controls `a-b.c:d` / `a*b`, S1, D `-deploy`, D control); the lone `*` admitted (4 — M ×2, S1, D `*`); aliases unjudged (10 — U3, M ×9); the store's door judging `__` only (5 — D).
|
|
78
|
+
|
|
79
|
+
### Every frontmatter line no value holds is judged in one place: an indented `key:` line under any value that is not a nested block refuses the skill naming both keys, and a flush-left line that is no key line refuses it naming its line; a lone CR is a line break and a quoted value over lines is kept whole (#1876, #1877; P1 / P3; security; behavior narrowing, and a widening for multi-line quoted values and flows — open to veto; **hard-breaking**: a skill installed since 7.51.x whose frontmatter holds a flush-left line that is no key line is now refused whole, named by its line; closed sets: none; exports: +1 `unreadableDenyEntries` (#1877), none removed; members: none) (@server @cli @client-core @test)
|
|
80
|
+
|
|
81
|
+
- **Before.** #1869 refused an indented `key:` line only under a PLAIN value. Under any other value the line was skipped with no warning, the key inside it lost. Reproduced through `createSkillsFromDirectory`: `description: "Deploy helper."` / ` disallowed-tools: Bash` / `allowed-tools: Read, Write` installed allow `[Read, Write]`, no deny, no warning. The same held for a single-quoted value with a tab-indented key; `argument-hint: [env]`; `metadata: {}`; a `description: |` whose body is indented 4 with the key at 2; a scalar value begun on the next line; and a tool list's quoted, flow or block-scalar value, which was refused without naming the key it hid. The scanner computed the problem for quoted and flow values and the caller threw it away; a block scalar's or a nested block's leftover lines reached a branch that skipped them. Flush-left, `allowed-tools:Read` (no blank after the colon) installed as a list, a stray `oops not a key` line was skipped and the `allowed-tools: Bash` after it installed, and a `disallowed-tools: Bash` line led by a byte-order mark or a zero-width space was skipped. In the session store, the write judge told a `toolDeny` entry no grammar reads that it "refuses nothing as written", though rules holding it refuse every call; and a grammar-0 advisory told `* (*)` (a spelling of `*`) to "spell the tool's name".
|
|
82
|
+
- **Now (one rule, one place).** Every value takes its own lines and no more: the scanner says how many lines of its text a value holds. It is over at the first line that holds anything after a quote or a flow closed or a comment ended it, and at a plain scalar's continuation line that is a key line; a block scalar ends at a less-indented line. Every line no value took reaches one branch of the frontmatter reader. Under a nested block (a mapping or a sequence under a key with nothing on its line: its first line is a `- ` entry, a key line, an explicit `? ` key, or a plain scalar reaching YAML's mapping mark) it is the block's own when it is indented at least as far as the block's first line (`metadata:` / ` owner: x` / ` disallowed-tools: Bash` is refused). A block scalar's header on the line below its key is a header; a refused block item holds only its own lines; inside a flow a mapping mark ends the key's scalar and the value's may begin, so `metadata: {note: "["}` loads; only a value that opens a flow takes a flush-left `]` / `}` (`metadata: {` / ` owner: team` / `}` loads). Under any other value — plain, quoted, a `[` or `{` flow, a `|` or `>` block scalar, on the key's line or the next — a key line refuses the skill naming both keys (`` `description` has a value the reader cannot read (an indented line under its value is a mapping key, `disallowed-tools` — YAML reads no key under a value that is not a mapping) ``), and any other line refuses a tool list. The deny list's alias keeps such a refusal (and an unclosed quote or flow, and a stray line under it) where a valued `disallowed-tools` shadows its list: lost text is not the alias's list. A `{` is a flow bracket like `[`, so `metadata: {a: b,` / ` c: d}` loads and an unclosed `{` refuses like an unclosed `[` (`` its `{` does not close within its lines ``). The block-sequence reader ends at the first line that is no `- ` entry and leaves that line to the same branch. #1869's plain-value lookahead and its plain-only clause are gone: one rule covers every value form, where the patch of the first version covered one.
|
|
83
|
+
- **Now (flush-left lines; hard-breaking, open to veto).** A flush-left frontmatter line is a YAML key line — a bare key (letters, digits, `_`, `-`) or one quoted scalar, then `:` and a blank or nothing — or a comment or blank line, or a flush-left `- ` entry of a nested sequence; anything else refuses the skill `no_frontmatter`, naming the line (`SKILL.md line 4 is no key line (…)`), or, behind a tool list, naming the list it would cut, with the line (`a flush-left line that is no key follows it (line 5)`). The refused shapes: `allowed-tools:Read` and `name:probe` (no blank after the colon), a stray word, a line led by a byte-order mark, a zero-width space or a no-break space, a flush-left `- ` under a scalar value, `allowed-tools:# c`, and the flush-left look-alikes of a fence (`----`, `---x`, `--- x`, `--- # end`, `a---b`, `-- -`, `---` followed by a no-break space or a form feed, a byte-order mark before `---`). `#` comment lines and blank lines pass; a whole-line `---` (trailing blanks allowed) still closes the block. **A skill installed on 7.51.x whose frontmatter holds a flush-left line that is no key line is now refused whole, named by its line.** Re-expressed pins: `test/backlog1841-frontmatter-fence.test.ts` B — the nine flush-left look-alike rows ("does NOT close the block": loaded with the key after it → refused naming line 5, the block not closed there; the fence rows unchanged); `test/backlog1845-tool-list-block-sequence.test.ts` I, last cell ("the same stray line behind a key that is NOT a tool list is skipped silently" → refused by its line).
|
|
84
|
+
- **Line breaks and multi-line quoted values (behavior change — open to veto).** Inside the frontmatter block a line break is YAML's: CR LF, LF, or a lone CR. A lone CR was a character, so `description: Demo` + CR + `disallowed-tools: Bash` kept the deny key inside the description, with no deny list and no warning; now the key after the CR is a key. A refusal still names the file's own line. U+2028 / U+2029 stay characters, as YAML 1.2 reads them. A quoted value over lines (a description, or any other key's) is kept as YAML decodes it over the lines the scanner says it holds: `description: "Deploy` / ` helper."` is `Deploy helper.`. Before, it was cut at its first line, quote included (`"Deploy`), and the skill loaded with that text. **A skill whose description or other quoted value was silently cut that way now reads the whole text.** KL-1849-b's clause on it is retired. A quote whose continuation after a line break is flush left (`"a` / `b"`, or the same with a lone CR) is unclosed and refuses, as its LF form always did. Re-expressed pin: `test/backlog1862-tool-list-scanner.test.ts` L, the lone-CR arm (`Bash` + CR + `Write`: refused for a whitespace entry → refused for a flush-left stray line; `Bash # note` + CR + `x`: installed `["Bash"]` → refused); the U+2028 / U+2029 arms are unchanged.
|
|
85
|
+
- **A folded block scalar's list (documentation corrected).** A literal block scalar (`|`) is cut line by line; a folded one (`>`) is folded first, as YAML folds it: `disallowed-tools: >` / ` Bash(echo` / ` a)` is `["Bash(echo a)"]`. The #1862 / #1866 entries and KL-1862-b said every block-scalar line was cut on its own; that held only for `|`. The code is unchanged.
|
|
86
|
+
- **Session rules' sentences.** The write judge says what an entry no grammar reads does in `toolDeny`: `… holds "-deploy" (earlier_grammar), which refuses every call as stored (rules whose toolDeny holds an entry no grammar reads cannot be read) — a write holding it is refused; nothing was written.` An allow entry still `admits nothing as written`, and a deny entry that names nothing still `refuses nothing as written`. An entry the list reader's voiding test reads as `*` (`* (*)`) gets the `*` way out in the earlier-grammar advisory (`"*" is no allow rule in any grammar …; to drop it …`, or beside others `To allow what the others meant, … leaving "*" out — …`) and in the write judge (`leave the entry out: it spells "*", …`), no longer "spell the tool's name". `unreadableDenyEntries` — THE answer to "can these rules' denies be read?" — is exported from the package root (#1877: server's one-time stamp migration had no engine answer to ask and stamped on its own premise).
|
|
87
|
+
- **Against Claude Code.** Claude Code reads a skill's frontmatter with a YAML parser. YAML's grammar has no key under a scalar or flow value and no non-key line where a mapping key is expected, so a parser following it rejects every shape refused here and the whole frontmatter with it (inferred from YAML's grammar, not run against the reference). Here the skill is refused by name, as #1869 already did for the plain value. Node properties on a key line followed by a nested mapping (`metadata: &m` / ` owner: x`) are legal YAML and refused here (KL-1876-a; #1869 refused them too).
|
|
88
|
+
- **Who breaks.** A stored skill with an indented `key:` line under a quoted, flow, block-scalar or next-line value: it installed with that key lost, and is refused now, naming both keys (re-indent the key flush left, or quote the text). A stored skill with a flush-left line that is no key line (above): it installed, the line skipped; refused now, naming the line. A skill whose `metadata: {…` flow mapping never closes is refused (it loaded). A skill whose multi-line `metadata: {a: b,` / ` c: d}` holds a key-shaped line keeps loading. server: the session store's sentences changed text only (codes, `detail` and judgments unchanged). Before its one-time stamp migration stamps an unstamped row, server asks `unreadableDenyEntries`, or the row loses its earlier-grammar announcement (see #1866's **Stored session deny rows**). cli / client-core: a shell previewing a skill's lists refuses the same shapes. test: the pin cells are the black-box set.
|
|
89
|
+
- **Pins.** `test/backlog1876-indented-key-under-value.test.ts`: 59 cells, 41 RED on the base. The 18 green there are the four plain-value cells #1869 already fixed, a tool list's flow-mapping value, a tool list's next-line value, the legal flows the base never scanned, and the controls: the next key flush left, a URL's colon, block-scalar bodies, nested mappings with bare / numeric / quoted / spaced / explicit `? ` first keys and deeper lines, a compact nested sequence, flows over lines, a quoted value over lines holding `key: `, KL-1876-a as it stands, the fence / comment / blank-line controls, and the unchanged D / E sentences. A — seven scalar-key value forms × an indented tool key or an indented non-tool key, and five tool-list value forms × both list keys, each refused naming both keys. N — next-line values; a nested block's shallower line; the alias seat; a refused block item; a lone CR in all three tool-key spellings; multi-line quoted values; flows closing by their own bracket and holding quoted brackets; a flush-left closer; a block-scalar header below the key; an unclosed `{`. B — `key:value`, a stray word, a byte-order mark / zero-width space / no-break space before a key, a flush-left dash, a glued comment, the list-charged form. C — `>` folds, `|` cuts. D / E / F as above. Mutations, each red on its cells: no key-line judgment under a value (38, the #1869 R6 cells among them); a plain scalar not ending at a key line (14); `{` no flow bracket (4); nothing opening a nested block (5, three #1845 controls among them); everything opening one (2); a nested block holding every indented line (1); a stray line skipped (17, the re-expressed #1841 / #1845 cells among them); a flush-left `key:value` read as a key (2); a refused item skipping its unheld lines (1); a flow's mapping mark beginning no value (1); a header below the key ignored (1); a flush-left `}` closing no flow (1); any value taking a flush-left closer (1); a lone CR as a character (2, the #1862 L CR arm among them); a closer of either kind closing any flow (1); no adjacent value after a quoted key (1); a quoted value kept by its first line (1); the deny sentence back (1); no `*` counted in the advisory (1); `*` read by the entry reader (2); the export removed (1); a folded list cut before folding (1); the alias's lost text dropped under a valued hyphen key (2).
|
|
90
|
+
|
|
91
|
+
### r9 closure: a tool-list key is read only flush left; a quoted value keeps its `#` lines; the parked Skill refusal says what is true; a name set and a sub-agent's allowed list read `Tool(*)` as `Tool`; the rule DSL refuses `*`; every selector layer is read by one reader, a denied wildcard refused in it (#1886, #1891, #1887, #1888, #1880 name-set half, #1889, #1890, #1901; P1 / P3 / P2 / P2 / P2 / P2 / P3, #1901 as ranked on its ticket; security; behavior narrowing, and a widening for `Tool(*)` / `Tool()` on an allowed list — open to veto; **hard-breaking**: a skill whose nested block — or whose key line after a closing quote or `]` — writes a tool-list key is refused whole, the nested case legal YAML; an allowed `Read(*)` / `Read()` admits `Read` in a name set and on a sub-agent's allowed list (both lanes); a rule-DSL `*` rule is an issue; a denied `*` in a ceiling, in upstream-judged lists or in the workflow lane refuses; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
|
|
92
|
+
|
|
93
|
+
- **Before.** Seven readings, each reproduced on the 7.52.0 stage before this entry. (#1886) A nested block (a mapping or a sequence under a key with nothing on its line) skipped every line indented at least as far as its first line, so `tags:` / ` - prod` / ` - ops` / ` disallowed-tools: Bash` / `allowed-tools: Read` installed allow `[Read]`, no deny, no warning; so did the same key deeper (` disallowed-tools: Bash` under ` - prod`, under ` owner: team`). (#1891) Text after another key's closing quote or `]` on its line was kept as part of that key: `description: "Deploy." disallowed-tools: Bash` and `argument-hint: [env] disallowed-tools: Bash` installed with no deny list. (#1887) A quoted value over lines dropped its last lines when they started with `#` — a line inside a quote, not a comment: `description: "Deploy` / ` #tag"` read `"Deploy`. (#1888) A Skill approval parked by 7.51.x on a list holding an entry that reads as nothing (`["*"]`, `["Read", "*"]`, `["WebSearch(x)"]`, deny `["*"]`) was refused on resume, the definition unchanged, with a sentence saying the skill "changed after the approval was given". (#1880) A name set kept `Read(*)` / `Read()` as written: `deny: ["Read(*)"]` denied nothing (no throw, no notice; `Bash(*)`, `mcp__srv(*)`, `mcp__srv__t()` alike), and `allow: ["Read(*)"]` removed `Read`. A sub-agent's allowed list matched `Read(*)` / `Read()` as written too (it admitted nothing, told as "grants nothing"). (#1889) The rule DSL compiled `*` / `*(*)` / `*()` with no issue and denied nothing. (#1890) The selector's layer screen read only the denied lists and refused only an entry the rule syntax cannot read: a ceiling layer whose denied list was no list or held `5` / `null` started the leg with no notice. The run's own allowed list holding `Read<TAB>(*)` was refused when this prepare judged it. With the spawning run's evidence (`judgedUpstream`) it passed.
|
|
94
|
+
- **Now (one rule for #1886 and #1891).** A tool-list key (`allowed-tools`, `disallowed-tools`, `disallowedTools`) is read only as a key of the frontmatter, written flush left. Written anywhere else, its list would be lost: in another key's nested block (any depth, a sequence or a mapping, a `- ` entry's key, a quoted key), or after another key's closing quote or `]` on the key's line. Either way the skill is refused `no_frontmatter`, naming both keys: `` `tags` has a value the reader cannot read (it holds the tool-list key `disallowed-tools` — a tool list is read only as a key of the frontmatter, written flush left; anywhere else its list would be lost) ``. Non-tool keys in nested blocks load as before. Text after a closing quote or `]` that writes no tool-list key is kept as written, as before: `argument-hint: [pr-number] [priority]`, `description: 'Don't do it'`. Nothing of a nested block is otherwise read (no YAML is modelled there; #1863 B reads it with a parser).
|
|
95
|
+
- **Now (#1887).** A value that is one quoted scalar keeps the scalar the scanner decoded, to its closing quote over every line it runs: `Deploy #tag`, `Deploy # helper`, `Deploy` + line break + `#tag` (a blank line inside folds to a line break). A comment after the closing quote — on its line or the next — is no part of it: `description: "Deploy." # note` reads `Deploy.` (it read `"Deploy." # note`, quotes and comment included).
|
|
96
|
+
- **Now (#1888).** The refusal of a parked Skill approval whose card's skill no longer digests says what can be true: `the allowed-tools / disallowed-tools of "h", as this resume serves it (one it does not load counts as absent), no longer bind to the digest this Skill approval was asked under — its definition changed, or this engine reads one of its entries differently than the engine that approved it (an entry that reads as nothing) — …`. The judgment is unchanged: the row stays pending at the claim (deny it, then re-activate), and is spent at the execution boundary.
|
|
97
|
+
- **Now (#1880, #1889).** A name set reads each entry as the rule syntax does: `Tool()` / `Tool(*)` are `Tool` (deny denies it, allow admits it; `nameSets` states `Tool`, once however many spellings write it); `Tool(x)` is still refused as a content form; the lone `*` is told apart by list (see the entry after this one). A sub-agent's allowed list is read the same way by the selector (`reachOf`) and by the judgment before the run: `Tool()` / `Tool(*)` admit `Tool`, with no account; `Tool(x)` still admits nothing and is told ("grants nothing"). The two lanes an agent definition's `allowTools` reaches read these entries alike (`Tool()` / `Tool(*)` admit `Tool` in both) and differ where they did before: `Tool(x)` admits nothing and is told on the Agent tool's selector but refuses the leg's launch in the workflow agentType's name set (`a rule CONTENT form`), and a lone `*` admits every tool on the selector but is one name no tool carries in the name set. The rule DSL refuses a rule whose tool name the syntax reads as the wildcard `*` (`*`, `*()`, `*(*)`; deny and ask alike): `invalid.tool_name`, `` `*` names no tool — a rule reaches the tool it names, so this one would compile and never match; name the tool, or state the answer for every call no rule reaches with `defaultAction` ``. An allow rule is still refused for its behavior first (`unsupported.allow`).
|
|
98
|
+
- **Now (#1890).** Before a leg starts, every layer of its selector — each ceiling layer and its own — has both lists, every member, read by one reader. A value that is not a list, a member that is not a string, or a spelling no tool name can have refuses the leg: `config.tool_face_control`, naming that layer's list (`the lists of the run this leg was spawned under …` / `the lists of the run 1 spawn above the run this leg was spawned under …` / `TaskSpec.allowTools …`, in its allowed or denied tools). This holds whatever `judgedUpstream` says (the engine that judged upstream may have read with an earlier grammar). The evidence spares only the question of whether a name is one a tool of the task answers to. A definition's lists and the workflow leg's denied reading go through the same reader. The reader also holds the refusal of a denied wildcard: `*`, `*()` and `*(*)` on a denied list hide nothing, so they refuse (`unsupported_pattern`) in every layer and lane — before, only a literal `*` in a list judged at this prepare was refused, and `*(*)` was only told. The sentences are the ones the list judgment already wrote.
|
|
99
|
+
- **Against Claude Code 2.1.296** (`cli296.js`, read, not run). Its frontmatter parse (`si` → `tXt`) falls back, on a YAML error, to a rewrite (`I`, @11653663, line 242128) that wraps a flush-left key's whole value in double quotes when it holds `[`, `]`, `#`, `: ` and similar. So `description: "Deploy." disallowed-tools: Bash` and `argument-hint: [env] disallowed-tools: Bash` load there as one string each, the deny list lost. Refusing the shape that writes a tool-list key is a divergence (stricter, open to veto); the bracketed hint without one loads alike. `metadata:` / ` disallowed-tools: Bash` is legal YAML (a nested key, no deny list there either), refused here (open to veto, KL-1886-a). A `*` rule: not run against the reference.
|
|
100
|
+
- **Who breaks.** A stored skill whose nested block writes a tool-list key (a mis-indented `disallowed-tools`, or one deliberately nested under `metadata:`) is refused, naming both keys: move the key flush left. So is a skill whose key line writes a tool-list key after a closing quote or `]`: put it on its own line. A skill's description that was a quoted value with `#` lines, or followed by a comment, now reads its whole text, or only the scalar. A name set with `allow: ["Read(*)"]` now admits `Read` (it denied every `Read` call: a widening). So does a sub-agent definition's or a run's `allowTools: ["Read(*)"]`, in the Agent lane and the workflow lane (it admitted nothing: a widening to what the author wrote, open to veto). A name set with `deny: ["Read(*)"]` now denies `Read` (it denied nothing). A ceiling, own lists judged upstream, or a workflow agentType's `denyTools` holding `*` / `*()` / `*(*)` now refuse the leg or the spawn (`unsupported_pattern`; they hid nothing — open to veto). @server: a rule set holding a `*` rule (deny or ask) now throws `config.invalid_permission_rules` at `createPermissionRulePolicy` (`onInvalidRule: "throw"`, the default), or is skipped and reported under `"skip"` (it compiled and matched nothing). Use `defaultAction` for every call no rule reaches. A parked delegated child whose row records a ceiling holding a list that is no list, a member that is not a string, or an allowed entry no rule syntax reads resumes into a leg refused at its prepare; the row stays pending — correct the list and resume again. Sentences only: the parked Skill refusal (above). Codes and `detail` are unchanged.
|
|
101
|
+
- **Pins.** `test/backlog1886-r9-closure.test.ts` (80 cells, the last two added with the entry after this one; of the first 78, 58 RED on the base `975f4c7c3`, 20 green there: 13 controls, the four known limits pinned as they stand, and three cells the base already refused — a tool-list key shallower than its block, and the run's own lists judged here). A — four reported shapes, each tool-list key spelling, a `- ` entry's key, a flush-left sequence and its flush-left entry, an explicit `? ` key, a shallower tool key (the indented-key clause), an explicit key shallower than the block and a `- ` entry's key under a quoted value; controls — nested mappings / sequences / a mapping in a sequence entry / an explicit non-tool key load, a tool list flush left after a nested block is read, a commented tool key. G — after `"…"`, `'…'`, `]`, a later word, a next-line value, a tool list's own value, a block item, a shadowed alias; the scan is linear (24 000 repeated `- ` markers); controls — a comment after the quote, the bracketed hint, `'Don't do it'`, inner quotes, a tool key inside the quote, a shadowed alias's garbage. KL — KL-1886-a, KL-1876-b, KL-1876-c, KL-1887-a, KL-1892-a as they stand. B — three shapes and two controls (a comment line after the closing quote; a `#` comment line inside a flow). C — four forms at both sites, and the byte-for-byte 7.51.x digest of a list of rules. D — `Read(*)` / `Read()` × deny / allow; `Bash(*)` / `mcp__srv(*)` / `mcp__srv__t()` deny; both lanes of an agent definition's `allowTools` (`Read(*)` admits, `Read(x)` does not) and the judgment's account. E — `*` / `*(*)` / `*()` × deny / ask, the allow control. F — eleven shapes × `judgedUpstream` true / false (a denied `*` / `*(*)` / `*()` among them), the workflow leg's read, a ceiling `{ deny: "Read" }` / `{ deny: ["Read", 5] }` refused through the real prepare (#1901: it compiled as hiding nothing, so the parent's refused `Read` reached the child), and the controls. Re-seated pins (old meaning → new): the parked-Skill refusal pins in `backlog1875-one-entry-reader`, `backlog1328-parked-skill-rescreen-swap`, `backlog1303c-park-resume-grants`, `backlog1372-skill-deny-unreadable`, `backlog1390-skill-table-value-one-read` and `backlog1348-card-grant-disclosure` ("no longer declares" → "no longer bind to the digest"). `tool-policy.test.ts` "the bare-name collapse forms" (`Read()` / `Write(*)` kept as literal names → read as `Read` / `Write`). `backlog1845-tool-list-block-sequence` F6 (a tool key under `metadata:` ignored → refused, naming both keys). `backlog1377-subagent-deny-names-one-judgment` ⑪ (two `compileAllowList` sites → one, the workflow helper reading through the one list read). `backlog1736-unknown-tool-name-lazy` (an allowed `Echo()` told "grants nothing" → admits Echo, no account; `Echo(x)` unchanged). `backlog1623-tool-selector` (own `excludeTools: ["*"]` with matching evidence → completed ⇒ refused `unsupported_pattern`; the evidence still spares the name account). `backlog1866-scanner-closure` (`deniedToolNamesOf(["Bash", "*"])` kept `*` ⇒ refused).
|
|
102
|
+
|
|
103
|
+
### A tool-list key written as a key of a flow mapping refuses the skill like one in a nested block; a name set's deny list refuses the wildcard and holds each name once (#1912 and two findings of the r10 review; P2 / P2 / P3; security; behavior narrowing — open to veto; **hard-breaking**: a skill whose frontmatter writes a tool-list key as a key of a flow mapping, on a key's line or on a line of a nested block, is refused whole; a name set whose deny list holds `*` / `*()` / `*(*)` throws `config.invalid_tool_name_set` (it denied nothing); closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
|
|
104
|
+
|
|
105
|
+
- **Before.** Reproduced on `a533ed5ce` through `createSkillsFromDirectory` and `createAllowDenyPolicy` before this entry. (#1912) `metadata: {disallowed-tools: Bash}` installed with no deny list and no warning; so did the same key in a flow inside a flow (`{a: {disallowed-tools: Bash}}`), over the flow's lines, quoted, as a pair of a flow sequence (`[disallowed-tools: Bash]`, `[{disallowed-tools: Bash}]`), as the value of `description`, and on a line of a nested block (` a: {disallowed-tools: Bash}`, ` - {disallowed-tools: Bash}`). The same key as a block (`metadata:` / ` disallowed-tools: Bash`) was already refused. (name set) `deny: ["*"]`, `["*()"]` and `["*(*)"]` constructed with no throw and no issue, denied nothing, and `nameSets` and the projection stated `*` (only the prepare-time name audit, a host-only advisory, said anything); `deny: ["Read", "Read(*)"]` stated `Read` twice.
|
|
106
|
+
- **Now (#1912 — #1886's rule, one more seat).** A tool-list key is read only as a key of the frontmatter, written flush left; written anywhere else its list is lost and the skill is refused `no_frontmatter`, naming both keys in the sentence the nested block's key gets. The seat added: a key of a flow mapping, or of a pair in a flow sequence, that another key's value holds — a bare or quoted key followed by YAML's mapping mark, at any depth, on the key's line, over the flow's lines, or on a line of a nested block (after its key or its `- `). The scanner reports it once (`flowKey`) and the nested block's line reader asks the scanner about the flow its line writes: a flow that closes on its line is read from it alone, one that does not is read over the lines it runs (as deep as the block, or opening with an indented closing bracket; a flush-left line is the frontmatter's own and is judged there) through a window whose size doubles until the flow closes in it, so the cost is the flow's own text, and the lines it ran over are still read at their heads as before — a key on a continuation line, JSON-style adjacent marks (`"disallowed-tools":["Bash"]`) included, is found. A string that spells a tool-list key and is no key stays a value: a flow sequence's item (`[disallowed-tools]`, quoted or not), a flow mapping's value (`{note: disallowed-tools}`, `{note: "disallowed-tools: Bash"}`), a key with no pair (`{disallowed-tools}`: YAML's null, nothing lost); a comment spells nothing.
|
|
107
|
+
- **Now (name set).** The lone wildcard in a name set's deny list — `*`, `*()`, `*(*)`, every spelling the rule syntax reads as `*` — is refused like any entry that names no tool: `config.invalid_tool_name_set` at construction (under `onInvalidName: "skip"`, dropped and reported through `onInvalidNameIssue`), `` the wildcard — `*` names no tool, and a deny entry reaches the tool it names, so this one would be kept and never match (a deny that reads protective and denies nothing). Name the tools to deny; to leave a run no tools, write an empty allow list. `` In the allow list it stays as it was: one entry no tool carries, the list admits what else it names and nothing for it (`allow: ["*"]` admits nothing, `["Read", "*"]` admits `Read`). A name set holds each name once, in the order of its first spelling: `["Read", "Read(*)"]` is `["Read"]` in `nameSets` and in the projection; the decisions are those of the repeated list.
|
|
108
|
+
- **The two lanes of an agent definition's `allowTools` (documentation corrected).** The entry before this one said the Agent tool's selector and the workflow agentType's name set "agree". They read `Tool()` / `Tool(*)` alike. They differ where they did before: `Tool(x)` admits nothing and is told on the selector but refuses the leg's launch in the name set; a lone `*` admits every tool on the selector (a documented all-admit sentinel) and is one name no tool carries in the name set, so `allowTools: ["*"]` leaves a workflow leg no tool — one fact read two ways, not changed here. A workflow agentType's denied `*` is refused before the name set sees it.
|
|
109
|
+
- **Against Claude Code.** The reference reads a skill's frontmatter with a YAML parser and takes the tool lists from the top-level keys, so a `disallowed-tools` that is a key of a `metadata` flow mapping belongs to `metadata` and the skill loads with no deny list there (inferred from YAML's grammar and from the reader taking top-level keys; not run against the reference). Refusing the shape is the same divergence as the nested block (stricter, open to veto). A name set has no counterpart in the reference.
|
|
110
|
+
- **Who breaks.** A stored skill whose frontmatter writes a tool-list key as a key of a flow mapping (`metadata: {disallowed-tools: Bash}`, or a flow on a nested block's line) is refused whole, naming both keys: it installed with that list lost. Move the key flush left, or quote the text. A name set built with `deny: ["*"]`, `["*()"]` or `["*(*)"]` now throws (or, under `"skip"`, is dropped and reported): name the tools, or write an empty `allow` list to leave a run none. A name set that repeated a name states it once in `nameSets` and the projection, so a consumer comparing the list it passed with the one stated sees the shorter list; a frozen projection carrying a repeat resumes alike (the replay is a membership test). Sentences only: the `names no tool` clause.
|
|
111
|
+
- **Pins.** `test/backlog1912-flow-tool-key.test.ts` (46 cells; 26 RED on the base `a533ed5ce`, 20 green there: the controls, a tool list's own flow value, the allow side, the lanes, the decisions of the repeated list, the known limits as they stand). A — the reported shape and its sentence equal to the block form's; a flow in a flow; each tool-list key spelling, bare, quoted, with a flow value, with no value, first and after another pair; over lines; a flow sequence's pair and a mapping in a sequence; `description`'s value; five shapes on a nested block's line, more on one whose flow runs over lines (JSON-style adjacent marks, a key after a comma, the continuation as deep as the block, a long opening line, thousands of blank or comment lines), the lines a flow ran over still judged at their heads (`- ` entry, explicit `? ` key and complex key), a flush-left closing bracket judged as before; controls — strings and values that spell a key, a key with no pair, comments, flows without a tool-list key, a tool list flush left beside a flow. B — three spellings, one lesson, the skip path, the allow side as it stands, both lists at once, the content-form lesson kept. C — dedupe with order, protocol spellings, the decisions. L — the two lanes' `*` and `Read(*)`. KL — KL-1892-a's continuation form and KL-1912-a as they stand. Mutations, each run against the cells: the flow-key judgment removed at the key line (12 RED), at the nested line (2), the plain-key arm (11), the quoted-key arm (3), the nested flow read over one line only (1), the lines it ran over not read at their heads (1), a flush-left closing bracket taken into the flow (1), the recheck without the one-line flow (1); the deny-wildcard refusal removed (6), widened to the allow list (3), the dedupe removed (2); the nested flow's window removed (the staircase cell below, RED; 6 s at 500 openers). Two cells added to `backlog1886-r9-closure` (a nested block of 6 000 flow lines, and a staircase of 600 nested flow openers each closing on the line below, load in well under a second and the key at their end is found); `flaky-guard` registers that file's wall-clock ceilings in `BUDGET_ASSERT_ALLOWLIST` (the stage's full run had it red: the 64 KB trailing scan's `< 3 000 ms` ceiling, ≥ 100× margin, was undeclared).
|
|
112
|
+
- **Known limits.** KL-1912-a: shapes the two rules do not reach load with the list lost — a key led by an anchor or a tag in a nested block or in a flow, text after a nested line's value or after a flow's plain item, an explicit key's value line, a flow's explicit key — a block scalar's body line, or a quoted value's continuation line, in a nested block that opens a flow writing a tool-list key is refused though it is text (KL-1886-a's over-refusal, widened by the flow rule), and the refusal sentence "YAML reads no key there" is not true of an anchored mapping. Three more shapes load with the list lost (found by the r10c review): a tool-list key in a flow mapping followed by a comment, its `:` on the next line (`metadata: {disallowed-tools # note` / ` : Bash}`); an anchor or a tag on the flow itself (`metadata: &m {disallowed-tools: Bash}`, `metadata: !!map {disallowed-tools: Bash}`, and the same on a nested block's line); a tool-list key written on the line where a nested block's multi-line flow closes, after the closing bracket (the same shape flush left is refused). KL-1892-a gains its continuation form (`argument-hint: env` / ` - disallowed-tools: Bash`). The YAML parser (#1863 B) closes them; the reader gets no further rule.
|
|
113
|
+
|
|
114
|
+
### A tool name a checkpoint row records is the bare name the rule syntax reads, and a row is read back in that spelling only: a parked child's ceiling and a frozen policy chain are no longer widened, or narrowed, by this engine's reading of an entry an earlier engine recorded; an allowed list's `*()` / `*(*)` is the wildcard (#1910, #1908; P1; security; behavior narrowing, and a widening for `*()` / `*(*)` on an allowed list — open to veto; **hard-breaking**: a row parked by 7.51.x whose ceiling or frozen chain holds an entry not in the bare spelling (`Tool()`, `Tool(*)`, `Tool(content)`) no longer resumes; a restored ceiling's refusals move before the claim (`checkpoint.config_refused`, `detail.code` prepare's own `config.tool_face_control`, `detail.reason` its arm); an allowed `*()` / `*(*)` lifts the allowlist; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
|
|
115
|
+
|
|
116
|
+
- **Before.** (#1910) A parked child's row recorded the ceiling it was spawned under as the spawning run's lists were written, and the resumed leg read the row with this engine's reader. A run that parked under 7.51.x with `allowTools: ["Agent", "Read", "Bash(*)"]` could delegate but not use Bash (7.51.x read `Bash(*)` on an allowed list as a name no tool has). Its child's row kept `Bash(*)`, and the same row resumed on the 7.52.0 stage read it as `Bash`: the child was offered Bash, the row's lists unchanged, nothing said (`Bash()` alike). (#1908) A frozen policy chain (`inheritedGate.constraintChain`) was replayed by matching each name as recorded. A chain holding `tool_deny: ["Read(*)"]` denied nothing, and `tool_allowlist: ["Read(*)", …]` removed `Read` — while a name set built today reads `Read(*)` as `Read`. A 7.51.x name-set row re-supplied on this engine was refused by the digest sentence, which named no entry. An allowed list's wildcard was the literal `*` only: `allowTools: ["*(*)"]` admitted nothing (no `Agent`, so nothing delegated). A ceiling layer that was not an object (`null`) failed the leg with a bare `TypeError` and no code.
|
|
117
|
+
- **Now (one rule).** The engine records a persisted tool-name list in its canonical spelling, through the one per-entry reading every list reader takes (`toolNameOfEntry`): `Tool()` / `Tool(*)` → `Tool`; a denied `Tool(content)` → `Tool` (it hid the whole tool); an allowed entry that admits nothing (`Tool(content)`) is left out. So the row reaches exactly what the list reached. The ceiling a parked child's row stamps (`CheckpointState.toolCeiling`) is written so (`allowTools: ["Agent", "Bash(*)"]` → `["Agent", "Bash"]`; `["*(*)"]` → `["*"]`). A name set (`createAllowDenyPolicy`) already records its frozen projection's names so. A row is read back in that spelling only — one predicate, `isCanonicalToolName` — before the claim, by one read-back (`persistedToolNamesRefusal`, the resume screen): the ceiling the leg will run under when it is the row's (no live seat hands one), each layer through the one layer reader, and the frozen chain (a `tool_deny` / `tool_allowlist`'s `names`, a `shell_command`'s `tools`). Any other entry refuses the resume: `checkpoint.config_refused` (`detail.code` `config.tool_face_control`, `detail.field` `inheritedGate`, `detail.reason` `unsupported_pattern` / `unknown_name` / `not_a_list` / `not_a_string`), the row pending whether or not the store has `reopen` (`the lists of the run this leg was spawned under declares "Bash(*)" in its allowed tools: a row records each tool name in its bare spelling … — as this leg's checkpoint row recorded it; refused before the claim …` / `the checkpoint's frozen inherited constraint chain holds "Read(*)" in layer 1's \`tool_deny\` names: …`). A member that is not a string is named by its type only. Neither reading is applied to such an entry — not the one of the engine that wrote it, and not this one's. The shapes #1875 refused on a restored ceiling at prepare (an entry the syntax cannot read, a member that is not a string, a list that is not one) are refused there too, now before the claim. An allowed list's wildcard is any entry the one reading reads as `*`: `*`, `*()`, `*(*)`. A ceiling layer that is not an object (`null`, a list, a string) is refused by the same layer reader: before the claim on a restored ceiling, and at prepare on a live seat (`config.tool_face_control`, `detail.reason: "not_a_list"`, `… are a value that is null, not a pair of tool-name lists …`). The compiled selector reads it as admitting nothing.
|
|
118
|
+
- **Against the reference.** Not compared: a row's ceiling and frozen chain belong to this engine's durable park, and no reference coordinate was read for them.
|
|
119
|
+
- **Who breaks.** @server (the store of parked rows, and a reader of the refusal code): a row parked by 7.51.x whose `toolCeiling` holds `Tool()` / `Tool(*)` / `Tool(content)`, on either list, no longer resumes: refused before the claim, `checkpoint.config_refused` (`detail.code` `config.tool_face_control`), the row pending. The denied side was read alike by both engines (`Read(*)` hid `Read`), so a row whose only such entry is denied is refused although nothing would widen: one rule, no reading of the writer's engine (open to veto). Finish such rows on a 7.51.x worker, or expire them and run the work again. The restored-ceiling refusals #1875 added (an unreadable entry, a member that is not a string, a list that is not one) move from prepare's `config.tool_face_control` — after the claim, the row back to pending only on a store with `reopen` — to the resume's one refusal channel before the claim — `checkpoint.config_refused`, `detail.code` `config.tool_face_control` (prepare's own code: a fresh run and a resume report one code, and `detail.reason` names the arm), the row always pending; the sentence is the same with the row's suffix (a host branching on the code: open to veto). A row whose frozen chain holds such a name is refused the same way (a 7.51.x name-set row already was, by the digest sentence; the sentence now names the entry). So is a row parked on this engine under a policy whose own projection writes its lists as written: a host-authored `projection`, or `createApprovalPolicy` with `deny` / `requireApproval` / `autoAllow` holding `Read(*)` or a content form. Those readers take entries literally, a second reading tracked as #1911. Name the tool itself there. An allowed `*()` / `*(*)` (a run's `allowTools`, a definition's) now lifts that layer's allowlist (it admitted nothing: a widening to what the author wrote, open to veto). A host that hands `RunInternals.toolCeiling` with a layer that is not an object gets the coded refusal instead of a `TypeError`. A row parked by 7.51.x under a name set that repeated an entry (`createAllowDenyPolicy({ deny: ["Read", "Read"] })`, or a repeat on the allow side) froze a projection holding both; this engine's name set holds each name once (#1912), so the parent chain the resume re-supplies digests differently from the recorded one and the resume is refused before the claim (`resume.parent_constraint_mismatch`, the row pending) — deny the row and approve the work again; the same row with the name written once resumes (open to veto; KL-1910-a ③). Every other prepare-time refusal of a resumed leg still lands after the claim and relies on `reopen` (#1914).
|
|
120
|
+
- **Pins.** `test/backlog1910-persisted-selector-canonical.test.ts` (run here with the re-seated pins: 3 files, 88 cells, exit 0). The one reading (twelve entries × allowed / denied / canonical). W — `persistedToolNameList` in → out; `*()` / `*(*)` wildcard. The read-back of a restored ceiling (`Bash(*)`, `Bash()`, `Read(x)`, `*(*)`, deny `Read(*)`; a deeper layer by its distance; all bare; absent) and the #1875 shapes there (unreadable, a non-string member by type, a list that is not one, a ceiling that is no list, layers that are `null` / a list / a string). A live layer that is `null` / a list / a string, at the judgment and through the real prepare. U — real park / resume, each counting the store's claims: U1 / U2 refused before the claim, Bash never offered, row pending, no claim (base `64341caba`: completed, Bash offered; `080b0d641`: `resume.tool_unavailable`); U4 deny `Read(*)` refused (base: completed); U5 / U6 / U7 on a store with no `reopen` — `Bash(*)`, an unreadable `Read<TAB>(*)`, a null layer — refused with no claim, the row pending (base: U5 completed; U6 failed at prepare with the row resolved — consumed; U7 a bare `TypeError` after the claim); U8 a resume handing `toolCeiling: null` — no live ceiling, as prepare reads it — has the row's ceiling read back and refused (base: completed, Bash offered); U3 control. W1–W3 — this engine's rows (`Bash(*)` → `Bash`; `Read(x)` left out; `*(*)` → `*`, the child parks). P1–P3 — a 7.51.x name-set row, a `tool_deny` / `tool_allowlist` projection, a `shell_command`'s tools, a non-string member (base: P1 refused by the digest sentence, P2 / P3 completed); P4 / P5 controls. Per-cell mutations (tsx lane probe, `.gate-logs/car-1910/`): the screen's ceiling read-back off ⇒ U1 / U2 / U4 / U5 completed, U6 consumed, U7 after the claim; the spelling rule off (shapes still read) ⇒ U1 / U2 / U4 / U5 completed, U6 / U7 still refused before the claim; the write canonicalization off ⇒ W1–W3 red (the row raw, and this engine refuses its own row); the chain read-back off ⇒ P1 (sentence) / P2 / P3 / P6 / P8 red; the literal-`*` wildcard back ⇒ W3 red (no park); the layer refusal off ⇒ the layer cells red (a bare `TypeError`, or a list / a string passing); the compile guard off ⇒ the selector throws; the screen reading only `undefined` as "no live ceiling" ⇒ U8 completed with Bash offered. Re-seated pins: `backlog1623-tool-selector` ⑦ (#1875 `Read<TAB>(*)` on a restored ceiling: `failed` / `config.tool_face_control` at prepare ⇒ refused before the claim, `checkpoint.config_refused` (`detail.code` `config.tool_face_control`), same sentence, row pending); `module-size-ratchet` (`resume-checkpoint-screen.ts` 393 → 396, `prepare-suspend-saga.ts` 876 → 877).
|
|
121
|
+
|
|
122
|
+
### A stop verb is answered until `done`, and it cuts whichever compaction pass is in flight — one caller-stop gate for `halt` / `interrupt` / `destroy` / the task's `signal` (#1828, #1838; P1; behavior change — the stop verbs' window widened, `compaction_outcome{mooted}` widened beyond `manual`, open to veto; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
|
|
123
|
+
|
|
124
|
+
- **Before (7.51.x, and the 7.52.0 stage after #1815).** A stop that landed while a compaction pass was in flight did not reach it. `interrupt()` and `halt()` were refused `steering.not_running` ("the task is no longer running") from the loop's end on, while `done` was still unsent, and the end-of-task pass ran out its summary call's retry schedule (two retry-after waits on a 503 gateway ≈ 10 s, every summary request still sent); `destroy()` was no stop fact at all and waited the same schedule out; a `halt()` during a turn-boundary pass (a manual `/compact` in flight) latched the stop and left the pass running — the session stayed locked behind it. Only the task's `signal` cut the end-of-task pass. A pass the leg's own abort did cut said nothing on the wire at a turn boundary, and a prompt-too-long recovery pass cut by any stop was reported `compaction_outcome{failed}` + `onError(phase:"compaction")` and fed the compaction breaker. The end-of-task skip read three separate stop facts (#1815).
|
|
125
|
+
- **Now.** One rule: a run has ONE caller-stop gate — minted beside the loop latch (`run-identity-wiring.ts`) and published on the live handle; `halt()`, `interrupt()` and `destroy()` trip it, and the task's own `signal` is composed into it. Every compaction pass reads it the same way — the end-of-task pass, the turn-boundary pass, the prompt-too-long recovery / guard pass: a tripped gate admits no pass, and a pass in flight carries the gate in its abort signal and as its withdrawal signal, so its summary call, its commit boundary, its preCompact / postCompact seats and its `RunnerDeps.summaryProvider` consult (the one await of the pass that read no signal: a provider's late answer is now dropped, never persisted; a provider that throws still falls back to the summary call) are cut and the cut is reported as the caller's cancel — `compaction.mooted{reason:"cancelled"}` on the trace, `compaction_outcome{outcome:"mooted", reason:"cancelled"}` on the wire with the pass's own `trigger` — never `failed`, never `onError`, never breaker evidence. `done` follows at once; `destroy()` no longer waits on a pass. The end-of-task skip reads the gate; the three #1815 facts are retired. The stop verbs' window closes on the published result (the station just before `done`) instead of the loop's end: an `interrupt()` / `halt()` after the loop's end is answered (`halt` → `{turnCut:false}` + `task.halt_unconsumed`), stops the tail, and changes nothing else — the loop's ending, its result and its attribution stand (a stop after the loop's end claims no `userInterrupted` / `haltedByUser`; an interrupt's claim is judged before any of its aborts runs a listener, and every verb trips the gate after its own abort of the run, so a verb a listener calls in reaction neither takes a claim away nor makes one — a `destroy()` is never signed as a person's interrupt). `task.halt_unconsumed`'s message now reads "it cut no turn and stopped no boundary" (was "nothing was cut or stopped by it"): a halt after the loop's end still cuts the tail's compaction pass, which reports itself. `steer` and `reconnectMcpServer` still close at the loop's end. Stop and attribution stay two facts: an engine stop (a limit, an exhausted output retry, a gate or guardrail) never trips the gate, so an engine-ended run keeps its between-task pass.
|
|
126
|
+
- **Who breaks.** Behavior widening on the wire (open to veto), same wording as the `TaskEvent` doc: `"mooted"` — the pass did not run to its end: a manual request no boundary could serve (`trigger:"manual"`, `reason:"task_ending"`), or a request withdrawn / a pass cut in flight by its caller — a `compact({ signal })` withdrawal, `halt()`, `interrupt()`, `destroy()` or the task's own `signal` (`reason:"cancelled"`), on any trigger: `"auto"`, `"manual"` or `"forced"`. Read `"mooted"` with its `trigger` — it is not a manual-only outcome. server / cli / client-core: a reader that takes `outcome === "mooted"` to mean "a manual `/compact` was mooted" (a manual-compact spinner, a counter) must also check `trigger === "manual"`. A halted run no longer runs a compaction pass at its halted boundary (a parked manual `/compact` is answered `mooted` / `task_ending`); the session's next run compacts at its own boundary. A recovery pass cut by a stop is no longer a compaction failure (no `onError`, no breaker count). The same holds (#1838) for a recovery or guard pass cut by a TURN interjection (a `steer` with `priority:"now"` landing while the pass runs, the leg itself still running): it was `compaction_outcome{failed}` with the reason "… cut short by a turn interrupt" (no `onError`, no breaker) and is now `compaction_outcome{mooted, trigger:"forced", reason:"cancelled"}` plus the `compaction.mooted{cancelled}` trace, like every other caller cut; a reader that matched that sentence reads `outcome` / `reason` instead (no such reader in the cli / server trees' `src` and tests at their local `origin/main` — inferred, not re-fetched). The engine's own stop (a limit) cutting the same pass still reads `failed` (KL-1828-b). `interrupt()` / `halt()` no longer reject in the teardown window: a host that read that rejection as "the run is over" now gets a resolved verb and `done` right after it. cli: Esc during a manual `/compact` in flight releases the session at once. server: a cancel through any verb (or the run's `signal`) reaches `done` without waiting on a summary call. Stated once, for both verbs (pinned per verb): once the result is published `halt()` / `interrupt()` are refused `steering.not_running` — the code `steer` answers, "the task has already finished" — and sign nothing; until then they are answered, and in the window after the loop's end `halt()` says so in `task.halt_unconsumed` while `interrupt()` leaves no notice of its own (KL-1828-c).
|
|
127
|
+
- **Pins.** `test/backlog1828-stop-during-pass.test.ts`: four verbs × three passes (the verb is answered, `done` within 2.5 s of the stop, `destroy()` itself within 2.5 s, no summary request after the stop, `compaction_outcome{mooted, cancelled}` with the pass's trigger, no `failed` / `onError` / `compaction.failed`; the end-of-task cells keep `completed` + the answer and sign no halt), controls (a boundary manual request and a recovery pass each land without a stop; an unstopped failing pass still reports `failed`, no `mooted`), a parked `summaryProvider` × the four verbs (no summary request, the late answer never lands, mooted / cancelled) with a reuse control, counterexamples (an engine-ended run keeps its pass and an interrupt during it still cuts it, the engine's terminal standing; a halt BEFORE the boundary pass is admitted by no pass — mooted / `task_ending`, no handler call; an interrupt keeps its marker when a listener on the cut summary call aborts the task's own signal, with its control; a destroy whose cut summary call's listener calls `interrupt()` writes no marker; after `done` the stop verbs refuse again; a halt cuts a pass parked on a preCompact handler), and per stop verb a refusal after the result (code, message, no notice, no marker) and an answer in the window with nothing left to cut; the recovery lane: a turn interjection landing mid-pass reads mooted / cancelled on the recovery pass and on the guard pass, while a real summarizer failure and an engine stop (the walltime limit) still read `failed` + `onError`. `test/ptl-lane-twins-474.test.ts` re-pinned to the same reading. `test/design504-user-halt.test.ts` T1: the halt's first attempt cuts the running turn on the call's own tick. `test/steer-interrupt-lifecycle-389.test.ts` HIGH-1 re-pinned: the late interrupt (after the loop's end, before `done`) is answered; still no person marker. Known limits, each pinned in the 1828 file's "weakest three": W1 a `Brain` that ignores the summary call's abort cannot be cut — `done` waits for that call, nothing it returns lands (KL-1828-d); W2 after the loop's end a halt leaves `task.halt_unconsumed`, an interrupt no notice (KL-1828-c); W3 a stopped run compacts nothing, so the session's next run sends its first request uncompacted (KL-1828-e). Also KL-1828-a (the window closes at the published result, a step before `done`) and KL-1828-b (an engine stop that cuts a recovery pass still reads as a compaction failure). `test/backlog1815-cancel-owes-no-compaction.test.ts` A.destroy added. `test/backlog677-cancel-window.test.ts` G4.b re-pinned: an interrupt from inside the end-of-task preCompact hook is answered and the pass is mooted (the old pin read the loop's end as the run's end). `test/refactor-a2-compaction-call-options.test.ts`: the lane discriminators read `force` (every lane carries a `signal` now).
|
|
128
|
+
|
|
129
|
+
### A call the engine's own judgment refused and never let go carries `notExecuted: true` — on its `tool_end` and on its persisted `toolResult` row, from one reading (#1831; P2; wire truth, additive; closed sets: none; exports: none; members: none — `tool_end.notExecuted` already exists, `details.notExecuted` is a new key in an untyped bag) (@server @client-core @web-admin @cli @test)
|
|
130
|
+
|
|
131
|
+
- **Before.** `tool_end.notExecuted` rode only on the closing station's own mark (the loop's, for a sibling a halted batch settled or a call a turn interrupt reached first; the reconcile's never-started class). A call the GATE refused — the host's SLA sweep deciding a durable card nobody answered (`gate.settlement.kind === "park_sla_expired"`), a person's refusal, a policy / hook / plan-mode deny, a post-approval veto before the run — closed `isError: true` with a `denied` gate record and no `notExecuted`, so the three renderers drew "Failed to run" for a tool that never started. The persisted `toolResult` row of such a call carried no such fact either, so a reload (`sessions.get`) lost what the live frame could have said.
|
|
132
|
+
- **Now.** One reading over the call's occurrence (`src/core/runner/tool-not-executed.ts`, `refusedBeforeRelease`; the occurrence record is `tool-occurrence.ts`): the latest gate record is a refusal, the call's life never left `announced`, and its start went out on this stream. The occurrence's life now says what the code always meant by "the engine let the call go": `announced → released → settled | interrupted` (the old `executing` spelling was true on the resumed leg only — a live leg never saw the loop start the body, so a live call stayed `announced` whether it ran or not); the ONE transition into `released` (`release()`) is made by the resumed leg right before it runs the tool body itself, and by a live leg at the gate's allow exit. `emitSettled` (the one mint of `tool_end`) ORs the closing station's mark with that predicate and returns the result; the resumed leg's `toolResult` rows (every refusal arm of the resolver) repeat the return as `details: { notExecuted: true }`; on the live leg the end handler (`run-harness-handlers.ts`) writes the same return onto the engine-minted result the loop persists as the row right after the end event, beside whatever the refusing station put in `details` (the shell read station's `read_path_denied` card keeps every member on the row, and the frame's card is projected before the write, so a row mark is never part of a card). Because the write reads the return and not a refusal, every live row whose frame says `notExecuted` carries it — a halted sibling's and a never-started interrupted call's included — and a refusal that also aborts the run (the headless classifier's denial limit) loses nothing. The row the interrupted-run reconcile appends for a never-started call carries it too (`closeNeverExecuted`, `session-reconcile.ts`: the one class→mark table the synthetic `tool_end` and the appended row both read). `details.notExecuted` on a persisted row is the engine's key alone: a tool's own value under that name is dropped from the row (live and resumed), the tool's other members stay. A refusal recorded AFTER the release — the shell read admission refusing inside the tool, live or resumed — is still `denied` by `read_boundary` with `isError`, and carries NO mark. The recovered-start exception: an occurrence whose start this stream never carried is closed by a station that knows only its own class.
|
|
133
|
+
- **Who breaks.** Nothing that worked: the member is additive and optional (the type already had it; the semantic widens from the station's marks to the engine's refusals). A consumer that rendered `isError && !notExecuted` as "Failed to run" now renders a refused call as "not executed" (web-client / cli / desktop — the intended change); a consumer that read `notExecuted` as "the loop skipped it" alone now also sees gate refusals; PostToolBatch is untouched (it keys on the blocked set and the loop's own mark). A reload that reads session rows sees `details.notExecuted === true` on such a row (a refusal row's `details` was `{}` or the refusing station's card before). A tool that wrote `details.notExecuted` itself no longer finds it on its persisted row (it was never lifted onto the frame). **Not covered, by design:** a call the loop itself closed without a gate record (an unknown tool, a refused input, an abort after the release) — absence is never "it ran", and a later window may widen the reading with its own pin. server: any adapter that copies `tool_end` keys by name should carry `notExecuted` (it was already in the type).
|
|
134
|
+
- **Pins.** `test/backlog1831-not-executed.test.ts` (frame AND row on every refusal cell): the host's SLA sweep, a person's durable refusal, the approved call's re-screen veto (resumed); a policy deny, a live person's refusal, a PreToolUse deny, plan mode, a shell read station deny whose card keeps its members (live); controls with no mark: a call that ran (live, resumed), the tool's own read admission refusing after the release (live, resumed), a tool writing `notExecuted` into its own `details` (success and failure, live and resumed — never on the frame, never on the row, its other members kept), the gate allowed and a hook rewrote the input to schema-invalid, an unknown tool, the next process's replay of a dead process's call (frame and row); a run aborted mid-batch (the never-reached siblings' frames and the appended rows marked, the executing call's not); the OR (a released call the loop closes unrun keeps the loop's mark; a halted sibling, frame and row), the denial-limit stop (frame and row agree on every refused call, the stopping one included), the recovered-start exception, and the predicate over a hand-built occurrence. Mutations: `.gate-logs/criteria-1831-mutations.md`.
|
|
135
|
+
- **Known limits.** KL-1831-a: the live release is the gate's allow exit, a hand-off and not proof the body started — a released call the loop then closes unrun (an abort between the gate and the body, an input the loop refuses after a hook rewrote it) carries no mark. KL-1831-b: the row key is the engine's against a tool's plain `details` member, not against a `details` that carries its own serializer (`toJSON`), which decides what a JSON-writing store persists under any key.
|
|
136
|
+
|
|
137
|
+
### A stopped run owes no end-of-task compaction, and a stop that lands during the pass cuts it — a terminal frame never waits on a summary call (#1815; P1; behavior change; closed sets: none; exports: none; members: none) (@server @cli @test)
|
|
138
|
+
|
|
139
|
+
- **Before (7.49.x – 7.51.2).** At a run's end the engine ran one more compaction pass when the session was past the trigger — also for a run that had been stopped before it gave a settled answer (an `interrupt()`, the task's `signal`, a limit, a bare `halt()`; in a retry backoff or before the first token alike). The pass was handed no cancel signal, so on a degraded gateway (503 / overloaded) its summary call went out after the stop and was re-sent on the client's retry schedule; `done` was not sent, the run stayed `running` and the session stayed locked. On a healthy gateway a stopped run still paid one full summary call after the stop.
|
|
140
|
+
- **Now.** One reading: a stopped run owes no end-of-task pass. `skipCompaction` (`run-terminal-adoption.ts`) now also stands down for a stop its CALLER asked for, whether or not an answer had settled — the interrupt verb, the task's own `signal`, the bare halt (`haltConsumed`; the halt ends the loop cleanly and leaves the leg's controller alone) — the turn boundary's own reading ("a dying loop won't compact"). The caller's channels are a closed set; an abort the engine raised on work (a limit, an exhausted structured-output retry, a gate or guardrail stop) cannot be told from them by the abort flag and keeps the documented between-task pass, unchanged; so does the existing yield for any abort that landed on a settled answer's tail. The session of a skipped run compacts at the next run's own boundary. A stop that lands while the pass is IN FLIGHT (the run ended on its own, then the caller's `TaskSpec.signal` fires during the summary call or a `preCompact` handler) now reaches it: `maybeCompact` and the hook seats take the caller's signal — never the leg's own controller, which is aborted on every exit — and the cut is a `compaction.mooted{reason:"cancelled"}` trace frame, not `compaction.failed` / `compaction_outcome{failed}` / `onError` — judged on the abort outcome's own marker (the one the turn boundary reads), so a storage failure that merely races a late cancel keeps the failure account. A pass that fails with no stop is unchanged (`compaction_outcome{failed}`, `compaction.failed`, `onError`, then `done`). The `finish()` option `cancelled` is gone (it could only be true on a pass that no longer runs).
|
|
141
|
+
- **Who breaks.** Nothing that worked. server: a run stopped under a degraded gateway now reaches `done` (the row terminal, the lock released) — S-1621 unblocks. A stopped run's history is no longer compacted at its tail: the next run's first boundary does it, so a stopped run over the trigger carries its history one run longer; the preCompact / postCompact handlers do not fire for that tail pass. Known limits (confirmed by probes in the review rounds): the three caller-stop facts this section introduced (the interrupt verb's first-abort claim, the task's `signal`, a consumed halt) left out `stream.destroy()` and an `interrupt()` landing after an engine abort, and `halt()` / `interrupt()` were refused once the loop had ended — all three are closed in this release by #1828 (one caller-stop gate, the section above), which retires the three facts; the awaits after the pass (the session touch, MCP / A2A disposal, the usage ledger's final charge) do not listen to a cancel; and the cancel-versus-failure split reads the same origin marker the turn boundary reads (set where the summary call's abort outcome is formed), so a custom `Brain` that rejects with the signal's reason, or a transport failure that a late cancel overtakes, is classified on that marker alone.
|
|
142
|
+
- **Pins.** `test/backlog1815-cancel-owes-no-compaction.test.ts` A (halt / interrupt / signal / before the first token / healthy gateway: `done` within 2.5 s, zero summary requests on the wire), B (an unstopped pass still runs; a failing pass still reports and `done` still comes; the next run on the same session compacts), C (the caller's signal during the summary call; during a parked `preCompact` handler); `test/backlog677-cancel-window.test.ts` G3.b re-pinned (a stop on work: no pass, no seat).
|
|
143
|
+
|
|
144
|
+
### An MCP server declares its own startup bound — `McpServerSpec.startupTimeoutMs`; env `MCP_TIMEOUT` is the default seat, not the only one (#1552; knob; additive for a task — and the no-run probe loses its own default verdict clock, a loosening, see Who breaks; hard-breaking: none; closed sets: none; exports: none; members: `McpServerSpec.startupTimeoutMs?` +1 optional; `McpConnectionPool.keyOf` +1 required parameter, a type-only mechanical BREAKING) (@sdk @client-core @dgx)
|
|
145
|
+
|
|
146
|
+
- **Before.** The startup phase of an MCP dial — the handshake, then the initial tool listing — had ONE bound for every server in the process: env `MCP_TIMEOUT`, else the SDK's 60 000 ms. The dials run side by side, so one upstream that hangs held the whole task's preparation for the full wait, and the only remedy was to shorten the bound for every server at once.
|
|
147
|
+
- **Now.** `spec.mcp[i].startupTimeoutMs` bounds THAT server's startup alone. One reader (`mcpStartupTimeoutMs(server)`, `src/core/mcp.ts`), one order: the declaration, else env `MCP_TIMEOUT`, else the SDK's 60 000 ms (the built-in default is unchanged). Both startup requests carry it (the handshake; the initial listing as one walk, however many pages). The declaration is whole milliseconds in `[1 000, 2 147 483 647]` — the range every MCP millisecond bound keeps; any other value (a string, a fraction, zero, a negative, NaN, `null`, a number outside the range) refuses THAT server at its dial: `status: "failed"`, `errorCode: "invalid_config"`, the warning's `cause.code` = `config.mcp_startup_timeout_invalid`, never spawned, its siblings mount; never clamped and never read as the default. Env stays the lenient process-level seat (clamp and announce); a malformed env value is announced even under a declaration that shadows it. Every seat that needs the bound asks that one reader, per server: the dial (and every re-dial) and the connection pool's key. `probeMcpServers` has no clock of its own by default any more: it dials through the entrance a task does, so each startup phase carries that server's bound from the same reader and a server that meets both phase bounds is `connected` there exactly as in a task; a declaration the reader refuses is the dial's to refuse, so the probe returns that server's `invalid_config` row and does not reject. An explicit `timeoutMs` is the caller's overall ceiling and lives beside the per-phase bounds — whichever arrives first decides.
|
|
148
|
+
- **Wait time, stated.** A hung server is cut at its bound; a stdio child that does not exit when its input closes then adds the transport's close ladder (≤ 2 s to SIGTERM, ≤ 2 s more to SIGKILL) before preparation proceeds. Measured here with a 2 s bound (`S1`, `S1c`): preparation settled in about 2.1 s for a child that exits on its closed input and about 2.4 s for a tsx-run child that ignores it.
|
|
149
|
+
- **Who breaks.** Nothing in a task: absent = today's reading, and `MCP_TIMEOUT` keeps its meaning for every server that declares no bound. **One behavior change, a loosening, in the no-run probe:** a `probeMcpServers` call that sets no `timeoutMs` used to cut each server's WHOLE dial at env `MCP_TIMEOUT` when it was set (no clock of its own when it was not); it now has no default clock and each startup phase carries the bound. A server whose handshake and listing each meet the bound reads `connected` where it read `failed` / `timeout`; the worst-case wait for a server that keeps its handshake just under the bound and then hangs on the listing is the two phases (plus the close ladder), where the whole-dial clock cut it at one bound. A caller that wants one overall ceiling passes `timeoutMs`. Type consumers: `McpServerSpec` gains one optional member — sdk / client-core type mirrors follow, and any table keyed `Record<keyof McpServerSpec, …>` needs a row (this repo's `MCP_SERVER_SPEC_LIVENESS` did). A host that assembles the declaration from JSON must pass a number: a numeric string is refused. **Mechanical BREAKING, type only:** `McpConnectionPool.keyOf(spec, principal, kind, startupMs)` — the static now takes the bound the dial runs under as a required fourth parameter (pass `undefined` for none: the key is then the one it always was); a TypeScript caller outside the engine fails to compile until it passes it, a JavaScript caller keeps today's key. No caller was found in sema-server or sema-cli `src` (grep). dgx asked for this (the one-service-hangs-every-turn case); a deployment that wants a short bound on a flaky upstream sets it on that server's entry and leaves the process default alone.
|
|
150
|
+
- **Two seats the review rounds found reading the bound wrongly, closed here.** ① The probe kept a verdict clock of its own: one bound, read from env alone, raced against the WHOLE dial, while a task grants the bound to each startup phase. With `MCP_TIMEOUT=1000` and a server declaring 10 000 whose handshake lands at 1.5 s, a task connected and the default probe reported `failed` / `timeout`; with 650 ms per phase under a bound of 1 000 (two phases, 1.4 s in all) the same. The clock is deleted — the probe asks the dial's per-phase reader and nothing else (`S7b`, `S7c`). ② The pool key left the bound out, so a mount joining a pooled dial still in flight waited on it under the dialling mount's bound (a 1 s mount rode a 20 s dial). The bound in force is now part of the key (`McpConnectionPool.keyOf`, threaded from the dial's one reading, as `kind` is): mounts under one bound share one connection — the environment seat counts as a bound, so env 20 000 and a declaration of 20 000 share — and mounts under different bounds dial their own (`S6`, `S6c`, `S6d`). Cost: two mounts of one server that disagree about their bound now start two connections where they used to start one; mounts of one server in one run tree normally carry one declaration.
|
|
151
|
+
- **Pins.** `test/backlog1552-mcp-startup-timeout.test.ts` S1–S8, 41 cells (a hung server and a healthy sibling; the declaration over env in both directions, env filling absence, the undefined default, a shadowed malformed env still announced; the listing phase; twelve bad values plus the range's edges; the real `Runner` preparation; the probe with no clock of its own, a server whose two phases each meet the bound but add up past it, the caller's overall ceiling beside the phase bounds, and the refused-declaration row; the pool key — one bound shares, different bounds dial their own, env and declaration alike; the in-session re-dial). Mutations: the dial reads the env seat only ⇒ 31 cells red; a bad declaration falls back to the process-level bound ⇒ 20 red (every bad-value cell, none of the honored-value controls); the initial listing drops the bound ⇒ S3 alone red; the probe races the whole dial against the bound again ⇒ S7c red (with the env seat's number, S7b too); the pool key leaves the bound out ⇒ S6 / S6c / S6d red. `test/module-size-ratchet.test.ts`: `src/core/mcp.ts` 4 413 → 4 422 (raised, the one reader). `docs/CLOSED-SETS.md` (the thrown code, an error and not a notice), `docs/INTEGRATION-CORE.md` §8.2 / §8.3.
|
|
152
|
+
|
|
153
|
+
### A model's own credential stays on the host it was first sent to: an entry moved to another origin under the same credential sends nothing (#1821; security; behavior narrowing — open to veto; **BREAKING**: hard-breaking one, the retired notice word; closed sets: `EngineNoticeCode` −1 `route.base_url_changed_key_unchanged` +1 `route.credential_origin_mismatch`, `RouteRefusalCode` +1 `route.credential_origin_mismatch`, `RoutePairingStatus` +1 `broken:credential_origin_mismatch`, `ROUTE_CREDENTIAL_PAIRING` +1 row `per-model / apiKey / options` (#1848); exports: +1 `__resetCredentialOriginBindings` (a test seam, #1837), none removed; members: `RouteRefusalDetail.boundOrigin?` / `.requestOrigin?` (optional, new); behavior narrowing also of which header is a credential, #1837 — open to veto) (@server @web-admin @cli @client-core @sdk @test)
|
|
154
|
+
|
|
155
|
+
- **Before (≤ 7.51.2).** A per-model credential — the per-model auth hook's key (`TaskSpec.getApiKeyAndHeaders`, which a server fills from a catalog entry's `apiKeyEnv`) or an auth header on `Model.headers` — was "paired by construction": the pairing law never refused it, wherever the entry pointed. A catalog swap (`Runner.swapDeps`) that moved an entry's `baseUrl` X → Y and left its credential reference alone sent the very next request to Y carrying X's key. The engine saw the moment and said so — `route.base_url_changed_key_unchanged`, audience operator, advisory — and sent it anyway; the user saw nothing.
|
|
156
|
+
- **Now.** One law for both credential sources: a credential has exactly one root. The deployment credential's root is unchanged (its brain's declared root, #1531). A per-model credential is bound to the ORIGIN — scheme + host + port — of the first request SENT carrying it (`src/brain/route-adjudicator.ts`: `resolveRouteCredential` tags each per-model credential that carries a secret with a digest of it; `adjudicateModelRoute` READS the engine's one process-wide credential-origin table and writes nothing; the table's one writer is the first-party brains' request gate, `admitRouteRequest`, whose admission runs when the engine commits that request to be sent — `SSERequest.onCommitted` — so a pre-flight, the read face, a consumer's own call of the law, a request the context-overflow recovery built speculatively and declined, and a request whose build fails after the gate bind nothing; the admission judges again and binds in ONE synchronous step, so a credential that went out to another origin between a request's build and its commit — caller code runs while a body is built, an `extraBody` member's `toJSON` — refuses that request at its commit, before anything is sent). Which header carries a credential, BY ITS NAME, is ONE reading, shared with the withheld-value table (#1754): `isCredentialHeaderName` (`src/core/secret-env.ts`, the credential vocabulary's record-key view — `Authorization`, `Proxy-Authorization`, `X-Api-Key`, Azure's `api-key`, `x-goog-api-key`, `Cookie`, any name holding a credential word; not a vendor's own spelling of a name the vocabulary declares non-secret, #1850). The withheld table adds a half of its own — a value that looks like a credential under any name — and the law does not (#1850). It decides which bag carries a credential (`hasAuthCarrier`, the deployment's `config.headers` included), which header values a per-model credential's identity is read from (`Authorization` / `Proxy-Authorization` as their `<scheme> <credentials>` part, any other whole), which headers a per-model winner strips, which the user-header merge leaves to that strip, and which `streamOptions.headers` refuses — the former two-name table (`authorization`, `x-api-key`) is gone. What is bound is what the deployment HANDED over (#1848): every non-empty credential value a request was given explicitly — an `apiKey`, a credential header's value — whatever its length or wording (a seven-character gateway key, `EMPTY`); being handed over as a credential is the evidence. The no-credential door is explicit: a local gateway that takes no credential is handed none (the hook answers no key, or the empty key `""`), and is served on every host. (The withheld-value table's qualification, #1816 — fewer than eight characters, a placeholder word — answers whether an environment value could be a secret; it is that table's alone.) The winning per-model source is ONE credential (#1848): the hook's answer — its `apiKey` and its credential headers — rides together and is bound together, as the deployment's `config.apiKey` + `config.headers` always rode as one; only the credential headers of the other bags are stripped — whether a header is stripped is decided by its SOURCE, not its name. The winning bag is read ONCE: the headers a per-model credential sends are the very ones its identity was computed from (a header whose getter answers one value when hashed and another when sent cannot carry a credential bound elsewhere), and the entry's root is read once per request build, before any such caller code runs: the URL the request goes to and the origin its admission judges and binds are that one reading; each caller header bag (`Model.headers`, the per-call `headers`) is read once too, into one copy that both the law and the header merge are handed (a bag whose keys hide a credential header from one reading and show it to the next cannot ride beside the deployment key). The key's own header (`authorization` / `x-api-key`) is written as a lock, case-blind, like the brains' structural headers; an answer that hands the key AND a header of that name (any case) is refused before anything is sent (#1850), so the lock supersedes nothing the answer carried. A request carrying it to another origin is refused before a byte is built: `[invalid_request] route.credential_origin_mismatch: …`, naming both origins, the entry and the fix; a move to another path of the same origin (`/v1` → `/anthropic`) is not a move. A NEW credential (the entry's credential reference changed with its address) binds afresh. Each secret a request carries is bound on its own — adding, dropping or changing another auth header beside a bound one does not make it new, and a refused request binds nothing new; `apiKey K`, `x-api-key: K` and `authorization: Bearer K` are one secret (an `Authorization` value is read as its `<scheme> <credentials>` part, an `x-api-key` value whole, both with the whitespace the wire strips removed). The refusal is terminal for the request: no retry (it is thrown while the request is built, before the retry ladder), and a failover chain's next brain meets the same binding (the table is per process, not per brain). The run announces it once per (model, bound origin, request origin): `route.credential_origin_mismatch`, audience **user** — the person whose request was refused hears why; `detail: { modelId, boundOrigin, requestOrigin, sessionId, runId }`, display spellings only, never a credential or a digest of one (`src/core/runner/credential-origin-seat.ts`, the run's fifth brain sink). Only the request gate's refusal reports it (`routeRefusalError`, thrown by `admitRouteRequest` in the three first-party brains); a pre-flight or read-face judgment neither binds nor reports. The read face says it before a request goes to the new host: once a request went out to the old origin, `routePairingStatus` answers `broken:credential_origin_mismatch` for the moved entry. A derived seat (a role- or tier-derived summarize / classifier model) whose credential went out to another origin falls back from its next pre-flight on (`route.fallback_to_primary`, `cause: "route.credential_origin_mismatch"`); a request of its own in the meantime is refused at its own request gate. A derived seat on another host that shares the main model's key no longer takes the key first: its pre-flight binds nothing, so the main model's first request binds it and is served. The swap-time advisory is retired: the swap never sees a hook's credential value, the request does.
|
|
157
|
+
- **Who breaks.** **Hard-breaking (the retired word):** `route.base_url_changed_key_unchanged` is no longer minted and left `ENGINE_NOTICE_CODES` — a consumer that keys a record by `EngineNoticeCode` (client-core's audience table, a server's notice routing / log readers, cli's notice renderer) gets a compile error until the row goes, and gives `route.credential_origin_mismatch` its row (audience `user`). **Behavior narrowing (open to veto):** web-admin's catalog publishing — an entry's `baseUrl` moved to another host must move its credential reference (`apiKeyEnv` / sealed key) in the same publish, or every request to it is refused; a provider that only changed domains and accepts the same key there needs an engine restart (the binding lasts as long as the process; no in-process rebind) — and so does an entry whose first request went to a wrong host that never answered (the binding is written when the request is committed to be sent, KL-1821-e). One real key used by two entries on two hosts refuses the one whose request goes out second — the "one credential, one root" reach, the same as for a deployment credential; a value handed over binds whatever it looks like (#1848: a seven-character key, `EMPTY`), so local nodes that share a placeholder key are served on every host only when they are handed NO key (the hook answers none, or `""`) — handing each the same `EMPTY` refuses the node sent second, and so does one gateway account header (`Helicone-Auth`) handed for models on two hosts. **The pairing table gains a row (#1848, closed table +1):** `ROUTE_CREDENTIAL_PAIRING` has `{ credentialSource: "per-model", carrier: "apiKey", carrierBag: "options", credentialKnobs: ["getApiKeyAndHeaders().apiKey", "getApiKeyAndHeaders().headers"] }` — the hook's answer carrying both, the per-model mirror of the deployment's dual row — so `terminal.detail.route` / `AssistantMessage.apiErrorRoute` can carry that shape; server / client-core / sdk code that pinned the table's rows or switches on the route shape adds it (`routePairingRowOf` finds it; `admittedRequestRoute` admits it). **The key header is a lock (#1848, a fix):** the three brains write the apiKey's header (`authorization` / `x-api-key`) case-blind, deleting a same-named header of another spelling first — the exemption "auth stays on the exempt path" is retired. On the deployment arm this changes one shape only: `config.apiKey` beside a `config.headers` entry spelled as the lane's key header in another case (`Authorization` on the OpenAI lanes, `X-Api-Key` on Anthropic) sent both, folded into one comma-joined header (a request no server accepts); the key now wins, as it already did for the lowercase spelling (the deployment's own contradiction; a per-model hook answer that holds both is refused, #1850). A header of another name beside the key (the dual-auth shape) still rides. **A hook answer's own credential headers ride (#1848):** a per-model hook that answers `{ apiKey, headers }` with a credential header beside its key now sends it (7.51.x stripped only `authorization` / `x-api-key` there; the #1837 tree stripped every credential-named one) — on the Anthropic lane an answer's own `Authorization` rides beside `x-api-key`, the per-model mirror of the deployment's dual-auth shape; a construction-time `Authorization` (another source) is still stripped under a per-model key. A consumer switching on `RoutePairingStatus` or `RouteRefusalCode` adds the new word. **Behavior narrowing of which header is a credential (#1837, open to veto):** a header whose name holds a credential word beyond the former two (`api-key`, `x-goog-api-key`, `Cookie`, a gateway's own `Helicone-Auth`, …) is now a credential everywhere the law reads one — on `Model.headers` it makes the entry's credential per-model (the deployment key no longer rides with it; a gateway's own header that must ride BESIDE the deployment key goes on the brain's `config.headers`, where it rides as part of the one deployment credential, KL-1837-a); in a per-model hook's answer it rides WITH the key — the answer is one credential (#1848): `{ apiKey: K, headers: { "Helicone-Auth": … } }` sends both and binds both, each secret to the origin, and an answer of headers only (`apiKey: ""` beside them) sends and binds the headers — while a credential header from another bag (the entry's under a hook answer, the deployment's under either) never rides beside a per-model credential; on `config.headers` alone it is a deployment credential, refused off its root as #1531 refuses `apiKey`; a per-model winner strips it from the deployment's headers; `streamOptions.headers` carrying one is refused at construction / `setStreamOptions`; `hasAuthCarrier` (exported) answers `true` for it (server's degrade-hop summary reads it). server: zero assembly change — its hook keeps answering per request; `GET /v1/models` (`routePairingStatus`) gains the new status word and no longer binds anything by listing an entry; `hooks/hook-llm.ts` calls `adjudicateModelRoute` for its own direct emission, which now reads the bindings the engine's brains wrote and writes none (its own sends bind no key — that lane's refusal is still core's code and text). client-core: the `adjudicateModelRoute` value port is a pure read again. **Test suites (server, cli, client-core, sdk):** the table is per process, so one fixture key sent to two local mock upstreams in one test process (two `listen(0)` ports are two origins) gets the second refused — give each case its own fixture key, or call the new test seam `__resetCredentialOriginBindings()` between cases.
|
|
158
|
+
- **How to migrate.** client-core / server / cli: drop the retired row, add `route.credential_origin_mismatch` (user audience; render `message`, or the two origins from `detail`). web-admin: change the credential reference together with the base URL; restart the engine for a same-key domain move.
|
|
159
|
+
- **Pins.** `test/backlog1821-credential-origin-binding.test.ts` (real `Runner`, real first-party brain, two real local listeners each its own origin, listeners record only whether a fixture credential rode): C1 X → Y under the same key ⇒ zero requests to Y and none re-routed to X, the run fails with the code and both origins, exactly one user-audience notice whose `detail` keys are exactly the five above and carry no key and no digest-shaped run, the swap mints no credential line; C2 a same-origin path move sends with the key; C3 a move with a new key sends to Y with only the new key; C4 a two-entry swap refuses only the moved one; C5 a failover chain of two brains: zero requests, one notice; C6 the same secret respelled as an `Authorization: Bearer` header on the entry is refused; C7 pointing the entry back sends again; C8 after a request to the old host, the read face says `broken:credential_origin_mismatch` before any request to the new one; C9 a second auth header added beside a bound one: refused, and the added secret's first use is still to come; C10 ` \tBearer\tK ` after `Bearer K`: refused; C11 an `x-api-key` value is opaque (`Bearer K` there is the apiKey `Bearer K`; `tenantA K` / `tenantB K` do not collide); C12 the law and the read face bind and report nothing, the gate's refusal reports once; #1837: F1 a summarize seat on another host sharing the main model's key — the main model is served on its own host on every run, zero requests to the seat's host, the seat falls back from the second run on with the cause; F1b the derived seat's own request refused at its own gate; F1c a speculatively built, declined request binds nothing; F1d a build failing after the gate binds nothing; F1e an admission judges again as it binds, F1f a request to X started from inside the build of a request to Y (an `extraBody` `toJSON`) commits first and Y is refused at its commit with zero bytes, F1g the same with the entry object moved to X from inside that build — the admission judges the request that was built (Y), zero bytes to Y, the key stays bound to X; F5 listing a candidate entry on another host binds nothing; #1848 (a): F3 explicit keyless (no key, the empty key) reaches two hosts with no credential header, F3c `EMPTY` / `lm-studio` / `Bearer EMPTY` handed as the credential bind (Y refused, zero requests; `declareWithheldEnv` still refuses the same values — its own question), S1 a seven-character apiKey, a one-character apiKey and a seven-character entry `x-api-key` bind, S2 the short key through the real Runner, F3b a real key beside `Bearer none` binds; #1848 (b): H1 a hook answer `{ apiKey, Helicone-Auth }` through the real Runner sends both and binds both (each refused on its own at Y), H2 an answer of headers only (no key, or `apiKey: ""`) sends and binds its header, H3 the entry's and the deployment's credential headers are stripped under the hook's answer, H4 (three lanes) an answer holding the apiKey and a header named as the lane's key header, any case, an empty value included, is refused by the pre-flight judge and by the build in one sentence, nothing sent (#1850), H5 the deployment twin (`config.apiKey` beside `Authorization` in another case: the key wins; `api-key` beside it still rides), H6 on all three lanes a hook header whose getter answers an unbound value first and X's bound credential after sends the first to Y, never X's, H7 on all three lanes a hook header whose getter points the entry back at X while its request to Y is built leaves Y refused with zero requests, H8 on all three lanes a hook bag whose keys hide its credential header from the first enumeration and show it to the second is enumerated once and sends nothing of it beside the deployment key; `test/request-params.test.ts` [1282] the construction-time `Authorization` is still stripped under a resolver key while the resolver's own `Authorization` rides beside `x-api-key` (Anthropic) and is refused beside the key (OpenAI, #1850); `test/route-adjudication.test.ts` the strip writes back what the resolver read, a hand-built per-model credential carries no header; F4 a key on `api-key` / `x-goog-api-key` / `Cookie` does not follow its entry, F4b a brain whose only credential is an `api-key` header is refused off its root, F4c a per-model key strips the deployment's `api-key`, F4d `hasAuthCarrier` and the withheld intake read one vocabulary, F4e `Proxy-Authorization: Basic K` is the secret `K`, F4f `Idempotency-Key` survives a per-model key's strip while `X-Api-Key` beside it does not; R1 `__resetCredentialOriginBindings()` rebinds afresh, R2 the `sema.core.credentialOriginTable` slot holds origins only; W1–W7 the weak points (below; W5 a derived seat that sends first takes the key, W6 a first request to a dead host holds the binding, W6b a request cut before its first attempt binds, W7 an entry's gateway header replaces the deployment key, W7b a hook's gateway header rides with its key whether the key is the apiKey or a header, W8 one gateway account header handed for models on two hosts refuses the host sent second, W9 local nodes handed `EMPTY` refuse the second while the empty key serves both, W10 an answer holding the key and a header under the lane's key-header name is refused whole — nothing sent, nothing bound (#1850); `test/side-query.test.ts` a gateway stub that requires the key and `Helicone-Auth` both is served; `test/backlog1399-terminal-detail-projection.test.ts` the new pairing row on all three lanes' 401 and in the table). Mutations (12, each red on its pin): `.gate-logs/mutations.md` of the car. `test/backlog1531-deployment-credential-one-root.test.ts` P18: an entry key binds without re-rooting the deployment key (both refusals side by side). Re-pinned: `test/swap-models.test.ts` (a swap mints the catalog line only), `test/route-adjudication.test.ts` (a derived seat whose own key moved host falls back with this cause; the refusal's rendering withholds query values), `test/brain-context-overflow-selfheal.test.ts` (the re-send check is exercised by a same-origin move; per-test fixture keys), `test/governance-codes.test.ts` (user-audience row), `test/module-size-ratchet.test.ts` (runtask.ts banked 1 500 → 1 385).
|
|
160
|
+
- **Known limits.** KL-1821-a (no in-process rebind; restart rebinds), KL-1821-b (the table holds 16 384 bindings; past that the least-recently-sent is forgotten), KL-1821-c (one value handed over on two hosts refuses the one sent second — a placeholder word and a gateway account header too; the no-credential door is no key — open to veto), KL-1821-d (a hand-built or copied `RouteCredential` carries no identity and binds nothing — nor any header of its own: the headers a per-model credential sends are the ones the resolver read, once), KL-1821-e (the binding is written when the request is committed to be sent, not when the host answers), KL-1837-a (one wide header vocabulary: an entry's own gateway header replaces the deployment key; a hook answer's gateway header rides and binds with its key).
|
|
161
|
+
|
|
162
|
+
### A child never inherits an env entry whose value is a credential the deployment declared or the engine holds — `declareWithheldEnv` takes one table `{ names, values }` (#1754; security; behavior narrowing — open to veto; **BREAKING**: hard-breaking one, the declaration's shape; closed sets: `EngineCapability` +1 member `inherit_env.own_credential_values_withheld`; exports: +1 type `WithheldEnvDeclaration`, none removed; members: none) (@server @cli @client-core @test)
|
|
163
|
+
|
|
164
|
+
- **Before (7.51.1 – 7.51.2).** The own-credentials subtraction read NAMES only (`declareWithheldEnv(names)`). A value the engine itself held under another name went to the child: a shell that copied the user's `OPENAI_API_KEY` to `MODEL_API_KEY` for the engine had one of the two declared, and under `"all"` the other copy of the same key reached the model's shell.
|
|
165
|
+
- **Now.** One table, one subtraction, one finding, one disclosure, by name and by value. `declareWithheldEnv({ names, values })` (`src/core/secret-env.ts`): `names` as before; `values` are credential values the deployment holds. `inheritedHostEnv` (the one reading every spawn site uses) withholds, under every policy (`"all"`, `"scrub"`, an allow list), every inherited entry whose name is declared or whose VALUE equals a declared value or a value the engine holds — compared whole, as the same string (`withholdOwnCredentials`). The engine adds what it holds at five intake lines, each where the engine receives the credential: a first-party brain's construction credential — `apiKey` and its credential headers — in `brainConfigSnapshot` (`src/brain/route-adjudicator.ts`, every `createOpenAIBrain` / `createAnthropicBrain` / `createOpenResponsesBrain`); a model entry's own credential headers (`Model.headers`) in `resolveRouteCredential` (same file, the route's read of them, per request — taking a value in twice is taking it in once); a protocol peer's credential headers in `resolveProtocolHttpHeaders` (`src/core/protocol-http-auth.ts`, every MCP http server and A2A peer dial); the memory distiller's chat seat key in `openAiCompatChatSeat` (`src/core/memory-engine/distiller.ts`, a host that builds the seat itself) and in the `RunnerDeps` receipt at `new Runner` (`src/core/runner/construction-doors.ts`, `memoryConsolidationDriver.chat`, held before the seat is built at the first consolidation). A credential header is one whose name the credential vocabulary reads as a credential (`Authorization`, `X-Api-Key`, `Cookie`, …) or whose value carries one; it is held as written and, when it reads `<scheme> <credentials>`, as its credentials part (`credentialHeaderValues`). A `User-Agent`, a region, the per-task principal (the task's identity) are not held. The engine's values are kept for the life of the process and a re-declaration never removes them; the deployment's are replaced whole by its next declaration. The table keeps no value: each is kept as a SHA-256 digest on the process slot (`Symbol.for("sema.core.withheldEnvTable")`, which any code in the process can read). Each withheld entry is a finding of kind `own-credential` (unchanged kind); `config.secret_env_scrubbed` (once per process, unchanged) lists it in `detail.names` and `detail.ownCredentials` — names only, never a value. An allow list naming an entry that holds such a value is read, not refused: the entry is withheld at the spawn, with its finding (a value is a fact of the environment, not of the configuration); an allow list naming a declared NAME is still refused.
|
|
166
|
+
- **The declaration's refusals.** Not a plain object `{ names, values }` — a names-only array included: `declareWithheldEnv: the declaration must be a table { names, values } (got an array — a names-only list is no longer a declaration: pass { names, values })`; a member besides the two; a list that is a Proxy or whose element throws while it is read (each list is read once, by index — what is judged is what is kept); `values` not an array; a value that is not a string a variable can hold (empty, holding a NUL or an unpaired surrogate) or is no credential (the entry qualification of #1816, below); a value that is, in this process's environment now, the value of a variable every child is handed whatever the spawn passes (`NODE_V8_COVERAGE`, the MCP stdio transport's default set `HOME LOGNAME PATH SHELL TERM USER`, Windows' required variables — the same set a declared NAME may not be in, for the same reason: no reading can withhold it). Each throws and leaves the previous declaration standing; no refusal sentence repeats a value. Every engine intake applies the same judgment and takes nothing in: a brain whose credential is such a value is refused at construction, a request whose model entry carries one is refused before it is sent, a peer whose credential header is one is not dialed, a `new Runner` whose consolidation seat key is one is refused (`config.invalid_argument`, the variable named), and a directly built seat is refused with the seat's own code (`config.memory_consolidation_driver`).
|
|
167
|
+
- **Who breaks.** **Hard-breaking (the shape):** every caller of `declareWithheldEnv(names)` — server `src/server-secret-env.ts` (`declareServerWithheldEnv` / `withdrawServerWithheldEnv` and the reset) — throws at its first call until it passes `{ names, values }`; nothing passes silently. A consumer that keys a record by `EngineCapability` (server `src/capabilities/engine-capabilities.ts`, the capability facts) gets a compile error until it gives the new word its fact. `openAiCompatChatSeat` now reads its `apiKey` once at construction and sends that value (it read the seat object again on every call): a host that rotated the key by mutating the seat object builds a new seat instead; a key read that throws is refused coded, without the thrown value. **Behavior narrowing (open to veto):** a deployment whose environment holds a copy of a brain key or of an MCP / A2A credential header under a name it never declared stops handing that copy to the model's shell under `"all"` (and under `"scrub"` where the name does not look like a credential); a variable that happens to hold the same string as such a credential is withheld too, and named in the disclosure.
|
|
168
|
+
- **How to migrate.** server: `declareWithheldEnv({ names, values: [] })` keeps today's behaviour exactly; add to `values` the credential values the service holds that may be copied into its environment under other names (the engine already holds its brains' `apiKey`s and credential headers, its model entries' and peers' credential headers, and its consolidation seat's key). `RemoteEnvHost` reads `inheritedHostEnv(policy, findings)` already, so its children get the value arm with no further change; project `inherit_env.own_credential_values_withheld` onto `/v1/capabilities` once it declares the table. cli: gate on the new word (sending `"all"` safely needs both words). client-core: the capability-word reader takes the new member.
|
|
169
|
+
- **ExecutionEnv contract.** An `ExecutionEnv` that starts a child on this host composes the child's inherited environment with `inheritedHostEnv(policy, findings)` — the one place both arms apply — and adds its own grants (`shellEnv`, a per-exec `env`) on top; an implementation that reads `process.env` itself applies neither arm.
|
|
170
|
+
- **Pins.** `test/backlog1754-held-values.test.ts` V1–V8 (V8 is #1816's): a declared value under `"all"` / `"scrub"` / an allow list (a same-name entry with another value and an entry holding the value as a part are handed on); each of the three first-party brains built with only an `apiKey` hides every alias of that key from a real `NodeExecutionEnv` child (the child prints its environment's names only), its credential headers too, a non-credential header not; a model entry's credential header once a brain's judge routes it; the distiller seat's key, built directly and through `new Runner` (before any consolidation); an MCP server entry's static credential headers through the public `probeMcpServers` (a real dial) hide their aliases from a real child, a region header and the principal do not; a real `Runner` run with two Bash spawns under `"all"`: neither child sees the aliases, one disclosure for the run naming them, no value in the notice, the tool outputs or the session record; the table's refusals (no refusal sentence repeats a value; a list read once by index — an element answering twice, an array species, an overridden iterator change nothing) and replacement (the engine's values stay); the process slot holds no value; `holdOwnCredentialValues(` has exactly five callers in src; the capability word. `test/backlog1753-*.test.ts` re-shaped to the table (`{ names, values: [] }`), every name-arm cell unchanged. Mutations (removing the value comparison reds 20 of 25 cells; dropping the value finding reds the disclosure cells; each intake and the scheme split red their own cells; a raw value on the slot reds the slot cell).
|
|
171
|
+
- **Known limits.** KL-1754-a: a credential resolved per request (`TaskSpec.getApiKeyAndHeaders`, the per-model auth hook) is not in the value arm — the engine asks for it per request and does not keep it; the host that resolves it declares it (by name, or by value). KL-1754-b: the value is compared whole — an entry holding it as a part (`Bearer <value>` where the engine holds only the key, a URL, a base64 or other encoding, the value with a trailing newline) is not its value and is handed on under `"all"` (under `"scrub"` the value rule still catches some of these shapes: a `Bearer …` value, a URL userinfo). KL-1754-c: whether a held value sits in a variable every child is handed is judged when the value enters the table, not again at each spawn. KL-1754-d: an intake holds what it read at that moment; a model entry whose `headers` answer differently on a later read (an accessor, a mutation) is read again by the brain's request build, so the value sent can be one the table never took in. KL-1753-a stands: the engine process's own initial environment stays readable to same-user processes; the word speaks of inheritance.
|
|
172
|
+
|
|
173
|
+
### A string is a credential the process withholds only if it could be one: one entry qualification for the declaration and the engine's intakes (#1816; security; behavior narrowing of the value arm — open to veto; hard-breaking: none beyond #1754's declaration; closed sets: `EngineNoticeCode` +1 `config.credential_not_withheld` (#1837); exports: none; members: none) (@server @cli @client-core @test)
|
|
174
|
+
|
|
175
|
+
- **Before.** `declareWithheldEnv` refused the empty string only, and every intake took any non-empty string. A brain key a local OpenAI-compatible gateway is told to be handed (`"true"`, `"1"`, `"EMPTY"`, `"none"`) was held as a credential, so under `"all"` every variable with the same value (`CI=true`, `FORCE_COLOR=1`) was withheld from every child for the life of the process and counted as an own credential; `Authorization: Bearer 1` held `1` the same way. A declared `" "` was accepted and withheld every blank variable.
|
|
176
|
+
- **Now.** One function judges a table entry for both doors (`notACredentialReason`, `src/core/secret-env.ts`): a string is **no credential** when it is white space only; fewer than 8 characters (`CREDENTIAL_MIN_CHARS` in `src/core/credential-vocabulary.ts` — the floor the vocabulary's vendor-token and bearer rows and the scrub's value rules already used, now spelled once); a flag or placeholder word in any case (the scrub's flag words `true false none null yes no on off enabled disabled required optional`, plus `undefined placeholder lm-studio not-needed sk-no-key-required`); or one character repeated three times or more, after at most one of the vocabulary's own token prefixes (`xxxxxxxx`, `00000000`, `sk-xxxxxxxx`, `ghp_000000000000`). The string is judged whole, as the table keeps it: an opaque key may hold a space (`r4NdomWord 42`) or end in a run of one digit (`a9B7c6D5e4F3g2H1-000`) and is a credential. One header-level reading: an `Authorization` / `Proxy-Authorization` value is `<scheme> <credentials>` by definition, so over a credentials part that is no credential (`Bearer 1`) the header hands the intake that part alone — nothing of it is taken in, and the part is said. **The declaration refuses** such a value and says which kind, never the value (`declareWithheldEnv: values[i] is not a credential value (got a string of fewer than 8 characters, which names no secret)`). **The five intakes** (a brain's `apiKey` and credential headers, a model entry's credential headers, a protocol peer's credential headers, the distiller seat's key, the Runner's consolidation seat key) **take it in no further**: the holder uses it as given — it is no bad value — and the variables that equal it stay with every child. Each such value is said once per process as the notice **`config.credential_not_withheld`** (audience operator, minter engine; `detail: { reason }` — the kind, never the value; #1837) through the engine's one notice throat: the intake that has the structured seat in hand — the `RunnerDeps` receipt at `new Runner` (`src/core/runner/construction-doors.ts`) — hands it to `RunnerDeps.onNotice`; the four intakes with no such seat (a brain's construction, a request's route reading, a peer's dial, a directly built distiller seat) print the same notice's line on `console.warn`, the throat's arm for an absent seat (KL-1816-a). The qualification is this table's alone — "could an environment value equal to this be a secret?" is a guess the table must make, because withholding a setting is a cost; a credential a request is HANDED is no guess, and the route law binds every non-empty one (#1848, which retires #1837's reuse of this judgment there: a seven-character gateway key followed its entry to another host). An absent or empty key says nothing. One reading made true with it (#1837): the credential vocabulary's record-key reading (`isSensitiveRecordKey`, `src/core/credential-vocabulary.ts`) said "`-` and `_` are one" but compared its non-secret exact names (`IDEMPOTENCY_KEY`, `PARTITION_KEY`, `SORT_KEY`, `CACHE_KEY`, `MAX_NEW_TOKENS`, …) as written — it now folds `-` there too, so a header or member spelled `Idempotency-Key` / `partition-key` is no credential: not held by the withheld-value intake (#1754's header door), not stripped or refused as a credential header (#1821), and no longer masked whole by the argument summary, the egress record-key mask and the terminal account's named-detail mask. The scrub's four value rules and the vocabulary's two rows spell the floor through the one constant (the regexes are unchanged); the assignment rule's flag-word list is the same table the placeholder rule reads.
|
|
177
|
+
- **Two readings of #1754's table made explicit.** A value listed twice in `values` is the one value (the declaration is a set; accepted, withheld once). An entry matched by its declared name AND by its value is one `own-credential` finding, not two.
|
|
178
|
+
- **Who breaks.** A caller that declares by value a string this judgment refuses now throws at the declaration (before: accepted). A real secret under 8 characters cannot be declared by value any more — declare the variable by name. A deployment whose brain key is a placeholder stops losing same-valued settings from its children (`CI=true`, `FORCE_COLOR=1` reach them again); the placeholder's own aliases are no longer withheld, which withholds nothing a secret could be.
|
|
179
|
+
- **Pins.** `test/backlog1754-held-values.test.ts` V8: the declaration's refusal for each kind and each word (one sentence each, no value repeated), the 7 / 8-character boundary, padding, whole-string acceptance of an opaque key with a space and of a mixed key ending in a run of one digit (through a header and through the declaration, under `"all"` and the scrub), the schemed-header reading; a declared placeholder still lets `CI=true` reach a real child while the table's real value is withheld; each of the six intake doors takes a placeholder in as nothing (five placeholders, with a `Bearer`-prefixed copy) and withholds a real key; the once-per-value report and its silence for an absent, empty or real key; #1837: the `new Runner` intake hands `config.credential_not_withheld` (operator, `detail: { reason }`, no value) to `onNotice` and prints nothing, and a seat-less intake prints that notice's own line word for word; a value listed twice; one finding per entry (under `"all"` and the scrub). Mutations, each red in its own cells: the intake takes everything in; the declaration without the judgment; the floor at 6; the scheme split moved into the predicate; no schemed-header reading; the schemed-header name widened to any name ending in `-authorization`; no repeated-character rule; any alphanumeric prefix before the repeated run; the extra placeholder words removed; the flag words removed; a report on every call; no trim; (#1837) the Runner intake drops its seat; the report never handed to the seat.
|
|
180
|
+
- **Known limits.** KL-1816-a.
|
|
181
|
+
|
|
182
|
+
### A vendor's own spelling of a non-secret name is no credential header, and a hook answer that hands the key twice is refused (#1850 / #1858 / #1859; correction of #1837 / #1848 inside 7.52.0; hard-breaking on one answer shape; closed sets: `RouteRefusalCode` +1 `route.credential_answer_conflict`, `RoutePairingStatus` +1 `broken:credential_answer_conflict`; exports: none; members: `resolveRouteCredential` input +1 optional `keyHeader`) (@server @web-admin @cli @client-core @sdk @test)
|
|
183
|
+
|
|
184
|
+
- **Before (the 7.52.0 stage).** The route law reads which header is a credential through the credential vocabulary's record-key view, whose non-secret exemptions (`CACHE_KEY`, `IDEMPOTENCY_KEY`, `PARTITION_KEY`, …) matched a WHOLE name only. A vendor's prefixed spelling of the same non-secret thing — Cloudflare AI Gateway's `cf-aig-cache-key`, `X-Idempotency-Key`, `X-Partition-Key` — read as a credential: on a model entry it made the entry per-model and the deployment key stopped riding (a 401 at a real provider); a hook answer carrying only such a header did the same; `streamOptions.headers` carrying one threw `must not carry an auth header`. 7.51.x read two names only, so the key rode. Separately, a hook answer that handed an `apiKey` AND a header under the very name the lane writes the key in (`Authorization` on the OpenAI and Open Responses lanes, `x-api-key` on Anthropic) was resolved by letting the key win silently, and the superseded header's value was bound to an origin though it never left.
|
|
185
|
+
- **Now.** ONE vocabulary, three readings of the same declared names. The HTTP header-name reading (`isCredentialHeaderName`, internal — the vocabulary's `isSensitiveHeaderName`) reads a name the vocabulary declares non-secret as the whole name OR its TAIL at a `_` / `-` boundary when the front part names no credential word, case and `-` / `_` alike: `cf-aig-cache-key`, `X-Idempotency-Key`, `X-Partition-Key` are no credential header; `secret-cache-key`, `auth-token-range-key`, `xcache-key`, `cache-key-id`, `x-cache-key-secret` still go by their words. The env-name view and the record-MEMBER reading (`isSensitiveRecordKey`, the name of a field a structured record shows or masks) keep the names WHOLE (`APP_CACHE_KEY` is a secret variable and is withheld, and a record member of that name is masked). Every header consumer reads the one predicate: the route law's bag classification, the per-model identity, the strip, the user-header merge, the `streamOptions.headers` refusal and drop, and the withheld-value intake's name half. **Which header carries a credential is one reading of the NAME; the withheld table adds a value half** (a value that looks like a credential under any name) because withholding a value from a child costs little and a wrong guess there is cheap, while the law replaces the deployment key and binds an origin on what it reads — a guess there drops a key that should have gone out, and refuses a request. So the sentence in #1837 / #1821 now says what is true: the NAME reading is shared, the value half is the table's alone (KL-1850-a). **A hook answer is one credential and one header holds one value:** a request with a non-empty `apiKey` and a header of the lane's key-header name (any case, an empty header value included) is refused before anything is sent, as a route verdict with its own code, `route.credential_answer_conflict` (#1859) — `[invalid_request] route.credential_answer_conflict: model "<id>": the request carries both an apiKey and a "<header>" header, and this lane writes the apiKey in "<key header>" — …` — by the pre-flight judge and the request build alike (one sentence), and binds nothing. The lane's key-header name is declared once per brain and read by its lock and by the law (`resolveRouteCredential({ keyHeader })`, `createBrainRouteJudge({ keyHeader })`; both optional — a caller that does not know its lane gets no conflict verdict). A failover chain's pre-flight (`createFailoverBrain(...).adjudicateRoute`) reads it as it reads every refusal, leg by leg like its stream: an answer one lane contradicts and another lane takes (OpenAI, then Anthropic) is servable; when no leg is servable it answers the first leg's refusal verdict. The empty key `""` writes no header, so an answer of `{ apiKey: "", headers: { Authorization } }` is the header's credential and is not refused. The deployment arm is unchanged (`config.apiKey` beside a same-named `config.headers` entry: the key wins, #1848).
|
|
186
|
+
- **BREAKING (one answer shape; the old form, the new form, the migration).** Old: a per-model auth hook (`TaskSpec.getApiKeyAndHeaders`) answering `{ apiKey: K, headers: { Authorization: … } }` on the OpenAI or Open Responses lane, or `{ apiKey: K, headers: { "x-api-key": … } }` on the Anthropic lane, was sent with the key winning and the header silently dropped. New: that answer is refused (`[invalid_request] route.credential_answer_conflict: …`, the run fails, nothing is sent). Migrate: drop the header from the answer or drop the `apiKey` — the lane writes the key's header itself (answer the key as `apiKey`, or as the header, not both). A header of another name beside the key (`Helicone-Auth`; `x-api-key` on an OpenAI lane) is unaffected.
|
|
187
|
+
- **The refusal is a verdict, not a throw (#1859; closed sets: `RouteRefusalCode` +1 `route.credential_answer_conflict`, `RoutePairingStatus` +1 `broken:credential_answer_conflict`; exports: none; members: none).** Before (the 7.52.0 stage): the refusal above was a `BrainError` thrown out of `resolveRouteCredential`, so each caller improvised its own handling — the read face `routePairingStatus` (typed as a closed set of status words) threw on a well-typed answer; a derived seat's pre-flight read the throw as "no judgment" and neither fell back nor said so, where the same seat falls back on any other refusal; a failover chain's judge caught `BrainError` from a leg, so "no leg serves" surfaced as a throw or as a `broken:*` status depending on leg order; and a `before_provider_request` handler that added the key's header after the pre-flight got a sentence blaming "the per-model auth answer", which never held it. Now `resolveRouteCredential` records the conflict beside the credential it resolved (a resolution fact, as the credential's identity is — never a member of the returned object) and `adjudicateModelRoute` rules it first, before any origin is read: one refusal shape for every caller, no branch of its own anywhere. The request gate turns it into `[invalid_request] route.credential_answer_conflict: …` exactly as it turns every refusal; `routePairingStatus` answers `broken:credential_answer_conflict`; a derived seat (compaction summary, auto-mode classifier, prompt suggestions) falls back with `route.fallback_to_primary` (`cause: "route.credential_answer_conflict"`), or, where its deployment declined a fallback, keeps the seat and announces `route.classifier_unroutable`; a failover chain reads a refusing leg as a verdict again (first OK leg wins; all refused ⇒ the first refusal in leg order; an unjudgeable leg without an OK ⇒ `undefined`), and a throw from a leg's judge — a `BrainError` included — escapes, as before #1850. The fix names what the request carries, not who put it there. A consumer holding a closed table over `RouteRefusalCode` or `RoutePairingStatus` (a roster annotator, a refusal renderer, a contract test forwarding the conformance corpus's `expect.code` — the corpus itself has no vector for it, as it has none for the origin binding: both are facts of a credential the engine resolved) adds one row. Pins: `test/backlog1859-answer-conflict-verdict.test.ts` (15 cells: V1 / V1b / V1c / V2 the law and the gate, the conflict ruled before the origin, the optional `keyHeader`, nothing bound; R1 ×3 lanes the read face; D1 a real Runner's compaction seat falls back and the task completes on the primary, D2 both seat postures; F1–F4 the failover chain in every leg order, a `TypeError` and a `BrainError` from a leg's judge escaping; S1 the side-query verb returns the failure with the code; P1 a `before_provider_request` handler adding `Authorization` beside the hook's key — the build refuses with the neutral sentence, nothing sent); H4 / W10 / F8b and the request-params resolver combo re-meant to the verdict.
|
|
188
|
+
- **Who breaks.** A hook that answers both forms above (server's key resolver answers `{ apiKey }` only — inferred from its source, not run). A consumer that keys on the old silent supersession. Anything that read a header bag through `hasAuthCarrier` (the public export whose answer moved; `isCredentialHeaderName` and `isSensitiveRecordKey` are internal, not package exports) and took a vendor-prefixed `…-cache-key` / `…-idempotency-key` / `…-partition-key` header for a credential: it is none now. No redaction consumer moved: the egress structure walk, the argument summary and the terminal account read a record member's name whole, so `pagerduty_routing_key` / `x-routing-key` / `cf-aig-cache-key` members are masked or omitted by name as before (#1858). **Export surface:** names unchanged; `resolveRouteCredential`'s input gains one optional member `keyHeader`; no removal; minor-compatible (`isSensitiveHeaderName` is added to the internal credential vocabulary module, not to the package).
|
|
189
|
+
- **Pins.** `test/backlog1850-vendor-header-names.test.ts` (13 cells; V2d the rule is one linear pass, a member name made of thousands of tails is answered without recursion): V1 the three vendor spellings are no credential to any consumer, V1b every declared name under any prefix depth / `-` / `_` / case, V2 real credential names (`Helicone-Auth`, `x-api-key`, `Authorization`, `api-key`, `x-goog-api-key`, `Cookie`, …) still are, V2b the boundary (`xcache-key`, `cache-key-id`), V2c a front part holding a credential word keeps the name a credential (`secret-cache-key`, `auth-token-range-key`), V3 / V3b the env view reads whole names and the three readings differ on `CF_AIG_CACHE_KEY` only where the header reading says so, V4 a vendor-tailed record member is masked by its name in the egress walk, V5 `streamOptions.headers` accepts the three names and still refuses `Helicone-Auth` / `x-api-key` / `Authorization` / `X-Routing-Key` at both seats; `test/backlog1821-credential-origin-binding.test.ts` F1 ×3 (a real OpenAI brain, a real listener: the deployment key still goes out with the entry header, with a headers-only answer, and a per-model key rides beside it), F1b the gateway account header still takes the deployment key off, F7 the law reads names only (a credential-shaped value under `X-Partition-Key` is withheld from children and rides beside a per-model key), H4 ×3 lanes (any case, empty value, judge and build one sentence, the lock name read off the wire = the name refused, controls: no key beside the header, the empty key, the other lane's key header, `x_api_key`), W10 the Runner failure with zero requests and nothing bound, F8b a failover chain (OpenAI contradicting + Anthropic fine: servable and the stream moves on; every leg contradicting: the first refusal, judge and stream in one sentence); `test/request-params.test.ts` the OpenAI-lane resolver combo is refused.
|
|
190
|
+
- **Correction (#1858; security; the tail exemption was first written into the reading the redaction consumers share).** The first form of the tail exemption lived in the record-key reading that the egress structure walk, the activity argument summary and the terminal account also use, so a structured record showed an opaque value verbatim under `pagerduty_routing_key` / `pagerduty-routing-key` / `x-routing-key` / `cf-aig-cache-key` / `x_partition_key` (egress, both tiers; the activity argument) where the tree before it masked or omitted it. The exemption now belongs to the HTTP header-name reading alone (`isSensitiveHeaderName`, one implementation, the name's source as its parameter); the record-member reading takes a declared non-secret name whole (`partition_key`, `idempotency_key`, `CACHE_KEY` are shown, as before). Pins: `test/backlog1858-redaction-keeps-whole-name-exemption.test.ts` (names × 4 redaction consumers; the header reading's cells; the two readings differ by the tail exemption and nothing else). One consequence of reading a header NAME inside a record as a member: a `headers` object in a tool result masks `X-Partition-Key` / `cf-aig-cache-key` by name (it over-masks a non-secret; a whole-name `Idempotency-Key` is shown). No earlier release carries the wider reach (inferred: the section above is the first to describe it).
|
|
191
|
+
- **Known limits.** KL-1850-a (a credential-shaped value under a name that is no credential is not bound by the law); KL-1837-a restated (the tail exemption is the header-name reading's).
|
|
192
|
+
|
|
193
|
+
### A routing key is a secret: `ROUTING_KEY` leaves the list of non-secret names, so every spelling of it is masked wherever a name decides (#1865, first leg; P2; security; behavior narrowing — open to veto; **hard-breaking**: a bare `X-Routing-Key` header is now a credential header name (a model entry carrying it is per-model; `streamOptions.headers` refuses it; one value sent to two origins is refused at the second), and an environment variable named `ROUTING_KEY` is no longer handed to a child; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
|
|
194
|
+
|
|
195
|
+
- **Before.** `ROUTING_KEY` was one of the credential vocabulary's exact non-secret names, beside `PARTITION_KEY`, `SORT_KEY`, `RANGE_KEY`, `IDEMPOTENCY_KEY` and `CACHE_KEY` (written for a message queue's routing key). A paging service's events API takes its integration key as the request-body member `routing_key`, so a record carrying one — a tool call's arguments, a tool result, an error's detail — showed the value verbatim in the activity argument summary and at both egress tiers (`routing_key`, `ROUTING_KEY`, `routing-key`, `Routing-Key`: all four on the tree before this entry; read by tsx probe on the 7.52.0 stage, not by a release). The same list feeds three readings and moved all three: the record-member reading, the HTTP header-name reading and the environment-variable reading.
|
|
196
|
+
- **Now.** The name is off the list. `routing_key` and its three spellings are credential record members (the egress walk at both tiers returns the marker whole, the activity summary omits the field, the terminal account masks it); nested members too. `pagerduty_routing_key` is masked as before; `partition_key` and the other four declared names are shown as before. **The cost, accepted and open to veto:** the one list cannot tell a paging service's integration key from a message queue's routing key, so (1) a queue's routing key in a record is masked in the activity summary and at egress; (2) a header named `X-Routing-Key` (or any `…-routing-key`) is a credential header name — on a model entry it makes the entry per-model (the deployment key stops riding with it), its value is bound to the first origin it is sent to, and `streamOptions.headers` refuses it (`must not carry an auth header`); (3) an environment variable named `ROUTING_KEY` is withheld from a child process (a finding like any credential name). Reading the declared non-secret names by SOURCE — an HTTP header, a record member, an environment variable — is #1865 itself and is left to 7.53.0 (KL-1865-a).
|
|
197
|
+
- **Who breaks.** (1) A deployment that sends a bare `X-Routing-Key` header (a queue gateway): on a model entry's `headers` the entry is per-model now (the deployment key stops riding with it, F9); in `streamOptions.headers` it is refused at construction and at `setStreamOptions` (`not an auth channel`). Until the names are read by source (#1865, 7.53.0, KL-1865-a) there is no way to send it beside the deployment key: either (a) rename the header, where the gateway accepts another name, or (b) let it be that model entry's credential header and send no deployment key with that entry — the two cannot ride together. (2) The same routing-label VALUE handed to two origins in per-model answers is one credential at two hosts: the law binds it to the first origin it goes to and refuses the second before a byte leaves, whatever apiKey that one carries (`route.credential_origin_mismatch`, F9b; found by the review round, reproduced on the wire) — give each origin its own label. (3) A child process that reads a bare `ROUTING_KEY` variable no longer receives it — rename the variable to a name that holds no credential word, or read it from the parent. (4) A reader of a tool result or an activity line that expected a queue's routing key in the clear sees the marker. Nothing that worked for a secret changes: a value that was shown verbatim under these names is hidden now, never the reverse.
|
|
198
|
+
- **Pins.** `test/backlog1865a-routing-key-is-a-secret.test.ts` (R1–R3: four spellings × the activity summary and both egress tiers, and the nested body at both tiers; C1–C3 controls; N1–N5 the list, the member reading, the header reading and the environment reading). Re-meant cells: `test/backlog1850-vendor-header-names.test.ts` (the vendor spellings lose `X-Routing-Key`; V5 refuses it), `test/backlog1858-redaction-keeps-whole-name-exemption.test.ts` (the routing-key family moves to the credential rows; `X-Routing-Key` is a credential header in H1), `test/backlog1821-credential-origin-binding.test.ts` (F1 and F7 use the names still declared; new F9, a real brain and a real listener: `X-Routing-Key` on the entry takes the deployment key off the request; new F9b, two real listeners: one routing label in two per-model answers is refused at the second origin, a label of its own per origin is served), `test/rb219-secret-env-exemptions.test.ts`, `test/backlog1072-secret-env-plurals.test.ts`, `test/secret-env-name-table.test.ts` with its fixture (the name delta gains the row `ROUTING_KEY` keep → drop; the 7.34.0 oracle keeps its own frozen exemption list).
|
|
199
|
+
- **Known limits.** KL-1865-a.
|
|
200
|
+
|
|
201
|
+
### A `.catch` that reuses the run its handler started below waits on it with its own clock stopped (#1818; behavior change — a `.catch` the kernel failed closed after its own second while it waited on the body's run now answers; closed sets: none; exports: none; members: none) (@server @cli @client-core @test)
|
|
202
|
+
|
|
203
|
+
- **Before.** A handler that called `next(e)` without waiting and then threw left that run going; its `.catch`, asking `next(e)`, got the same run — and the `.catch`'s own 1 s budget kept running while it waited. On `tool.check` (a refusing station) a judge below that took longer than a second failed the dispatch closed at about 1011 ms (`its .catch ran past its 1000ms budget`), where the same `.catch` over a body that had not started anything answered after the same wait.
|
|
204
|
+
- **Now.** The `.catch` pauses its clock for as long as the run it reused is in flight and resumes when that run is on record, the pause a handler's own `next` takes (`runCatch`'s `replay`, `src/core/mods/kernel/dispatch.ts`). A reused run costs a `.catch` no more than a run it started itself; its own work still counts, before and after the wait.
|
|
205
|
+
- **Who breaks.** Nothing that worked: a dispatch that was failed closed for waiting on the body's run now ends with the `.catch`'s answer. A `.catch` that waits on a run that never settles waits as long as that run does (the dispatch's own abort and the run's own bounds apply, as for a handler's `next`).
|
|
206
|
+
- **Pins.** `test/mods-kernel-296.test.ts`: the reusing `.catch` over a 1.2 s judge answers; the control with a fresh `next(e)` answers the same; the clock starts again when the run is on record (a `.catch` that then spends more than its second is failed closed, named), and when the run failed. Mutations: no pause (the reuse cell red); no resume (both overspend cells red); resume on a fulfilled run only (the failed-run cell red).
|
|
207
|
+
- **Known limits.** KL-1818-a.
|
|
208
|
+
|
|
209
|
+
### A `*` in a stored row's `toolAllow` (rule grammar 0) is one entry that reads as nothing, not a verdict on the whole list (#1790; behavior change — back to the 7.50.x reading, open to veto; closed sets: none; exports: none) (@server @cli @test)
|
|
210
|
+
|
|
211
|
+
- **Before.** A session-rules row stored before the grammar stamp (a row carrying a store's `rev` and no `grammar`) whose `toolAllow` held a `*` was read with the whole allowlist voided: the list's other entries read as nothing too, the allowlist stayed present and empty, and every call of the session was refused with only "it is not in the session-rule allowlist" — no word of `*` or of the grammar — and the one advisory the run raised was the generic name audit (`config.toolpolicy.unmatched_names`: `"*"` matches no tool, "enforcement is unchanged"), which said nothing of the voided list. 7.50.x compared each entry as a tool name as written: `*` matched no tool and the entries beside it admitted what they named (`v7.50.2:src/core/runner/session-rule-policy.ts` — `new Set(rules.toolAllow)` and the name match).
|
|
212
|
+
- **Now.** One reading, the one `earlier_grammar` already had: in grammar 0 a `*` (or a `*(content)` spelling) is an allow entry that names no tool and admits nothing, by itself (`earlierGrammarReads`, `session-policy-store.ts` — the predicate `compileSessionRuleBucket` and the write judge both ask); the entries beside it are read as written. `["Read","*"]` admits `Read` and refuses the rest; `["*"]` alone is an allowlist that is present and admits nothing, so every call is refused. The entry is announced like every other entry its grammar reads as nothing — `config.session_rules_earlier_grammar`, once per distinct set per layer (`earlierGrammarAnnouncer`; no second sentence for a host to mint). The advisory says what `*` is: no allow rule in any grammar (a write holding it is refused), so the way out is to leave it out; a parenthesized entry beside it keeps the rewrite way. A run-time refusal under an allowlist its earlier grammar reduced now ends with the clause `; the session's rules are stored in rule grammar 0, where "*" names no tool and admits nothing` (a parenthesized entry is counted, never quoted — a stored entry's text is data). The write judge answers the same for the same entry: a stored grammar-0 row handed back is refused `earlier_grammar` ("leave the entry out"), and the tighten comparison reads the row's effective entries, so writing the entries a row enforced is no widening. The engine's own grammar is unchanged: a stamped row or a fresh value holding `*` still voids the list (and a fresh write holding `*` is refused `wildcard_all`).
|
|
213
|
+
- **Who breaks.** Nothing that worked. A session whose old row held `*` beside other entries runs those entries again (the 7.50.x reading) where 7.51.0 – 7.51.2 refused all of it; a host that stripped `*` out of such rows before upgrading (the SQL twin's migration) can drop that step — the engine now reads them. `KL-1761-b` is closed. `wildcard_all` stays in `AllowEntryInertReason`: it is still the reader's word for `*` in the engine's grammar, in the deny direction, and in the skill / request / deployment allow lists, none of which this changes; the closed set is not touched.
|
|
214
|
+
- **Pins.** `test/backlog1790-grammar0-star.test.ts` A1–A7 (the layer, the compile, the refusal sentence, the stamped control, the real Runner lane over a stored row, the advisory, the write and the tighten comparison); `test/backlog1761-rule-row-grammar-version.test.ts` unchanged and green.
|
|
215
|
+
|
|
216
|
+
### A recursive shell read is judged by the root it names, never by walking the tree below it (#1714; ruled C-R478 — zero friction; behavior change, loosening; closed sets: none; exports: none) (@cli @server @test @web-client)
|
|
217
|
+
|
|
218
|
+
**What changed.** `grep -r` / `rg` / `diff -r` and the other contents-disclosing recursive forms used to be judged by what the station could enumerate under the root (256 directories / 8192 entries): a credential-class file or a deny row met on the walk refused the command whole, a tree past the bound asked a mandated question on every face and mode. Both arms are gone. A traversal root is judged by what the command NAMES: the root itself (an operand, judged like any other), and the one thing a deny table can say about a subtree without opening it — the PLACED rows (a home-anchored or rooted deployment row, and where it really leads) whose location the root contains. Which files exist below the root is never asked, so a small tree and a wide tree get the same answer, and the answer no longer depends on the node's type at judgment time (the placed-row relation is a relation on locations and is consulted for every traversal root). The read-only face and the full shell mount the same deny seat, so they answer alike.
|
|
219
|
+
|
|
220
|
+
**Who breaks.** Three things a recursive read now reaches that it did not: ① a credential-class file somewhere below the root that only a SPELLED row covers (`.ssh/`, `id_rsa*`, `.netrc`, `.git-credentials` — rows with no place meet only what a command names; `cat sub/.ssh/id_rsa` and `grep pat sub/.ssh/id_rsa` stay refused), ② a deployment deny row written in the spelled form (`secrets/**`, `**/.env`) for a directory or a file somewhere BELOW the root: `grep -r x sub` does not name `sub/secrets`, so the walk reaches it (`grep -r x secrets` and `grep -r x secrets/inner` name it and stay refused; a rooted or home-anchored row, `<root>/secrets/**` / `~/secrets/**`, is placed, so any root that contains its location is refused as before), ③ a link below the root that leads outside the project, including to a home-anchored credential file: `grep -R` / `-S` follows it and the target's bytes come back (the containment judgment, like the deny rows, is made on the root, not on members). A bind mount of the home below the root behaves the same way: the identity seat answers only for a path it is asked about, and judging a root asks about none of its members, so `grep -r x .` reads the home's `.npmrc` there (KL-r2-a; a host-declared mount point that places the home-anchored rows at the mount's spelling is #1814). The Grep TOOL is not affected: it prunes by path before reading. The traversal forms read this way are every contents-disclosing walk the station models: `grep -r` / `-R` / `--recursive` / `-d recurse` (and `egrep` / `fgrep`), `rg` / `ag` / `ack` (with no path operand they walk `.`: `rg pat` — #1786), `diff` (member-wise, `-r` or not), `tar`'s archive-building keys (`c` / `r` / `u`), `git grep`, a container mount source (`docker run -v <src>:…`, `podman … --mount source=<src>`), and each root a glob expands to — the glob's own expansion keeps its bound (256 directories / 8192 entries), unchanged. A deployment that relied on the walk refusing ① or ③ has no equivalent switch (the enumeration arm is retired, not disabled); the structured Grep tool and named sub-directory roots are the remaining shapes.
|
|
221
|
+
|
|
222
|
+
**Type member renamed (mechanical BREAKING, no implementer downstream).** `ReadDenyMatcher.placedRowUnder?` → `ReadDenyMatcher.rowUnderRoot?`: the relation widened from placed rows to every segment row the root names or contains (rule rows still never answer it), so the name follows the meaning; a hand-built matcher that implemented the old member compiles unchanged (optional) and simply names no row.
|
|
223
|
+
|
|
224
|
+
**How to migrate.** Nothing to change for the common case — the friction this removes (a refusal in a small project and a question in a large one for the same `grep -rn TODO .`) is the point. Deployments: spell directory deny rows rooted or home-anchored. Reviewers of a recursive read's result: a link below the root is followed, so a project that links out of itself exposes what it links to.
|
|
225
|
+
|
|
226
|
+
### A home view nobody could read is no reading: every structured face refuses past it, as the shell station already asks (#1686; security; behavior narrowing — open to veto; hard-breaking: none; closed sets: none; exports: none; members: none at the package root)
|
|
227
|
+
|
|
228
|
+
**What changed.** The home-anchored deny rows (`~/.npmrc`, `~/.sema/settings.json`, …) meet a path through a bind mount or firmlink of the home by its HOME VIEW — the path respelled through the home's directory chain, matched by the environment's identity seat (`fileIdentity`). When an identity lookup the view stands on does not answer (it throws, times out, or fails with an error that is no conclusive "not there"), the view is unknown. The shell station already read that as a judgment it could not finish (`unanswered`: the mandated question on the full shell, refused on the read-only face). The structured faces read it as "no view" and judged the spelling: `Read <alias of the home>/.npmrc` served the home's token, as did a Grep or Glob over a tree holding such an alias. Now there is one answer per path, `{ view?, unread? }`, and every structured place that judges a path by its home view reads it. The target judgment of Read / Glob / Grep / RepoMap (and the write tools' write-deny judgment) refuses with `read_path_denied` / `write_path_denied`: no pattern is named (no row matched), and the sentence names the lookup that did not answer. A Glob / Grep / RepoMap walk, or ripgrep's output check, that meets such an entry refuses the whole call. A search scope whose view is unread refuses the search before either leg reads. The explicit-file lane in particular reads its target without judging it again. A missing file's sibling suggestion is not offered past it. A deny row the spelling or the view still meets refuses by that row, as before.
|
|
229
|
+
|
|
230
|
+
**Who breaks.** While the DECLARED home's chain does not answer, every structured read whose view is asked is refused until it answers (the chain is asked again by each judgment). While one level under a path does not answer, that path and everything below it are refused. An AUXILIARY home (the account home beside the declared one, or a host's declared configuration root) that does not answer stays its placement's best-effort loss, as documented: paths through an alias of it are judged as spelled, and this is said once. The shell station now reads that same answer, so a command whose reads do not pass through such a home is no longer asked on its account. That is a loosening of the 7.49 reading, one reading with the structured faces (`backlog1672` ⑩ ③ re-pinned).
|
|
231
|
+
|
|
232
|
+
**How to migrate.** Nothing for an environment whose identity seat answers. A host adapter whose `fileIdentity` fails for reachable paths should return `not_supported` (the lexical reading, unchanged) or a conclusive `not_found`, not a generic error.
|
|
233
|
+
|
|
234
|
+
### An environment with no home holds the home-anchored rows wider on a recursive read's root too: the root may hold them (#1803; ruled C-R480 — open to veto; security; behavior narrowing; hard-breaking: none; closed sets: none; exports: none; members: `ReadDenyMatcher.rowUnderRoot?` may answer `{ pattern, unplaced: true }`, `BashReadonlyRootBoundary.denyUnder?` (the member `ShellReadBoundary` inherits from it) may answer `{ unplaced }`) (@server @cli @test)
|
|
235
|
+
|
|
236
|
+
**What changed.** When the environment declares no home (`homeDir` absent: an `inheritEnv` allowlist that withholds `HOME`, a remote adapter that declares none), or its placement did not answer, the home-anchored rows cannot be placed, and a direct read already met their run anywhere (`cat .npmrc` refused). On a recursive read's root, though, an unplaced row was read as a spelled row: `grep -r X .` with the working directory holding the home's files ran unasked and returned `.npmrc` / `.sema/settings.json` bytes, while `grep -r X .sema` was refused. The root relation now has three answers. A row the root names or contains is a deny, as before, and an unplaced row whose spelled run the root names (`.sema`) is one of these. Otherwise, an unplaced home-anchored row is one the root MAY hold (`unplaced`, after every named row). The shell station reads `unplaced` as its `unanswered` finding: on the full shell it is the mandated question on either read face (a person answers it; no allow, mode or bypass releases it), and on the read-only face it is refused. A re-entered text (`sh -c '…'`) counts it as it counts a deny hit.
|
|
237
|
+
|
|
238
|
+
**Who breaks.** In an environment with no home, every contents-disclosing recursive read (`grep -r`, `rg`, `diff -r`, `tar c`, `git grep`, a mount source, …) is asked on the full shell and refused on the read-only face, whatever its root. This is the stated cost (C-R480). A deployment that declares a home is not affected. A host that consumes `ReadDenyMatcher.rowUnderRoot` or `BashReadonlyRootBoundary.denyUnder` (also read through `ShellReadBoundary`, which extends it) itself now sees the third answer. Reading it as a deny is the stricter side.
|
|
239
|
+
|
|
240
|
+
**How to migrate.** Declare the home (`ExecutionEnv.homeDir`; for `NodeExecutionEnv`, pass `HOME` to the child) to place the rows and lift the question. Test fixtures that compile the table under no home to test something else compile it placed (`test/read-deny-tiers-shared.ts` `HOME_ELSEWHERE`).
|
|
241
|
+
|
|
242
|
+
### A walker given only a pattern walks `.` — the root is judged there, on every reader (#1786; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @server @test)
|
|
243
|
+
|
|
244
|
+
- **Before.** `rg` / `ag` / `ack` declared no pattern operand and no options, so the pattern was read as a traversal root and the walk of the working directory a pattern-only invocation makes (`rg pat`, `rg -e pat`, `ag pat`, `ack pat`) was never judged: a placed deny row under `.` (rooted or home-anchored) let the walk run and the file's bytes came back, where `rg pat .` was refused. The same root reading hid `.` where the deployment does not list `grep` (`grep -r pat`), where a pattern FILE was given (`grep -r -f pats`, `rg -f pats`: the file was the only root) and where an option written after the pattern took a separated value (`grep -r pat --include '*.ts'`, `rg pat -g '*.ts'`: the value was the only root), on the listed and the unlisted reading alike. Present before #1714.
|
|
245
|
+
- **Now.** One root rule for both readings (`RootsGathered`, `shell-read-argv.ts`; the two copies are one): the operand in the row's first-operand slot — the PATTERN, a lone `-` there too — is no root; a value that fills that slot (`-f <file>`) is a file the program reads, still judged as a read, and no root; with no root the walk is `.`; a word an option written after the pattern may own is read both ways — a root, and `.` beside it (a path operand no option can own keeps the walk on that path alone: `rg pat -g '*.ts' sub`). The walker rows declare their pattern slot (`rg -e` / `--regexp` / `-f` / `--file` / `--files`, `ag -g`, `ack --match` / `-f` fill it) and only the value options whose arity is not in doubt (ack's context counts are declared attached-only and read both ways — #1792 —, ag's short counts require their value and its long names take theirs attached only; `ack --ackrc` / `--files-from`, `ag -p`, `rg --ignore-file` values are reads); an option a row leaves undeclared is read as taking no value — the reading that swallows no path — and, on a listed reader, puts the PATTERN slot word in doubt: it is judged as a path too (it may be that option's value). Where the options end is the walk's own reading (`program-read-model.ts` `walkArgs`): a `--` an option takes as its value (`rg -r -- -e x <file>`, `grep --label -- -e x <file>`) ends nothing, so the `-e` after it still fills the slot and `<file>` is judged — a grep defect of the same kind, closed with it. An unlisted program's words are all still candidates (the verb-blind over-approximation is unchanged); a listed walker's pattern is no path, as grep's.
|
|
246
|
+
- **What narrows.** `rg pat` / `ag pat` / `ack pat` / `grep -r pat` (grep unlisted) / `grep -r -f <file>` / `rg pat -g '*.ts'` are judged on `.`: a placed row under the working directory refuses them on both faces, before anything runs. A listed reader's pattern behind an unexplained option meets the deny rows and the roots as a path. On the read-only face with ack on its allow list, `--files-from` (what it opens is not in the command line, as find's `-files0-from`) is refused, and so is an unquoted glob or expansion among ack's words (it may expand into it).
|
|
247
|
+
- **Known residue.** (Closed since, as three forms of one hole: a count the row declares attached-only written apart from its option — `ack -C 3 pat`, `ack --context 3 pat`, `grep -r --context 3 pat` — #1792; a switch letter in front of a value letter in one short cluster — `rg -ie pat`, `rg -nC 3 pat`, `ag -iC 3 pat` — #1800; ag's short counts, which REQUIRE their value and were declared attached-only — `ag -C 3 pat` — #1792.) rg's options follow `rg --help` (ripgrep 15.1.0), ag's / ack's only the ones whose arity is certain, and the binary gate runs the walkers' walk cells, not their options; `rg --files` / `ack -f` list names but are still read as a contents walk (the stricter side); rg `--pre` / `--hostname-bin` and ag / ack `--pager` (a program the walker runs) are undeclared on purpose; a value they take written before the pattern (`ack --pager less pat`) moved the slot onto the value and left `.` unjudged — closed by #1819 (see the #1800 section: a long name the row declares nowhere may take the next word; a short letter it declares nowhere is still read as a switch, KL-1819-a).
|
|
248
|
+
- **Pins.** `test/backlog1714-literal-root.test.ts` #1786 ①–⑥ (both seats, listed and unlisted, the real shell lanes; ⑥ = the review round's forms); re-pinned `backlog222` ①, `backlog945-plan` F2 (the pattern is no root; `rg KEY` walks `.`), `backlog764` G-2d / G-6-c (`grep -r -f <file>`: the file is read, the walk is `.`); the equivalence tables' classes `1786-walker-pattern-slot` / `1786-traversal-roots-one-rule` and `1786-pattern-slot` / `1786-cwd-root` / `1786-slot-in-doubt` / `1786-ack-files-from` (18 grep-family entries leave the table — `egrep -Q /v1 a/b`, `fgrep --efm /v1 a/b` and their kind: behind an unexplained option that may take the next word, the slot word reads as the old tables read it; an option carrying its own value, `grep -i.txt pat f`, owns no next word and leaves the slot the pattern's).
|
|
249
|
+
- **#1792 · Before.** `ack -C 3 pat` / `ack -A 2 pat` / `ack --context 3 pat` / `ag -C 3 pat` / `grep -r --context 3 pat`: the rows filed a context count attached-only (what ag's long names and BSD grep do), so the separated `3` was read as the pattern and `pat` as the root. ack (an optional integer), ag's SHORT counts (`A:B:C:` — required) and GNU grep (`--context` requires its value) take that `3` as the option's value — the pattern is `pat` and the walk of `.` was judged by no reader, so a placed deny row under `.` let `ack -C 3 pat` run where `ack -C3 pat` was refused.
|
|
250
|
+
- **#1792 · Now.** A BARE option that some grammar of the row declares as taking a count attached-only, followed by a word spelled like a count (`3`, `+3`, `3_0`), may own that word: the argv walk (`bareOptionValueOf`, `program-read-model.ts`) hands the word over as a value read both ways, and the one root rule (`traversalRoots`, `shell-read-argv.ts`; the listed and the unlisted reading alike) judges every reading of who owns it. A word no option can own is a root as before; when the pattern-slot word may be owned, the first word no option can own is the pattern in the other reading, and when every remaining word may be owned the walk of `.` is a root too (`ack -C 3 pat`, `ack pat -C 3`, `ack -C 2 -A 3 pat`, `ack -C 3 -- pat`); the pattern of that reading may be a lone `-` (`ack -C 3 -` searches `.`: the `-` is standard input only in the reading where the 3 was the pattern); `ack -C 3 pat sub` is still judged on `pat` and `sub` alone. An option the row does not declare (`rg -n pat sub`, `ack -i 404 sub`), a count attached (`-C3`, `--context=3`), a word another option took as its value (`ack -C 2 --match -C 3`) and a non-count next word (`ack -C pat sub`) own nothing. Rows: ag's short counts (`-A` / `-B` / `-C`) are declared with the required value ag's short-option string gives them, so `ag -C 3 pat` reads like `rg -C 3 pat`; ag's long names stay attached-only.
|
|
251
|
+
- **#1792 · What narrows.** The forms above are judged on `.` as well: a placed deny row under the working directory refuses them on both faces. `ag --context 3 pat` (ag's long names take a count attached-only, getopt_long's optional argument) is judged on `.` too although ag leaves the `3` an operand — one grammar row cannot be both parsers and the stricter reading is taken, with no ag special case (KL-1792-a). Pins: `test/backlog1714-literal-root.test.ts` #1792 ①–⑪ (both seats, listed and unlisted; the ack / ag cells are judgement cells, neither binary being installed where the pins run); ⑦–⑨ are this paragraph's review-round forms (a pattern `-`, `3_0`, a word another option took).
|
|
252
|
+
|
|
253
|
+
### A switch letter in front of a value letter in one short cluster no longer hides the walk of `.` (#1800, #1819, #1826; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @server @test)
|
|
254
|
+
|
|
255
|
+
- **Before.** `rg` / `ag` / `ack` declare their value options but not their switches, and the three readings of one short cluster disagreed about it: the option-slot reading stopped at the first letter the row does not declare (`-ie` = unexplained, read the speculative way), the pattern-slot reading stepped over it (`-e` supplies the slot, so `pat` was an ordinary operand), and the next-word reading looked only at the cluster's last unreadable letter. `rg -ie pat`, `-ne`, `-we`, `-nf pats`, `-nA 3`, `-nC 3`, `-im 1`, `-ig '*.ts' pat`, `-nt ts pat`, `ag -iC 3 pat`, `-ig`, `-ip x`, `ack -iC 3 pat` therefore took the value for an operand, found a root in it, and never judged the walk of `.`: with a rooted deny row under the working directory all 13 shapes were let through on both seats, listed or not, and the real `rg -ie KEY` / `rg -nC 1 KEY` returned the member's bytes, where the split spelling `rg -i -e KEY` was refused.
|
|
256
|
+
- **Now.** One reading of a bare option's value, in the argv walk (`bareOptionValueOf`, `program-read-model.ts`; the cluster is read as getopt reads it — a letter the row does not declare is a switch, the first value-taking letter owns the rest of the token, so the next word only when it ends the token — and a long name as any name it abbreviates): the word an option may own is handed over as a value read both ways, and both readers take their roots from that one stream (`traversalRoots`). `rg -ie pat` is `rg -i -e pat`: the value is the pattern, no word is left over, the walk is `.`; `rg -ie pat sub` is judged on `sub` alone. A `--` an option may have taken as its value (`rg -ir -- -e pat`, `rg elsewhere -r -- -e pat`: the `--` ends nothing, so the `-e` after it supplies the pattern and the first operand is a path) leaves the program's parse and the walk's apart for every word after it, which one walk cannot state twice: the slot word and `.` are judged beside the roots — stricter than either parse alone (`rg -ir -- pat sub` judges `.` too: KL-1792-c). An owned `-` is a value, not standard input (`rg -ie -`, `rg pat -g -` walk `.`; `rg pat -` still reads standard input). The speculative reading of an unreadable cluster (its payloads as candidates) is unchanged. The `readOptionSlot` / `suppliesFirstOperandSlot` / `selectedOptions` cluster scans are untouched; unifying them behind one split is not done here.
|
|
257
|
+
- **What narrows.** The 13 shapes and the clusters of their kind are refused where the split spelling was; a cluster whose last letter is a declared value letter and that leaves no operand behind walks `.` (`rg -nA 2 pat`). Nothing else changes: a switch cluster (`rg -n pat sub`), a cluster with an operand left over (`rg -nC 3 pat sub`) and an attached value (`rg -nC3 pat`) read as before.
|
|
258
|
+
- **Pins.** `test/backlog1714-literal-root.test.ts` #1792 ⑩ (the 13 shapes and their kind, both seats, listed and unlisted; the leftover-operand controls), ⑬ (the option-shaped and `-` forms above) and ⑫ (the real lanes: `rg -ie pat`, `rg -nC 3 pat`, `rg -uu -ie pat`, `rg -nf pats`, both seats, no byte of the member).
|
|
259
|
+
- **Also (#1819).** The same hole through an option the row does not declare at all (security, P1; behavior narrowing — open to veto). `rg --hostname-bin x MARK`, `rg --unknown-opt v pat`, `ag --foo x pat`, `ack --pager less pat`: the rows leave a long name undeclared where its option runs a program (`--pre`, `--hostname-bin`, `--pager`) and the walk read it as taking nothing, so `x` was the pattern, `MARK` the root and the walk of `.` was judged by no reader — with a rooted deny row under the working directory the command was let through on both seats, listed or not, and the real `rg --hostname-bin x MARK` ran, where `rg MARK` is refused. Now a long name that no grammar of the row declares — exactly or as the abbreviation of a declared name — and that carries no `=value` may take the next word (`bareOptionValueOf`): the word is read both ways, like every word an option may own, and the one root rule judges both parses (`rg --hostname-bin x MARK` walks `.` beside `MARK`; `rg --hostname-bin x MARK sub` is judged on `MARK` and `sub`). A dash word it may have taken is no option in the parse where it was taken — it supplies no pattern and takes no value of its own — so the parse is unsettled as for a `--` (KL-1792-c), and the slot word and `.` are judged beside the roots (a chain of unknown options, `rg --hidden --hostname-bin x MARK`; `rg --hostname-bin --files pat`, `rg -e pat --hostname-bin -r --hostname-bin x`, `rg --foo --regexp=pat sub`, and #1800's declared owner, `rg -ie --files pat`). The scan for an option that supplies the pattern no longer reads a word that is another option's value (`rg -r --files pat`, `rg pat -r --files`, `ack -m -f pat`: `-r` takes `--files`, so `pat` is the pattern and the walk is `.`; the scan read `--files` as the switch, made `pat` a root and judged no `.`). A declared switch (`rg --files sub`, `grep -r --line-number pat sub`), a long name with its value attached (`rg --unknown-opt=v pat sub`) and a short letter the row leaves undeclared (`rg -n pat sub`) own nothing, as before. What narrows: an undeclared long SWITCH in front of the pattern (`rg --hidden pat sub`, `rg --no-heading pat sub`, `grep -r --no-ignore-case pat sub`), and one in front of another option (`rg --no-heading -n pat sub`, `rg --hidden -g '*.ts' pat sub`), is in the same doubt, so `.` is judged beside `sub` and a rooted deny row elsewhere under the working directory now refuses where it did not (fail-closed; the rows declaring rg / ag / ack's switch sets, #1813, takes it back). Known residue: a short letter the row leaves undeclared is read as a switch, so ag's width option (`ag -W 80 pat`) still leaves `.` unjudged (KL-1819-a; the row cannot tell it from `-w`; rg's table of value letters is complete); KL-1792-b is closed. Pins: `test/backlog1714-literal-root.test.ts` #1819 ①–⑧ and ⑦b (both seats, listed and unlisted; ⑧ the real lanes, `rg --hostname-bin x MARK`, `rg --hidden --hostname-bin x MARK`, `rg --hostname-bin --files MARK`, `rg -r --files MARK`, no byte of the member, with `rg -n MARK sub` still run under a row elsewhere); #1792 ⑥ no longer lists `rg --hidden pat sub` among the commands that read as before.
|
|
260
|
+
- **Also (#1826).** The same hole through an option whose value is a FILE the program reads (security, P1; behavior narrowing — open to veto). `rg --ignore-file ig MARK`, `ag -p ig pat`, `ack --ackrc ig pat`, `grep -r --exclude-from ig pat`: the separated value counted as the root the walk was given, so the walk of `.` was judged by no reader — with a rooted deny row under the working directory the command was let through on both seats, listed or not, and the real lanes ran `rg --ignore-file ig MARK` unrefused; the attached spelling (`--ignore-file=ig`) was right only because an attached value was never a root. Now the rows say which path values are a place the walk works in — `OptionSpec.walks`, on exactly `tar -C` / `--directory` and `diff --from-file` / `--to-file` — and the one root rule reads that fact for the separated spelling and the attached one alike. Every other path value of a walker (an ignore, exclusion or config file, a pattern file, tar's archive `-f`) is a candidate of its own, judged against the deny rows as itself (`rg --ignore-file sub/.ssh/id_rsa pat elsewhere` and `tar -tf <denied file>` still refuse), and no root: `rg --ignore-file ig MARK` walks `.`, `rg --ignore-file ig MARK sub` is judged on `sub` alone. What narrows: the attached spelling of a place was no root and is now (`diff -r --from-file=sub other`, `tar -cf - -Csub x`, `--directory=sub` — a rooted row under `sub` refuses where it let them through; `tar -C` stays the over-approximation it was apart from its option: the operands resolve under it, so it may hold what they name). What loosens: nothing that was a root of a real walk — tar's `-f` archive (`tar -cf - d`) is no longer judged a second time as a root (it stays a candidate); a list an option delivers the operands from (`ack --files-from LIST`, `tar -T LIST`) is a root of the walk in either spelling — the program works on what the list names — so `ack --files-from=list pat` no longer judges `.` as well, as `--files-from list` never did (a rooted row elsewhere under `.` no longer refuses it); and diff's `-S NAME` / `--starting-file NAME` (a member name to resume at, which diff matches inside the compared directories and never opens at the working directory) is no longer a root when written apart (`diff -r -S sub other`: a rooted row under `sub` does not refuse; the operands are the roots), nor when written inside the word, where it never was, and its value is a datum now, handed to the deny judge as no path at all (`diff -r -S sub/secrets/token.txt a b` is not refused by a row on that spelling; `diff -r a sub/secrets` still is). Pins: `test/backlog1714-literal-root.test.ts` #1826 ①–⑨ (both seats, listed and unlisted; ⑤ the real lanes, no byte of the member; ⑥ places in either spelling, ⑨ the rows' `walks` invariant); the equivalence tables carry two classes (`1826-read-value-no-root`, `1826-place-either-spelling`).
|
|
261
|
+
|
|
262
|
+
### Operands that arrive in a file are not in the command: a segment that takes them from a list is asked on the full shell and refused on the read-only face (#1834; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @server @test)
|
|
263
|
+
|
|
264
|
+
- **Before.** `ack --files-from LIST`, `tar -T LIST` / `--files-from LIST`, `find -files0-from LIST` work on the names the list holds. Since #1826 the list is the root of the walk (and `.` is not), which is right — but the places the list names were read by no judge: with `sub/secrets/token.txt` written in `list` and a rooted deny row `<dir>/sub/secrets/**`, `ack --files-from list MARK` was `none/vouch` on both seats, listed and not (eight cells), and the real lanes ran it. **The attached spelling regressed against 7.51.2 on the merge tree and is fixed in the same version, unreleased:** `ack --files-from=list pat` was `deny_hit/refuse` at the 7.51.2 base (889c90a15; the walk of `.` met the row — the C-R99 astra leg's reading of that tree) and `none/vouch` after #1826 made an attached list a root as the separated one already was; no released version carries the vouch.
|
|
265
|
+
- **Now.** One reading for both spellings. The rows already say which options deliver the operands from a file (`OptionSpec.delivers`: `ack --files-from` and its switch `-x` — the list on standard input —, `tar -T` / `--files-from` (GNU tar's NUL-separated list is `--null -T LIST`, the same option), `find -files0-from`, and the launcher `xargs -a`); `deliversOperands(row, args)` (`program-read-model.ts`) answers whether an invocation selects one — over the merged row, as the classify face's guard arm reads `delivers`: the separated and the attached spelling, a cluster (`-cvT list`), an abbreviation (`--files=list`), the option after the operands, and the traditional key word letter by letter (`tar cTf list out.tar`, `tar cfT out.tar list`). Both readers (the listed reader and the verb-blind one) mark such a segment's reads **not established** with one sentence (`takes the files it works on from a list file, whose names this station does not read`) — the existing mark (`ShellReadSegment.unreadProgramText`, which program text the station could not read already hosts), so the fold gives the existing `unestablishable` finding and its cells are the table's: **asked** on the full shell (mandated on the narrowed read face, the engine default; the ordinary ask on the open face where only a deny list asks), **refused** on the read-only face. No new finding word, no new disposition. The list file stays a read of its own: naming a denied file as the list is `deny_hit/refuse` (the deny hit outranks the ask), a list outside the roots is `outside_roots`; the roots rule is unchanged (the list is the root, `.` is not walked). The list's content is never read — a list naming a denied path, one naming a path outside the roots and one naming nothing denied answer alike.
|
|
266
|
+
- **What narrows (cli, client-core, test).** The forms ask where they were vouched (`ack -x pat < LIST` included — a switch the row did not declare before; from ack's manual, ack is not installed on the gate hosts): a mandated card on the full shell whose account reads `"<command>": takes the files it works on from a list file, whose names this station does not read, so where it reads cannot be established …`, a refusal on the read-only face; also an empty list (`--files-from /dev/null`) and `tar -x … -T LIST` (member names; over-approximated, KL-1834-b). Where the station guards nothing (open face, no deny row) there is nothing to confirm and the command is vouched as before. `xargs -a LIST cmd` is unchanged (the launcher's stream-fed operands are already `declined`: an ordinary ask / a refusal). Nothing else moves: a pattern file (`rg -f`, `grep -f`), a config file (`ack --ackrc`) and tar's archive (`-f`) are files the program reads, not lists of operands (pinned unchanged).
|
|
267
|
+
- **Pins.** `test/backlog1714-literal-root.test.ts` #1834 ①–⑩ (the three forms × both seats × the default table / listed / unlisted readers × a row under `.`, one elsewhere and none; ② the list as an explicit read; ③ the key word, ④ abbreviations and positions, ⑤ the place in a line — three weakest points, one counter-example each; ⑥ the neighbours; ⑦ the real lanes, no byte of the member; ⑧ the closed enumeration of the `delivers` options; ⑨ both spellings, the content of the list and the open face, ⑩ the switch that reads the list from standard input); #1826 ⑦ re-pinned (not refused, not vouched). The equivalence table carries one class (`1834-list-operands-not-vouched`, 28 `tar` entries re-pinned).
|
|
268
|
+
- **Known limits.** KL-1834-a (programs whose row declares no list option: `du` / `wc` / `sort --files0-from`, `rsync --files-from`, `git add --pathspec-from-file`, `zip -@`), KL-1834-b (the over-approximations: extraction mode, an empty list, a value spelled like the option), KL-1834-c (the list's content is not read).
|
|
269
|
+
|
|
270
|
+
### A prefix of a long option is that option only in a program that takes abbreviations: ripgrep's `--ignore` is a switch, not the start of `--ignore-file` (#1836; security; behavior narrowing — open to veto; closed sets: none; exports: none; members: none) (@cli @client-core @test)
|
|
271
|
+
|
|
272
|
+
- **Before.** Every reader that matched a long option against a row's table read ANY prefix of a declared name as that name (thirteen sites, each a prefix test on the declared name), whichever program the row describes. ripgrep's parser takes no abbreviation (ripgrep 15.1.0: `rg --ignore-f x` and `rg --igno MARK` exit 2 with `unrecognized flag`; 13.0.0, the gate hosts' container, rejects `--ignore-f` too — `Found argument … wasn't expected` — and both run `rg --ignore` as a switch of its own). So `rg --ignore pat sub other` read `--ignore` as `--ignore-file`, handed it `pat` as its value, took `sub` for the pattern and judged only `other`: with a rooted deny row `<dir>/sub/secrets/**` the verdict was `none/vouch` on both seats, listed or not (four cells; the real ripgrep prints `sub/secrets/token.txt` for it), and `rg --ignore pat sub/secrets/token.txt other` was `none/vouch` on both seats with ripgrep listed as a reader (the common deployment; unlisted it was refused) — the real ripgrep prints the member for that too. Found by the C-R99 round-3 opus leg; the first form's four cells are `none/vouch` on the 7.51.2 base as well (that leg's A/B reading, not re-measured here). The fifth form of the family #1800 / #1819 / #1826 / #1834: a word attributed to the wrong option hides a root.
|
|
273
|
+
- **Now.** One fact, one reading. `Grammar.acceptsAbbreviations` says whether the program reads an unambiguous prefix of a long name as that option (absent = it does: the `getopt_long` family — GNU and BSD grep, tar, diff, … — and the two other contents walkers, ag (`getopt_long`, its `options.c`) and ack (Getopt::Long's default, its `ConfigLoader`'s option parse); read from the upstream sources, neither binary is installed on the gate hosts); ripgrep's row declares `false` (`program-read-model.rows.ts`, `contentsWalker`'s `extra`). `isLongOptionSpellingOf(name, full, accepts)` (`program-read-model.ts`; it replaces the prefix-only `isLongOptionAbbrevOf`) is the one place a long name is matched against another, and each of the thirteen sites passes the fact (merged names carry it as `Merged.abbreviable`, a grammar's own readers ask the grammar; the first-operand slot carries `abbreviableLongNames`). A long name rg's row leaves undeclared is read as undeclared, with the doubt #1819 already gives such a name: it may own the next word, the roots of both readings are judged. `rg --ignore pat sub other` is `deny_hit/refuse` on both seats with a row under `sub`; the real lanes refuse both forms before the walk (`read_path_denied`, no byte of the member).
|
|
274
|
+
- **What narrows (cli, client-core, test).** The two forms above (and `rg --igno pat sub other`, `rg --ignore-f ig pat sub`, `rg --colo never pat sub` — spellings real ripgrep rejects — read as an undeclared name, never as the declared option owning its value). `--ignore` is now an undeclared switch like `--hidden`: `rg --ignore pat sub` also judges the walk of `.` (the stricter side the #1819 note already takes: a rooted row elsewhere under `.` refuses). `rg --pre cat pat sub` (`--pre` is a real flag, formerly read as `--pre-glob`'s prefix) keeps the same verdicts in the pinned cells.
|
|
275
|
+
- **What loosens.** One shape, the misreading's by-product: with `--ignore` taken as `--ignore-file`, the words behind it were parsed on from the wrong place, and a pattern could become a root — `rg --ignore -g glob -e <a protected file outside the tree> other` judged the pattern of `-e` as a root and was refused on a row for that file; it is vouched now (the pattern is no path; the roots are `other`, and the doubtful walk of `.` beside it), and still refused with a row on `other`. Pin ⑨. Everywhere else the undeclared reading judges the roots of both readings and also reads the next word as itself, which is a superset of what the abbreviation reading judged in every other pinned cell — a general superset is not claimed.
|
|
276
|
+
- **Pins.** `test/backlog1714-literal-root.test.ts` #1836 ①–⑦ (① the two leaks × both seats × listed / unlisted, with a row under `sub` and the exact-file row; ② the real lanes, no byte of the member, and the real ripgrep's own reading — the leak prints the member, a prefix is `unrecognized flag`; ③ the undeclared reading of a prefix; ④ the getopt_long contrast, grep and tar abbreviations still read, against the real binaries here; ⑤ the neighbours, and ag / ack / grep reading `--max` where rg does not; ⑥ nine readers asked the same question of one table, fact true vs false, a negated spelling and a mixed-grammar row; ⑦ the fact written once, on rg; ⑧ the stated boundary of KL-1836-a; ⑨ the one shape that loosens). The equivalence tables are unchanged.
|
|
277
|
+
- **Known limits.** KL-1836-a (the fact is written for rg alone: other rows whose program takes no abbreviation — measured on this machine: `curl`, `node`, `python3`, `docker`, git's top-level options — still carry the default).
|
|
278
|
+
|
|
279
|
+
### What a judge read is what runs: a tool-gate pass holds one immutable copy of the call's args (#1773; behavior narrowing — open to veto) (@server @cli @client-core @test)
|
|
280
|
+
|
|
281
|
+
- **Before.** The gate handed the model's own args object to every judge that is not a person — the tool policy (`req.args`), the org face, the stored-rule lane, the auto-mode classifier — and ran that object. A deployment policy that kept `req.args` and wrote into it while another judge waited (`tool.check`, the classifier) made the call run bytes nobody judged (judged `/safe`, ran `/secret`). Only the person's path ran a snapshot: the approval seat's retained clone, which the gate adopted as `presentedInput` at five sites.
|
|
282
|
+
- **Now.** One value. `ownArgs` (`tool-policy.ts`) takes args into the engine's own copy — an exact structured clone (`tryCloneArgs`), deep-frozen — at the gate's entry, and every value that replaces it comes through the same function: a hook's or a policy's `updatedInput` at the decision receipt, a person's edit at the approver's answer and at the gate's adoption (the same value when the engine's resolver took it). Every judge reads that value; the call runs it. The approval seat's retained snapshot IS that value (no second copy) and `tool.check` / `classic.PreToolUse` get it as `e.input` (no per-raise copy); the gate adopts no resolver snapshot any more (five adoption sites retired; `ContentAskOutcome.presentedInput`, read by nothing after that, is gone — an internal type).
|
|
283
|
+
- **What narrows, per face.** (1) A write into the args any gate seat was handed (`req.args`, the org face's `args`, the stored-rule lane's request, the classifier's request, `e.input`) throws `TypeError` — a rewrite is a decision's `updatedInput`; a policy that edited its request in place now fails with the policy-error refusal instead of having its edit run. (2) `runToolGate`'s result carries `updatedInput` on every allow exit (the pass's copy), not only when something rewrote — a host that read "present ⇒ rewritten" compares values; the loop re-validates it as before and re-runs `validateInput` only on a value change. (3) A host's own `resolveAsk` answering a plain yes with `presentedInput` no longer chooses what runs: the pass's args run. (4) Args the gate cannot copy (a function, a shared buffer) are refused at the entry, before any judge — `deniedBy: "policy"`, `the arguments of "X" could not be taken into the gate's own copy (unclonable value: …)`; the runner's own calls are always clonable (the loop's validation clones them first). (5) Args are plain data — plain objects, arrays, primitives: a Map, a Set, a Date, a buffer or a typed array keeps its contents mutable under a freeze, so a value holding one is refused, naming where and what (`args.m is a Map, whose contents cannot be made immutable — args are plain data`; in a hook's or a policy's rewrite, the receipt's refusal `… (updatedInput.probe is a Date, …)`); such a rewrite could not pass a tool's own schema anyway, and a durable park no longer meets one (its codec projection still handles `-0`, `NaN`, an undefined-valued key). (6) The request a judge is handed — `req` of a policy and of each layer of a composed one, the classifier's `req` — is frozen like the args: re-pointing its `args`, `toolName` or `face` throws, and the policy's throw is its refusal.
|
|
284
|
+
- **Reference.** CC 2.1.296 holds one input variable from its PreToolUse hooks through the permission decision to the call (`cli296.js:249217`, bytes 17634161 / 17637907; `:249218`, 17644878) and its judges sit across a process boundary — same rule, our mechanism is the in-process copy; no divergence entry.
|
|
285
|
+
- **Pins.** `test/gate-judged-is-run.test.ts` J1–J11 (the caller's object, the org and stored-rule lanes, a received rewrite, a person's edit, the entry refusal, a host resolver's snapshot, an in-place policy edit and a re-pointed request on a real Runner, an edit the gate cannot own, a policy's and the classifier's re-pointed request, plain data), N1–N3 (the person's path unchanged); `test/mods-tool-check.test.ts` M-a / M-b / M-c now assert what ran; re-pinned to the law: the direct gate's `updatedInput` on a plain allow (`hooks` / `core-security-coverage` / `backlog1236` / `backlog286` / `gate-stations-wiring`), the approval factory's changed-request cells at the gate (`backlog595` A2 / A4 / A6 / A7 / E5″ — the factory's own refusal stays pinned on a direct call), the resume belt's in-place swap (`backlog1328` ⑨; ⑨b pins, on base and branch alike, that a belt policy's throw interrupts the run instead of being the belt's named refusal — a defect left open), the park's projection cells (`park-mint-backend-fidelity`: a Date / Map / Set rewrite is the receipt's refusal; the projection is driven by plain-data shapes — likewise `backlog1079` / `backlog1092` / `design384-slice2` / `design388-write-fences` / `park-bidi-presence`, whose projection trigger is now an undefined-valued key). `docs/INTEGRATION-CORE.md` §4.6w. KNOWN-LIMITS KL-1398c-c retired.
|
|
286
|
+
|
|
287
|
+
### A refusal reports the args its refusing station judged (#1772; record change) (@server @cli)
|
|
288
|
+
|
|
289
|
+
- **Before.** `tool.check`'s refusal and a composed policy chain's accumulated rewrite carried the args they judged; an org deny, a stored deny row, a skill row, a classifier block, a mode refusal or the content refusal over a policy's rewrite reported the args before the rewrite, and every refusal on an approved-edit round (a PreToolUse re-screen, the re-check policy, the org layer, a stored row, the mode, the person's refusal of the round's question, the write-protection and read-boundary re-judges, the round cap) reported the args before the edit.
|
|
290
|
+
- **Now.** One mint (`refusalOf`, `tool-policy.ts`), read by the gate's lanes and by `combinePolicies`: a refusal carries the args its station or layer judged — the final args (after the policy's rewrite) on the first adjudication, the edited bytes on an approved-edit round, the denying layer's own args inside a composed chain (a nested chain's included: before, an outer composition's earlier rewrite overwrote an inner deny's); a deny that already carries the args its own layers judged keeps them. The exit reads that one member for `finalInput` (the call's `tool_end` record) and the deny observer's `input`.
|
|
291
|
+
- **Consumers.** A reader of a refused call's args (`PermissionDenied.input`, the `tool_end` record) now sees the redacted or edited bytes that were refused where it saw the raw or pre-edit ones.
|
|
292
|
+
- **Pins.** `test/gate-judged-is-run.test.ts` R1–R6 (with `test/mods-tool-check.test.ts` E1b / 9b).
|
|
293
|
+
|
|
294
|
+
### An extension's `ask` on an approved edit puts the edit to a person again — the PreToolUse re-screen and `tool.check`, one form (#1774; behavior change — open to veto)
|
|
295
|
+
|
|
296
|
+
- **Before.** On an approved-edit round, `tool.check` answering `ask` put the edited call to a person again, while a `classic.PreToolUse` re-screen answering `ask` refused the edit ("raises a new PreToolUse approval requirement the original approval does not cover").
|
|
297
|
+
- **Now.** Both put it to a person again. The re-screen's `ask` folds over the edit's verdict by phase 1's law, now one function both call (`foldHookAsk`, `tool-policy.ts`) — an allow becomes the hook's question (its message), an ask keeps its members, takes the hook's provenance and carries the hook's message beside its own, the hook's `requiresRealApproval` rides — and goes to the edit's resolution behind the mode arbitration (a hook's ask is not released); the three-round cap bounds the chain. A re-screen `deny` still refuses.
|
|
298
|
+
- **Reference.** CC 2.1.296 runs PreToolUse once, before the permission decision (`cli296.js:249217`, byte 17634161), and a hook's `ask` puts the call to the prompt (`:249209`, 17595387); it has no re-screen of an approved edit (ours is the superset). The shared form is the reference's meaning of a hook's `ask`.
|
|
299
|
+
- **Pins.** `test/gate-judged-is-run.test.ts` A1–A3.
|
|
300
|
+
### #1604 M1a (#1662) — prompt text changes through overlays only: a band table, three source classes in one order, the request-body sugar, a selector with a host role and form; the `PromptProvider` family retires; `prompt.compose` / `prompt.section` are the mods write ports (**BREAKING**; hard-breaking: four — H3, and H4 / H5 / H10 open to veto; closed sets: +3 `OverlayOp` / `HostRole` / `HostForm`; exports: see below) (@server @cli @client-core @test)
|
|
301
|
+
|
|
302
|
+
- **The rule.** Every system-prompt byte is a catalog message's selected variant; any source that wants different text writes an overlay — `replace` / `append` a message, `add` an entry to a band, `drop` a section — and the band table (`src/prompt-assembly/bands.ts`) says which source class may do what where. Classes apply in one fixed order, `bundled < task < center < deploy`, declaration order within; the later application on one target wins and the overridden one is named (`onError`, `phase: "prompt-constitution"`) and recorded (`Prepared.promptManifest.superseded`). INTEGRATION-CORE §12.2 / §12.6–§12.9, §10.8.
|
|
303
|
+
- **H3 — the `PromptProvider` family retires.** *Changed:* `PromptProvider` (`stableBlocks` / `stableSystem` / `replaceAll` / `section`), `TaskSpec.promptProvider`, `RunnerDeps.promptProvider`, `StablePromptContext`, `PromptTextDeclaration` (export), `analyzePromptCacheFriendliness` / `assertPromptCacheFriendly` / `PromptCacheReport`, `coworkPromptProvider` / `COWORK_PROMPT_SECTIONS`, `PromptSectionSeat` / `PromptSectionDisposition` are gone; a deployment writes prompt text through a `prompt.compose` registration on `RunnerDeps.mods` answering `{ overlays }`, and disposes of an MCP server's instructions through a `prompt.section` registration answering `{ text }` / `{ text: null }`. A wired `promptProvider` is refused by name (`config.spec_retired_key` / `config.deps_retired_key`; the sentence names the route). *Who breaks:* sema-server (origin/main `b3eb99f5`): `src/capabilities/scenarios.ts:172-230` (`semaIdentityProvider`, `codePromptProvider`), `src/capabilities/prompt.ts:17-21` (`stablePrompt`), `src/capabilities/memory-notice.ts:164-213` (`withNoPersistentMemoryNotice`), `src/capabilities/center-prompts.ts:130-147` (`centerPromptProvider`, `centerIdentityAssembled`), `src/task-settings.ts:728-760` (`hasConstitutionAnchors`, `providerDropsAppend`), `src/run-local.ts:1331`, `src/boot/resolve-spec.ts:443,1284,1644`, the TaskSpec key list in `src/trace/core-keyset-guard.ts:1180,1212`; any library host that wired a provider. cli / client-core / web-admin: no reference. *How to migrate:* the code scenario's role base → `TaskSpec.promptRole: "code"` (also `"coordinator"`, `"cowork"`); the brand line → `add` to the `role` band (it lands ahead of the role base); a role layer → `TaskSpec.systemPrompt` or a deploy `replace core/role.base`; the no-persistent-memory notice → a deploy `add` to `behavior`; the Cowork base → `promptRole: "cowork"` (the manifest names the variant `cowork` on `core/role.base` instead of `sema/cowork.*` sections). **Behavior change, open to veto:** a brand line added by the deployment now composes even when a center persona replaces the role base (before, a center identity replaced the scenario provider and the brand with it).
|
|
304
|
+
- **H4 — an already-assembled prompt is refused, never passed through (open to veto).** *Changed:* the "three constitution anchors ⇒ pass through un-doubled" heuristic is gone; any overlay whose text contains, verbatim, the canonical text of a `constitution` entry is a violation — the task's own `systemPrompt` / `appendSystemPrompt` (and a role preset) fail the task with `prompt.assembled_role_refused`, a mods answer carrying it is skipped and named. Every entry is compared whole (the survey `test/prompt-lock-text-survey.test.ts`: zero legitimate role texts contain one). *Who breaks:* a host still handing an assembled prompt as the role layer (server stopped at 1.310); a role text that quotes a safety sentence verbatim; server's `centerIdentityAssembled` / `providerDropsAppend` gates lose their subject. *How to migrate:* hand only the role layer.
|
|
305
|
+
- **H5 — `replaceAll` → `TaskSpec.promptForm: "minimal"` + the role text (open to veto).** *Changed:* the minimal form leaves the core-behavior band out; the locked sections (the mode sections of mechanisms in play among them) stay, and the task's `appendSystemPrompt` always composes. A fable-family model's three classic lines (core-behavior band) no longer compose on this shape. The form rides the parent spec seat (`ParentSpecSeat.promptForm`, a clamp-table row): every engine-built child composes in its parent's form, and `tightenTaskSpec` keeps it. *Who breaks:* a host that used `replaceAll` (server never did): with goal / orchestration / worktree active, or with an appended block, the prompt now carries those sections; the manifest (v3 — H8 below retired the top-level `constitution` member) reports block `role.base` in the `role` band where it said `replaced` / `provider.replace_all`. *How to migrate:* `promptForm: "minimal"` + `systemPrompt`.
|
|
306
|
+
- **H10 — a colliding center section is no longer dropped (open to veto).** *Changed:* center and deployment text are both overlays; on one target the deployment's applies after the center's and wins, and the center's is named superseded; the "every center section shadowed ⇒ do not pin" special case and `prompt.center_unmountable` are gone. *Who breaks:* operators keyed on the old "center section … not mounted" line (server maps it to `prompt_provider_needs_upgrade`). *How to migrate:* read the `prompt-constitution` line naming the superseded overlay.
|
|
307
|
+
- **Addition (non-breaking): `TaskSpec.promptRole` and `TaskSpec.promptForm`** — the host selector's two writers (`"general" | "code" | "coordinator" | "cowork"`, `"minimal"`); an out-of-set value refuses the task by name (`config.prompt_selector_invalid`).
|
|
308
|
+
- **The two stations take data and apply what they checked.** A `prompt.compose` answer is a plain `{ overlays }` with plain-data overlays, a `prompt.section` answer a plain `{ text }`; a class instance, an accessor or a Proxy is refused (that handler skipped and named). A `prompt.compose` handler that answers without awaiting `next(e)` is skipped and what is below it runs in its place. What applies is the copy checked at each handler's boundary; an answer handed up unchanged stands as taken. An overlay whose `message` has no selected variant this run is named superseded `(absent this run)`. `ParentSpecSeat` gains the optional member `promptForm` and the clamp table a `promptForm` row (restricting `"minimal"`, inherited only — a workflow script cannot declare it).
|
|
309
|
+
- **The center layer's text** reaches the composition through the version-2 artifact's overlays (the next section, #1604 M1b); the version-1 artifact is not read.
|
|
310
|
+
- **Exports.** Removed: `PromptProvider`, `StablePromptContext`, `PromptTextDeclaration`, `PromptCacheReport`, `analyzePromptCacheFriendliness`, `assertPromptCacheFriendly`, `coworkPromptProvider`, `COWORK_PROMPT_SECTIONS`, `PromptSectionDisposition` (each a breaking removal). Added: `PromptOverlay`, `SupersededOverlay`, `OVERLAY_SOURCE_ORDER`, `PROMPT_BANDS`, `OVERLAY_OPS`, `HOST_ROLES`, `HOST_FORMS`, `PromptOverlayOp`, `PromptOverlaySource`, `PromptHostRole`, `PromptHostForm`, `PromptVariantWhen`. `materializeMcpTools`'s trailing parameter is the `prompt.section` disposer (was a section seat).
|
|
311
|
+
- **Gates and pins.** New gate `gate:sugar-single-site` (the three role-layer input names have one reader in the assembly pipeline). `test/prompt-overlay-migration.test.ts` (P15: 136 cells against the dispatch base, byte-identical except H3 block views, H4 refusals, H5's declared delta); `test/prompt-golden-matrix.test.ts` unchanged (65 536 cells); `test/prompt-golden-1302.json` refrozen in two declared cells (`replace-all-provider` → minimal: manifest `constitution` / role block id; `provider-assembled` removed).
|
|
312
|
+
|
|
313
|
+
### #1604 M1b (#1662) — the center layer has one wire form (artifact v2) and composes; "which prompt" has one answer (the run's identity); the manifest is v3 (**BREAKING**; hard-breaking: three — H6, H7, and H8 open to veto; closed sets: `PromptEpochActivation` −1 +2, +`PromptEpochChange`, +`ArtifactVerifyCode`; exports: see below) (@server @web-admin @settings-schema @sdk @client-core @cli @test)
|
|
314
|
+
|
|
315
|
+
- **The rule.** A config center publishes one thing: an artifact v2 — `{ payload: { schemaVersion: 2, requires, overlays, evidence }, artifactDigest, sourceRevision }` — whose overlays are the `center` source class (between task and deploy); `scope.binding` selects the subset a run's binding (`TaskSpec.promptBinding`) gets. A run's prompt has one identity, `H(catalog, renderers, H(center, deploy, task), binding)`; only the center layer is pinned (adopted at session start or a compaction, resolved by digest in between), code and inputs are current and recorded when they move. INTEGRATION-CORE §12.10–§12.13. No reference-implementation counterpart (the reference has no center artifact).
|
|
316
|
+
- **H6 — center wire v1 → v2 (no converter, no dual read).** *Changed:* `verifyPromptArtifact` reads schema 2 only; `compatibleCore` / the slot catalog / `closure` / `catalogDigest` are gone — `requires` (targets / ops / traits / bindings) is a stored field the verifier recomputes from the overlays and checks against this engine (`unmetRequirements`), every overlay passes the center class's band columns and the locked-text guard, two `replace`s of one target under one binding and condition are refused, `evidence.intent` is required and a core-behavior `replace` needs an `evidence.refs` entry; `buildPromptArtifact({ overlays, evidence, sourceRevision })` is the one builder (it returns the verifier's result). `CenterPromptSource` takes no `engineVersion`; `PromptArtifactSource.sourceState()` is required. A retired v1 body is refused by schema (`ArtifactVerifyResult.code` `"shape"` / `"schema"`). The envelope is closed like the payload: a top-level key beyond `payload` / `artifactDigest` / `sourceRevision` (a v1 field written at the top included) is `shape` (#1805). *Who breaks:* settings-schema `src/types.ts:2819-2927` (`EffectivePromptsWire` v1, `PublishedPromptArtifactEnvelope`, `PromptEpochArtifactPayload`, `PromptBinding.declarations`) and `src/prompts-digest.ts`; web-admin `src/app/api/config/effective/route.ts:66,316-334`, `src/lib/config/personal-face.ts:39-136`, `src/lib/config/prompt-artifact-cas.ts:13-32` (`buildPromptArtifactEnvelope`, `closure.promptCatalog.sections`); server `src/prompts-domain-validate.ts:18,100-176`, `src/capabilities/center-prompts.ts:22-147`, `src/boot/web-admin.ts:41-55,618-704`, `src/run-local.ts:57,962-969`, `src/config-provider.ts:545-550`, `src/web-admin-client/candidate-gate.ts:60`, `src/web-admin-client/http-client.ts:292-303` (origin/main `9ed93bb` / `21ff0f84` / `b3eb99f5`). *How to migrate:* the three steps, in order — settings-schema major (`EffectivePromptsWire` = `{ schemaVersion: 2, artifact }`, v1 deleted) with this release; web-admin W (the worker lane publishes `buildPromptArtifact` output only; a role card in append mode is `{ op: "append", target: "core/role.base" }`, a conduct card `{ op: "add", target: "behavior" }`, a scenario override an overlay with `scope.binding`); server S (the candidate judge reads v2 only and hands `wire.artifact` to `CenterPromptSource.applySnapshot`; `centerPromptProvider` / `providerDropsAppend` / `hasConstitutionAnchors` / `centerIdentityAssembled` retire; the scenario name becomes `TaskSpec.promptBinding`). Roll back W first, S second. Migration pre-pins (server): **Pre-1** an old server (7.125.x) refuses a `schemaVersion: 2` candidate whole and keeps its LKG — two black-box cells on 7.125.x: a refresh tick receiving a v2 body leaves the live config and `promptSource.current()` unchanged and logs one `config_candidate_rejected`; a boot receiving a v2 body falls back to the disk LKG. **Pre-2** a new server with no v2 artifact composes with an empty center layer — unreachable in this order except: the boot fetch exceeds `CONFIG_BOOT_FETCH_BUDGET_MS` while the disk LKG is an old server's v1 ⇒ the new judge refuses the LKG ⇒ env fallback until the late fetch lands; pinned and stated in the pickup slip.
|
|
317
|
+
- **H7 — `PromptEpochDescriptor` is reshaped; a retired-shape record is answered by name.** *Changed:* `artifactDigest` / `packId` / `assemblyApi` / `centerArtifactDigest` are gone; a record is `{ epoch, identity, layers: { catalog, renderers, center?, deploy?, task?, binding }, activatedBy, changed?, sourceRevision? }` (`identity` must equal `promptIdentityOf(layers)`); `activatedBy` is `session_start | compaction | code_change | selection_change` (`legacy_migration` → `code_change`, which names the moved layers in `changed`). `normalizePromptEpoch` judges the v3 shape only; every reader of a stored record — `Session.getPromptEpoch()` and the session import door — reads it through `readStoredPromptEpoch`: a v3 pin, or `{ kind: "legacy", centerPinned }` for a record recognized by its retired field names (nothing in it parsed but whether `centerArtifactDigest` is a digest — the one value the retired reader took for a pin; `null` or a malformed value there is no pin, #1805); a record that is neither is skipped by the walk and refused at the import door (an upgraded session keeps its retired records in its history, never rewritten, so its own export imports). A session whose nearest record is a retired center pin is handled by the center source's tri-state at its next run: `active` ⇒ the current candidate composes and a new record says `code_change` + `changed: ["published_schema"]` (a mid-conversation replacement of the center text, recorded; the prefix cache misses once); `disabled` ⇒ an empty center layer, recorded the same way; `unavailable` (or no source) ⇒ the run fails `prompt.snapshot_unavailable` and the old record is kept (retry once the source has a verified state). A retired bundled record ⇒ `code_change`, no center layer until a compaction adopts one. `SessionStorage.getEpochAnchor()` returns the anchor record AS STORED (`Promise<unknown>`; a backend must not normalize it first, or a retired center pin would read as no record). *Who breaks:* server `src/plugins/pg-session-storage.ts:29-30,174-185` and `src/plugins/tidb-session-storage.ts:6-7,39,74-98` (they normalize the anchor — return it raw), `src/audit.ts:34-36,117-125` (the epoch shape it exports), test files that mint the v2 shape; sdk `packages/sdk/src/types.ts:2624-2632` (`PromptEpoch`) + `spec/openapi.yaml:13907-13923`; web-admin `src/lib/chat/prompt-epoch.ts:6-41`, `src/components/chat/chat-page.tsx:587,894,2054-2067` (the badge). *How to migrate:* store records opaquely and judge them with `normalizePromptEpoch`; read `identity` / `layers.center` / `activatedBy` / `changed`.
|
|
318
|
+
- **H8 — the manifest is v3 (open to veto).** *Changed:* `prompt.assembled` is `version: 3`: top-level `constitution` is gone, `identity` (`{ artifact, layers, epoch, activatedBy }`) and `selectors` (`{ binding?, traits, host, facts }`) are added; each section is `{ id, band, rank, variant?, renderer?, chars, locale: "canonical" | "overlay", contentHash, chain }` — `hash` is gone, `contentHash` is `sha256:<hex>` for published text and `salted:<hex>` for text carrying the task's or the run's own inputs, `chain` lists the catalog text and every overlay applied (superseded ones marked); `superseded` (the overridden overlays) rides the frame. `explainPromptAssembly` takes that shape (`ExplainInput`); `describeCatalog().sections[].locked` is replaced by `ops: { center, deploy, task }` (the band's columns as they are — a center editor reads `ops.center`). *Who breaks:* server `src/observability/prompt-manifest.ts:11-58` (`promptManifestRecordOf` whitelist), `src/budget.ts:304-315` + `src/observability/metrics.ts:375` (`prompt_constitution_total{mode}` — retires: its three values have one), `src/trace/project.ts:183,2349-2356`; sdk `packages/sdk/src/types.ts:2605-2618` (`PromptManifest`) + `spec/openapi.yaml:16895-16911`; client-core `src/adapter/downstream/eventToSdkMessage.ts:1897-2007` and `src/adapt/arms.ts:705-726` (`PromptManifestSectionView`; reads `id` / `chars` — unchanged — and `slot`, already gone since M0); cli `src/utils/analyzeContext.ts:321-325,1242-1257`, `src/sema/upstreamBridge.ts:3381-3394` (reads counts only — unchanged); web-admin `src/lib/trace/prompt-manifest.ts:35-136,184` + `src/components/analytics/prompt-composition-card.tsx:65-152` (`constitution` badge, `LOCKED_SLOTS`). *How to migrate:* project the new members; observe overridden overlays through `superseded` / `chain`; read `ops` for editability.
|
|
319
|
+
- **Additions (non-breaking).** `TaskSpec.promptBinding` (the deployment's scenario name; out of grammar ⇒ `config.prompt_selector_invalid`; rides the parent spec seat to every engine-built child — `ParentSpecSeat.promptBinding`). Every top-level session now adopts the current candidate at a compaction (a session that started with no center layer included); a child composes its parent's layer and never adopts one at its own compaction (before, a center-pinned child adopted newer candidates there).
|
|
320
|
+
- **Exports.** Removed: `artifactDeclarations`, `buildPromptArtifactEnvelope`, `computeCatalogDigest`, `computeBundledArtifactDigest`, `CenterOverlaySection`, `CenterOverlaySlot`, `CoreCompatRange`, `PromptCatalogSnapshot`, `PromptDependencyClosure`, `PromptEpochArtifact`, `PromptEpochArtifactPayload`, `PublishedPromptArtifactEnvelope` (each a breaking removal). Added: `buildPromptArtifact`, `requiresOf`, `unmetRequirements`, `ArtifactVerifyCode`, `PromptArtifact`, `PromptArtifactPayload`, `PromptArtifactRequires`, `PromptArtifactEvidence`, `PublishedPromptArtifact`, `promptIdentityOf`, `PROMPT_EPOCH_ACTIVATIONS`, `PROMPT_EPOCH_CHANGES`, `PromptEpochChange`, `PromptIdentityLayers`, `StoredPromptEpoch`, `PromptChainLink`, `PromptManifestIdentity`, `PromptManifestSection`, `PromptManifestSelectors`. Reshaped: `ARTIFACT_LIMITS`, `verifyPromptArtifact`, `ArtifactVerifyResult`, `CenterPromptSource`, `PromptArtifactSource`, `PromptArtifactStore`, `PromptEpochDescriptor`, `PromptEpochActivation`, `ExplainInput`, `CatalogDescription`, `CompiledSection` (`chain` / `renderer` in, `origin` / `declaredContentHash` out), `MaybeCompactOptions` (`promptIdentity` replaces `epochDeclaredSections`; `centerAdoption` carries the identity after the change).
|
|
321
|
+
|
|
322
|
+
### Every model call made beside the main loop is on the run's account: the permission classifier's replies and a mod's `$.model.complete` are booked into `stats`, through one function with the compaction summarizer (#1413; behavior addition — a run's reported tokens and cost grow by what these calls spent; additive optional member `stats.costBreakdown.sideCallMicroUsd`; hard-breaking: none; closed sets: none; exports: +0 / −0) (@server @cli @test)
|
|
323
|
+
|
|
324
|
+
- **Before.** The auto-mode classifier's reply kept only its verdict — its usage was dropped — and a mod's `$.model.complete` handed its usage back to the caller only. A run that classified every tool call reported the cost of its main loop alone.
|
|
325
|
+
- **Now.** `recordSideCallUsage` (`side-call-account.ts`) is the one place a side call reaches the account, with a lane — `compaction`, `classifier`, `mod`. The compaction summarizer's own accounting is lane `compaction` of it (its separate function is retired). Each reply is re-priced in core (the deployment's table, the cache family of the model that ANSWERED — a reply a degrading brain served from its fallback is priced as the fallback; never the brain's own `usage.cost`) and added to `tokens`, the prompt / output / cache counters and `costMicroUsd` — so the reported total, the budget gate at the next turn end and the trace agree. Every call is one `brain.call` trace row without a loop-turn index (as the summarizer's already was). The classifier books each paid reply, the re-ask included, before it judges the reply a failure; a classification cut by its deadline (or a cancel) before its reply is in hand marks `stats.usageMissing` at the cut — a brain that settles later books into an account the result has already left. The mods door is `Prepared.sideCallBookRef.book` / `bookSideCall`, the same function.
|
|
326
|
+
- **`sideCallMicroUsd` is an "of which" line.** `stats.costBreakdown.sideCallMicroUsd` (optional; present once a classifier or mod call was booked this leg, absent otherwise) is part of `costMicroUsd` and stays INSIDE `llmRootMicroUsd`: `llmRootMicroUsd + compactionMicroUsd === costMicroUsd` still holds, and a reader that sums the four partition lines must not add it. Compaction keeps its own partition line. The cumulative stats of a repair loop and a cascade carry the line as the sum of their legs'.
|
|
327
|
+
- **Server billing, check your table.** A run in `auto` mode now reports a higher `costMicroUsd` / `tokens` than before by exactly its classifier spend (the identity above is unchanged; nothing is double counted by reading the four lines). A side call whose model has no price table taints the run as unpriced spend — `costMicroUsd` and `costBreakdown` are then absent, as for any unpriced spend. A reply that carried no usage sets `stats.usageMissing` (the totals are a lower bound) instead of adding a 0. Across a multi-slice `resource_limit` resume the prior slices' side-call spend is inside the exact `costMicroUsd` and `llmRootMicroUsd` but not in this line (the same per-leg limit `compactionMicroUsd` has).
|
|
328
|
+
- **Pins.** `test/side-call-usage-account.test.ts` (the function: lane routing, re-pricing, unknown-is-not-free, unpriced taint, the trace row; the classifier lane through a real Runner: one booking per call, the re-ask, a billed failed reply, the total, the "of which" arithmetic, absent-when-none; the mods chain bottom: one reply = one booking; the import graph: one definition, one call site, no second door), the cumulative-stats pins, and the mutation table in the car receipt.
|
|
329
|
+
|
|
330
|
+
### Internal: the multi-package release base — a `packages/` workspace, `gate:packages` / `gate:packages:pack`, package rows in the layering registry, a package dimension in the published-tag check (no engine behaviour; `src/` and the root package's `files` / `exports` / dist untouched)
|
|
331
|
+
|
|
332
|
+
- `packages/mods-kernel/` is a private package cut at the root version (it began as a shell with no source; the next section records the kernel landing in it); `docs/RELEASE-TIERS.md` "多包发版底座" is the publish procedure, and names what the base deliberately leaves open (per-package wire manifest, the package → core build resolution).
|
|
333
|
+
|
|
334
|
+
### Internal: `@sema-agent/mods-kernel` builds from the kernel source — the package is no longer a shell (no engine behaviour; the root dist gains nothing from this step; the package stays `private`)
|
|
335
|
+
|
|
336
|
+
- **Wiring.** `packages/mods-kernel/tsconfig.json` compiles `src/core/mods/kernel/*.ts` — ES2023 + DOM libs, `types: []` (no ambient node types), strict, `rootDir` the kernel directory, `outDir` the package's own `dist/`; the entry is `index` (`exports["."]`). The package has no dependency of any kind and its `docs/LAYERING.json` row lists no bare import at all (not even a builtin). `gate:packages` now reads it as a package with source (7 files: tsc, the frozen export surface, the smoke table, the layering row), `npm run build:packages` leaves `packages/mods-kernel/dist/` (the 7 modules, `.js` + `.d.ts`), and `gate:packages:pack` builds, packs, installs the tarball offline and smokes it.
|
|
337
|
+
- **Pins.** `test/mods-kernel-package-wiring.test.ts` reads the committed files (the gate does not read `lib` or `types`; the pins do) and runs the gate over a copy of the real tree — static arms green, pack arm green with the built entry, red for an include that points at the wrong directory, a bare import in the kernel source, and a `private` that is no longer an explicit boolean. The package README no longer calls the package a shell.
|
|
338
|
+
|
|
339
|
+
### A project's own `.npmrc` / `.pypirc` are read like any project file; the home's pair keeps the credentials line — one table, two columns (#1741; behavior change: a READ refusal is lifted, open to veto, C-R455 / C-R456)
|
|
340
|
+
|
|
341
|
+
**What changed.** The built-in read-deny table's registry pair is now home-anchored — rows `~/.npmrc` and `~/.pypirc` (`credentials` tier, in the default selection) replace the unanchored `.npmrc` / `.pypirc` — so `cat .npmrc`, `Read <project>/.npmrc`, `grep -rn TODO .` in a project that carries either file are served in every permission mode (bypass included), while the home's own pair, by any spelling that lands there (absolute, `~`, `$HOME`, `..`, a link from a project), is refused exactly as before; the table now reads: a name that is a credential wherever it is found is unanchored, a name that is the person's file in the home but the project's own configuration elsewhere is home-anchored. **Who breaks.** Nobody loses a protection the home had; a project's registry file whose lines carry a real token now reaches the model and the session record when read (the cost is stated: a recursive `grep -rn registry .` prints those lines), and a deployment whose `readDenyBuiltinExclude` names `.npmrc` / `.pypirc` gets a loud refusal at wiring that names the new row (`~/.npmrc`); an environment that declares no home still refuses the project's pair (the anchored rows hold wider where they cannot be placed — on both relations a path has to them: a direct read meets their run anywhere, and a recursive read's root may hold them, so `grep -r X .` there is asked, #1803), as does a command-substitution spelling such as `cat $(pwd)/.npmrc` (KL-r2-b). **How to migrate.** Rename an exclude entry to `~/.npmrc` / `~/.pypirc`, or delete it if it only existed to read a project's file; a deployment that wants the project's pair refused again adds `.npmrc` / `.pypirc` to `readDenyPatterns`.
|
|
342
|
+
|
|
343
|
+
- **Pins.** `test/backlog1741-project-rc-reads.test.ts` (① table and matcher, ② both direct-read faces × five modes, ③ the home column × five modes × four spellings and a link, ④ a project outside the home, ⑤ Grep, ⑥ no declared home and the win32 fold, ⑦ the recursive read of a project carrying the pair, ⑧ the declared bound fork, ⑨ the unplaceable-spelling residual); the fixtures of `backlog245` / `763` / `764` / `889` / `1179` / `read-deny` / `rev7340` / `R-2` that used `.npmrc` as "a deny-row filename" now use `.netrc`.
|
|
344
|
+
- **Recursive reads (#1714).** Superseded in this same release: a recursive shell read is judged by the root it names, never by enumerating the tree below it — see the #1714 section above.
|
|
345
|
+
|
|
346
|
+
**#1398 wave one, the kernel (with #1733 folded in) —** hard-breaking: none in the kernel itself (the `Hooks` retirement and its three hard-breaking items are the stations section below); behavior: none from the kernel for a host that registers nothing — with no handler on the bus for an event nothing is dispatched: each station returns at its first guard (`observeClassic`, `dispatchClassic`, `raiseToolCheck`, `raisePromptCompose`: `src/core/mods/stations.ts`) and the engine goes on with its own answer; the two caller-object receipts (#1733) keep their readings. Exports (the kernel's own): +29 / −0 — values `KERNEL_CONTRACT_VERSION`, `MOD_TIERS`, `MOD_SEATS`, `MOD_EVENT_SIDES`, `MOD_EVENT_TABLE`, `MODS_INVALID_CODE`, `MODS_REGISTRATION_REFUSED_CODE`; types `ModTier`, `ModRegistrationTier`, `ModSeat`, `ModEventSide`, `ModEventName`, `ModEventRow`, `ModStation`, `ModRegistration`, `ModUses`, `On`, `ModHandler`, `ModHandlerOptions`, `ModNext`, `ModOrigin`, `ModTraceEntry`, `ModCaughtError`, `ModDollarBase`, `ModEventPayload`, `ModMatcher`, `ModAdmissionRecord`, `EngineDollar`, `ModelCompleteBlock`. Closed sets: +9 — `ModTier`, `ModSeat`, `ModEventSide`, `ModFailureAnswer`, `ModAbortPosture`, `ModEventName` (the event table's keys: the reference's 142 names), `ModFailureCause` (`threw` / `timeout` / `violation` / `reentry`), `ModTraceOutcome`, `ReceiptFaultKind` (kernel-internal). Notice codes: +1 — `mods.registration_refused` (audience `user`, the seat in `detail`). Configuration code: `config.mods_invalid`. Type members: `RunnerDeps.mods?` and `TaskSpec.mods?` (both optional).
|
|
347
|
+
|
|
348
|
+
### The mods kernel: one bus, one chain, one failure law (#1398 car A; additive) (@server @cli @client-core @test)
|
|
349
|
+
|
|
350
|
+
- **Before.** An extension point was a single slot (`Hooks`, `promptProvider`); several extension authors on one point were composed outside the engine.
|
|
351
|
+
- **Now.** `src/core/mods/kernel/` — registration and its two seats (`RunnerDeps.mods`, `TaskSpec.mods`, kept side by side), the seat × tier table, admission through `plugin.register`, the `uses` upper bound, the chain `($, e, next)` with `next.to` (CC's allow table), `next.origin` / `trace` / `signal` / `budget`, re-entry frames, a top-level exact matcher, site rules (pinned keys, the station's answer form; a violation is judged by the failure column and never reported as a crash), `observe`, `.catch`, the per-handler self budget (paused in `next` and `$`, not in `$.clock.*`), and the per-event failure / abort / pinned columns in one event table (`MOD_EVENT_TABLE`: 33 `classic.*` names, ten with a sema station; the wave-one engine events; the shell's surface names with `side: "surface"`). `$` on the engine bus carries eleven methods whose bottoms are existing seats (`src/core/mods/dollar.ts`). INTEGRATION §10.
|
|
352
|
+
- **The reference's event set (CC 2.1.296).** `MOD_EVENT_TABLE` holds all 142 names — 33 `classic.*`, 46 engine events, 63 `$` methods, generated once from the reference into `test/fixtures/cc296-mod-events.json` and pinned against it; the shell's names (`ui.*` including `ui.notify`, `command.*`, `config.*`, `audio.*`, `prompt.fill` / `suggest` / `edit` / `mention` / `autocomplete`, `prompt.read`) are `side: "surface"`. Stations in this version: eleven `classic.*` (`classic.SubagentStop` beside `classic.Stop`, same columns), `tool.check` (pins `ceiling` too), `prompt.compose`, `prompt.section` (pins `name`, `key`, `mountedTools`), `plugin.register`, and the eleven methods; `prompt.context` and `prompt.attachment` are named, not raised.
|
|
353
|
+
- **Selection.** An `on` pattern is checked against `uses.events` on every name it selects, raised or not, before the station filter — an undeclared `on("tool.call")` is refused now, not first in the version that raises it; a pattern that selects only names with no station refuses the prepare.
|
|
354
|
+
- **Re-entry asks `.catch` (CC 2.1.296).** A handler left out for re-entry that sits on a frame above the raiser, is not the raiser's, and has a `.catch` answers through it (`next.error.kind = "reentry"`); the raiser's own handlers stay silent.
|
|
355
|
+
- **A deny after `next` (CC 2.1.296).** On a `$` method, a handler whose `next(e)` came back with a result and that then answers `{ deny }` fails (named on `onError`); the caller gets the real result. Handing up a deny from below is not one; a judging event (`tool.check`) tightening after reading the engine's verdict is ordinary use. The bus names every handler it skips on its own methods.
|
|
356
|
+
- **Also (#1794): a `.catch`'s `next` counts as running, like a handler's.** The run a `.catch` starts below with `next(e)` is now counted in flight by the one start both paths share; a `.catch` that calls `next(e)` without waiting and answers `{ deny }` at once fails exactly as the handler form does (named on `onError`, the handler skipped, the caller waits for and gets the real result). Before, that deny was taken: the caller got the refusal while the call below ran on and finished. A `.catch` that awaits `next(e)` and then denies was a late deny already. The same start also puts a settled run on record in the same turn it stops counting as running (a deny answered a turn or two after a fast run finished was taken, for a handler's `next` as well), and no longer returns the failure it records (a run below that fails with a thenable as its reason left an unhandled rejection).
|
|
357
|
+
- **Also (#1806): a `{ value }` answered while `next` still runs fails like a deny does.** On a `$` method, a handler (or its `.catch`) that starts `next(e)` without waiting and answers `{ value }` at once stands in place of a call that is still running: it fails (named on `onError`, `a value while its next() still runs`), and the caller gets the real result. Before, the value was taken: the caller got the substitute while the call went on to finish, with nothing reported. A value written after `next(e)` was awaited is the handler rewriting what it read, and stands.
|
|
358
|
+
- **Payloads and answers received whole (CC 2.1.296 fixes a truncated hook input).** What a handler hands on and answers goes through the engine's one receipt of a caller's object (`src/core/mods/kernel/receive.ts`, zero-dependency): copied at every depth, cycles and shared references kept, a function or class instance handed as it is, no getter ever run — an accessor, or a payload that cannot be copied whole, is a site violation; never a shorter payload, no depth limit. The observer's copy detaches data containers at any depth.
|
|
359
|
+
- **One receipt (#1733).** The configuration receipt (`receiveOwnProperties`: the top record, each getter once) and the station-answer receipt (owned data to eight levels, nothing of the caller's run) are now two modes of that one receipt; both read as before.
|
|
360
|
+
- **`$.model.complete` (CC 2.1.296).** `prompt` and `system` take a string or a list of blocks `{ text, cache?: true }`; a list travels on the event as its joined text beside `promptBlocks` / `systemBlocks`. `cache: true` on a system block ends a span on the brain's existing system-block cache seat (`Context.systemBlocks`); on a prompt block it is the caller's throw (the user message has no cache seat). Every reply is booked once on the run's side-call account (`ModSeatView.book`, a required seat) as well as returned.
|
|
361
|
+
- **Budget constants** are the reference's (10 s own, 1 s `.catch`, runaway 5 s after), re-anchored on 2.1.296.
|
|
362
|
+
- **Wired.** `RunnerDeps.mods` / `TaskSpec.mods` are read: each leg's prepare admits both seats once (`admitModChain`; `plugin.register` runs at admission) and the stations raise on the leg's bus — the eleven `classic.*` stations, `tool.check` (and an ancestor's chain on a delegated child), `prompt.compose` and `prompt.section` (#1604 M1a); the sections below carry each station's law.
|
|
363
|
+
- **Package.** `@sema-agent/mods-kernel` compiles `src/core/mods/kernel/` on its own — zero dependencies, ES2023 + DOM globals only, one entry `index.ts`, version = core's — for the shell and client-core (its shell is wired with the multi-package base). `KERNEL_CONTRACT_VERSION` (1) is an integer compared for equality and moves only when the chain semantics, the registration form, the event closed set (names and side) or the failure columns change. The package name is open to veto.
|
|
364
|
+
- **Divergences from the reference.** `budgetMs: 0` is a zero budget (the reference reads ≤ 0 as unbounded); a handler failure on `classic.PreToolUse`, `classic.UserPromptSubmit` or `tool.check` is final (the reference skips every failure; `plugin.register` follows the reference: a failing judge is skipped, only `{ refuse }` refuses); a matcher compares top-level fields only (the reference walks nested objects); a task registration may not refuse a deployment one (the judge is skipped; the mounted record lists the skipped judges in `skipped`).
|
|
365
|
+
- **Divergence (stricter than the reference):** `cache: true` on a prompt block is refused (`mod-model-complete-prompt-cache-refused`; the reference's handling not yet read; retires when a message-level cache seat lands).
|
|
366
|
+
- Pins: `test/mods-kernel-registration.test.ts`, `test/mods-kernel-next-to.test.ts` (25 cells), `test/mods-kernel-dispatch.test.ts`, `test/mods-methods.test.ts`, `test/mods-kernel.layering.test.ts` (leaf imports, no `instanceof` / module identity, the standalone kernel build, two built copies interoperating, one integer contract number, one receipt), `test/mods-host.test.ts`, `test/mods-kernel-296.test.ts`; the receipts' own `test/own-property-receipt.test.ts` (Q1–Q7) and `test/backlog1709-host-verdict-one-reading.test.ts` (H1–H9) unchanged.
|
|
367
|
+
|
|
368
|
+
### The classic stations raise `classic.*` on one bus per leg; `Hooks` retired; both seats run; a delegated child's stop is `classic.SubagentStop` (#1398 car B; **BREAKING**; hard-breaking: three, open to veto) (@server @cli @client-core @test)
|
|
369
|
+
|
|
370
|
+
**Hard-breaking (open to veto).**
|
|
371
|
+
1. **What breaks:** the `Hooks` family is gone — `Hooks`, `TaskSpec.hooks`, `RunnerDeps.hooks`, `HOOK_FAMILIES` / `HookFamily`, `HOOKS_INVALID_CODE` (`config.hooks_invalid`), `preToolUseObservational`, `Hooks.timeoutMs` and the hook context types (`HookToolContext`, `UserPromptSubmitContext`, `PostToolBatchContext`, `StopHookContext`, `StopFailureContext`). A `hooks` key present on either seat at run time (plain JavaScript, or past the type checker) is refused by name, as every retired seat is — `new Runner({ hooks })` throws `config.deps_retired_key`, a task spec carrying it fails the leg `config.spec_retired_key` (the root and a delegated child alike), and a resume carrying it is refused before the claim as `checkpoint.config_refused` carrying `config.spec_retired_key` — instead of being ignored. **Who goes red:** every host that builds a `Hooks` record: a TypeScript build fails; a run that still passes `hooks` is refused with a sentence naming the retirement and `mods`. **How to fix:** register on `RunnerDeps.mods` / `TaskSpec.mods` (`ModRegistration`; the recipe below).
|
|
372
|
+
2. **What breaks:** the host-side folding tools retire and the constraint mint changes its input — `receiveHookResult` / `isEngineRefusal` are no longer exported (INTEGRATION §4.6f is gone); `createPreToolUseConstraintPolicy(hook, env, onCrash, timeoutMs)` is now `createPreToolUseConstraintPolicy(chain, env)` over a bus (an inherited entry carries `mods`, the installing leg's chain, where it carried `preToolUse`). **Who goes red:** a host that folds several hooks into one before handing it to the engine (server `composeHooks` and its slot table), and a host that rebuilds a screening entry for a cross-process resume. **How to fix:** hand each party its own registration — the engine's chain composes them; rebuild an entry as `createPreToolUseConstraintPolicy(await admitModChain({ deployment, task }), env)`.
|
|
373
|
+
3. **What breaks:** the whole-slot override becomes two seats side by side, and four shapes follow — a task's registrations no longer replace the deployment's (`spec.hooks ?? deps.hooks`; the core-internal slot multiplexer of `runSpec` and `tightenTaskSpec`'s "both set ⇒ throw" arm retire with it); `WiringManifest.hooks[]` becomes `WiringManifest.mods[]` (`{ name, seat, tier, events, observe?, skipped? }` mounted, `{ name, seat, tier, refusedBy, reason }` refused); the `hook` member of `allowRuleShadowed[]` carries the allowing registration's name in `entries` (it was always empty); `ToolGateInput.preToolUse` / `hookTimeoutMs` become `ToolGateInput.modBus`; a delegated child's stop station raises `classic.SubagentStop` (a resumed child too), any other leg `classic.Stop`. **Who goes red:** a host that set a task record to silence the deployment's hooks; a projection reading `wiring_manifest.hooks` (server `trace/project.ts`, client-core `wiringManifestView.ts` / `eventToSdkMessage.ts`, cli `wiringManifestStore.ts`); a host driving `runToolGate` with a raw `preToolUse`; a registration hung only on `classic.Stop` that relied on it to see a child's end. **How to fix:** a deployment handler that must not act for some task branches on `e`; projections read `mods[]`; a direct gate driver passes a bus (`admitModChain`); hang the same handler on `classic.SubagentStop` too.
|
|
374
|
+
|
|
375
|
+
**Migration recipe (a host's own; the engine ships no converter).** One old record = one `ModRegistration` (`name` unique in the run; `tier` `"user"` on the task seat, `"prepend"` / `"user"` / `"append"` on the deployment's; `uses.events` lists every event hung), each seat one handler: `preToolUse` → `on("classic.PreToolUse", …)` (`e.tool`, `e.input`, `e.tool_use_id`; return `next(e)` where the seat returned `undefined`), `postToolUse` / `postToolUseFailure` / `postToolBatch` → `classic.PostToolUse` / `classic.PostToolUseFailure` / `classic.PostToolBatch`, `userPromptSubmit` → `classic.UserPromptSubmit` (`e.prompt`, `e.source`, `e.inputId`), **`stop` → the same callback on both `classic.Stop` and `classic.SubagentStop`** (byte for byte the old reach: the old seat ran on every leg), `stopFailure` → `classic.StopFailure`, `preCompact` / `postCompact` → `classic.PreCompact` / `classic.PostCompact`, `permissionDenied` → `classic.PermissionDenied` (the three observer seats — `postCompact`, `stopFailure`, `permissionDenied` — hang `{ observe: true }` and return nothing: an observe handler gets a writable detached copy, as the old seats did; any other handler's `e` is a frozen received copy); `timeoutMs` → the registration's `budgetMs` (the old default was 600 000 ms, a registration's is 10 000 — carry the old value to keep the old bound); `preToolUseObservational: true` → `{ observe: true }` on the PreToolUse handler. INTEGRATION §10.6 has the station table.
|
|
376
|
+
|
|
377
|
+
- **Before (v7.51.0).** Ten callback seats on one `Hooks` record, one record per task (`spec.hooks ?? deps.hooks`), each seat with its own crash / timeout / abort posture; several extension parties folded outside the engine; the stop seat ran on every leg under one name.
|
|
378
|
+
- **Now.** Each station raises its event on the leg's bus (one admission per leg, at that leg's prepare — root, delegated child, resume): `classic.PreToolUse` (the gate's phase 1, the approved-edit re-screen, the resumed leg's re-screen), `classic.PostToolUse` / `PostToolUseFailure` / `PostToolBatch`, `classic.UserPromptSubmit` (objective, live steer, parked redelivery, wake message), `classic.Stop` / `classic.SubagentStop` (by the delegation fact, never by the leg kind — a resumed leg's kind reads `resume`), `classic.StopFailure`, `classic.PreCompact` / `PostCompact`, `classic.PermissionDenied` (the gate's deny exit; a host driving `runToolGate` itself has its raw `permissionDenied` callback run as one more observe handler of that event). Both seats run, deployment first within a tier. A delegated child dispatches its parent's task seat as its own task seat (the parent spec seat carries `mods`; a child's own `mods`, `[]` included, wins), its events carrying the child's identity and `agentId`; the parent's chain still rides the delegation fold as one constraint, which collapses into the child's phase 1 only when the child mounted the same chain (registrations admitted, each having hung the same handlers — pattern, matcher, observe, `.catch`, in order) under the same installation context (approver, durable mandates, environment, classifier instance). The constraint is minted from the installing leg's `classic.PreToolUse` handlers that judge (an observe handler never enters it). A thrown value in any failure sentence is named by the engine's egress-redacted renderer (the one the gate's own refusals use), handed to the bus as `describeThrown`.
|
|
379
|
+
- **Behavior changes beyond the shape.** (B2) a task with its own registrations no longer silences the deployment's. (B15) a registration hung only on `classic.Stop` is no longer called at a child's end. A decision station already cancelled is not consulted at all. A failed handler is named in the model-facing refusal (`a PreToolUse hook crashed (extension "x" threw …)`). An `observe` PreToolUse handler that throws or overruns is skipped (the old observational face failed the call closed). A `classic.PostToolUse` / `PostToolUseFailure` handler that throws is skipped and reported on `onError`. `runSpec`'s oracle stop gate is a task-seat registration (budget 600 000 ms) — a deployment's own user-tier `classic.Stop` handler now runs outside it.
|
|
380
|
+
- **Exports (`handoff:diff` against the release base).** Removed (each BREAKING, all in hard-breaking 1–2 above): `HOOKS_INVALID_CODE`, `HOOK_FAMILIES`, `isEngineRefusal`, `receiveHookResult`, `type HookFamily`, `type HookToolContext`, `type Hooks`, `type PostToolBatchContext`, `type StopFailureContext`, `type UserPromptSubmitContext`, `type WiringManifestHookEntry`. Added: `admitModChain`, `type ClassicEventBase`, the classic events' `e` types (`PreToolUseEvent`, `PostToolUseEvent`, `PostToolUseFailureEvent`, `PostToolBatchEvent`, `UserPromptSubmitEvent`, `StopEvent`, `StopFailureEvent`, `PreCompactEvent`, `PostCompactEvent`) and `type StopHookResult`. Configuration code: `config.hooks_invalid` retires (a `hooks` key is refused by the retired-key codes, `config.deps_retired_key` / `config.spec_retired_key`; `config.mods_invalid` stays a malformed registration's). Closed sets: `HookFamily` retires. Wire: `wiring_manifest.mods[]` replaces `wiring_manifest.hooks[]`; a run may now carry `classic.SubagentStop` in a projection that names the event.
|
|
381
|
+
- **Kernel (one host-facing addition on the bus input).** `ModBusInput.isProxy` — the host's Proxy probe: a Proxy a handler answers or hands on through `next()` is refused before any of its traps runs (a violation of the site); the engine passes `types.isProxy`, so a `classic.PreToolUse` handler answering a Proxy is refused fail-closed as it was before this release (the deny observer fires). Without it the bus read the Proxy through its traps and took the copy.
|
|
382
|
+
- **Who is hurt / compensation.** The three hard-breaking groups above; compensation = the recipe, the retired-key refusal (`config.deps_retired_key` / `config.spec_retired_key`) for a forgotten `hooks` key (loud, never a silently dropped deny), `wiring_manifest.mods[]` naming what each leg mounted.
|
|
383
|
+
- **Known limits.** KL-1728-a closes: a handler's failure reaches `onError(phase: "hook")` as the bus's sentence, the thrown value named by the redacting renderer — never the raw thrown object (pin `test/mods-classic-redo.test.ts` T1). KL-1398b-a (runSpec's gate inside a deployment's user-tier stop handler), KL-1398b-c (`$.session.messages()` reads the leg's latest model request) and KL-1398b-d (a `register` that hangs the same handlers on two legs but branches inside them collapses — the handler body is a per-leg closure, not compared) are new.
|
|
384
|
+
- **Divergences (registered):** `child-legs-read-ancestor-task-mods-as-constraints` (a child runs the same chain, CC form; only a different chain or a different installation context adds one constraint judgment), `mod-observe-declaration`, `mod-hook-failure-fails-closed`.
|
|
385
|
+
- **Pins.** `test/mods-classic-stations.test.ts`, `test/mods-child-legs.test.ts`, `test/mods-classic-redo.test.ts` (stop naming, both seats, the chain-collapse cells, the redaction of a thrown value, `hooks` refused at the three entrances, runSpec's gate beside a deployment stop); the pre-mods hook pins migrated through the test-only `test/helpers/legacy-hooks.ts` (one record ⇒ one registration; `stop` hung on both names).
|
|
386
|
+
|
|
387
|
+
### `tool.check`: an extension reads the gate's verdict once per adjudication round — it tightens freely and widens only through the allow layer (#1398 car C; behavior addition for a host that registers on `tool.check`, none otherwise; hard-breaking: none; closed sets: none; exports: +0 / −0; members: none) (@server @client-core @test)
|
|
388
|
+
|
|
389
|
+
- **What changed.** The gate raises `tool.check` (`e` = `tool`, `input` — the round's final args, after the Write spelling repair and every rewrite —, `tool_use_id`, `agentId?`, `ceiling?`, all pinned) at most once per adjudication round and never over a standing deny: in front of the ask branch over a standing allow (behind the org layer and the stored deny / ask rows); else behind the read-only arm, ahead of the classifier, over the standing ask or the allow the branch cleared it to; and once in each approved-edit round. The chain's bottom answers the verdict (`{ decision, reason?, classes?, mandated?, ceiling? }`). One comparison reads the answer: handed up unchanged ⇒ the verdict stands; `deny` (or a handler that fails, overruns, answers out of form, or a cancel) ⇒ refused, `deniedBy: "hook"`, the handler named, the refusal reporting the args the check judged (a policy's redaction, an approved edit); `ask` ⇒ the extension's question under the hook provenance (`decisionReason: "hook"` — over a standing ask, that ask keeps its members) — no mode releases it, no classifier answers it, no sandbox admits it; `allow` over an ask ⇒ cleared (`decisionReason: "hook"`) only behind the read-only arm and only where a member of the allow layer may clear it, otherwise the ask stands with `allowRuleShadowed` `{ member: "hook", entries: [<registration>] }`; an approved-edit round only tightens. A clear does not end the classifier's denial streak. INTEGRATION §10.7.
|
|
390
|
+
- **Who breaks.** Nobody: with no handler on `tool.check` nothing is raised and the gate reads as before (the existing gate pins are unchanged). A deployment that registers gains the event. The existing words carry the new source — `decisionReason: "hook"` on an ask or an allow, `deniedBy: "hook"` on a refusal, the `hook` member of `allowRuleShadowed` — so a projection that reads them as "a PreToolUse hook" now also sees an extension's verdict (the `entries` name which registration).
|
|
391
|
+
- **How to migrate.** Nothing to do. server — the registering side: a settings-hook adapter that judges a call after the permission rules hangs on `tool.check` (answer `{ decision, reason? }`; `next(e)` = no opinion), not on `classic.PreToolUse`.
|
|
392
|
+
- **Known limits.** KL-1398c-a — a defect carried by #1769: a delegated child that authors its own task seat did not raise its parent's task-seat `tool.check` (retired in this release: the #1769 section below). KL-1398c-b — an extension's question is answered like a PreToolUse hook's own ask: a blanket `onAsk: "allow"` or a deployment policy's approval answers it; the answer form follows the reference (`{ decision, reason }`) and carries no real-approval bit. KL-1398c-c — a defect carried by #1773: a judge that is not a person (`tool.check`, as the classifier already) judges the live args and the gate runs the live args, so a deployment policy that keeps `req.args` and edits it during the wait moves what runs (retired in this release: the #1773 section above).
|
|
393
|
+
- **Divergence (registered):** `mod-tool-check-widens-through-the-allow-layer` — the reference lets a hook's `allow` replace the engine's ask or deny for any tool not marked tighten-only; here a deny raises nothing and an `allow` widens only through the allow layer's predicate (CC 2.1.296 `_ht` / `BYn`, the tighten-only list `I8r`).
|
|
394
|
+
- **Pins.** `test/mods-tool-check.test.ts` (the two raise points and the round's flag, a deny raises nothing, the comparison over the mandated family and the class column, the hook-provenance ask under bypassPermissions / dontAsk, the event's final args and read-only `ceiling`, the approved-edit round, and on a real Runner: a deny rule's refusal, an ordinary ask cleared, the question face and the plan pause kept, a throwing handler named, a resumed leg raising nothing, the Write repair); the mutation table in the car receipt.
|
|
395
|
+
|
|
396
|
+
### An ancestor's `tool.check` judges a child that authored its own task seat — one reading of "the parent's guard constrains its children" (#1769; behavior narrowing — open to veto; hard-breaking: none; closed sets: none; exports: +0 / −0; members: `ToolGateInput.inheritedToolChecks?` (optional, new)) (@server @cli @client-core @test)
|
|
397
|
+
|
|
398
|
+
- **Before.** A parent's task-seat guard reached a delegated child two ways at once: its `classic.PreToolUse` handlers rode the inherited constraint into the child's fold, its `tool.check` handlers did not — a child whose task seat was authored for it (`mods`, `[]` included: a deployment baseline, a trusted script, a host's own spawn with the trusted chain) ran calls its parent's `tool.check` guard would refuse. A parent chain with only `tool.check` judges handed its children no constraint at all.
|
|
399
|
+
- **Now.** One reading: an ancestor's chain the child does not itself mount is a constraint on both judging events. The screening entry a leg hands down is minted whenever its chain judges a call (a judging `classic.PreToolUse` OR `tool.check` handler — `chainJudges`), and the chain travels on the minted policy. At each `tool.check` raise point (in front of the ask branch, behind the read-only arm, each approved-edit round) the child's gate raises its own bus, then each inherited installation in the chain's order (the order the PreToolUse constraints fold in), each over the verdict the previous left standing, all read by the one comparison: a `deny` refuses (`deniedBy: "hook"`, the registration named, a refusal ends the raise), an `ask` is the extension's question put to the child's approver (the hook provenance — no mode releases it), an `allow` clears nothing (a constraint only tightens; over an ask it is disclosed as the hook member's shadow). `e` is the same as the child's own raise (`agentId` = the child). An installation whose chain the child already raised (its own — a travelled task seat — or an earlier installation's) is not raised again; any other is raised whole, because a handler's answer depends on the chain around it (a child's own handler that answers first, or a deployment wrapper ruling on an inner answer, makes the same registration answer differently), so a registration the child also mounts — a deployment registration included — judges again inside the ancestor's chain, as the `classic.PreToolUse` constraint already judges it (a second judgment, never a lost one). Observe handlers never run as a constraint.
|
|
400
|
+
- **Who is affected.** ① A child spawned with an authored task seat under a parent whose task seat hangs on `tool.check`: its calls now meet the parent's guard — a refusal (`denied by extension "<parent registration>"`) or a hook-provenance question on the child's card where it ran before. server / cli / client-core: a child's card or refusal can now name a registration of an ancestor's task seat (same words, same members — `decisionReason: "hook"`, `deniedBy: "hook"`, the `hook` member of `allowRuleShadowed`). ② A durable park of a child records one more inherited constraint where an ancestor's chain (either seat) has only `tool.check` judges (`parentConstraintCount` +1, as a judging `classic.PreToolUse` chain already does); a cross-process resume re-supplies it like any screening entry (§4.6). server: a revive leg that supplies a fixed layer count (`boot/parked-revive-gate.ts` supplies the caller policy only) meets `resume.parent_constraint_mismatch` on such a row once a deployment or task registration judges on `tool.check` — today none does. ③ A host re-supplying a screening entry: hand back the policy `createPreToolUseConstraintPolicy` minted, as it is — an entry whose `mods` names a judging `tool.check` handler beside a policy the mint did not make (a wrapper, a spread copy) is refused: before the claim on a resume (`resume.parent_constraint_mismatch`, the row stays pending), `config.mods_invalid` at a fresh leg's prepare. ④ The UNGATED write warning no longer fires for a deployment whose only adjudicator is a judging `tool.check` handler (it judges every call). ⑤ A deployment `tool.check` handler with side effects (an audit line, a counter) sees a call of a child that authored its own seat twice — once in the child's chain, once in each differing ancestor chain — as a `classic.PreToolUse` handler already does through the fold. A child whose chain is its parent's (the Agent, fork and Workflow lanes when the child authors no seat) is judged exactly as before — once, on its own bus.
|
|
401
|
+
- **Reference.** CC 2.1.296 runs one module chain per session and raises `tool.check` for a subagent's call on that chain with the subagent's `agentId` (`cli296.js:246803` `_ht` / `hht`, bytes 15804187–15804771; `"tool.check"` 29× in the corpus); there is no per-leg task seat, so the guard always judges a subagent's call — the outcome sema now has; no divergence entry.
|
|
402
|
+
- **Pins.** `test/mods-tool-check.test.ts` K3 (flipped: refuses), A1–A13 (deny / ask / allow-never-widens / three generations in order / the parent's chain raised whole / the approved-edit round / an observer never a constraint / a host spawn with no parent seat / a host re-supply, minted and refused / a child's own first-answering handler beside the shared guard / a durable park refused pre-CAS then redeemed / an append-tier deployment guard the child's chain skips / a prepend deployment wrapper's ruling kept), N1–N3 (the Agent, fork and Workflow lanes unchanged: once), G1–G4 (the fold at the gate: the minted ask's evidence stamp, an inherited `allow` disclosed, both shadows, a refusal ends the raise); the mutation table in the car receipt. `docs/INTEGRATION-CORE.md` §10.7 / §4.6, `docs/GATE-OUTCOME-STATIONS.md` 36 / 37. KNOWN-LIMITS KL-1398c-a retired.
|
|
403
|
+
- **Also (#1801 / #1802) — one installation identity, one place an ancestor's question goes (behavior narrowing — open to veto; exports: +0 / −0; members: the unreleased `InheritedToolCheck` gains `settle`).** ① An inherited entry's chain identity is read off the bus its policy was minted over (each engine admission — a leg's, or `admitModChain` — records the chain it mounted), never off the `mods` list the entry carries: before, a host re-supplying an entry with `mods: []` (or a list equal to the child's own chain) on a fresh Runner or a cross-process resume skipped the ancestor's `tool.check` guard (#1801, P1). `mods` is now read only as a declaration — a judging `tool.check` handler named beside a policy the mint did not make is still refused, and such an entry licenses no ask grant. ② One predicate (`sameInstallation`: that chain plus the frozen approver, the two durable mandates, the env and the classifier instance) is read by all three places that asked "is this the same installation?": chain assembly (a leg already on the chain is not appended — a non-minted policy declaring the leg's chain no longer keeps the leg's own entry, and so its screening, from its descendants), the `classic.PreToolUse` pass-through, and the `tool.check` skip (the pass-through's own test: same identity, no mandate, a judging own chain; before, the skip compared chains only). An installation with the same chain under another approver, env, classifier or mandate is now raised on `tool.check` too — a handler with side effects may see such a call once more. ③ An ancestor installation's `tool.check` `ask` is settled where the installation froze it, exactly as its `classic.PreToolUse` ask is in the fold (#1802, C-R481): the ancestor's classifier and approver, the child's mode arbitrating first; headless ⇒ refused; an approver answering `"unavailable"`, or a durable mandate, floats the call (marked) to the child's park — in the approved-edit pass too, over the edited bytes (the first pass's unavailable re-route; #1817); a person's approval there that edits the call takes the gate's approved-edit round (re-judged, `tool.check` raised again on the edit; the round cap holds; a question of the child's own still outstanding is put to the child's approver again over the edited bytes). The answer is read by the gate's settlement ledger as the fold reads an inherited answer: a person's refusal there is `deniedBy: "ask_resolution"` with the `human_refused` settlement (it was `hook` with none), a person's yes is the call's settlement; a floated question re-reads the marked-call seat, so no mode releases the marked call. Before, the question went to the child's approver (a child's blanket `onAsk: "allow"` or a child-side yes ran a call the parent's approver would refuse). The gate's approved-edit round now has one entrance for a person's approval that carries args, whoever's question it answered. Pins: `test/backlog1801-inherited-check-identity.test.ts` DD0–DD4 / DD1b / X1 / X3 / T1 / T1b / T1c / T1d / E1 / E2 / U1 / U2 / U2b / U3 / R1–R5 / GC1; `test/mods-tool-check.test.ts` A2 (flipped: the parent's approver), G1 / G1b / G5; `test/inherited-gate-pretooluse-hook.test.ts` (a non-minted entry declaring the leg's chain: the leg's own entry is appended); `test/prepare-inherited-gate-transcript.test.ts` (the ancestor entry handed as minted).
|
|
404
|
+
|
|
405
|
+
### SendMessage's `summary` is optional: the first line of the message by default, an over-long one cut and told, never refused (#1632; behavior change, loosening — open to veto; closed sets: none; exports: none; members: none)
|
|
406
|
+
|
|
407
|
+
- **What changed.** A SendMessage call without a `summary` (or with a blank one) is no longer refused with `summary_required`; the label is the first line of the message (trimmed, CC 2.1.296 `pHn` @cli296.js:245008), and a summary over 200 characters — the caller's, or a long first line taken as the default — is no longer refused at the schema door ("Fix the arguments") but cut at 200 with the existing ` [summary truncated: N chars total]` marker, on the live frame, the notification line, the parked form, the uplink line and the receipt alike. The advertised schema loses `maxLength: 200` and the "required when message is a string" wording (new description = the reference's, cli296.js:277703), the cut keeps whole code points (the helper had been unreachable behind the schema bound; its surrogate-pair split would have gone live), and the operator continuation verb (`createAgentContinuationVerb`) no longer derives a label of its own (whitespace-collapsed first 200 characters, `operator follow-up` fallback) — it passes only an explicit `summary`, so its label is the first line like every other call's.
|
|
408
|
+
- **Who breaks.** Nobody is refused anymore; a caller or test that matched `details.error === "summary_required"` (the code is retired) or a schema validator that read `maxLength: 200` off the advertised parameters sees them gone; an operator-verb receipt's `details.summary` is now the content's first line instead of its collapsed head.
|
|
409
|
+
- **How to migrate.** Nothing to do; drop any handling of `summary_required`. Reference difference, kept on purpose: the reference cuts at 199 + `…`, this engine keeps its counted marker (`test/backlog1632-send-message-summary-optional.test.ts` D1–D6, L1–L4, C1, O1–O2; `test/prompt-golden-1302.json` refrozen, the entire delta is the `summary` property).
|
|
410
|
+
|
|
411
|
+
### An objective whose attachments cannot ride one request is refused as a whole, before anything is sent (#1682; behavior change, narrowing — open to veto; closed sets: none; exports: none; members: none)
|
|
412
|
+
|
|
413
|
+
- **What changed.** After the attachments are read and before the human message is appended, the seat sums the frames (as the engine mints them) and the objective text in the request guard's own estimator and compares it with the line the guard enforces for the leg's model (`guardBudget(model, the deployment's live window overlay)`, window less headroom); over it, the run ends `failed` with `config.user_attachment_invalid`, naming the count, the estimated tokens and the line, and nothing is sent, appended or committed (a later run on the session reads the same file in full). Measured at the base on the Runner lane (20 host text entries of ~99 000 characters on a 128K-window model): no refusal and no model error — the run completed, but the first request held attachments 18–20 only (the guard's last-resort trim dropped the objective and attachments 1–17) while `human_input{applied}` reported all 20 as `attached`. A model with no declared window has no line, so nothing changes for it.
|
|
414
|
+
- **Who breaks.** A host that sends an objective whose text and attachments together exceed the model's request line (about 109K tokens on a 128K window, counted at ~3 characters per token) now gets a configuration refusal where it got a run that answered from the last few attachments; it has to surface the sentence or split the attachments.
|
|
415
|
+
- **How to migrate.** Attach fewer or smaller parts (a path entry can take `lines`), or run on a model with a larger window; read the line from the refusal, or compute it as `guardBudget`. The guard's silent trim itself is not changed here (`test/backlog1682-attachment-room.test.ts` P1–P3, Q1–Q3, R1–R4).
|
|
416
|
+
|
|
417
|
+
### The system prompt is catalog data: every engine-authored prompt text is one catalog message, placed by a catalog section, chosen by traits and facts (#1604 M0, implemented as #1662; BREAKING exports; manifest shape)
|
|
418
|
+
|
|
419
|
+
- **What changed.** `src/prompt-assembly/catalog/` holds every system-prompt text the engine authors as literal data (messages with variants, sections with a rank, a band and a block, a nine-row band table). The composer reads the catalog; the six renderers (role base, model guidance, MCP instructions, reminder mark, environment, memory) are the only code that renders bytes beside catalog text and overlay text (the caller's appended block is a task-class `add` overlay the request-body sugar writes), each renderer with a version. The exported prompt constants (`DEFAULT_SYSTEM_PROMPT`, `CYBER_RISK`, `SUPERVISOR_PROMPT`, `COORDINATOR_ROLE_PROMPT` …) are projections of their catalog entries — the same text, one home. Every byte of the composed prompt is unchanged: a 65 536-cell matrix (2^11 facts × read/write faces × deferred × lean × fable, frozen before the move) and the 20 exported texts are byte-identical. The catalog data's digest and the renderer version vector are elements of the run's prompt identity (the whole identity is in the #1604 M1b section below), so every existing session records one `code_change` record on its next resume (one prefix-cache miss) — the same path any prompt-changing upgrade takes.
|
|
420
|
+
- **BREAKING — retired exports (H1), can be vetoed.** `harnessContext`, `constitutionBlocks`, `composeConstitution`, `defaultPromptProvider` and `SEMA_DEFAULT_PACK` are gone. Who breaks: a host that called them to preview a prompt (they never produced the bytes a run sends). How to migrate: preview with `describeCatalog()` for structure and `prompt.assembled` for what a run sent; a host that wanted "the default provider" wires nothing — the catalog is the default prompt.
|
|
421
|
+
- **BREAKING — pack shapes become the catalog (H2), can be vetoed.** `compose`, `validatePack`, `PackSectionDeclaration`, `PromptPack`, `PromptSlot`, `PromptOwner`, `PromptMutability`, `PromptRenderCadence`, `PromptCacheClass`, `describeDefaultPack` and `DefaultPackDescription` are gone; `describeCatalog()` / `CatalogDescription` / `SEMA_DEFAULT_CATALOG` / `catalogText` and the catalog types (`PromptCatalog`, `PromptBandId`, `PromptTrait` …) are added. A section's `slot / owner / trust / mutability` are replaced by its `band`; what each source class may do to a section is read off the band table (`describeCatalog().sections[].ops`, the three columns `{ center, deploy, task }`): the core-behavior band's sections — the ten simple-roster sections, now writable, and the classic fable paragraph — admit replace / append / drop through the center and deployment columns (the task column is empty), the five mode sections are locked (the constitution band: no source holds any op). Who breaks: the center tier's default-pack route and its `LOCKED_SLOTS` copy; a host linting a custom pack with `compose`. How to migrate: read `describeCatalog().sections[].band` and `.ops` (a center editor reads `ops.center`).
|
|
422
|
+
- **BREAKING — manifest projection (H7a), can be vetoed.** `prompt.assembled.sections[]` drops `slot / carrier / cadence / cacheClass` and gains `band` (always) and `variant` (the selected variant ids, for a message-bodied section; the host role's variant id for the role base); `context_usage.sections[].kind` is now the band id (a section's full v3 member list is the manifest item of the #1604 M1b section). Who breaks: the server's manifest whitelist projection, client-core's two section-view readers, the SDK's `PromptManifest` / `ContextUsageSection.kind` documentation values. How to migrate: read `band`; group by band.
|
|
423
|
+
- **New gates.** `gate:catalog-literal` — English prose of 40+ characters under `src/prompts/` or `src/prompt-assembly/` outside the catalog is red unless it is an operator diagnostic or an identity-registered exemption (renderer inputs, standalone assets, the Cowork base template carried verbatim as the role base's `cowork` variant). `gate:prompt-docs` — `docs/PROMPT-SECTION-MAP.md` is generated from the catalog and `docs/PROMPT-SENTENCE-EVIDENCE-INDEX.md` carries a generated message index; the generator fails on any catalog expression it cannot read.
|
|
424
|
+
- **Composition refuses a duplicate section id or rank** among the rows it may render (the catalog's own and the added entries) before rendering, as the retired pack validation did; two `add` overlays reusing one id are one entry — the later one composes and the earlier is named superseded (`test/prompt-catalog-m0.test.ts`, "composition identity").
|
|
425
|
+
|
|
426
|
+
### The simple roster, the subagent notes and the `# Memory` instruction re-anchor to the current reference; the fable preset stops repeating the fable roster (#1607; model-visible text, can be vetoed)
|
|
427
|
+
|
|
428
|
+
- **Re-anchored text.** The fable arm of "Communicating with the user" takes the reference's current punctuation and "the change merges"; action caution drops "if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding"; the autonomy section's parenthetical becomes "(such as restarts, deletes, or config edits)"; the delegated child's notes say "always absolute"; the `# Memory` instruction's frontmatter example sits in a markdown fence with "one-line summary, used to decide relevance".
|
|
429
|
+
- **Delivering-work and corrections stay on the fable axis.** A fable-family model on the simple shape receives both sections, as before. The reference lights them on capabilities of their own; that writer arrives with model traits, and until then the fable axis is their gate.
|
|
430
|
+
- **The fable preset says each thing once (behavior change, open to veto).** `fable5Model()` no longer carries `promptGuidance`: its three lines are catalog text (`core/behavior.fable-classic-guidance`) and compose exactly where the simple roster does not — the classic profile (the `lean_prompt` trait absent) in the standard form (the minimal form leaves the core-behavior band out). On the simple profile a run on the preset loses only that repeated paragraph (each of its three topics was sent twice per turn; the roster's wording now says it once), and every other shape is byte-identical for the preset and for a non-fable model (held per cell across the overlay move by `test/prompt-overlay-migration.test.ts`: the preset, a plain model, a fable-family model and an opus model on both profiles). A fable-family model that is not the preset (a deployment's own `claude-fable-*` / `claude-mythos-5` entry) used to receive no such paragraph on the classic profile and now receives it exactly as the preset does, nothing else moving; an entry's own `promptGuidance` still composes, after it.
|
|
431
|
+
- **Not followed (registered divergences):** the tool-parameter JSON line stays on for every simple run (the reference gates it on a remote quirk config, default off); the `# Memory` index bound stays 25 KB in bytes (the reference counts 25 000 characters).
|
|
432
|
+
|
|
433
|
+
### Two release-tooling gates: the commit log's tickets are the floor of the release notes, and a ledger row's ticket id stands in its first cell (#1742, #1775; no engine behaviour — `src/` and the package surface are untouched)
|
|
434
|
+
|
|
435
|
+
- **`gate:changelog-coverage` (#1742, new; in `gate:all`'s static tier).** Every `#N` a commit names between the previous `v*` tag and HEAD (subject and body; the commits a merge brings in count, but a merge's own message and the message of a commit whose diff touches only bookkeeping paths — the ledger, the limits list, release criteria, wire manifests, the CHANGELOG, plans; one table in the script — make no fix claim and are not read, and the verdict line counts them) must appear in the CHANGELOG's newest section, or be exempted there on a line that begins `no-notes:` — `no-notes: #101(ledger bookkeeping), #102` (comma-separated, `#N` or `#N(reason)`). The exemption is written by a person, per ticket, in the release's own section; no commit prefix, author or path exempts anything, and a malformed item is red. Red names each missing ticket with the commits that carry it. A tag-less checkout (the remote gate host's throwaway git index) is announced and skipped, exit 0; git that cannot run, or a shallow history (its range would read smaller than it is), is a refusal (exit 2); a commit record that does not frame (a control character in a message) is refused, never read short. The ticket-set reader (`ticketsOf`) is exported for a post lint to read the same set. Why: 7.51.0 shipped two commits naming #1728 that no notes, criteria file or post mentioned — the notes had been written from the cars' dispatch list, not from `git log`. On this tree the gate also found the two #1604 M0 / #1607 sections filed under 7.51.1 by a three-way merge (moved back by the stage head).
|
|
436
|
+
- **`gate:backlog-ledger` (#1775).** A table row that carries a bare `#N` cell anywhere but its first cell (`| | #9001 | … |`, a doubled leading cell), or a second one behind a good first, or a first cell that carries the number dressed (`#9001 (dup)`, `**#9001**`), or a row that ends like a ticket row (a status word, then a window) under a first cell that shows no id at all (so no spelling of the id — markup, an entity — hides a row), is red and named by line (eight progress-table labels that predate the rule are frozen in a list, whole-cell match). Before, only a row whose first cell was the id was read at all, so such a row's status, window and a CHANGELOG mention that shipped it were never judged. One row shape, no cell-count rule: the head cells differ from era to era (169 of the ledger's 1,320 ticket rows have a seventh cell) and the gate reads the tail two.
|
|
437
|
+
- **Known limits.** KL-1742-a — in a checkout with no `v*` tag reachable the coverage gate says so and passes (the remote gate host's throwaway git index is one); the judgment is made where the tags are, in the local release-candidate `gate-all`. Any `#N` on any line of the newest section counts as mentioned — an example in prose included — a written range (`#101–#104`) names only its two ends, a token glued to a word (`#1398a2`) is read as no ticket, and the newest section ends at the next column-0 `## ` line (no markdown container is modelled: a `## ` line inside a code fence ends it early — a false red, never a false green).
|
|
438
|
+
- **Pins.** `test/changelog-coverage-gate.test.ts` (the reading cells, the `no-notes:` grammar, the newest section, the verdict, and the script over throwaway repositories: which tag is the base, a tagged HEAD, a merge, a body, a suffix, no baseline), `test/backlog-ledger-gate.test.ts` ("the id cell is the first cell"), `test/gate-git-unavailable.test.ts` (the new gate refuses on a broken git and outside a repository).
|
|
439
|
+
|
|
440
|
+
### `gate:import-cycles`: a load-order cycle can no longer enter `src/` unseen — a ratchet over the value-import graph, on one import reader shared with `gate:layering` (#1791; no engine behaviour — `src/` and the package surface are untouched)
|
|
441
|
+
|
|
442
|
+
- **What it reads.** Every `src/**/*.ts` is a node; an edge is a **value** import — a default / named / namespace / side-effect import, an `export … from`, an `import x = require()` — the statements that survive into the emitted JavaScript. `import type`, `export type … from`, inline `import("…").T`, a `declare module "./x.js"` augmentation and a dynamic `import()` are not edges (erased, or resolved after the static evaluation order is settled). The repo compiles with `verbatimModuleSyntax`, so "not marked `type`" and "loaded at run time" are the same set: `import { type A } from "./x.js"` is emitted as `import {} from "./x.js"` and is a value edge.
|
|
443
|
+
- **What is red.** The baseline `scripts/import-cycles-baseline.json` lists today's cyclic strongly connected components (4: sizes 13 / 6 / 4 / 2). A module in a cycle but in no baseline component is a **new member**, named with the shortest import path that closes the loop back to it and the line of each import on it; two baseline components joined into one by a new edge are a **merge**, named with the joining edge; a baseline component the tree no longer reproduces whole (fewer members, split, gone) is **STALE** until banked. What the reader cannot follow is red too — a graph with an edge it could not follow cannot be judged: a relative specifier that lands on no file, climbs above the tree's root, or is spelled with a percent escape, a backslash or whitespace (Node reads `./b%2Ec.js` as `b.c.js` and strips a trailing space; TypeScript looks for files named `b%2Ec.ts` and `b.js .ts`); a `#` subpath import, an absolute path or a non-`node:` URL; a value import that lands on a declaration file or outside `src/`; a value import of the package's own name (it resolves through the exports map to built output); a symbolic link under `src/`. A root with no `src/` is exit 2, and without `--baseline` the baseline is the one inside `--root`.
|
|
444
|
+
- **How the baseline moves.** `npm run gate:import-cycles -- --write` banks a shrink and only a shrink: it refuses (exit 1, the file untouched) while any new member, merge or unreadable edge is present, rewrites nothing when the tree is already within the baseline, and creates the baseline only when the file does not exist. `--root <dir>` / `--baseline <file>` point it at another tree (the pins use them).
|
|
445
|
+
- **One reader.** The import-edge reader moved out of `gate:layering` into `scripts/import-edges.mjs` (`scanImports` → `{ specifier, kind, line }`, `readImportEdges` → resolved edges with `kind` / `resolved`); `gate:layering` calls it and its verdict is unchanged (2112 registered edges, 0 violations, before and after). `verify-layering.mjs` exports its own judgment only; the pins that read the reader (`specifiersOf` / `resolveSpecifier` / `UNRESOLVED_DYNAMIC_IMPORT`) import it from `import-edges.mjs`, and `npm run car:gate` runs `gate:import-cycles` beside `gate:layering`.
|
|
446
|
+
- **First reading.** 670 modules, 4 cyclic components, 25 members — the same member sets as an independent regex reading of the tree; the AST reader additionally sees 16 edges the regex missed (`export { … } from` blocks whose comments contain a `;`), none of them on a cycle. Run against the commit before the `extensionWords` move (the 23-module `fs-shared → mcp → prompt-section-seat → stations → tool-policy → tools/fs/index → fs-shared` loop), the gate is red and names it.
|
|
447
|
+
- **Known limits.** KL-1791-b — the reader is syntactic and models plain relative specifiers only; every other spelling it meets is reported, not guessed, and the compiler's own module resolution (`ts.resolveModuleName` with the NodeNext options: `#imports`, exports maps, symlinks) is the route to following them. A load that goes around the import syntax (`createRequire(import.meta.url)("./x.js")`) is no edge; the one `createRequire` in `src/` reads `../../package.json`. KL-1791-a — the baseline records membership, not edges: a new value import between two modules already in one baseline component is not seen. A hand edit of the baseline (or deleting it and running `--write`) banks growth as a visible diff in a tracked file — no script can forbid the file's author. Other scripts that read import declarations for a different purpose (binding-level analyses in `gate:phase-api`, `gate:closed-sets`, `gate:field-liveness`, `gate:spawn-sites`, `wire-changes`) are not on this reader.
|
|
448
|
+
- **Pins.** `test/import-cycles-gate.test.ts` (142 cells: every spelling's kind held against the compiler's own emit under the repo's tsconfig; the gate as the real script over throwaway trees — new member, shortest path, merge, stale / vanished / shrunk / split, `--write` refusing growth with the file byte-for-byte untouched, unreadable edges, an 8,000-module chain; the repo's own tree and baseline; roster and one-reader wiring) and `test/load-order-fs-shared-first.test.ts` (loads `tools/fs/fs-shared.js` before the barrel — the entry order that exposed the loop).
|
|
449
|
+
|
|
450
|
+
### A command cut for silence has its own error code: `liveness_no_output` joins both execution error sets, and the Bash tool names the window that cut it (#1795; closed sets: `ExecutionErrorCode` +`liveness_no_output`, `RemoteExecutionErrorCode` +`liveness_no_output`, `RETRYABLE_REMOTE_ERROR_CODES` +`liveness_no_output`; members: `ExecutionError.noOutputSec?` added, optional; exports: none) (@server @cli @sdk @test)
|
|
451
|
+
|
|
452
|
+
- **Before.** An execution env that cut a command for producing no output answered the same code as a command that ran out of its wall-clock budget — `timeout`, by the written contract — and the Bash tool projected that code as "Command timed out after N s (timeout limit: N s)" with N the budget the caller REQUESTED (`details.timedOut` and `details.timeoutSec` likewise): a number that had not elapsed, beside advice (a larger timeout) that could not help.
|
|
453
|
+
- **Now.** `liveness_no_output` is a member of both error-code sets and of the retryable family. It is ran-then-cut like `timeout`: the output captured before the cut rides the error, and zero captured bytes is an error result. The env states the window it enforced as `ExecutionError.noOutputSec` (a new optional member, the fifth constructor argument); the Bash tool says "Command was killed after producing no output for N s (… this is not the timeout you requested)" with N that window — an env that states none gets the same sentence without a number — and its details are `{ livenessNoOutput: true, noOutputSec? }`, with no `timedOut` / `timeoutSec`. `timeout` is unchanged: the wall-clock budget, projected as before. The remote contract text now says which bound answers which word (`readTimeoutMs` → `liveness_no_output`; the command's `timeout` and the lifecycle ops' idle bound → `timeout`). Both code sets are now registered in `docs/CLOSED-SETS.md` (`gate:closed-sets` reads them). The tools that recover a cut command's partial output read the two time-cut words as one (`isTimeCutCode`, `src/engine/harness/types.ts`): `Grep`'s ripgrep leg keeps the matches it captured (fenced, with its incompleteness caveat), and the PDF text and page legs of `Read` keep the leading text and the contiguous complete pages — a silence cut recovers what a budget cut recovers. The PDF legs' failure sentence for a silence cut names the silence ("was cut after producing no output for N s", no number when the env states none), not the 120 s budget.
|
|
454
|
+
- **Who breaks.** Nothing changes until an env answers the new word; the server's execution lanes switch codes in the same window. A consumer that holds a closed copy of either code set (cli, sdk) adds the word. A reader of a Bash result's `details.timedOut` / `timeoutSec` no longer sees them on a silence cut — it sees `livenessNoOutput` / `noOutputSec`. A retry whitelist keyed on `timeout` that meant to cover silence cuts adds the word (`RETRYABLE_REMOTE_ERROR_CODES` already carries it). **Known residue:** when the PDF legs recover a PARTIAL result from a silence cut, `Read`'s card still words it "pdftotext / pdftoppm timed out after N s" (`fs-pdf.ts`), with N the window the env stated (the 120 s budget when it stated none) — the partial content is complete and flagged; only that sentence's verb and, in the unstated case, its number are the budget's.- **Pins.** `test/backlog1795-liveness-no-output.test.ts` L1–L8 (the projection with and without a stated window, a window that is not a positive number, zero-output, the sets, the constructor, the unchanged `timeout` arm), `test/backlog1795-time-cut-consumers.test.ts` C1–C6 (both PDF legs with and without recovered output, the ripgrep leg, the unchanged budget arm, the other codes staying unrecovered, the predicate's total table), `test/rb439-remote-env-lifecycle.test.ts` (the retryable family).
|
|
455
|
+
|
|
456
|
+
### A parked resume whose child transcript is damaged names its cause: `transcript_corrupt` (#1796; closed sets: `RESUME_STARTUP_CAUSES` +`transcript_corrupt`; exports: none; members: none — `details.cause` gains a value) (@server @cli @sdk @test)
|
|
457
|
+
|
|
458
|
+
- **Before.** `RESUME_STARTUP_CAUSES` held five words and none for a transcript the store holds but cannot read: a parked resume over such a transcript failed startup with `details.error: "parked_resume.startup_failed"` and no `cause`, so a consumer could not tell it from any failure the table does not name.
|
|
459
|
+
- **Now.** A sixth word, `transcript_corrupt`, read from the session store's own codes: `corrupt_session` (the log has no header its store wrote) and `invalid_session` (a leaf or parent entry is missing from the log). The resume rewind joins them: a checkpoint cut at an entry the transcript no longer holds (a lost tail — the log is a clean prefix) used to surface the store's bare `not_found`, which is also the word for a missing session; it is now raised as `invalid_session` there, and only there, so the receipt carries the cause. It reopens as `env_failed`, exactly as an absent transcript (`session_gone`) does, so the reopen compensation and its sentences are unchanged; only the receipt gained its reason. `ResumeStartupCause` is now registered in `docs/CLOSED-SETS.md` with its reopen table (`ReopenReasonTableCoversEveryCause`), so a seventh cause is a compile error until it has a row.
|
|
460
|
+
- **Who breaks.** A consumer that switches over the five words (the server's passthrough, the sdk's closed copy, cli) adds the sixth; one that read "no `cause`" as "something else failed" now reads the word for this case.
|
|
461
|
+
- **Pins.** `test/backlog1796-transcript-corrupt-cause.test.ts` T1–T5 (a real file backend, a cold second generation, the store's own refusal for both codes, the lost-tail prefix, and the absent-transcript neighbour), `test/backlog1448-resume-startup-cause.test.ts` R1 / R1b / R11 (a storage rejection with a hostile `code` getter keeps its own coded cause through the rewind's mapping).
|
|
462
|
+
|
|
463
|
+
### The built-in memory tools' "were NOT mounted" advisories are told once per Runner, through one mint (#1797; behavior change — the unit moves from the session to the Runner; closed sets: none; exports: none; members: `AnnounceOnceSink.onErrorOncePerRunner` added to an internal contract) (@server @cli @test)
|
|
464
|
+
|
|
465
|
+
- **Before.** The four stations that decline to mount a built-in memory pair (the shared-memory pair and the engine memory trio; selector excluded, name occupied, or the ToolSearch retraction) announced on the once-per-session ledger, each station with its own copy of the sentence. A serving layer that opens a fresh session per request delivered the same standing configuration fact (`memory-tools-not-mounted`, `shared-memory-not-mounted`; `onError`, `phase: "config"`) on every request.
|
|
466
|
+
- **Now.** One mint, `announceMemoryNotMounted`, writes the sentence frame, the classification word and the scope for all four stations. The scope is the Runner: the first prepare that meets the fact tells the operator sink, every later run of that Runner — the same session or another — is silent, and a second Runner (its own sink wrapper) is told once itself. A changed sentence (the excluded name moved) is a new line; the identity is the redacted `(code, message)` digest, as before. The sentences are byte-for-byte what they were. The seat is `AnnounceOnceSink.onErrorOncePerRunner`: a memo keyed on the Runner's sink object, bounded like the session record, writing nothing to the transcript.
|
|
467
|
+
- **Who breaks.** An operator that counted these advisories per session now sees one per Runner. A process restart over an existing session tells the new Runner once again (the session record no longer suppresses it). The shared-memory pair's advisory moved with the engine trio's — one family, one scope. A test fixture that builds an `AnnounceOnceSink` by hand adds the member (two fixtures here did). Free-function `runTask`, which builds a Runner per call, tells once per call.
|
|
468
|
+
- **Pins.** `test/backlog1797-memory-advisory-per-runner.test.ts` P1–P7 (both pairs, both station kinds, one Runner and two, a changed sentence), U1–U5 (the seat), S1 (one sentence frame, four stations through it); `test/backlog560-memory-advisory-once.test.ts` ① ④ re-pinned (the digest and changed-text cells keep their meaning).
|
|
469
|
+
|
|
470
|
+
### `BackgroundChildEvent.parentToolCallId` documents the Workflow lane (documentation only — no behavior, no member)
|
|
471
|
+
|
|
472
|
+
- The field's note named only the `Agent` call. A workflow's `wa*` rows (spawn, every tick, the terminal frame) have always carried the `Workflow` call's id, one id shared by every row of the run — one card to N children, so only `taskId` tells the rows apart — and carry none when the workflow was started with no launching tool call. The note now says so; `test/hardening-cls-c-field-mismatch.test.ts` CLS-C-7 gained the fan-out cell.
|
|
473
|
+
|
|
474
|
+
### Documents brought to what the code does; `composeSystemPrompt` retired; the PDF partial-result sentences name the cut that happened (#1811, #1795 follow-up; BREAKING export: −1; behavior change: model-facing sentences on a silence cut only; closed sets: none)
|
|
475
|
+
|
|
476
|
+
- **Documents (no behavior).** `docs/PROMPT-CUSTOMIZATION-GUIDE.md` taught the retired `PromptProvider` family (`stableBlocks` / `stableSystem` / `replaceAll`, the slot picture, `assertPromptCacheFriendly`, the v1/v2 manifest faces); it now routes a deployer to the doors that exist — `TaskSpec.systemPrompt` / `appendSystemPrompt` / `promptRole` / `promptForm`, a `prompt.compose` registration on `TaskSpec.mods` or `RunnerDeps.mods`, the center layer, `prompt.section` — and points at INTEGRATION-CORE §12 / §10.8 and `docs/sdk/06` for the contract and the sample instead of carrying its own copy; the retired doors are one table. INTEGRATION-CORE: the H2 migration rows no longer say the catalog view's `locked` (it carries `ops`, §12.3), and the §10.3 "a deny after `next`" bullet states the value arm of #1806 (a `{ value }` answered while `next(e)` still runs fails like a deny does; after `next(e)` answered, a value that rewrites what the handler read stands). `docs/RELEASE-TIERS.md`: `car:gate` runs four steps (six with `--full`), `gate:import-cycles` among them. Two `RunnerDeps` JSDoc links named the retired `promptProvider`; they name `mods`. Three more documents taught the retired provider family and the pack (`stableBlocks` / `stableSystem` / `describeDefaultPack()` / `assertPromptCacheFriendly`): `docs/THIRD-PARTY-INTEGRATION.md` §2.1, `docs/ARCHITECTURE.md` §3.1 and `docs/SERVICE-INTEGRATION-GUIDE.md` §6 now say catalog / overlays / `describeCatalog()` and link the customization guide. INTEGRATION-CORE §10.8 and §12.7 no longer say "the later one on the same target wins" for every op: what a later overlay does to an earlier one is the op's rule — `replace` of a message won by the later, `append` accumulates, `add` keyed by overlay id (same id replaces and moves to the end of the band's additions, a different id stands beside it), `drop` removes a section or a named entry (`foldOverlays`, `overlay.ts`).
|
|
477
|
+
- **BREAKING — retired export, can be vetoed.** `composeSystemPrompt` (and its private helper) is gone from the root export (−1; `test/export-surface.snapshot.json` regenerated, the diff is that one name). Who breaks: a host that called it to join a base prompt and a memory block. No engine path called it (the composer places the memory tail), sema-server has no source reader (its consumption ledger lists the name as `skip`), and no other neighbour imports it. How to migrate: join the two strings yourself, or stop assembling a prompt by hand and write overlays (INTEGRATION-CORE §12.6).
|
|
478
|
+
- **The Read tool's PDF partial-result sentences (#1795 follow-up, KL-1795-a).** A `pdftotext` / `pdftoppm` run cut by the env's silence window used to be reported "timed out after N s" in its five model-facing sentences (the partial text note in both Read shapes, the partial and the complete-at-teardown page headers, the vision-fallback note), with N the 120 s budget when the env did not state its window. The partial results now carry the cut itself (`cut: { kind: "budget", sec } | { kind: "silence", sec? }`, replacing `timedOutAfterSec` on the two internal result types) and the sentences read it through one function (`pdfCutWords`): "was cut after producing no output for 30s", or "was cut for producing no output" when no window is stated, and "before the cut" for the later clause. A budget cut reads byte for byte as before. The `timedOut: true` card markers stay: a silence cut is a time cut in the engine's one reading of the two codes (`isTimeCutCode`).
|
|
479
|
+
- **Pins.** `test/backlog1795-time-cut-consumers.test.ts` C1 / C1d / C2 / C4 (the result carries the cut; no number when the window is unstated) and R1–R5 (the five sentences through the real `readPdfFile`, and the budget arm unchanged); `test/prompt-cache.test.ts` and `test/memory.test.ts` join their strings locally; `test/export-surface.test.ts` green against the regenerated snapshot.
|
|
480
|
+
|
|
481
|
+
### The engine layer imports nothing from `tools/`; the tools reach it through the `internal/harness` facade again (#1829; internal layering — no behavior change; closed sets: none; exports: none; members: none)
|
|
482
|
+
|
|
483
|
+
- **Before.** The loop (`engine/loop/agent-loop.ts`), compaction (`engine/compaction/utils.ts`), the symlink-chain walk (`engine/execution-env/symlink-chain.ts`) and one inline type in `engine/loop/types.ts` imported from `tools/fs/` (`image-admission`, `safety`). Any tool that imported the facade (`internal/harness`) then closed a load cycle (facade → harness → loop → tools → facade), so five tools (`dir-entries`, `pdf`, `search`, `fs-bash`, `fs-read`) deep-imported `engine/harness/types.js` instead — which `test/vendor-insulation.test.ts` reads as a facade bypass.
|
|
484
|
+
- **Now.** The shared pieces sit below both layers, each defined once, every importer pointed at the new place (no re-export left behind): `fileArgPath` in `src/core/tool-registry.ts` (the `file_path` / `path` slot read by `pathTargetValue`), `isUncSpelling` in the new dependency-free `src/core/unc-spelling.ts`, and the media admission in `src/core/image-admission.ts` (moved from `src/tools/fs/image-admission.ts`) together with the parameters it checks — `MAX_IMAGE_READ_BYTES`, `MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES`, `PDF_TARGET_RAW_SIZE`, `imageMagicMatches`, `pdfMagicMatches` (from `fs-shared` / `pdf` / `safety`). The five tools import the facade; `gate:domain-lexicon` names the UNC leaf as the second home of the path pattern.
|
|
485
|
+
- **Who breaks.** Nothing at run time: same code, same answers (`fileArgPath` over a function-typed `args` object is the one input class whose reading differs — not reachable from model-supplied JSON). The root export surface is unchanged (`handoff:diff` empty). A deep import of the moved names from their old files (a copy-paste demo, a fork) must follow them.
|
|
486
|
+
- **Pins.** `test/vendor-insulation.test.ts` — no engine file names a `tools/` module (static, `export … from`, dynamic, inline `import("…").T`), the facade's value-import closure holds no `tools/` module, and the scan's own positive controls; `test/backlog1829-engine-below-tools.test.ts` — `fileArgPath` read against the body it replaced (17 shapes, a throwing accessor), the UNC recognizer and the family reading on it, each moved name defined once with no re-export, the admission leaf's closure inside `core/`.
|
|
487
|
+
|
|
488
|
+
### `verify-debts`: a 待排 window's 依据 must be one the ledger's own checkout can find, an IN-PROGRESS row may not be pending, and the RC gate's cut must be X.Y.Z (#1899; release tooling only; ZERO runtime behaviour; +0 exports; @server @cli @client-core @test)
|
|
489
|
+
|
|
490
|
+
- **Before.** The 依据 of `待排(<依据>;<类别>[;<理由>])` was judged by shape alone — five regex arms (`C-R<n>`, `X-<n>`, `#<n>`, `[NNNN]`, `MM-DD`) — while the comment above them said "must carry an addressable coordinate". So `待排(UTF-8;其余)`, `待排(SHA-256;其余)`, `待排(ISO-8601;其余)`, `待排(GPT-6;其余)`, `待排(99-99;其余)`, `待排(00-00;其余)`, `待排([000];其余)` and `待排(#0;其余)` were valid pending rows: a due row dressed as one passed `gate:debts:rc` untouched (the 7.52.0 audit's 28-form attack found three, the neighbours five more). And an IN-PROGRESS row could be pending — green here, red in the ticket-ledger gate (`pendingWindowProblem`): two answers for one shape.
|
|
491
|
+
- **Now.** A 依据 is `C-R<n>` or `[NNNN]` and nothing else (two arms, same boundaries as before), and EVERY coordinate the cell names must resolve in the checkout the ledger lives in (the directory above `DEBTS_FILE`'s): `audits/CLAY-RULINGS.md` has a table row whose first cell is `C-R<n>`, `posts/<NNNN>-*.md` exists. A number that is not there, or a register / posts directory that cannot be read, is red and named (fail-closed) — each read once, and only when a row's window reads as pending. A date, a ticket id, an `#<n>` and a term are no longer a 依据. A pending window is OPEN-only: an IN-PROGRESS row that writes one is red with the BLOCKED row's sentence form. The look-up sits in the one reader (`readPendingWindow`); no second regex.
|
|
492
|
+
- **The cut must be a version.** `--rc-gate` compared every window to `--version` with no check on its form: `v7.52.0`, `next` turned each comparison into NaN (every versioned row "not due" — a row promised for 7.10.0 passed at a 7.52.0 cut), `7.52` lost its patch, and an empty or missing value fell back to package.json's version without a word. A version that is not three decimal segments (no prefix, no suffix) is now refused up front with exit 2 and `--version must be X.Y.Z, got …`, before the ledger is read; a flag that is present names the version, whatever follows it.
|
|
493
|
+
- **Who is hurt.** A DEBTS row whose 依据 is a date / ticket id / `#<n>` / unregistered number, or an IN-PROGRESS row with a pending window; a release script that passes `--rc-gate --version` anything but `X.Y.Z` (it was passing vacuously; in-repo callers pass `X.Y.Z` or nothing, which reads package.json). None live: DEBTS-core at the 7.52.0 cut has no row whose promise is 待排 (its three OPEN rows carry versions, one BLOCKED row; `gate:debts:rc --version 7.52.0` exits 0 before and after).
|
|
494
|
+
- **Pins.** `test/verify-debts-pending-layer.test.ts` (67 → 138 cells; the 23 old cells that flipped are re-aimed or given a checkout, the other 44 keep their intent): every fixture ledger lives in its own checkout (`debts/` + `audits/CLAY-RULINGS.md` + `posts/`), nothing reads a shared one; H the look-up (ruling present / absent, post present / absent, register or posts unreadable, first-cell-only, lettered sibling id, erratum row, post file-name traps, every-coordinate, exact number, the reader's root); I the audit's 28 forms verbatim plus 14 neighbours, one cell each; F4–F6 the IN-PROGRESS rule; J the version (`v7.52.0` / `7.52` / `next` / empty and six more refused, `7.52.0` the control). Eighteen mutants (`.gate-logs`): each one turns the pins red on named cells.
|
|
495
|
+
|
|
496
|
+
no-notes: #595(car-door 重钉 backlog595 四格〔人批路里「请求在等待中被他人改掉」不再可能〕,是钉随律改,不是修复)
|
|
497
|
+
no-notes: #1787(立票:stations 双读 handler 答形 → 内核「答案是数据」,7.53.0), #1788(立票:side-query 第二次读 role-base,7.53.0), #1789(立票:`resumesCheckpoint` 可选 wire 成员,7.53.0;本版零行为面)
|
|
498
|
+
|
|
499
|
+
no-notes: #1839(待排 7.53.0 —— `McpConnectionPool.keyOf` 公开面收回,本版只在 #1552 节披露第四参;ledger 提交 8b6c8ed58 顺带改了 #1552 的 probe JSDoc 才被计入)
|
|
500
|
+
no-notes: #1958(立票:tool-list digest 的集合语义需随行带格式位或一次性迁移,设计定,7.53.0;本版 digest 保持顺序敏感、不去重 —— 宿主按引擎写出的顺序原样递回即匹配,既有契约,倒序 / 重复递回按宿主自己的 TaskSpec 座位判)
|
|
501
|
+
no-notes: #1949(立票:无规则文法工具的问卡零候选零缺席词 → 裸名规则任何工具 = CC 形,C-R504,7.53.0;本版只在 INTEGRATION-CORE 写过渡句), #1950(立票:续跑 admission 在 claim 前跑配置门,7.53.0 设计)
|
|
502
|
+
no-notes: #1960(立票:委派名登记表 / 天花板读回四边角〔sealed globalThis / 换新 WeakMap / 占位拒范围与 detail.field / 成员读抛 TypeError〕,7.53.0;本版只改一行 JSDoc 与 KL-1947-b 句)
|
|
503
|
+
no-notes: #1843(待排 7.53.0 —— `onCommitted` 早于 stream-engine 本地校验的时序,本版未动;car-1821b 的 WIP 提交只点名了票号), #1864(JSDoc sentence correction in side-query.ts only; the pairing law is told in the #1850 / #1858 / #1859 section)
|
|
504
|
+
|
|
505
|
+
## 7.51.2 — 2026-10-14
|
|
506
|
+
|
|
507
|
+
### A stored session-rules row carries the rule grammar it was written in; a row from before keeps the reading it had (#1761; security; behavior narrowing open to veto; hard-breaking: none; closed sets: +1 member `AllowEntryInertReason` `earlier_grammar`; exports: +1 const, +1 function, +1 type) (@server)
|
|
508
|
+
|
|
509
|
+
**Behavior narrowing (open to veto).** A rules row written before this version has no grammar stamp. In such a row, a parenthesized `toolAllow` entry **no longer admits anything**. That covers `Tool(content)` (`Bash(rm:*)`, `Edit(src/**)`, `WebFetch(domain:x)`) and also `Tool(*)` / `Tool()`, which the entry reader reads as the bare tool. That was the entry's meaning when it was written (7.50.x compared session entries as tool names as written, so `Bash(rm:*)` and `Bash(*)` named no tool), and since 7.51.0 the same stored string had been read as an allow rule, or for `Bash(*)` as the whole shell. Upgrading turned it into a grant with no signal: the real Runner lane ran `rm` under a 7.50.x row whose allowlist admitted nothing. Rows written by 7.51.0 / 7.51.1 carry no stamp either, so they read the same way until rewritten.
|
|
510
|
+
|
|
511
|
+
- **What changed.**
|
|
512
|
+
- **The stamp.** `judgeSessionRulesWrite` is the one write judge every store calls. It now returns the rules with `grammar: SESSION_RULES_GRAMMAR` (`1`). Both bundled stores, and a durable twin that stores what the judge returns, keep the stamp on the row with no change of their own. `StoredSessionRules.grammar?: number` is new.
|
|
513
|
+
- **The reading.** `readSessionRulesShape` is the one shape reading, and every station reads through it: the write, a store's read-back, the tighten comparison's prior, the run-time layer and a parked checkpoint's ancestor snapshot. It now also answers the grammar, as `rules.grammar`, always present. A value carrying a store's `rev` and no stamp is grammar 0. A value no store stamped (no `rev`) is written now, in this engine's grammar.
|
|
514
|
+
- **Grammar 0.** An allow-direction entry whose row the one entry reader reads as speaking for a name other than the entry as written reads as nothing, with reason `earlier_grammar`. That is a `Tool(content)` rule, or `Tool(*)` / `Tool()` read as the bare tool; the test is the reader's own `compiledEntryName(row) !== entry`. A **deny** entry of that form refuses in every grammar, because reading it as the rule it spells only ever refuses more than the name it was. Bare names, namespaces and `allowDirs` read the same in both grammars.
|
|
515
|
+
- **The announcement.** The engine announces grammar-0 entries once per distinct set per layer, through `onError` with code `config.session_rules_earlier_grammar` (phase `config`, the same channel as `config.session_rules_unreadable`). The message names the session and the entries, and how to rewrite them.
|
|
516
|
+
- **Refusals.** A stamp above the engine's, or one that is not a whole number ≥ 0, is a shape fault (field `grammar`). The file store refuses that row as `corrupt` (the existing damaged-row path). The run-time layer refuses every call. A write refuses it as `invalid_shape`.
|
|
517
|
+
- **New list reading.** `compileSessionRuleBucket(rules, field, roster?)` is the list reading of one bucket in its row's grammar. The run-time layer and the tighten comparison both read through it.
|
|
518
|
+
- **The way out (the rewrite).** An operator write of the rules **afresh** stamps them. That means a value without the stored row's `rev`, through `putRules`. After it, the entry is the allow rule it spells. A non-operator write of the same entry is a widening (`loosen_forbidden`, `widened toolAllow with "Bash(rm:*)"`), because the stored entry admitted nothing. A stored row handed back to a write is refused by name (`inert_entry`, reason `earlier_grammar`, nothing written). That covers a read-modify-write and a session-sync replay of an export, so neither can turn the entry into a grant.
|
|
519
|
+
- **Who breaks.**
|
|
520
|
+
- Rows written before this version whose `toolAllow` holds a parenthesized entry. 7.51.0 / 7.51.1 rows meant as allow rules are included. They admit less until an operator rewrites them; the advisory names each.
|
|
521
|
+
- Exact-equality on rule rows. `getRules` / `putRules` / `listBySession` values and `readSessionRulesShape(…).rules` now carry `grammar`.
|
|
522
|
+
- A switch over `AllowEntryInertReason` has one more word.
|
|
523
|
+
- Rollback or a mixed-version fleet (KL-1761-c). 7.51.1 and earlier read `grammar` as a key that is no bucket, so every row this version writes is a malformed row there: the file store refuses it as `corrupt`, the layer refuses every call, and the SQL twin judges it the same way. The way out on such an engine is to delete the row. A fleet should not let an older replica read a stamped row (server: take this in the same version as M17).
|
|
524
|
+
- *server*: the SQL twin stores `JSON.stringify(judge result)` and serves `{ ...readSessionRulesShape(body).rules, rev, gen }`, so the stamp rides both ways with no code change. A body read through the shape reading carries no `rev`, so the twin's rows read in this grammar. The twin's own one-column-one-grammar migration (M17) remains its fence for rows written before 7.51.0. A session-sync import of an export holding an unstamped row is now refused by name (400 `rules.inert_entry`, reason `earlier_grammar`) instead of granting. Pins that deep-equal the judge's or the shape reading's output gain `grammar: 1`.
|
|
525
|
+
- **How to migrate.** For each entry the advisory names, either write the session's rules afresh as an operator (the entry becomes the allow rule it spells) or leave it (it keeps admitting nothing). Nothing to do for rows without such entries.
|
|
526
|
+
- **Pins.** `test/backlog1761-rule-row-grammar-version.test.ts` (P0 red on 7.51.1: the lane ran `rm`). It covers the real-lane refusal and advisory, the operator rewrite (stamped; `rm` then runs), the non-operator widening, the handed-back row and the export → import replay refused, an old row's deny still refusing, a later stamp and malformed stamps refused at the store, the layer and the write, a stamped row read as written (and, over the published safety-merge corpus, every tighten verdict and bucket compile unchanged by the stamp), `Bash(*)` / `Bash()` / `Read(*)` / `mcp__srv(*)` read as nothing in grammar 0 (lane, write, widening), and the checkpoint face: a parked unstamped ancestor snapshot resumes in grammar 0, and a parking leg records the stamped row it enforces. Re-pinned: the row key set in `backlog1003-1000` / `file-session-policy-store` (+ `grammar`) and the reason-set arrays in `backlog1358` / `backlog1352` (+ `earlier_grammar`).
|
|
527
|
+
|
|
528
|
+
### `BUILTIN_SUBAGENT_TYPES` is exported from the package root (#1771; additive; hard-breaking: none; closed sets: +1 set `BuiltinSubagentType`; exports: +1 constant, +1 type) (@client-core @cli @desktop)
|
|
529
|
+
|
|
530
|
+
- **What changed.** The `subagent_type` words a default deployment accepts without defining an agent of its own — the two built-in definitions `Explore` / `Plan` and the two built-in routes `fork` / `general-purpose` — are one closed set on the root, `BUILTIN_SUBAGENT_TYPES` (frozen; type `BuiltinSubagentType`; registered in `docs/CLOSED-SETS.md`). A deployment definition of the same name shadows the built-in, as before; the Agent tool's accepted domain per deployment is this set ∪ the roster, less shadowed built-ins.
|
|
531
|
+
- **Who breaks.** No one: additive. client-core's generated table (CC-748) and desktop's / cli's local copies retire onto it.
|
|
532
|
+
- **How to migrate.** `import { BUILTIN_SUBAGENT_TYPES } from "@sema-agent/core"`; render pickers from it and keep no copy. `worker` is not a core word (an agent-teams role name lives with the shells).
|
|
533
|
+
|
|
534
|
+
### `McpServerSpec.toolAxes` has one reader, `readMcpToolAxes`, exported from the package root; the preparation door asks it of every MCP entry (#1763; additive export; behavior narrowing at the door, open to veto; hard-breaking: none; closed sets: none; exports: +1 function, +1 type; members: none) (@server @web-admin)
|
|
535
|
+
|
|
536
|
+
**Behavior narrowing (open to veto).** (1) **What changed:** a task whose `mcp[i].toolAxes` does not fit now fails at prepare with `config.invalid_argument`, before any server is dialed. (2) **Who is hurt:** a host that declared an axis the type never allowed. It ran before, with the declaration folded as written; it is refused now. (3) **Compensation:** the refusal sentence names the server, the tool and the member, and lists the legal members or words; a host fixes the one declaration it names and resubmits, or asks `readMcpToolAxes` first and never submits the bad one. The compensating alternative is the same reader at the host's own door, so a refusal there and a refusal here are one answer.
|
|
537
|
+
|
|
538
|
+
- **Before (7.51.1).** `McpServerSpec.toolAxes` was a TypeScript type with no reader behind it. The connect folded `spec.toolAxes?.[remoteName]` as it arrived (`src/core/mcp.ts:4098` into `applyCallerAxisOverride`, `:2573-2593`): an off-vocabulary word, a wrong-case word, a string `egress` or an unknown member went into the fold unjudged. A host that assembled the field from JSON wrote its own reader (server `src/mcp-tool-axes.ts`; web-admin `src/lib/config/mcp-tool-axes.ts` as a transitional copy), so one fact had three readers.
|
|
539
|
+
- **Now.** `readMcpToolAxes(declared: unknown): McpToolAxesRead` (`src/core/mcp-server-spec.ts`, beside the type) answers `{ ok: true, toolAxes? }` or `{ ok: false, problem }`.
|
|
540
|
+
- **Accepted.** Absent. Otherwise a plain record of per-tool objects whose members are exactly `effect` / `egress` / `irreversibility`: `effect` one of the published vocabulary's tool effects, `egress` a boolean, `irreversibility` one of the vocabulary's words less `maybe` (the word the engine derives from a tool's own declaration; a caller can vouch only for the two poles). The words and the member set are derived from `SAFETY_AXIS_VOCABULARY` and the `toolAxes` type, so a word or member the engine gains moves the reader with it (a member the type gains is a compile error in the reader; the irreversibility words are held to the vocabulary by a compile-time lockstep).
|
|
541
|
+
- **Refused, whole.** A declaration that is not a plain data record (null, an array, a scalar, a map, a date, a class instance, or an object whose members are inherited from a prototype or are not enumerable: the connect used to read `toolAxes[name]` through the whole chain, so reading only own enumerable keys would have dropped a tightening mark without a word), an empty tool name, a tool whose declaration is not an object, an unknown member, a word outside the vocabulary, a non-boolean `egress`. A null-prototype record and a record built in another realm are plain. One bad tool refuses the whole declaration: the good tools are not kept and no word is defaulted, because a caller axis is two-way (it can vouch a tool down as well as mark it up) and a lost half is a trust root that is not in force. Present `null` is a refusal, not absence.
|
|
542
|
+
- **The sentence** names the tool and the member and never a value; names are quoted and cut at 80 characters. The reader is total: a getter, a proxy trap or a tag that throws while the declaration is read comes back as a refusal (without the thrown text), never as a throw.
|
|
543
|
+
- **The copy.** `ok: true` carries a plain copy of what was judged. Empty per-tool objects are dropped and `toolAxes` is absent when nothing is declared; the engine read those as absent already (`applyCallerAxisOverride` folds `{}` to the server's hint unchanged). A tool named `__proto__` is an ordinary key.
|
|
544
|
+
- **The door.** `snapshotAndValidateMcpEntries` (`src/core/locked-config.ts`) asks the reader once per entry, after the content-class question. The entry then carries the reader's copy, frozen with the entry, so what the door approved is what connect and every refresh read: a caller writing its own `toolAxes` object after prepare no longer changes a later refresh, a tool that shallow-copies a server from `mountedMcp()` cannot rewrite the parent's axes (a write throws), and a getter is read once. It runs on every prepare leg (fresh, resume, child), before any side effect.
|
|
545
|
+
- **The entrance.** `materializeMcpTools` (`src/core/mcp.ts`, right after the snapshot) asks the same question per server through the same function (`judgeEntryToolAxes`, `src/core/locked-config.ts`), because the library entrance and the no-run probe reach it without the door. A declaration the reader cannot read fails THAT server's dial: never spawned, a `failed` status of class `invalid_config`, a warning naming the tool and member (`config.invalid_argument`); its siblings dial normally, and a re-dial of it is refused the same way. A passing entry carries the judged frozen copy, so a caller writing its own object after the call does not reach a refresh. `probeMcpServers` dials through this entrance, so a refused declaration is a failed row of class `invalid_config` there.
|
|
546
|
+
- **Not covered, stated.** `A2aServerSpec.toolAxes` has the same shape and no reader yet (A2A peers take neither the door nor the entrance). On a resume leg the door runs after the checkpoint claim, as the content-class question does: the resume's pre-claim screen (`refuseResumeDoors`) does not carry the MCP door, so a resume whose re-supplied `mcp` entry is refused here consumes the row the way those doors already do.
|
|
547
|
+
- **Differs from the server's reader.** It refuses the layouts above (the server's reader returned an empty declaration for a map, a date, and a record whose tools are inherited or not enumerable, and took the inherited members of one tool), and its sentences differ in wording and add the legal members and words. Cell for cell on the server suite's own samples the verdicts are the same (the pin's B group).
|
|
548
|
+
- **Consumers named.** *server* S-1581: retire `readMcpToolAxes` in `src/mcp-tool-axes.ts` and call this one (`web_admin_mcp_tool_axes_invalid.problem` takes the new wording). *web-admin*: delete the transitional copy and the copy in `scripts/check-mcp-header-owner.mjs`.
|
|
549
|
+
- **Pins.** `test/backlog1763-mcp-tool-axes-judge.test.ts`: A (the reader: accepts, each refusal arm, whole refusal, no value in the sentence, bounded names, `__proto__`, one read per property, every vocabulary word), B (the server suite's own cells), C (the door: code and sentence, whole-prepare refusal, the plain-copy hand-off and its freeze, absent and empty mint no key), D (the public task lane: a refused declaration spawns no server, a legal one mounts, and the axes the door approved are the axes the fold carries), E (a resume leg: the MCP door sits after the claim, characterised against a screened door as the control), F (the library entrance, no door: a refused declaration fails that server's dial and is never spawned, siblings dial, a re-dial is refused, the probe's row is `invalid_config`, a passing entry carries the judged copy).
|
|
550
|
+
|
|
1
551
|
## 7.51.1 — 2026-10-14
|
|
2
552
|
|
|
3
553
|
### `inheritEnv: "all"` is defined once, in core: the whole environment less the deployment's own credentials (#1753; security; behavior narrowing where a deployment declares its list, open to veto; closed sets: +1 set `EngineCapability`, +1 member `SecretEnvFindingKind` `own-credential`; exports: +6, none removed; members: +1 optional notice detail key)
|
|
@@ -1195,7 +1745,6 @@
|
|
|
1195
1745
|
|
|
1196
1746
|
**#1383 car B + #1388 —** hard-breaking: **two** (`ToolPolicy.pathDenyRules` → `ToolPolicy.pathRules(anchors)` with `toolPolicyPathDenyRules` → `toolPolicyPathRules(policies, anchors)`; `adjudicatePersistedPathRules` takes the call's views, a per-row anchor function and the leg's head resolution); closed sets: +1 new (`ChildPlacement`: `honored` / `caller_tree` / `elsewhere`, reached through `ResolvedWorkspace.placement`), `ENGINE_NOTICE_CODES` +1 (`permission.rule_row_dropped`, audience user), `CheckpointErrorReason` +3 (`installation_record_absent` / `installation_record_misaligned` / `installation_record_invalid`, on `resume.parent_constraint_mismatch`); `unsupported.path_base` narrowed (only a declared base that is unusable); exports: +3 / −1 (+`toolPolicyPathRules`, +`BoundPathRule`, +`PathRuleSpec` (types), −`toolPolicyPathDenyRules`); new optional members: `ToolPolicy.pathRules`, `ToolCallRequest.pathRuleAnchors` (engine-internal seat — present only on the copy an installed policy hands its own inner `check`; the request the gate holds, an `AskRequest`, a hook's input and a parked row never carry it), `CallTargetReading.leads`, `InheritedGate.pathRuleAnchors` / `CheckpointState.inheritedGate.pathRuleAnchors` and `InheritedGate.pathRuleInstallations` / `CheckpointState.inheritedGate.pathRuleInstallations` (data half), `ResolvedWorkspace.placement`, `RunInternals.callerRoot` / `worktreeOffered` (core delegation callers only), `CheckpointState.handsHome` (no row format raised), `EffectivePermissionRules.droppedRows`; optional params `pathRuleReachOf(…, treeFamily?)`, `directoryRuleAdmits(…, treeFamily?)`, `scopeCoversCwd(…, identity?)`. **Behavior narrowing:** yes (see the section) — rules are anchored where they are installed (some cells reach more, some reach less), the allow direction admits only when every view is admitted, a project scope root must be a complete absolute path, a worktree child its environment left in the caller's tree is refused, a resume whose re-supplied chain lacks a usable installation record for an entry whose rules depend on its installing leg is refused.
|
|
1197
1747
|
|
|
1198
|
-
|
|
1199
1748
|
**Package exports (the whole release):** +8 / −1 against v7.45.0 (`test/export-surface.snapshot.json` 2617 → 2624): +`BoundPathRule` +`CallTargetReading` +`CallTargetView` +`PathRuleSpec` +`toolPolicyPathRules` (#1383) +`ENGINE_BUILTIN_TOOL_NAMES` +`isEngineBuiltinToolName` (#1422) +`renderListEntry` (#1428); −`toolPolicyPathDenyRules` (#1383 car B, hard-breaking). The program read model stays internal; `judgeShellRead` stays its public reader. Two exported values change content, not shape: `SHELL_WRAPPER_TABLE` (+19 keys; `env` / `strace` / `ltrace` / `nsenter` rows change — #1102 car C) and `BARE_INTERPRETER_NAMES` (+`nocorrect`).
|
|
1200
1749
|
|
|
1201
1750
|
**Closed sets (#1102 car C):** `ArgRoleKind` +1 (`binds-name`: 9 members), `PathEffect` registered (5: `read`, `names`, `write`, `create`, `edit-in-place`), `ShellWrapperName` +19 (the keys of `SHELL_WRAPPER_TABLE`: 42 → 61), `BARE_INTERPRETER_NAMES` +`nocorrect`; the gate's `NOT_RUN` list +7 spellings (`nodejs --inspect-brk` / `--test` / `--watch`, `env -P`, `grep` / `egrep` / `fgrep --exclude-from`), each with its reason; car C2: `NOT_RUN` +4 (`setsid -c` / `--ctty`, `find -fprintf`, `flock --command=`) and the gate's `SINCE` list (+2: `env -a` / `--argv0`, coreutils 9.5), each with its reason.
|
|
@@ -1588,7 +2137,6 @@
|
|
|
1588
2137
|
|
|
1589
2138
|
**#1362 / #1374 — behavior:** `task.user_followup_undrained` now carries `detail.cause` (`leg_end` from this engine) like `task.user_steer_undrained` (#1362); `steering.duplicate_input_id` now carries `detail.differs` on both faces, and a repeat that differs only in tier / trust / attribution is no longer told to re-issue under a fresh `inputId` (#1374). What counts as a replay is unchanged.
|
|
1590
2139
|
|
|
1591
|
-
|
|
1592
2140
|
### A workflow park entry names who stands on it — `parkedBy` / `generation` (#1319; additive record members) (@server @client-core @test)
|
|
1593
2141
|
|
|
1594
2142
|
- **Before (v7.43.x).** An entry carried `callKey` / `sessionId` / `token`, `originRunId` (the run whose journal holds the park's material — not the run standing on it) and `originUnconfirmed`. A same-token re-park whose journal append failed and an inherited park the run never reached left the SAME entry on the successor (measured on the base: `{ token, sessionId, originRunId: <prior run> }` in both), and nothing said which record of a resume chain is its tail — so a consumer routed a decision by the record's `agents[]` rows and redeemed it by `parks[]`, two readings of one question.
|