@sema-agent/core 7.54.1 → 7.56.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 +950 -0
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/builtin-agents.d.ts +12 -14
- package/dist/agents/cascade.js +1 -1
- package/dist/agents/cc-peer-directory.d.ts +36 -0
- package/dist/agents/cc-peer-directory.js +139 -0
- package/dist/agents/cc-peer-wire.d.ts +105 -0
- package/dist/agents/cc-peer-wire.js +98 -0
- package/dist/agents/cc-uds-peer-channel.d.ts +40 -0
- package/dist/agents/cc-uds-peer-channel.js +457 -0
- package/dist/agents/child-model-seat.d.ts +0 -11
- package/dist/agents/child-model-seat.js +0 -3
- package/dist/agents/cross-session-envelope.d.ts +4 -1
- package/dist/agents/cross-session-envelope.js +8 -1
- package/dist/agents/cross-session-judge.d.ts +2 -2
- package/dist/agents/list-agents-tool.d.ts +3 -1
- package/dist/agents/list-agents-tool.js +9 -8
- package/dist/agents/observer.d.ts +4 -5
- package/dist/agents/observer.js +2 -1
- package/dist/agents/peer-admission.d.ts +6 -0
- package/dist/agents/peer-admission.js +4 -1
- package/dist/agents/peer-directory.d.ts +65 -13
- package/dist/agents/peer-directory.js +90 -25
- package/dist/agents/peer-notices.d.ts +1 -1
- package/dist/agents/peer-session-drain.d.ts +3 -1
- package/dist/agents/peer-session-drain.js +3 -2
- package/dist/agents/read-inbox-tool.js +2 -1
- package/dist/agents/repair-loop.js +2 -2
- package/dist/agents/retain-ledger.d.ts +1 -1
- package/dist/agents/roster-store.d.ts +1 -1
- package/dist/agents/send-message-tool.d.ts +10 -0
- package/dist/agents/send-message-tool.js +37 -8
- package/dist/agents/subagent.d.ts +39 -82
- package/dist/agents/subagent.js +109 -106
- package/dist/agents/tool-filter.d.ts +31 -46
- package/dist/agents/tool-filter.js +13 -33
- package/dist/agents/verify.d.ts +6 -5
- package/dist/agents/verify.js +3 -3
- package/dist/brain/anthropic.js +43 -81
- package/dist/brain/errors.d.ts +15 -0
- package/dist/brain/errors.js +10 -0
- package/dist/brain/media-degrade.d.ts +18 -7
- package/dist/brain/media-degrade.js +13 -2
- package/dist/brain/model-capability.d.ts +37 -0
- package/dist/brain/model-capability.js +34 -0
- package/dist/brain/model-presets.d.ts +6 -12
- package/dist/brain/model-presets.js +1 -6
- package/dist/brain/open-responses.js +27 -35
- package/dist/brain/openai.js +30 -98
- package/dist/brain/provider-dialect.d.ts +39 -3
- package/dist/brain/provider-dialect.js +28 -0
- package/dist/brain/reasoning-request.d.ts +96 -0
- package/dist/brain/reasoning-request.js +174 -0
- package/dist/brain/reasoning.d.ts +12 -12
- package/dist/brain/reasoning.js +4 -3
- package/dist/brain/request-params.d.ts +62 -9
- package/dist/brain/request-params.js +42 -7
- package/dist/brain/retry.d.ts +1 -1
- package/dist/brain/route-adjudicator.d.ts +15 -0
- package/dist/brain/route-adjudicator.js +15 -0
- package/dist/brain/status-sink.d.ts +20 -0
- package/dist/brain/status-sink.js +7 -0
- package/dist/brain/stream-engine.js +6 -5
- package/dist/brain/stream-shared.d.ts +1 -1
- package/dist/core/a2a.d.ts +2 -1
- package/dist/core/a2a.js +19 -13
- package/dist/core/advisor-verdict.d.ts +120 -0
- package/dist/core/advisor-verdict.js +92 -0
- package/dist/core/agent-definition.d.ts +17 -8
- package/dist/core/allow-entry.d.ts +69 -36
- package/dist/core/allow-entry.js +81 -52
- package/dist/core/allow-rule-shadow.d.ts +10 -3
- package/dist/core/approval-object.d.ts +2 -1
- package/dist/core/approval-object.js +6 -6
- package/dist/core/ask-origin.d.ts +127 -29
- package/dist/core/ask-origin.js +87 -25
- package/dist/core/ask-question.d.ts +51 -8
- package/dist/core/ask-question.js +12 -7
- package/dist/core/ask-unresolvable-notice.d.ts +8 -3
- package/dist/core/ask-unresolvable-notice.js +16 -2
- package/dist/core/auto-compaction.d.ts +11 -8
- package/dist/core/auto-compaction.js +4 -2
- package/dist/core/auto-mode-arming.d.ts +2 -2
- package/dist/core/auto-mode-defaults.d.ts +8 -8
- package/dist/core/auto-mode-prompt-assets.d.ts +3 -3
- package/dist/core/auto-mode-prompt.d.ts +1 -1
- package/dist/core/auto-mode.d.ts +17 -9
- package/dist/core/auto-mode.js +9 -6
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/canonical-json.d.ts +1 -1
- package/dist/core/canonical-json.js +2 -2
- package/dist/core/checkpoint-generation.d.ts +53 -0
- package/dist/core/checkpoint-generation.js +35 -0
- package/dist/core/checkpoint-queries.d.ts +180 -0
- package/dist/core/checkpoint-queries.js +272 -0
- package/dist/core/checkpoint-requirements.d.ts +19 -4
- package/dist/core/checkpoint-requirements.js +8 -14
- package/dist/core/checkpoint-store.d.ts +165 -72
- package/dist/core/checkpoint-store.js +79 -82
- package/dist/core/child-clamp-seats.d.ts +6 -0
- package/dist/core/child-clamp-seats.js +3 -16
- package/dist/core/compliance.d.ts +2 -2
- package/dist/core/context-edit.d.ts +6 -7
- package/dist/core/cross-boundary-taint.d.ts +11 -4
- package/dist/core/cross-boundary-taint.js +3 -3
- package/dist/core/decision-receipt.d.ts +37 -3
- package/dist/core/decision-receipt.js +19 -19
- package/dist/core/delegation-not-started.d.ts +6 -0
- package/dist/core/delegation-not-started.js +17 -0
- package/dist/core/effective-path-target.d.ts +1 -1
- package/dist/core/engine-attestation.d.ts +93 -0
- package/dist/core/engine-attestation.js +70 -0
- package/dist/core/engine-capabilities.d.ts +4 -1
- package/dist/core/engine-capabilities.js +1 -1
- package/dist/core/engine-notice.d.ts +45 -12
- package/dist/core/exec-output-tail.d.ts +1 -1
- package/dist/core/file-history-retention.d.ts +1 -1
- package/dist/core/file-history-store.d.ts +147 -21
- package/dist/core/file-history-store.js +58 -32
- package/dist/core/gate-fold.d.ts +1 -1
- package/dist/core/gate-fold.js +24 -11
- package/dist/core/gate-lanes.js +81 -68
- package/dist/core/gate-outcome.d.ts +40 -6
- package/dist/core/gate-outcome.js +56 -0
- package/dist/core/governance-codes.d.ts +17 -3
- package/dist/core/governance-codes.js +16 -0
- package/dist/core/hands-band.d.ts +1 -1
- package/dist/core/hooks.d.ts +21 -20
- package/dist/core/hooks.js +3 -1
- package/dist/core/host-floor.d.ts +128 -0
- package/dist/core/host-floor.js +206 -0
- package/dist/core/host-target.d.ts +84 -0
- package/dist/core/host-target.js +92 -0
- package/dist/core/image-downsample.d.ts +1 -1
- package/dist/core/lsp-diagnostics.d.ts +4 -4
- package/dist/core/lsp.d.ts +3 -3
- package/dist/core/mailbox-store.d.ts +4 -0
- package/dist/core/mcp-connection-pool.d.ts +18 -2
- package/dist/core/mcp-connection-pool.js +53 -6
- package/dist/core/mcp-failure.d.ts +23 -4
- package/dist/core/mcp-failure.js +26 -11
- package/dist/core/mcp-listing-drift.d.ts +10 -0
- package/dist/core/mcp-listing-drift.js +6 -0
- package/dist/core/mcp-server-link.d.ts +173 -0
- package/dist/core/mcp-server-link.js +127 -0
- package/dist/core/mcp-server-spec.d.ts +4 -2
- package/dist/core/mcp-session-end.d.ts +26 -0
- package/dist/core/mcp-session-end.js +29 -0
- package/dist/core/mcp-startup-request.d.ts +17 -0
- package/dist/core/mcp-startup-request.js +17 -0
- package/dist/core/mcp.d.ts +17 -13
- package/dist/core/mcp.js +240 -189
- package/dist/core/media-byte-cap.d.ts +1 -1
- package/dist/core/memory-engine/delegation-settlement.js +15 -24
- package/dist/core/memory-engine/engine.d.ts +9 -9
- package/dist/core/memory-engine/engine.js +4 -4
- package/dist/core/memory-engine/layout.d.ts +60 -8
- package/dist/core/memory-engine/layout.js +65 -6
- package/dist/core/memory-engine/provenance-wording.d.ts +1 -1
- package/dist/core/memory-engine/tools.js +4 -1
- package/dist/core/mode-policy.d.ts +1 -1
- package/dist/core/model-seat.d.ts +10 -3
- package/dist/core/mods/classic-events.d.ts +67 -0
- package/dist/core/mods/classic-events.js +1 -0
- package/dist/core/mods/dollar.d.ts +13 -0
- package/dist/core/mods/extension-call.d.ts +31 -0
- package/dist/core/mods/extension-call.js +43 -0
- package/dist/core/mods/kernel/dispatch.d.ts +20 -3
- package/dist/core/mods/kernel/dispatch.js +140 -69
- package/dist/core/mods/kernel/events.d.ts +47 -11
- package/dist/core/mods/kernel/events.js +66 -42
- package/dist/core/mods/kernel/index.d.ts +4 -4
- package/dist/core/mods/kernel/index.js +2 -2
- package/dist/core/mods/kernel/receive.d.ts +39 -5
- package/dist/core/mods/kernel/receive.js +290 -17
- package/dist/core/mods/kernel/registration.d.ts +33 -2
- package/dist/core/mods/kernel/registration.js +6 -7
- package/dist/core/mods/kernel/site-rules.d.ts +44 -16
- package/dist/core/mods/kernel/site-rules.js +24 -42
- package/dist/core/mods/spec-seats.d.ts +69 -0
- package/dist/core/mods/spec-seats.js +114 -0
- package/dist/core/mods/stations.d.ts +28 -8
- package/dist/core/mods/stations.js +29 -13
- package/dist/core/mods/tool-call.d.ts +79 -0
- package/dist/core/mods/tool-call.js +152 -0
- package/dist/core/namespaced-rule-name.d.ts +44 -24
- package/dist/core/namespaced-rule-name.js +50 -43
- package/dist/core/output-style.d.ts +1 -1
- package/dist/core/param-rule-reach.d.ts +15 -0
- package/dist/core/param-rule-reach.js +30 -0
- package/dist/core/parent-spec-seat.d.ts +13 -1
- package/dist/core/parent-spec-seat.js +6 -0
- package/dist/core/park-selfcheck.js +1 -0
- package/dist/core/pause-registry.d.ts +16 -5
- package/dist/core/pause-registry.js +6 -2
- package/dist/core/peer-channel.d.ts +70 -0
- package/dist/core/peer-channel.js +1 -0
- package/dist/core/permission-rule-consent.d.ts +30 -5
- package/dist/core/permission-rule-consent.js +147 -50
- package/dist/core/permission-rule-model.d.ts +245 -65
- package/dist/core/permission-rule-model.js +421 -107
- package/dist/core/permission-rule-org.d.ts +6 -2
- package/dist/core/permission-rule-org.js +7 -3
- package/dist/core/permission-rule-provider.d.ts +6 -2
- package/dist/core/permission-rule-provider.js +41 -11
- package/dist/core/permission-rule-store.d.ts +22 -26
- package/dist/core/permission-rule-store.js +12 -6
- package/dist/core/permission-rule-sync.d.ts +11 -2
- package/dist/core/permission-rule-sync.js +11 -3
- package/dist/core/permission-rule-syntax.d.ts +5 -4
- package/dist/core/permission-rule-syntax.js +2 -2
- package/dist/core/permission-rules.d.ts +53 -15
- package/dist/core/permission-rules.js +125 -100
- package/dist/core/persisted-rule-arms.d.ts +6 -4
- package/dist/core/persisted-rule-arms.js +10 -12
- package/dist/core/present-plan-tool.js +3 -1
- package/dist/core/principal-name.d.ts +20 -0
- package/dist/core/principal-name.js +22 -0
- package/dist/core/prompt-section-seat.js +4 -6
- package/dist/core/protocol-http-auth.d.ts +26 -5
- package/dist/core/protocol-http-auth.js +137 -4
- package/dist/core/protocol-naming.d.ts +2 -2
- package/dist/core/read-only-shell-table.d.ts +1 -1
- package/dist/core/read-only-shell.d.ts +1 -1
- package/dist/core/retired-keys.d.ts +16 -0
- package/dist/core/retired-keys.js +16 -4
- package/dist/core/rewrite-rejudge.d.ts +60 -0
- package/dist/core/rewrite-rejudge.js +36 -0
- package/dist/core/roles.d.ts +34 -3
- package/dist/core/roles.js +66 -53
- package/dist/core/rule-lane-reads.d.ts +20 -15
- package/dist/core/rule-lane-reads.js +45 -17
- package/dist/core/rule-offer-read.d.ts +53 -0
- package/dist/core/rule-offer-read.js +146 -0
- package/dist/core/rule-owner.d.ts +46 -0
- package/dist/core/rule-owner.js +29 -0
- package/dist/core/runner/advisor-instructions.d.ts +70 -0
- package/dist/core/runner/advisor-instructions.js +61 -0
- package/dist/core/runner/advisor-tool.d.ts +33 -0
- package/dist/core/runner/advisor-tool.js +112 -0
- package/dist/core/runner/announce-once-ledger.d.ts +18 -26
- package/dist/core/runner/announce-once-ledger.js +19 -46
- package/dist/core/runner/approval-feedback.js +6 -3
- package/dist/core/runner/assemble-result.d.ts +2 -1
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/call-hook-stations.d.ts +3 -2
- package/dist/core/runner/caller-roster.d.ts +21 -2
- package/dist/core/runner/caller-roster.js +38 -0
- package/dist/core/runner/capability-withheld-seat.d.ts +5 -0
- package/dist/core/runner/capability-withheld-seat.js +24 -0
- package/dist/core/runner/classifier-ladder.d.ts +33 -13
- package/dist/core/runner/classifier-ladder.js +15 -14
- package/dist/core/runner/compaction-restate.d.ts +10 -6
- package/dist/core/runner/compaction-restate.js +9 -3
- package/dist/core/runner/construction-doors.js +3 -0
- package/dist/core/runner/contracts.d.ts +40 -33
- package/dist/core/runner/conversation-segment.d.ts +28 -0
- package/dist/core/runner/conversation-segment.js +42 -0
- package/dist/core/runner/cross-boundary-lane.d.ts +21 -16
- package/dist/core/runner/cross-boundary-lane.js +58 -34
- package/dist/core/runner/decide-continuation.d.ts +7 -4
- package/dist/core/runner/decide-continuation.js +2 -2
- package/dist/core/runner/denial-limit-arms.d.ts +4 -4
- package/dist/core/runner/denial-limit-arms.js +1 -1
- package/dist/core/runner/derived-route-fallback.d.ts +24 -24
- package/dist/core/runner/derived-route-fallback.js +9 -7
- package/dist/core/runner/file-history-request.d.ts +18 -0
- package/dist/core/runner/file-history-request.js +21 -13
- package/dist/core/runner/file-history-restore.d.ts +45 -0
- package/dist/core/runner/file-history-restore.js +46 -0
- package/dist/core/runner/gate-exit.d.ts +9 -3
- package/dist/core/runner/gate-exit.js +11 -5
- package/dist/core/runner/image.d.ts +7 -17
- package/dist/core/runner/image.js +9 -60
- package/dist/core/runner/initial-run-state.js +1 -1
- package/dist/core/runner/permission-rule-lanes.d.ts +6 -6
- package/dist/core/runner/permission-rule-lanes.js +38 -15
- package/dist/core/runner/person-path-rows.d.ts +12 -9
- package/dist/core/runner/person-path-rows.js +19 -8
- package/dist/core/runner/prepare-advisor.d.ts +63 -0
- package/dist/core/runner/prepare-advisor.js +77 -0
- package/dist/core/runner/prepare-ask-lane.d.ts +2 -2
- package/dist/core/runner/prepare-ask-lane.js +5 -2
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -6
- package/dist/core/runner/prepare-caps-and-workflow.js +5 -4
- package/dist/core/runner/prepare-config-doors.d.ts +31 -38
- package/dist/core/runner/prepare-config-doors.js +57 -34
- package/dist/core/runner/prepare-defer-classify.d.ts +4 -3
- package/dist/core/runner/prepare-defer-classify.js +7 -8
- package/dist/core/runner/prepare-delegation-surface.d.ts +2 -2
- package/dist/core/runner/prepare-delegation-surface.js +1 -1
- package/dist/core/runner/prepare-file-history.d.ts +48 -8
- package/dist/core/runner/prepare-file-history.js +41 -73
- package/dist/core/runner/prepare-gate-stations.d.ts +2 -5
- package/dist/core/runner/prepare-gate-stations.js +12 -8
- package/dist/core/runner/prepare-hands-readface.d.ts +2 -8
- package/dist/core/runner/prepare-hands-readface.js +15 -14
- package/dist/core/runner/prepare-inherited-gate.d.ts +11 -7
- package/dist/core/runner/prepare-inherited-gate.js +8 -6
- package/dist/core/runner/prepare-memory-engine-session.js +1 -1
- package/dist/core/runner/prepare-park-ask.js +12 -12
- package/dist/core/runner/prepare-policy-chain.d.ts +7 -4
- package/dist/core/runner/prepare-policy-chain.js +68 -81
- package/dist/core/runner/prepare-project-context.d.ts +5 -2
- package/dist/core/runner/prepare-project-context.js +6 -3
- package/dist/core/runner/prepare-prompt-assembly.d.ts +5 -7
- package/dist/core/runner/prepare-prompt-assembly.js +10 -8
- package/dist/core/runner/prepare-prompt-inputs.d.ts +11 -9
- package/dist/core/runner/prepare-prompt-inputs.js +4 -6
- package/dist/core/runner/prepare-protocol-tools.d.ts +4 -2
- package/dist/core/runner/prepare-protocol-tools.js +5 -4
- package/dist/core/runner/prepare-safety-scan.d.ts +9 -12
- package/dist/core/runner/prepare-safety-scan.js +8 -23
- package/dist/core/runner/prepare-suspend-saga.d.ts +2 -0
- package/dist/core/runner/prepare-suspend-saga.js +5 -6
- package/dist/core/runner/prepare-task.d.ts +1 -1
- package/dist/core/runner/prepare-task.js +41 -35
- package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +7 -4
- package/dist/core/runner/prepare-tool-disclosure-mount.js +24 -18
- package/dist/core/runner/prepare-turn-wiring.d.ts +0 -4
- package/dist/core/runner/prepare-turn-wiring.js +2 -7
- package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
- package/dist/core/runner/prepare-wiring-manifest.js +3 -3
- package/dist/core/runner/resume-admission.js +1 -1
- package/dist/core/runner/resume-apply.js +11 -12
- package/dist/core/runner/resume-checkpoint-screen.js +3 -0
- package/dist/core/runner/resume-claim.d.ts +4 -2
- package/dist/core/runner/resume-claim.js +15 -8
- package/dist/core/runner/resume-policy-outcome.js +1 -1
- package/dist/core/runner/retired-runner-deps.js +2 -1
- package/dist/core/runner/rewind-files.d.ts +15 -0
- package/dist/core/runner/rewind-files.js +132 -0
- package/dist/core/runner/run-attachment-seats.js +2 -2
- package/dist/core/runner/run-brain-sinks.d.ts +4 -1
- package/dist/core/runner/run-brain-sinks.js +4 -2
- package/dist/core/runner/run-compaction-machinery.d.ts +1 -1
- package/dist/core/runner/run-compaction-machinery.js +2 -12
- package/dist/core/runner/run-git-lane.d.ts +1 -0
- package/dist/core/runner/run-harness-handlers.js +14 -7
- package/dist/core/runner/run-leg.d.ts +1 -1
- package/dist/core/runner/run-leg.js +43 -18
- package/dist/core/runner/run-settle-and-teardown.js +3 -2
- package/dist/core/runner/run-telemetry-and-budget-seats.js +1 -1
- package/dist/core/runner/run-turn-boundary.d.ts +1 -0
- package/dist/core/runner/run-turn-boundary.js +15 -22
- package/dist/core/runner/runtask.d.ts +3 -0
- package/dist/core/runner/runtask.js +8 -9
- package/dist/core/runner/session-read-file-states.d.ts +15 -0
- package/dist/core/runner/session-read-file-states.js +28 -0
- package/dist/core/runner/session-rule-policy.d.ts +22 -21
- package/dist/core/runner/session-rule-policy.js +17 -31
- package/dist/core/runner/shell-env-withheld.d.ts +33 -0
- package/dist/core/runner/shell-env-withheld.js +51 -0
- package/dist/core/runner/side-call-account.d.ts +32 -8
- package/dist/core/runner/side-call-account.js +23 -1
- package/dist/core/runner/skills-seat.js +6 -2
- package/dist/core/runner/stream-settle-backstop.js +1 -1
- package/dist/core/runner/strict-output-schema.d.ts +1 -1
- package/dist/core/runner/sub-agent-deny-lists.js +4 -6
- package/dist/core/runner/synthetic-tools.d.ts +16 -13
- package/dist/core/runner/synthetic-tools.js +8 -3
- package/dist/core/runner/tool-disclosure.d.ts +10 -12
- package/dist/core/runner/tool-disclosure.js +3 -4
- package/dist/core/runner/tool-end-body.d.ts +1 -1
- package/dist/core/runner/tool-end-body.js +10 -9
- package/dist/core/runner/tool-face-overlay.d.ts +2 -1
- package/dist/core/runner/tool-face-overlay.js +17 -1
- package/dist/core/runner/tool-not-executed.d.ts +5 -0
- package/dist/core/runner/tool-not-executed.js +11 -0
- package/dist/core/runner/tool-occurrence.d.ts +8 -0
- package/dist/core/runner/tool-occurrence.js +7 -0
- package/dist/core/runner/tool-progress-lane.d.ts +2 -2
- package/dist/core/runner/tool-turn-repair-seat.d.ts +3 -2
- package/dist/core/runner/turn-attachments.d.ts +91 -80
- package/dist/core/runner/turn-attachments.js +62 -37
- package/dist/core/runner-deps.d.ts +42 -42
- package/dist/core/runtime-caps.d.ts +4 -4
- package/dist/core/safety-axis-vocab.d.ts +6 -4
- package/dist/core/safety-axis-vocab.js +6 -5
- package/dist/core/safety-merge-corpus.js +26 -2
- package/dist/core/same-plain-value.d.ts +40 -0
- package/dist/core/same-plain-value.js +49 -0
- package/dist/core/scheduler.d.ts +1 -1
- package/dist/core/secret-env.d.ts +26 -3
- package/dist/core/secret-env.js +44 -8
- package/dist/core/select-model.js +3 -3
- package/dist/core/sensitive-path-policy.d.ts +1 -1
- package/dist/core/sensitive-path-policy.js +1 -1
- package/dist/core/session-policy-store.d.ts +102 -35
- package/dist/core/session-policy-store.js +210 -48
- package/dist/core/session-reconcile.d.ts +2 -2
- package/dist/core/session-store.d.ts +106 -37
- package/dist/core/session-store.js +165 -91
- package/dist/core/session.d.ts +101 -57
- package/dist/core/session.js +53 -0
- package/dist/core/shared-memory/tools.js +3 -1
- package/dist/core/shared-memory/types.d.ts +1 -1
- package/dist/core/shell-read-silence.d.ts +11 -12
- package/dist/core/shell-read-silence.js +9 -5
- package/dist/core/shell-run-candidates.js +3 -1
- package/dist/core/shell-wrapper-projection.js +1 -1
- package/dist/core/shell-wrapper-table.d.ts +14 -7
- package/dist/core/shell-wrapper-table.js +5 -4
- package/dist/core/side-query.d.ts +10 -0
- package/dist/core/side-query.js +15 -1
- package/dist/core/skill-grant.d.ts +7 -2
- package/dist/core/skill-grant.js +5 -0
- package/dist/core/skills-directory.js +6 -2
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +11 -1
- package/dist/core/store-contracts/checkpoint-store-contract.js +168 -3
- package/dist/core/store-contracts/file-history-store-contract.js +74 -4
- package/dist/core/store-contracts/peer-directory-contract.d.ts +7 -2
- package/dist/core/store-contracts/peer-directory-contract.js +14 -2
- package/dist/core/store-contracts/session-policy-store-contract.d.ts +7 -0
- package/dist/core/store-contracts/session-policy-store-contract.js +130 -0
- package/dist/core/store-contracts/session-repo-contract.js +176 -0
- package/dist/core/store-contracts/session-store-contract.d.ts +6 -8
- package/dist/core/store-contracts/session-store-contract.js +361 -51
- package/dist/core/store-contracts/workflow-journal-store-contract.js +26 -1
- package/dist/core/stub-env.d.ts +2 -0
- package/dist/core/stub-env.js +1 -0
- package/dist/core/surrogate-safe-slice.d.ts +3 -3
- package/dist/core/synthetic-read-verdict.js +3 -3
- package/dist/core/task-event.d.ts +42 -8
- package/dist/core/task-notification.d.ts +19 -15
- package/dist/core/task-registry-agent.d.ts +4 -4
- package/dist/core/task-registry-shared.d.ts +4 -4
- package/dist/core/task-registry-shared.js +1 -0
- package/dist/core/task-registry.d.ts +13 -4
- package/dist/core/task-registry.js +14 -2
- package/dist/core/task-result.d.ts +21 -3
- package/dist/core/task-spec.d.ts +69 -64
- package/dist/core/task-stream.d.ts +6 -6
- package/dist/core/task-tool-shape.d.ts +4 -4
- package/dist/core/tighten-task-spec.d.ts +1 -1
- package/dist/core/tighten-task-spec.js +2 -1
- package/dist/core/tool-catalog-entries.d.ts +36 -2
- package/dist/core/tool-catalog-entries.js +69 -43
- package/dist/core/tool-check-reading.d.ts +11 -0
- package/dist/core/tool-check-reading.js +11 -6
- package/dist/core/tool-conformance.js +1 -0
- package/dist/core/tool-errors.d.ts +2 -2
- package/dist/core/tool-face-reader.js +5 -3
- package/dist/core/tool-face.d.ts +21 -2
- package/dist/core/tool-face.js +1 -1
- package/dist/core/tool-model-gate.d.ts +5 -5
- package/dist/core/tool-name-aliases.d.ts +29 -8
- package/dist/core/tool-name-aliases.js +22 -4
- package/dist/core/tool-name-readings.d.ts +10 -43
- package/dist/core/tool-name-readings.js +2 -11
- package/dist/core/tool-policy.d.ts +85 -196
- package/dist/core/tool-policy.js +150 -175
- package/dist/core/tool-registry.d.ts +14 -7
- package/dist/core/tool-registry.js +17 -7
- package/dist/core/tool-result-store.js +5 -1
- package/dist/core/tool-roster.d.ts +57 -33
- package/dist/core/tool-roster.js +56 -68
- package/dist/core/tool-spec-members.js +8 -3
- package/dist/core/tool-spec.d.ts +53 -31
- package/dist/core/tools.js +51 -10
- package/dist/core/trace.d.ts +6 -2
- package/dist/core/types.d.ts +1 -1
- package/dist/core/untrusted-egress.d.ts +1 -1
- package/dist/core/untrusted-text.d.ts +1 -1
- package/dist/core/user-attachment.d.ts +8 -1
- package/dist/core/user-attachment.js +64 -4
- package/dist/core/warm-resume.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +58 -0
- package/dist/core/wiring-manifest.js +14 -5
- package/dist/core/workflow-journal-store.d.ts +62 -0
- package/dist/core/workflow-journal-store.js +78 -2
- package/dist/core/workflow-run-store.d.ts +6 -0
- package/dist/core/workflow-run-store.js +2 -0
- package/dist/engine/compaction/compaction.d.ts +9 -9
- package/dist/engine/compaction/compaction.js +3 -2
- package/dist/engine/execution-env/node-execution-env.d.ts +26 -8
- package/dist/engine/execution-env/node-execution-env.js +54 -24
- package/dist/engine/harness/agent-harness.d.ts +39 -9
- package/dist/engine/harness/agent-harness.js +87 -33
- package/dist/engine/harness/types.d.ts +58 -9
- package/dist/engine/harness/types.js +12 -1
- package/dist/engine/llm/types.d.ts +36 -19
- package/dist/engine/llm/validation.d.ts +4 -1
- package/dist/engine/llm/validation.js +13 -1
- package/dist/engine/loop/agent-loop.d.ts +23 -1
- package/dist/engine/loop/agent-loop.js +26 -34
- package/dist/engine/loop/types.d.ts +31 -19
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -2
- package/dist/engine/session/memory-repo.d.ts +18 -12
- package/dist/engine/session/memory-repo.js +24 -9
- package/dist/engine/session/parent-chain-guard.d.ts +3 -2
- package/dist/engine/session/parent-chain-guard.js +1 -1
- package/dist/engine/session/repo-utils.d.ts +32 -3
- package/dist/engine/session/repo-utils.js +50 -5
- package/dist/index.d.ts +47 -23
- package/dist/index.js +35 -17
- package/dist/internal/harness-types.d.ts +1 -1
- package/dist/internal/harness.d.ts +3 -3
- package/dist/internal/harness.js +2 -2
- package/dist/internal/llm.d.ts +2 -2
- package/dist/internal/llm.js +1 -1
- package/dist/orchestration/goal.js +2 -2
- package/dist/orchestration/run-workflow-tool.d.ts +8 -6
- package/dist/orchestration/run-workflow-tool.js +10 -6
- package/dist/orchestration/workflow-size-guideline.d.ts +4 -4
- package/dist/orchestration/workflow-types.d.ts +18 -0
- package/dist/orchestration/workflow-types.js +6 -0
- package/dist/orchestration/workflow.d.ts +5 -13
- package/dist/orchestration/workflow.js +57 -49
- package/dist/prompt-assembly/artifact.d.ts +2 -2
- package/dist/prompt-assembly/artifact.js +5 -3
- package/dist/prompt-assembly/assemble.d.ts +3 -2
- package/dist/prompt-assembly/assemble.js +1 -1
- package/dist/prompt-assembly/catalog/schema.d.ts +43 -12
- package/dist/prompt-assembly/catalog/schema.js +34 -2
- package/dist/prompt-assembly/catalog/sections.js +0 -1
- package/dist/prompt-assembly/composer.d.ts +3 -1
- package/dist/prompt-assembly/composer.js +1 -1
- package/dist/prompt-assembly/event-registry.js +2 -0
- package/dist/prompt-assembly/overlay.js +3 -3
- package/dist/prompt-assembly/renderers.d.ts +1 -1
- package/dist/prompt-assembly/renderers.js +0 -3
- package/dist/prompt-assembly/role-base.d.ts +15 -0
- package/dist/prompt-assembly/role-base.js +9 -0
- package/dist/prompt-assembly/select.d.ts +39 -14
- package/dist/prompt-assembly/select.js +47 -16
- package/dist/prompt-assembly/sugar.d.ts +17 -11
- package/dist/prompt-assembly/sugar.js +8 -3
- package/dist/prompt-assembly/tool-catalog.d.ts +39 -2
- package/dist/prompt-assembly/tool-catalog.js +117 -4
- package/dist/prompt-assembly/turn-snapshot.d.ts +4 -10
- package/dist/prompt-assembly/turn-snapshot.js +0 -3
- package/dist/prompt-assembly/types.d.ts +0 -7
- package/dist/prompts/default.d.ts +14 -14
- package/dist/scenarios/full-body.d.ts +15 -15
- package/dist/scenarios/full-body.js +4 -3
- package/dist/shell/lex.js +10 -1
- package/dist/stores/file/adoption/adopt.d.ts +4 -3
- package/dist/stores/file/adoption/adopt.js +27 -33
- package/dist/stores/file/adoption/marker.d.ts +6 -13
- package/dist/stores/file/adoption/marker.js +9 -17
- package/dist/stores/file/checkpoint-store.d.ts +24 -0
- package/dist/stores/file/checkpoint-store.js +45 -7
- package/dist/stores/file/file-history-store.d.ts +20 -19
- package/dist/stores/file/file-history-store.js +157 -141
- package/dist/stores/file/fs-atomic.d.ts +15 -0
- package/dist/stores/file/fs-atomic.js +89 -14
- package/dist/stores/file/index.d.ts +1 -1
- package/dist/stores/file/index.js +2 -1
- package/dist/stores/file/mailbox-store.d.ts +3 -0
- package/dist/stores/file/mailbox-store.js +3 -1
- package/dist/stores/file/permission-rule-adopt.d.ts +1 -1
- package/dist/stores/file/permission-rule-adopt.js +18 -18
- package/dist/stores/file/permission-rule-store.d.ts +18 -2
- package/dist/stores/file/permission-rule-store.js +31 -18
- package/dist/stores/file/session-policy-store.d.ts +3 -3
- package/dist/stores/file/session-store.d.ts +54 -16
- package/dist/stores/file/session-store.js +114 -85
- package/dist/stores/file/workflow-journal-store.d.ts +10 -4
- package/dist/stores/file/workflow-journal-store.js +28 -13
- package/dist/tools/artifact/artifact-text.d.ts +1 -1
- package/dist/tools/artifact/artifact-tool.js +5 -3
- package/dist/tools/fs/bash-program-position.d.ts +19 -2
- package/dist/tools/fs/bash-program-position.js +56 -9
- package/dist/tools/fs/bash-readonly-classifier.d.ts +3 -3
- package/dist/tools/fs/fs-bash.d.ts +2 -2
- package/dist/tools/fs/fs-bash.js +8 -6
- package/dist/tools/fs/fs-glob.d.ts +86 -0
- package/dist/tools/fs/fs-glob.js +226 -0
- package/dist/tools/fs/fs-read.js +14 -14
- package/dist/tools/fs/fs-search-tools.js +7 -7
- package/dist/tools/fs/fs-shared.d.ts +8 -8
- package/dist/tools/fs/gh-rate-limit.d.ts +1 -1
- package/dist/tools/fs/notebook.d.ts +2 -2
- package/dist/tools/fs/pattern-kill-guard.d.ts +22 -0
- package/dist/tools/fs/pattern-kill-guard.js +397 -0
- package/dist/tools/fs/pdf.d.ts +9 -6
- package/dist/tools/fs/pdf.js +3 -3
- package/dist/tools/fs/program-read-model.d.ts +57 -7
- package/dist/tools/fs/program-read-model.js +11 -1
- package/dist/tools/fs/program-read-model.rows.js +78 -21
- package/dist/tools/fs/read-deny-home.js +3 -2
- package/dist/tools/fs/read-deny.d.ts +29 -7
- package/dist/tools/fs/read-deny.js +63 -22
- package/dist/tools/fs/removal-targets.d.ts +18 -8
- package/dist/tools/fs/removal-targets.js +47 -20
- package/dist/tools/fs/safety.d.ts +9 -9
- package/dist/tools/fs/search.d.ts +16 -6
- package/dist/tools/fs/search.js +20 -26
- package/dist/tools/fs/shell-read-argv.js +89 -12
- package/dist/tools/fs/shell-read-bindings.d.ts +31 -0
- package/dist/tools/fs/shell-read-bindings.js +41 -0
- package/dist/tools/fs/shell-read-check.js +12 -20
- package/dist/tools/fs/shell-read-environment.d.ts +5 -1
- package/dist/tools/fs/shell-read-environment.js +3 -2
- package/dist/tools/fs/shell-read-establish.d.ts +15 -0
- package/dist/tools/fs/shell-read-establish.js +19 -20
- package/dist/tools/fs/shell-read-judge.d.ts +4 -0
- package/dist/tools/fs/shell-read-judge.js +5 -2
- package/dist/tools/fs/shell-read-plan.js +24 -16
- package/dist/tools/fs/shell-read-start.d.ts +6 -1
- package/dist/tools/fs/shell-read-write-targets.d.ts +5 -1
- package/dist/tools/fs/shell-read-write-targets.js +2 -3
- package/dist/tools/fs/shell-reentry-text.d.ts +9 -0
- package/dist/tools/fs/shell-reentry-text.js +40 -2
- package/dist/tools/loop-tick.d.ts +2 -2
- package/dist/tools/scheduler-tools.js +5 -1
- package/dist/tools/task-list.d.ts +7 -7
- package/dist/tools/task-list.js +5 -1
- package/dist/tools/todo.js +65 -64
- package/dist/tools/web.d.ts +20 -15
- package/dist/tools/web.js +55 -69
- package/dist/tools/worktree.d.ts +3 -3
- package/dist/tools/worktree.js +3 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +5894 -5638
- package/wire-changes/core@7.55.0.json +3318 -0
- package/wire-changes/core@7.56.0.json +3891 -0
- package/wire-changes/mods-kernel@7.56.0.json +303 -0
- package/dist/core/store-contracts/session-store-incarnation-contract.d.ts +0 -39
- package/dist/core/store-contracts/session-store-incarnation-contract.js +0 -95
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,951 @@
|
|
|
1
|
+
## 7.56.0 — 2026-10-23
|
|
2
|
+
|
|
3
|
+
### One writer per fact on a permission decision — a policy's or a hook's decision that states a word the gate writes is refused by name, and the integrity guards and a fold's collapsed classes ride the engine's attestation record alone (#2134 / #1617 / #1329; hard-breaking, open to veto; security narrowing; closed sets: `RETIRED_DECISION_MEMBERS` +2 (`guards`, `foldedAskClasses`); exports: `PermissionResult`'s ask arm loses `guards` and `foldedAskClasses`, `EngineAttestation` gains an optional `guards`, `AskClassFacts` (the facts `classifyAsk` takes) gains a required `call`) (@server @test @cli @client-core)
|
|
4
|
+
|
|
5
|
+
- **Before.** A decision a policy or a PreToolUse hook returned could carry the words the gate itself writes after it answers — `origin`, `askClass`, `classes`, `mandate`, `allowRuleShadowed`, `ruleEvidence`, `segmentCoverage`. The receipt took them (each held to its closed set), the class derivation did not read them, and the gate's stamp replaced them: `askClass: "cross_boundary"` on an ask without `crossBoundaryMandated` was derived as an ordinary ask and released under `bypassPermissions`, and a `segmentCoverage` the lane did not overwrite was kept. The integrity guards (`guards`: a protected removal, an unverifiable delete or shell text, the session transcript, another person's shared artifact) were a member any policy could write, and they count as explainers of the real-approval bit — a policy writing `guards: ["transcript_write"]` beside `requiresRealApproval: true` moved its own question out of the bypass-immune `policy_rra` class into one `bypassPermissions` releases. A fold's collapsed classes stood twice, as the member `foldedAskClasses` and as `attestation.foldedClasses`; the receipt compared the two by the record's brand only, and the class derivation read the member — so a fold record handed back for another call (which reads as absent for this one) still added its classes.
|
|
6
|
+
- **Now.** One table says who writes each member (`DECISION_MEMBER_WRITERS`, beside `DECISION_MEMBER_ARMS`, total over the decision union): the composer states its own question (`action`, `message`, `updatedInput`, `decisionReason`, `requiresRealApproval`, `mandated`, `authoredBy`, `matchedAskRule`, `probeMandated`, `crossBoundaryMandated`, `denialLimitFallback`, `classifierNoRuling`, `ruleUnreadable`, the `probe*` display members, `editRewritten`); the gate's stamp writes `origin` / `askClass` / `classes` / `mandate` / `allowRuleShadowed` / `ruleEvidence` / `segmentCoverage` — a decision that states one is refused by name at the receipt (fail-closed deny, the member named), on the policy seam and the hook seam alike; the engine's record (`attestation`) carries what only an engine station knows. `guards` and `foldedAskClasses` are retired names: the guard policies, the read station's silence and the artifact tool's consent attest their guard on the call's record (`attest(…, call, { guards })`), `combinePolicies` and the gate's two own writers attest the collapsed classes, and the class derivation reads both off `attestationOf(decision, call)` once — the call is a required member of the facts it is handed; an ask whose record belongs to another call is refused by the receipt that decides this one. A spread of a fold's answer keeps its record, as before.
|
|
7
|
+
- **Hard-breaking (open to veto).** *What changed*: a decision carrying `origin`, `askClass`, `classes`, `mandate`, `allowRuleShadowed`, `ruleEvidence` or `segmentCoverage` is refused (the call does not run); `guards` / `foldedAskClasses` are refused by name and left `PermissionResult`; `classifyAsk`'s facts require `call: { toolCallId, toolName }`. *Who is hurt*: a host policy or hook that puts any of these on its answer (TS hosts see `guards` / `foldedAskClasses` as compile errors; a JS host gets the refusal), a host that reads `decision.foldedAskClasses` or writes `guards`, and a host calling the exported `classifyAsk` with facts that name no call. A policy that caches an ASK answer across calls and returns it for another call id has that call refused. *How to migrate*: drop the members; ask again rather than relay a cached ask; to make a person answer, state `requiresRealApproval: true` (class `policy_rra`); to get a guard's question, install the engine's guard policy (`createUnverifiableDeletePolicy`, `createTranscriptIntegrityPolicy`); read a fold's classes off the `AskRequest` / `GateDisposition` `classes` the gate writes; pass the call the facts are about to `classifyAsk`.
|
|
8
|
+
- **Behavior narrowing (open to veto).** A self-declared stamp word was silently replaced and the call went on; it is now refused. Measured on a real Runner, previous stage head vs this one: `askClass: "cross_boundary"` under `bypassPermissions` ran and is refused; an ancestor ask stating `origin: "org_rule"` was released under the engine's word and is refused; a host's `guards: ["artifact_shared_read"]` beside the real-approval bit was released under bypass and is refused; `mandated: true` with a `mandate` word beside it was put to a person as `operator_forced` and is refused (`mandated: true` alone still is put to a person); a policy's `segmentCoverage` was received and is refused. An ask carrying the engine's record of another call — a fold's answer a policy cached or relayed from another call id — is refused where the call it decides is received (the gate, the fold's layers, the inherited stations, the approved-edit, park and synthetic-read re-judgments): its recorded findings are the other call's question, and reading them as absent would drop them (its card read `hook` before; read as absent it would have been released under bypass). An ALLOW carrying another call's record still reads it as absent (it settles nothing here), as before.
|
|
9
|
+
- **What still stands (named, not closed).** A policy can still obtain a genuine guard record for its call by running an exported guard policy itself — with its own configuration — and attach it to another ask of the same call: the record is bound to the call (`toolCallId` + `toolName`), not to the bit it explains, so the guard can still explain that ask's real-approval bit away (the former one-word route needed none of that). The second explainer rule stands too: a bare `mandated: true` reads `operator_forced` only when no other class is present, so adding `probeMandated` or `decisionReason: "mode"` to it yields a class bypass releases. Both close with the one design that makes the composer's statements a class of their own that no other class explains away (filed for 7.57.0; `docs/KNOWN-LIMITS.md` KL-1111i).
|
|
10
|
+
- **Consumers.** @server: `operator-ask.ts` / `runtime-governance.ts` already hand the fold's answer back as it is and stamp `authoredBy` on the leaves — no code change; the S-958 notes saying a copy of the fold's answer is refused "by object identity" have been untrue since 7.49.0 (the record rides the copy) and stay untrue. @test: a black-box cell per stamp word and per retired name (refused, the member named, nothing run) and the KL-1111i form (a host's `guards` beside the real-approval bit under bypass: refused). @cli / @client-core: the `AskRequest` and park-row `origin` / `askClass` / `classes` are unchanged (the stamp still writes them).
|
|
11
|
+
- **Pins.** `test/backlog1329b-one-writer.test.ts`; re-pinned to the one carrier: `backlog1266` (the fold's classes on the record; the retired member refused), `backlog1231` (the stamp words refused whatever their value; ⑤ a self-reported `origin` / `mandate` refused on both lanes), `backlog1214` G2, `backlog1293b-*`, `backlog595` #33, `backlog564`, `design469-r3-boundary-legs` (the #2134 cell), and the guard fixtures across the arbitration pins, which attest their guard for the call (`test/helpers/engine-record.ts`).
|
|
12
|
+
|
|
13
|
+
### The role base has one source chain — the task, then the requested model role's preset, then an engine spawner's child persona — and the first source that names a role base decides it wholly; a role preset and the delegation tool can name the catalog's role base by word (`promptRole`), at the same position as the text it replaces (#2235; behavior change, open to veto; hard-breaking: two (the task's word now beats a role preset's text; `CODE_ROLE` changes shape); closed sets: none; exports: +1 member `RoleSpec.promptRole?`, +1 member `SubagentToolOptions.promptRole?`, `CODE_ROLE` value reshaped; no new index names) (@server @dgx @test)
|
|
14
|
+
|
|
15
|
+
- **Before.** The role layer had two independent readings. The text — `TaskSpec.systemPrompt ?? <role preset>.systemPrompt ?? <the delegated child's default persona>` — replaced `core/role.base`; the host role — `TaskSpec.promptRole ?? "general"` — selected its catalog variant. Any text anywhere beat the task's word, so a host that wanted the catalog's coding base on a role preset, in `runDeveloperTask`, or for the code bundle's children could only paste the catalog's text (`CODE_AGENT_PROMPT`) into a text seat, bypassing the selector — and a task writing `promptRole: "cowork"` on a deployment whose default role carried coding text got host role `cowork` with the coding text. `runDeveloperTask` and the code bundle ran with host role `general` while sending the coding base.
|
|
16
|
+
- **Now.** One decision (`src/prompt-assembly/role-base.ts`, read by the request-body sugar): the first of the task, the requested role's preset and the engine's child default that names a role base decides it — a word (`promptRole`) is the run's host role and selects the catalog variant (no overlay); a text replaces the base; a source naming both gives the host role by the word and the base by the text (the `TaskSpec` reading, now every seat's). `RoleSpec.promptRole?` and `SubagentToolOptions.promptRole?` are new seats with `TaskSpec.promptRole`'s closed set and door (`config.prompt_selector_invalid`, naming `roles.<role>.promptRole` / `SubagentToolOptions.promptRole`; the tool option is judged when the tool is built). `runDeveloperTask`, the code bundle's children and `CODE_ROLE` write the word on their own seat when that seat names no role base. A `TaskSpec.roles` override that names a role base replaces the deployment's statement whole (an override's word is never joined to the base's text). The workflow lane writes the requested role's preset onto its child's seat by the role resolution's own reading (`presetRoleBaseOf`: the merged maps' chain-head role — it no longer reads the raw `roles.subagent` past a child's own `roles` or explicit `modelRole`; the lane reads and the child's prepare judges, so a preset that read refuses lifts nothing and the child's own prepare refuses it on the run's failure path, never a throw at the launch site). The side query reads the same decision over its own text and the preset; a word preset is rendered by the one role-base renderer (the Cowork base renders only its unconditional sections there — a side query has no workspace and no mounted tools). Every role map is judged whole at the config doors, so a bad word on an entry no task selects refuses, and a resume refuses it before the claim (`checkpoint.config_refused`, `field: "deps"` / `"taskConfig"`). The delegation and workflow spawn reviews state a word role base as `args.promptRole` instead of sampling the engine's own catalog text.
|
|
17
|
+
- **Hard-breaking (open to veto) — the task's word now beats a role preset's text.** What changed: with `TaskSpec.promptRole` written and the requested role's `RoleSpec.systemPrompt` set, the base was the preset's text (host role still the task's word); it is now the variant the task's word selects. Who is hurt: a host that sets a role preset's text AND writes `promptRole` on tasks and relied on the preset's text winning — e.g. a deployment that attaches coding text to `roles.default` (`MODEL_CODE_ROLES=default`) and serves a `promptRole: "cowork"` scenario: those runs now get the Cowork base. How to migrate: to keep the preset's text, leave `promptRole` off those tasks or write the text on the task's own `systemPrompt`. This is the reading the selector's "task-level `promptRole` unchanged" statement now has: the key and its closed set are unchanged; its order against a preset's text changed to remove the mismatch.
|
|
18
|
+
- **Hard-breaking (open to veto) — `CODE_ROLE` is `{ promptRole: "code", thinking: "high" }`** (was `{ systemPrompt: CODE_AGENT_PROMPT, thinking: "high" }`). Who is hurt: code that reads `CODE_ROLE.systemPrompt` for the coding text. How to migrate: read `CODE_AGENT_PROMPT`; to preset a role, use `CODE_ROLE` as is.
|
|
19
|
+
- **Other behavior changes.** `runDeveloperTask` and the code bundle's children: host role `code` (was `general`), the `core/role.base` section's variant `code` and chain `bundled` (was a task `replace`); the bytes are the same unless an extension conditions on the host role (`when.host.role: "code"` overlays and `prompt.compose` handlers reading `e.selectors.host.role` now apply to these runs — the host role is true now). A spec that writes `systemPrompt: undefined` explicitly reads as naming no role base (it used to suppress `runDeveloperTask`'s default through the spread). These runs record one prompt change after upgrade (the task layer and the selector record change); the provider cache is keyed by the sent bytes and is not affected by that alone. A workflow child with an explicit non-subagent `modelRole` no longer takes the `roles.subagent` preset.
|
|
20
|
+
- **Consumers.** @server: `MODEL_CODE_ROLES` can write `roles[r].promptRole = "code"` instead of pasting `CODE_AGENT_PROMPT` (the leader legs, the side query and engine-built subagent children all read the same seat); the cowork-scenario change in the hard-breaking item above is yours to check; the new refusal seats (`roles.<role>.promptRole`, deployment-configured — not reachable from a request) carry `config.prompt_selector_invalid`. @dgx: a library host may name a role base by word on a role preset; nothing else changes for you here. @test: the black-box cells are in the design (role preset word ⇒ manifest `selectors.host.role` and the role section's `variant`; cowork task word over a coding text preset; a `roles.subagent` word reaching a delegated child).
|
|
21
|
+
- **Pins.** `test/role-base-source-chain.test.ts` — real lanes (a Runner's prepare, the delegation tool over a Runner, the workflow launch site, the side query, the resume screen): the task word over a preset text, the preset word, both on one seat, the unit merge, the door on four bad values / an unread entry / the resume screen / the tool option, the delegated child under a `roles.subagent` word and under the tool option's word, the definition over the option, `runDeveloperTask` (default, task word, a host-role-conditioned overlay), the workflow lift (and a child whose requested role is not the subagent role), the side query's three forms.
|
|
22
|
+
|
|
23
|
+
### A resume's CAS carries the deployment's claim holder, and the store judges it in the same atomic step — a run whose claim was taken over applies no decision (#2119; security fix and behavior narrowing for callers that pass a holder, open to veto; hard-breaking: none; closed sets: +1 `CheckpointError.code` word `resume.holder_superseded` (the `resume.*` family); exports: +2 root names `checkpointHolderMatches` (function) and `HolderJudge` (type); new optional members `ResolveExpectation.holder`, `CheckpointStore.fence`, `RunInternals.holder`, `RunWorkflowOptions.holder`, the in-tree stores' `holderIsCurrent` option and the store-contract kit's `holders` option) (@server)
|
|
24
|
+
|
|
25
|
+
- **Before.** A deployment that accepts a person's decision under its session claim hands it to the next run it starts on that session (`RunInternals.workflowParkedResume`, or a top-level `resumeStream`); the run's resume wins the checkpoint's CAS with it. The CAS judged the row (token, scope, pending) and its generation (`expect.rev`) — nothing about who was resuming. A run that lost its claim after the decision was written (a stall past the deployment's staleness bound) and a run that took the claim over and accepted a newer decision both raced the same CAS, and the first one won: measured on a real Runner, a superseded run's allow executed the action, and a superseded run's deny landed first so the person's newer allow never ran; a Workflow drive under a superseded claim drove the parked child. No transaction of the deployment's own could close it — the apply is the engine's CAS.
|
|
26
|
+
- **Now.** One precondition object, judged whole in the store's atomic step. `ResolveExpectation` gains `holder` — the deployment's credential for the claim the resolving run acts under (opaque; the engine never judges, stores or prints it). A resume carries the one it was handed (`RunInternals.holder`), and every Workflow drive a run makes carries that run's (`RunWorkflowOptions.holder`, threaded by the Workflow tool; spawned children do not inherit it). A store that declares `fence = { holder: true }` lets the CAS win only while that grant is current — the in-tree `InMemoryCheckpointStore` / `FileCheckpointStore` take a synchronous `holderIsCurrent` judge (called inside the CAS's step, before the commit; with it they declare the fence), through one exported predicate `checkpointHolderMatches` (a holder with no judge loses — never ignored; the judge's answer is current only when it is exactly `true`, and an answer that is not a boolean — the promise an async judge returns included — throws `config.invalid_checkpoint_store` with the row untouched, never read by its truthiness nor as a lost grant). A provided `RunInternals.holder` that is empty or not a string is refused at the door (`config.invalid_argument`, at the deployment's seat). A lost CAS whose row is still the one the resume validated (same scope, pending, same generation) is `resume.holder_superseded`: nothing applied, the row unchanged by that resume, do not retry it — the reading the reference implementation's remote worker gives a superseded worker epoch (refused at the write, then it stops). A Workflow drive refused this way re-parks its ordinal and the run suspends, as any refused drive does. A holder handed to a run whose store does not declare the fence is refused at the store seat (`config.invalid_checkpoint_store`; on a resume, before the claim, by the resume's one refusal channel) — a holder the store would ignore would fence nothing.
|
|
27
|
+
- **Reach, stated.** The precondition belongs to the decision-bearing verb: `expire` carries no expectation (as today, not even `rev`), so a destroy or a preflight-terminal settle is not fenced; the background lane's parked drive answers to its own reservation ticket, not a session claim (that lane's documented gap between its seat check and the CAS is the same law's next consumer). A store without `claimTerminal` classifies the loss from its diagnostic read (a later instant): under a race the row's later state answers instead (`checkpoint.reopened_concurrently` / `checkpoint.already_resolved`), fail-closed either way. The in-tree judge closes the race only when the claim's transfers happen in the same process, synchronously; a claim another process can transfer needs a store whose CAS and claim table share one transaction. A run that won while holding and loses its claim mid-execution finishes that one action (the decision was valid when it was applied). The refused-drive run-log sentence now says "refused before this run consumed the checkpoint … the park stands on this run's record" (it was untrue for a lost CAS), and its quoted reason replaces the claim holder by `<holder>` as it already replaced the checkpoint token by `<token>` (a judge's or a store's error may quote either). A workflow run reads its drives' trusted inputs — the decisions and the holder — once at its start, so a caller that edits or reuses its options object afterwards changes neither.
|
|
28
|
+
- **Behavior narrowing (open to veto).** Only for a caller that passes a holder: a resume under a superseded holder, which used to win, is refused; a holder over a store that cannot judge it, which used to be ignored, is refused at the store seat. Without a holder every path is byte-identical. Hard-breaking: none. A consumer switching exhaustively over `CheckpointError.code` sees one more `resume.*` word; the `claimTerminal` loss answer's `status: "pending"` now has two arms (a moved `rev` = the OCC arm, the caller's own `rev` = the holder arm).
|
|
29
|
+
- **Consumers.** @server (S-1869, 7.135.0 pickup): pass `RunInternals.holder` on every run started or resumed while holding a session claim, minted per grant and passed only after the grant commits; the SQL store declares `fence = { holder: true }` and adds the holder predicate to the same `UPDATE` (the claim row locked in share mode, in an order consistent with the reaper's) — `claimTerminal` recommended for the exact loss word; the local lane hands its in-process claim reader to `FileCheckpointStore` as `holderIsCurrent` (and its wrapper forwards `fence`); map `resume.holder_superseded` on a top-level resume to "superseded: stop, do not retry"; retire the known-boundary sentence on runs that lose the claim after the write. The store-contract kit's holder case runs for a store that declares the fence, given the binding's `holders` set.
|
|
30
|
+
- **Pins.** `test/backlog2119-resume-holder-fence.test.ts`: the store law on both in-tree stores (claim route and two-step route; current / released / no judge / throwing judge / no holder), the kit's holder case bound to both judged stores, the resume lane on a real Runner (released-then-current, current-then-released, a holder over a store without the fence, a fresh run's prepare door, the no-holder control), the loss reading cell by cell on a scripted two-step store (the expectation's shape, same row at the validated rev, a moved rev, another scope, a consumed or missing row), and the Workflow drives (the Workflow tool's three legs, `runWorkflow` on both spawn lanes).
|
|
31
|
+
|
|
32
|
+
### A pattern kill that selects the agent's own process or a process above it is refused before it runs — `pkill`, `killall` and a `kill` fed by `pgrep` / `pidof` put the same question to the real `pgrep` first, in every permission mode (#2348; security hardening and behavior narrowing, open to veto; hard-breaking: none; closed sets: +1 member `bash_self_kill_refused` in the Bash tool's card vocabulary (`engineCardTypes()`, and `ENGINE_CARDS` for the module that mints it); exports: +2 internal names in `src/tools/fs/pattern-kill-guard.ts` (`patternKillRefusal`, `patternKillReadings`) and the type `PatternKillReading`, not index-exported) (@test @cli @server)
|
|
33
|
+
|
|
34
|
+
- **Before.** With the permission mode bypassed (or after a person allowed the call), a model's `pkill -f <word>` whose word is also in the command line of a process above the agent — the shell a harness started the agent under (`bash -c "…; sema -p '<prompt with the word>' | tee log"`), a CI wrapper that carries the prompt in its argv — selected that process and terminated it (exit 143 for the user's script). The gate had judged the command ordinary and nothing else asked: no refusal, no sentence. The reference implementation's Bash tool refuses this at its shell (a `pkill` shell function runs `pgrep` with the same selectors first and refuses when its output holds the CLI's own pid: "this pattern matches the Claude CLI process … Narrow the pattern, or target your own children with `pkill -P $$ ...`"). Measured on procps (Linux); on BSD's `pkill` (macOS) the same command leaves the caller and its ancestors out unless `-a` is given, and `killall NAME` selects them there too.
|
|
35
|
+
- **Now.** One guard at the Bash tool's execution door (`createBashTool`'s `execute`, beside the read station's admission and ahead of the foreground / `run_in_background` split, so every mode reaches it — it is self-preservation, not an approval, and nothing answers it). It reads the command with the plan the read station plans (the one program-position reader: launchers, absolute paths, `nohup` / `env` / `timeout`, nested `bash -c` text and substitution bodies included) and finds every `pkill`, every `killall`, and every `pgrep` / `pidof` in a command that also runs a `kill` (`kill $(pgrep -f X)`, `pgrep -f X | xargs kill`, a loop over `$(pgrep …)`). For each it puts ONE question to the platform's real `pgrep`, with the selectors the killer would select by (signal words and the switches that only change the output — a count, the signal echo, the delimiter — are left out, bundled or not, because a count would be read as a pid; a signal word is `-<digits>` or `-` plus a name of the signal table (`-KILL`, `-SIGKILL`, `-USR1`, `-RTMIN+3`, in either letter case), never a word by its letter case alone; `-f`, `-x`, `-u`, `-P`, `-v`, the pattern pass through, and so does a value attached to its switch (`-U1000`, `-P1`, `-G0`, `-F/tmp/x.pid`); a `killall` / `pidof` name is asked as an exact, escaped process name, `-u` / `-I` / `-r` narrowing it as `pgrep` narrows, and `killall -u USER` alone is every process of the user), through the scrubbed environment, never a shell. A `pgrep` / `pidof` is a killer only when a `kill` in the same command takes its operands from somewhere (`kill $(pgrep …)`, `… | xargs kill`, a loop or a variable, a `kill "$@"` in a nested shell); a `kill` of literal pids beside a `pgrep` is not fed by it. It reads this process's pid and every parent (`/proc` on Linux, `ps` elsewhere). A selection that holds this process or any of its parents is refused before anything is spawned — a tool error (`isError: true`) whose sentence names the command, the pid and whether it is the agent's own process or one above it, and the way out in the reference's words (narrow the pattern; stop the process by its PID; target your own children with `pkill -P $$ ...`). `details` = `{ type: "bash_self_kill_refused", code: "bash_self_kill_refused", finding: "hit", pid, relation: "engine" | "ancestor" }`.
|
|
36
|
+
- **Two more refusals, one law (the question must be askable).** A selector the shell decides only when the command runs (`pkill -f "$X"`, `pkill -f $(cat pat)`, a `pkill` whose arguments arrive on a stream, `kill $(pgrep -f "$X")`) cannot be put to `pgrep` first: refused with `finding: "computed"` (a value the plan settles is asked as written: `X=word; pkill -f "$X"`). A question the platform cannot answer (no `pgrep`, no readable process table, a timeout) is refused with `finding: "unavailable"` and the reason, never silently run. Two forms whose targets this question cannot list are refused the same way (`finding: "computed"`, the reason says so): `killall -g` (it signals the whole process group of each match) and a negated selection with `-P $$` (`-v` / `--inverse` reaches everything the other selectors do not name). The one exemption is the way out itself: a `-P` / `--parent` selector whose value is the running shell's own pid (`pkill -P $$ …`, `-P $BASHPID`), not negated, selects that shell's children and nothing above, so it is not asked.
|
|
37
|
+
- **Options are read as one grammar, a signal word is what a table names (follow-up; security fix and behavior narrowing, open to veto; hard-breaking: none).** Before this follow-up `killall`'s words went through the `pgrep`-shaped pre-pass, which does not know `killall`'s options: a value attached to its switch (`killall -uUSER`), a long option with `=` (`killall --user=USER`) and a bundle (`killall -Iu USER`) selected nothing or read USER as a process name, so the line ran unasked; and every word of `-` plus upper-case letters and digits was dropped as a signal, which also dropped `pkill -U1000`, `-P1` and `-G0` (no question at all) and `-U1000` from `pkill -x -U1000 node`. Now ONE table lists every option of `killall` (the procps and the BSD spellings) with what it does to the selection, and ONE reader walks it: a bundle is split (`-Iu USER` → `-I -u USER`), a value attached to its switch (`-uUSER`, `-sTERM`, `-o5m`) or to a long option (`--user=USER`) is that option's value exactly as the next word would be, and every word after `--` is a name (put to `pgrep` after its own `--` when it begins with `-`, so `killall -- -zsh` asks for the process named `-zsh`). An option the table does not list — an unknown switch, an abbreviated long option (`--us=USER`), a value given to a switch that takes none (`--quiet=1`) — selects an unknown set and is refused with `finding: "computed"` and the reason, as `-g` is (spell the option in full, or stop the process by its PID). The table carries, per option, the `pgrep` switch that says the same: `-u USER` and `-t TTY` select as `pgrep -u` / `pgrep -t` do (alone: every process of the user / tty; beside names: a narrowing); `-Z CONTEXT` has no such switch, so beside a name, user or tty it only narrows, and alone it is refused as an unaskable selection. A `kill` of the pid that means every process (`kill -9 -1`, `kill -- -1`, `kill -s TERM -1`, after any spelling of the signal) holds this agent by construction and is refused as a hit without a question to `pgrep` (minus one in any numeral spelling: `-1`, `-01`); a signal word alone (`kill -1`) and literal pids are as before. Where `killall`'s options end is the platform's, and the reader follows it with one platform constant (beside the one for `pgrep`'s ancestors): procps permutes the words, so an option may follow a name; BSD's (macOS) getopt stops at the first name, so `killall node -unobody` there names the processes `node` and `-unobody`. BSD's `-c PROCNAME` is a name every selected process must also carry: alone it is the name asked, beside names it is one more name asked, and the last one counts (the two sets are not intersected by comparing spellings: the platform compares the `-c` name with a process's name over its first sixteen bytes only, so `killall -c <B> <A>` with A and B sharing their first sixteen bytes selects the process named A — measured on macOS — and a name that no given name equals is no proof that nothing is selected; either name selecting this agent or a process above it is refused). A signal word is `-<digits>` or `-` plus a name of ONE signal table (`-KILL`, `-SIGKILL`, `-USR1`, `-RTMIN+3`, in either letter case — BSD's `pkill` and `killall` read `-term` as SIGTERM and `-io` as SIGIO, measured), shared by the `pkill` and the `killall` readers: `killall` drops it wherever it stands, `pkill` takes ONE, where its platform takes it — procps' the first word that spells a signal, anywhere (`pkill -f -9 WORD` asks `pgrep -f WORD`; a second signal-shaped word stays a switch bundle), BSD's only its first argument (a later `-io` is ignore-case plus oldest — measured: it selected and killed a process the case-sensitive question missed — and a later `-9` is a usage error of the killer itself, asked as written); `pgrep` has no signal, so a `pgrep` that feeds a kill keeps every word as written (`-io` is ignore-case plus oldest there, and a `-9` reaches `pgrep` unread); every other word that begins with `-` is put to `pgrep` as written and `pgrep` reads the attached value itself (a signal name outside the table reaches `pgrep` unread: the question is `unavailable` and the line is refused with the reason, never run). One exception to "the signal is the killer's alone": on procps `-H` / `--require-handler` keeps only the processes that handle the signal, so the signal the killer takes (the last `--signal` option, in any unambiguous spelling of it such as `--sig`, else the one signal word — which procps' `pkill` takes in a pre-scan before it reads any switch, so the first signal-shaped word is the signal even where it would otherwise be a switch's value or follows `--`, and the guard reads it in that order) is put to the question as `--signal <name>` (`pkill -H -USR1 -f WORD` asks `pgrep --signal USR1 -H -f WORD`, not `pgrep -H -f WORD`, which asks about SIGTERM and would miss an ancestor that handles only USR1); without `-H`, and on BSD (no such switch), the signal stays out of the question, and a `pgrep` too old to know `--signal` fails the question, which is refused as `unavailable` like any question that cannot be asked. The upper-case-word rule is retired; `pkill` / `pgrep` / `pidof` words are otherwise put to `pgrep` as before, except that the word after a switch that takes a value (`-F`, `-u`, `-P`, `-t`, …) is that value as written, whatever it looks like (`pkill -TERM -F -cont` names the pidfile `-cont`; it used to be read as the switches `-c -o -n -t` and asked as the pidfile `-ont`); that one value rule serves the long options too, over procps 4.0.4's option table (`--runstates`, `--older`, `--pidfile`, `--parent`, `--session`, `--terminal`, `--uid`, `--euid`, `--group`, `--pgroup`, `--ns`, `--nslist`, `--cgroup`, `--queue`, `--signal`, `--delimiter`, and the short `-r`; an exact name or any unambiguous prefix of one, its value attached by `=` or the next word; on procps `pkill`'s `-q` / `--queue` payload is the killer's alone and leaves the question with its value, as `pgrep` has no short `-q`), so `pkill -H --runstates --sig=USR1 -f WORD` asks `pgrep -H --runstates --sig=USR1 -f WORD` (the signal stays SIGTERM and `--sig=USR1` is the run states; it used to be read as the signal, and the question `pgrep --signal USR1 -H --runstates -f WORD` took `-f` as the run states and missed an ancestor that is asleep and handles SIGTERM), and the delimiter's value goes with its switch in a bundle (`pgrep -fd , x`) as it does apart. Behavior narrowing, stated (open to veto): a `killall` line with an option outside the table, which ran before, is refused now; and a line that selects by an attached value (`killall -uUSER`, `pkill -U1000`, `pkill -P1`) is asked now where it ran unasked.
|
|
38
|
+
- **Behavior narrowing (open to veto).** A command that ran before is refused now: a pattern kill whose selection holds the agent or a process above it, a pattern kill with a computed selector, and a pattern kill on a host where `pgrep` cannot be run. Nothing else changes: a pattern that selects only other processes, a literal-PID kill, `pkill -P $$`, and every command that names none of these programs run exactly as before. Hard-breaking: none. Reach, stated: the guard asks the question of THIS process's table, so it is not asked for a remote or sandboxed environment (`isRemoteExecutionEnv`) nor on Windows; a kill by literal pid (`kill -9 1234`, `kill $PPID`), shell text built only when the command runs, a script file's own contents, `ps | grep | awk | xargs kill` pipelines and the `Monitor` tool's commands are not read (KL-2348-a / b); on procps a `pkill -f <word>` still selects the tool's own `bash -c` (the word is in its command line), which is unchanged (KL-2348-c).
|
|
39
|
+
- **Consumers.** @test: the `selfkill-20261021` fence's red arm (`s1-self-wrap-bypass`) turns to a refusal on procps (`pkill -f <word>` with the word in an ancestor's argv: refused, the outer shell lives; the no-match / `pkill -P $$` / children controls run as before; on macOS the form that selects ancestors is `pkill -a -f <word>`) — rerun on this version. @cli: nothing to do (the refusal is a tool error carrying its own sentence; it renders as any Bash error). @server: the Bash tool's `details.type` vocabulary gains `bash_self_kill_refused` (a consumer that pins the set `bash`, `readonly_out_of_root`, `read_unestablished`, `bash_invalid_timeout` sees one more).
|
|
40
|
+
- **Pins.** `test/backlog2348-pattern-kill-self-preservation.test.ts` on two real layers. Lane cells L1–L8: a child engine (real Runner, real `NodeExecutionEnv`, real `/bin/bash`; `test/helpers/selfkill-engine-child.ts`) started under a shell whose command line carries a random marker word (`test/helpers/selfkill-lane.ts`) — six spellings in bypass (the outer shell lives, the agent finishes, the pid named is the outer shell's), the marker in the agent's own argv, `killall <outer shell's name>`, default mode after a person allowed the call, headless bypass, `run_in_background`, the controls (a pattern nothing carries, a live process the test started is killed, `pkill -P $$` kills only the shell's child) and the reference's words. Reading cells R0–R7: the real `pgrep` and this process's own ancestry on a table of spellings that hit and a table that run, the computed and unavailable arms, the remote-environment skip, a cancelled call, and the exact `pgrep` argv each killer form is asked. Option cells (the follow-up): L9–L12 on the real lane through a stand-in killer script (`killall -u<user>`, `pkill -U<uid>`, `kill -9 -1`: refused as hits, the stand-in never runs) and `killall -- -<outer shell's name>`; R14 (`-t` as `pgrep`'s `-t`, `-Z` unaskable alone) and R15 (`kill` of the pid `-1`); R9 (every spelling of `killall`'s options asks what the spaced one asks; options outside the table are unaskable), R10 (the spellings that used to read as no selector, with the real `pgrep`), R11 (controls: listed options with names nothing carries run), R12 (the signal table, shared by both readers) and R13 (`pkill -U<uid>` / `-G<gid>` are hits); each in a base-versus-head reading and with a mutation per rule. Second follow-up: L13 (a stand-in `killall -c <other name> <the outer shell's name>` is refused as a hit), R14 (the `-c` block: both asked), R17 (a signal word is asked about the way the host's `pkill` takes it, with the real `pgrep`) and R18 (the procps and the BSD grammar of the one signal word, both read on any host by loading the module as each platform loads it). Third follow-up: R19 (the probe each `-H` / `--signal` spelling is asked, in both grammars), R20 (a stand-in `pgrep` modelling procps' handler selection: an ancestor that handles only USR1 is refused for `pkill -H -USR1`, one that handles only SIGTERM is not) and R21 (a `pgrep` without `--signal` fails the question that needs it: `unavailable`); the value rule over the option table: R19's valued-option rows, R22 (procps' long options whole: every name, every prefix, spaced and with `=`, in both grammars) and R20's run-state rows.
|
|
41
|
+
|
|
42
|
+
### The MCP failure text takes the request out of an endpoint's echo — every header value the request carried, whatever the header's name, and every `Cookie` / `Set-Cookie` member are removed at the one mint, ahead of the credential vocabulary, on every face that prints the remote's words (#2410; security fix and behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none — two internal functions in `protocol-http-auth`, not index-exported) (@server)
|
|
43
|
+
|
|
44
|
+
- **Before.** The connect, re-dial and refresh failure sentence (`statuses[].error`, the operator warning and its `cause`, the wiring manifest's `mcp[].error`, `announcedListings.mcpFailed[].error`, the refresh result and the re-dial `reason`) ran only the credential vocabulary over the endpoint's words, and the vocabulary reads header NAMES: an `Authorization` or `X-Api-Key` line came out `[redacted]`. An endpoint that answers a 4xx with the request headers it received in its body therefore handed every other value the request carried — `X-Org-Handle: acme`, a `Cookie: sid=…` line, the principal the engine sent under `principalHeader` — verbatim to every one of those faces (seven read faces on the server, measured). The faces that run after the mount read the remote's words the same way: a tool call's failure text and isError text, and a resource list / read / directory failure. Not a regression: the same on 7.55.0.
|
|
45
|
+
- **Now.** At the mint (`mcpRemoteText`, one helper behind every place that redacts remote text, and it takes the request's values as a required argument, so no face can read the remote's words without naming its request), before the credential vocabulary and before `inlineUntrusted`, both kinds of removal found on the ORIGINAL text and their ranges merged, one marker per merged range: (1) every VALUE the request carried is removed from the remote's text — each string value of the declaration's `transport.headers` (whatever its name), the principal sent under `principalHeader`, each in every spelling the one derivation below yields (as declared and as the transport sends it — the whitespace at a value's edges is not sent, so an endpoint echoes the trimmed value —, the part after its first word as in `Bearer x` → `x`, the spelling an "invalid token x" echoes, and their JSON, percent-encoded and `+` spellings) — in any letter case, longest spelling first; (2) every `Cookie` / `Set-Cookie` member is replaced whole, whether or not the request declared it — a header line to its end, a record member's string, array or object to its own close (across lines, brackets inside its strings skipped; sibling members stay). Found on the original text and merged, because a declared value that sits inside the word `Cookie` (`ok`) must not disarm the Cookie rule (it leaves the name and the member's value still goes), a declared value that overlaps the name from outside (`"cookie"` with its quotes) must not eat the name and leave the member's value behind (both ranges go), and no marker is rescanned. A failure text can go through the pass more than once on its way out (a tool call's failure is read where it is caught and again where it is rethrown; a resource-list failure where the rejection is captured and again where its line is printed), and a second pass reads the first pass's marker as text: a declared value that sits inside the marker (`act` in `[redacted]`) turns it into `[red[redacted]ed]` — legibility only, nothing that was removed comes back. The marker is the vocabulary's `[redacted]`; the vocabulary still runs after, as the supplement for what the engine was not sent. The status line's reason phrase and a URL's userinfo are unchanged (the first never echoed, the second is refused before any request with a fixed sentence), and the 240-character bound on the inline faces is unchanged.
|
|
46
|
+
- **Spellings (#2445).** An endpoint rarely prints a value the way it was sent. ONE derivation (`requestEchoSpellings` in `protocol-http-auth`, internal, not index-exported) yields every spelling that is removed, from each declared value alike — no list of header names, no guess at what a token looks like. Three bases: the value as declared; as the transport sends it; and, of that, the part after its first word when it reads `<word> <rest>` (`Bearer x` → `x`; the cut is made of the value's shape, so `Realm x`, `Token x` and a plain two-word tenant name give their second part too — the cut used to be made only under credential-looking headers). Each base in eight spellings: as it is, JSON-string escaped, that with `/` written `\/` (PHP and some other servers escape the slash: `Bearer q7Zk/9xW` → `Bearer q7Zk\/9xW`), and with `+` for a space; and the percent-encodings as a closed grid, two reserved sets × two spellings of the space — the set `encodeURIComponent` writes (it leaves `~ ! ' ( )` bare) or the set the platform's form serializer (`URLSearchParams`) writes (it encodes them: `tenant~key!2491` → `tenant%7Ekey%212491`), each with `%20` or with `+` for the space. The form-serializer spellings come from that one encoder, not from a character table of ours. The match ignores letter case, so `%2f` and `%2F`, `BEARER X` and a lower-cased echo are one spelling. The measured leaks this closes: `Bearer%20<value>` and `Bearer+<value>` in the endpoint's words, a value in upper or lower case, a percent-encoded Cookie value (`sid%3dabc`), a bare token with its scheme word dropped, the form serializer's spellings of a value holding `~ ! ' ( )` (`tenant%7Ekey%212491`, `Acme%20Widgets%2F7%21x`), a base64 credential printed with escaped slashes (`Bearer q7Zk\/9xW+aB3\/Lm0=`, as a PHP-style JSON body writes it), and the same spellings of the principal. Not removed, and stated in KL-2410-c: base64 and other re-encodings, a percent-encoding whose reserved set is neither `encodeURIComponent`'s nor the form serializer's (a form that encodes only some of `!'()~`, another language's encoder), a JSON escape other than the standard ones and `\/`, double encoding, a value the endpoint splits across lines, the single pairs of a `;`-joined Cookie value echoed one at a time, and a case rewrite that changes the string's length or its encoded bytes (`ß` upper-cased to `SS`; a non-ASCII value upper-cased and then percent-encoded). A declaration too large for one matcher (tens of thousands of characters in one value or in the spellings together, which no header carries) makes the whole remote text the marker `[redacted]` instead of throwing — the matcher's own error text would have carried the value. A text that holds none of the spellings and no Cookie member is still returned as it is.
|
|
47
|
+
- **The collision rule.** A declared value that is also an ordinary word or number (`en`, `1`, `error`, `application/json`) is removed, in any letter case, wherever it occurs in the text the pass reads, and that text is the failure's whole message — the SDK's prefix, Node's error text and any URL the message quotes, not only the remote's own words — inside a word as well as as a whole word: a header value `1` takes the `1` out of `401`, `127.0.0.1` and `HTTP/1.1` in a connect failure (`40[redacted]`, `[redacted]27.0.0.[redacted]`), a value of only spaces takes every space, and on the tool-call faces a tool's own isError text goes the same way (a header value `user` takes the word `user` out of `/home/user/data` there), a header value `error` takes the `Error` of `Error POSTing to endpoint` as well, and a two-word value such as `hello world` takes `world` wherever it occurs (the part after its first word is one of its spellings): the sentence loses legibility, never a secret. Two declared values that overlap are removed one at a time, leftmost first: the tail of the second can stay (KL-2410-c). The failure's closed fields (`errorCode`, `httpStatus`, `delivered`) are not text and do not change.
|
|
48
|
+
- **Reach, stated.** Every MCP face that prints a failure text reads the same way (a server's `instructions` and a tool's description are not failure text and are read as before): the connect, re-dial and refresh sentence and every face it reaches, the note of a tool listing that stops on a later page, a tool call's failure sentence (the transport branch and the raw rethrow of a JSON-RPC error) and its isError text, and a resource list, read and directory failure (the extension's InvalidParams detail and the prefix-fallback listing included). Each connection carries its request's values — the tool closures and the resource-server record are handed them at the dial. What is not covered is stated in KL-2410-b (the A2A path has no echo pass: a peer's JSON-RPC error text reaches the model's tool error with an out-of-vocabulary header value or the principal as the peer echoed it, and only the vocabulary lines are redacted there) and KL-2410-c (re-encodings other than the spellings above, overlapping values, the multi-cookie form).
|
|
49
|
+
- **Behavior narrowing (open to veto).** A failure sentence carries less of the remote's words than before: header values the request sent and Cookie / Set-Cookie members no longer appear in it, and neither does any other occurrence of the same string. Nothing is blocked and no closed set changes. One visible side effect for a session carried across the upgrade whose server still fails against an echoing endpoint: the once-per-session record identifies a failure by server name and sentence (and the operator warning by code and message), and the sentence changed, so the model and the operator sink are told of that failure once more, and the stored row that carried the old text is not carried forward.
|
|
50
|
+
- **How to migrate.** *server*: on pickup the S-1948 read faces turn to zero hits for out-of-vocabulary header values and Cookie lines — flip the 🚧 reads to "zero hits"; your second redaction that rewrites the engine's `[redacted]` to `«redacted»` is yours to retire (S-2027), the engine already redacts at the one mint.
|
|
51
|
+
- **Pins.** `test/backlog2410-mcp-request-echo.test.ts` (a real local HTTP endpoint that echoes the request into a 400 body: E1–E9 and E13–E17 on the connect — E15 a declared value inside the word `Cookie`, E16 whitespace-padded values, E17 multi-line structured Cookie members with their siblings kept — R1 the refresh arm, R2 the re-dial arm, L1 / L2 a listing that stops on a later page at the connect and at a refresh, F1 a real run's manifest, events and operator chain, C1–C7 the faces after the mount — a tools/call answered 4xx, an isError result, a JSON-RPC error in a 200, a resources/list with a Cookie, a read, a directory read through the fallback listing and through the extension — C8 the law read off the source (every production page walk hands the walker the request's values), C9 / C10 a resource-list rejection that settles just before the run's abort, on the first and on a continuation page, T1 the Cookie rule alone, U1–U3 the two functions, G1–G4 the spellings on a connect, the principal, a real run's tool-call text and a refresh, U4–U9 the derivation itself and its controls, the Cookie / value overlap shapes and the oversize declaration, H1–H4 the form serializer's spellings of `~ ! ' ( )` values removed beside the other encodings — direct, on a connect, in a real run's tool text — and the reserved-set × space grid closed with the mixed form kept, I1–I3 the escaped-slash JSON spelling (direct, on a connect, in a real run's tool text); E10–E12, G5, H5 and I4 are the controls that hold on the pristine tree — an undeclared header is left alone, the reason phrase and URL userinfo do not echo).
|
|
52
|
+
|
|
53
|
+
### A row's owner is recorded by the write that creates it and `register` never changes it — the session-store kit's "register on a row that exists does nothing" cell asserts that for a placed row too, instead of finding `null` there, and the `headOf` / `register` contract notes say so (#2403; behavior narrowing of the kit, open to veto; hard-breaking: none; closed sets: none; exports: none) (@server)
|
|
54
|
+
|
|
55
|
+
- **Before.** The kit cell `[ownership] register on a row that exists does nothing, whatever it carries — an owned row, an anonymous row, a row acquire created, a placed row` ended with a placed arm that registered a name onto a placed row and asserted its owner `null`; the `headOf` note listed "a placed row" among the rows that read `null`. That was a second law beside the first (a row's owner is recorded by the write that creates it): a store whose placing write records a principal in the creating write — the server's two SQL session stores record the delegation tree's owner (or a sentinel no caller matches) on every placed row — failed the cell at the placed step although no `register` had moved anything.
|
|
56
|
+
- **Now.** The placed arm reads the owner the placing write recorded (through the one owner reader every ownership cell uses), registers `null` and then a name onto the row, and asserts the owner is the recorded one after each; no `null` literal stands for a placed row any more. The cell keeps its text and stays in the required set. The `headOf` note now says a placed row carries the owner its placing write recorded, and the `register` note names the placed row among the rows it leaves alone. The engine's own placement write is unchanged: `acquire`'s options and `SessionPlacement` carry no principal, so a placed row on a bundled store (`TtlSessionStore` over `FileSessionRepo`, `FileStorageBackend`'s store) records no owner and reads `null` — now pinned, so the note's sentence cannot rot silently.
|
|
57
|
+
- **Behavior narrowing of the kit (open to veto).** The kit demands less of a placed row: it no longer requires the placed row's owner to be `null`. A store that passed the old cell still passes (an owner the placing write recorded as `null` is invariant under a register); a store whose placing write records a principal now passes it, and a store whose `register` changes a placed row's owner — from `null` to a name, from a name to another, or from a name to `null` — fails it.
|
|
58
|
+
- **How to migrate.** *server*: on pickup the four `it.fails` cells the session-store kit run registered (tidb and pg, the SQL store and its cache wrapper — S-2010) turn green: delete those four rows and the positive pin that stood in for the cell; the stores themselves need no change.
|
|
59
|
+
- **Pins.** `test/backlog1468-s1-session-store-kit.test.ts` (K2 positive control: a store whose placing write records a principal passes the whole kit and keeps it through every register — red on the previous cell, naming exactly this cell; M3c / M3d / M3e: a `register` that overwrites a placed row's owner — `null` to a name, a recorded principal to `null`, a recorded principal to another name — reddens the cell; K6: a placed row on each bundled store that keeps the tuple records no owner and a register does not give it one).
|
|
60
|
+
|
|
61
|
+
### Four shell read-boundary corners close under one law — execution text the lexer cannot see is an unreadable definition, judged ambiguous (ask / refuse), never silently allowed (#1064 / #1415 / #2298 / #1628; four narrowings and one widening — the removal planner reads rm's own refusals, a divergence from the reference — all open to veto; hard-breaking: none; closed sets: +1 member `wildcard_program_word` on `PROGRAM_POSITION_REFUSAL_KINDS`; exports: +7 internal readers, not index-exported (`exportedFunctionNames`, `envFunctionNames`, `inheritedExportedFunctions`, `inheritedFunctionsOf` and the type `InheritedBody` in shell-read-bindings; `rowRefusesArgv` in program-read-model; `refusedByRemoval` in removal-targets, read by the read station's acceptEdits license too); members: a `ProgramPositionRefusal` gains the `wildcard_program_word` kind, a `ProgramRow` gains `optionsComplete?`, a `RemovalFinding` carries no new member; internal, not index-exported: an `ArgWord` gains `rewritten?` and `vanishes?`, and the plan start gains `exportedFunctions?`) (@test @cli @server)
|
|
62
|
+
|
|
63
|
+
- **#1064 — a `#` inside an arithmetic command `(( … ))` is arithmetic, not a comment.** *What changed:* the one lexer no longer starts a comment at an unquoted word-start `#` while inside an arithmetic command `(( … ))` (recognised by the adjacent `((`, which bash reads as arithmetic wherever a command or a `for` header begins; nested subshells need a blank, `( (`); a following command is no longer swallowed, so `(( 1 #)); cat <deny>` reads and refuses the deny row where it was vouched at zero cards, and `for (( i=0 # )); do cat <deny>; done` the same. A `#` inside a subshell `( )` is still a comment, as bash reads it. *Who is hurt:* a command relying on the lexer mis-reading a trailing segment as a comment — none benign (measured against bash 5.3 and 3.2: the trailing command runs). *How to migrate:* nothing.
|
|
64
|
+
- **#1415 — a program word carrying an unquoted glob is an unreadable program position.** *What changed:* `s[h]`, `s*h`, `./c?t` at the program position make `readProgramPosition` refuse with the new `wildcard_program_word` kind (the shell expands the word against the filesystem, a shell among the matches — `touch sh; s[h] -c '…'` runs the local `sh`), so both read faces ask — an ask a person must answer (class `unverifiable_shell_text`; `bypassPermissions` and an allow rule do not clear it, and a run with no approver refuses the call); the glob word is a deny-only read (a deny-listed program path still refuses), its operands handed over (a deny operand still refuses), and a pattern a substitution completes is the same unreadable position: an unquoted `*`, `?` or `[` beside a parameter, command or arithmetic substitution in the program word (`s[$(printf h)]`, which is `s[h]` once the substitution lands; `"$DIR"/run*`) asks the same question a person must answer, where `touch sh; s[$(printf h)] -c '…'` ran with no ask at all in `bypassPermissions`; the shell rewriting such a word, its read stays the unresolvable one every rewritten program word gets (the read station's mandated, unestablishable reading — it does not drop to the pure pattern's deny-only read) — a substitution with no metacharacter beside it (`$(printf sh)`, `"$X"`) is an unknown program, not this question. An operand glob (`ls *.txt`) is unchanged — only the program word is the question, and a pattern is only what the lexer carves as one: a lone `[` or `[[` (the test command, `[ -f q ]`, `if [[ $x == y ]]`) and an unclosed `[abc` are literals that bash runs as written, so a conditional test reads as it did. *Who is hurt:* a command whose program is spelled with an unquoted glob, or with a pattern a substitution completes, now asks (the person's answer, as above) where it was auto-allowed. *How to migrate:* quote the program word, or name it without a pattern.
|
|
65
|
+
- **#2298 — an unknown program verb is read as `rm` would read the same words.** *What changed:* the removal guard has ONE planner. A removal the text names (`rm`, `rmdir`) is read by its own row; a program word the text does not settle (a loop word `$p`, a `$VAR`, a substitution, a launcher's shell-place program `su -s "$SHELL"`) could be any program, `rm` among them, so its words are read exactly as `rm` reads them — the options `rm`'s grammar selects, the operands it removes, judged by the same arm as the literal — wherever it stands (the top-level command, a `bash -c` / `eval` body, a launcher's composed line), rather than falling through `verb.basename ∈ REMOVAL_COMMAND_SET` to silence. No flag shape gates it: `$T -r <ws>`, `$T <ws>`, `for p in rm; do $p -r <ws>; done`, `$T -r -- -f <ws>` and `$X -r -f ~` ask (`dangerous_delete`) as `rm -r <ws>` does, and a recursive forced form whose operand the shell rewrites (`$T -rf "$W2"`) asks as `rm -rf "$W2"` does (`unverifiable_delete`). The planner reads `rm`'s own refusals (next bullet), so an unknown program whose words `rm` would refuse or would remove nothing protected with stays quiet: `$PYTHON -m pytest .` and `$PYTHON -m pytest -rfE .` (`-m` / `-E` are no `rm` option), `$EDITOR .`, `$GREP -r TODO .`, `$SUDO chmod -Rf 755 .`, `$KUBECTL apply -R -f .`, `$CP -rf build/ .` (`rm` refuses `.`), `$EDITOR file`, `$EDITOR $X`. `$SUDO cp -rf build/ /usr/` asks: read as `rm`, those words remove `/usr` (whether `$SUDO` is a wrapper is not in the text). A bare protected operand asks too, as the literal `rm ~` does in 7.55.0 and in the reference's own check (which cards every `rm` target whatever its flags): `$EDITOR ~`, `$EDITOR <ws>`, `$X /usr`, `"$EDITOR" "$PWD"`, `$OPEN ~` read as `rm ~` and ask, although a real `rm` with no `-r` / `-R` / `--recursive` refuses a directory operand and removes nothing — the planner does not yet read that fact (a top-level operand may be a file it would remove, which the text alone cannot tell; #2389); `$EDITOR ~/.bashrc`, `$EDITOR <ws>/x`, `$EDITOR README.md` stay quiet. An unknown program handed literal shell text is not read as a shell: `$X -c 'rm -rf ~'` (and `$SUDO bash -c '…'`, `"$SHELL" -c '…'`) runs in `bypassPermissions` with no card — default mode asks about it as a read it cannot establish — where the literal `sh -c 'rm -rf ~'` is the mandated dangerous removal; the same on 7.55.0, a candidate ticket (#2372). Loop values are not resolved, so a flag the loop binds (`for f in -rf; do $p $f <ws>; done`) is an operand word, and `<ws>` is then judged as `rm "$f" <ws>` would remove it (a dangerous removal); the loop-beat binding is untouched. *Who is hurt:* a command whose program is a variable, a loop word or a substitution and whose words, read as `rm`'s, remove a protected location (a workspace directory or one of its ancestors, a home, the filesystem root, a top-level directory) now asks — a question a person must answer (`bypassPermissions` and an allow rule do not clear it; with no approver the call is refused) — whatever its flags (7.55.0 and the first cut of this entry asked only with both `-r` and `-f`), including `$SUDO cp -rf build/ /usr/` and `su -s "$SHELL" … -rf <home>`, which was an allow-clearable unplaced read (the #1421 ⑪ behavior is superseded, open to veto); and one whose recursive forced words carry an operand the shell rewrites (`$CP -rf "$SRC" "$DST"`) now asks `unverifiable_delete` where it ran. *How to migrate:* name the program literally (`cp -rf build/ /usr/`), or settle the name in the command (`CP=cp; $CP -rf build/ /usr/` reads `cp`).
|
|
66
|
+
- **The removal planner reads `rm`'s own refusals (behavior change, open to veto; a divergence from the reference).** *What changed:* two facts of `rm` itself are read, for a literal removal and an unknown program alike (one planner): an operand whose last path component is `.` or `..`, trailing separators stripped (`.`, `./`, `..`, `../`, `src/..`, `~/.`), is skipped — POSIX `rm` step 1, which GNU, BSD and BusyBox `rm` all keep (`rmdir(2)` fails on such a component too); and an argv carrying an option no `rm` implementation declares, where every implementation reads it as an option (before the first operand: `-m`, `-E`, `--bogus`), runs nothing — getopt reports the invalid option; the refusal is read only off the words in front of the first one whose text the program does not receive as spelled (a substitution, a parameter, a pattern, or a value the lexer leaves unresolved), and the first of those words that is an argument for certain — a word of nothing but expansions that may yield no word (`$E`, `$(true)`, `"$@"`) may not be there, so it ends nothing and selects nothing — where options are still being read (`rm` reads neither it nor a word before it as an operand: no operand and no `--` in front of it), is read as any option `rm` declares — one reading of the options `rm` receives, for the refusal and for the recursive forced test alike, so `rm -r$(printf f) <dir>`, `rm $'-\x72f' <dir>`, `rm -r$'\146' <dir>`, `rm -r$F <dir>`, `rm -$R <dir>`, `rm -r $'-\x66' <dir>`, `rm $E -r$F <dir>`, `rm "$@" $'-\x72f' <dir>` and `rm -v $(true) -r$F <dir>` are `rm -rf <dir>` and ask: as a dangerous removal where `<dir>` is a protected location (`~`, the workspace), and as an unverifiable delete (mandated, also in bypass) where `<dir>` is an operand the shell rewrites from a value the text does not settle (`"$X"`, `$(cat list)`, `$'\x2fusr'`) or the stream `… | xargs rm $'-\x72f'` fills in. A rewritten word that arrives as one word and that `rm` reads as an operand — one the text does not show beginning with a dash (`rm "$(echo -rf)" "$X"`; `rm $'\x2drf' "$X"`, whose value the text settles but the planner does not yet decode — #2412) — is read as the operand it may be (a word wholly unknown at an operand position is as likely an operand as an option, and reading it as one would ask about every `rm "$X"` — the open design question, #2413), and one past the first operand or `--` is no option here (`rm -r "$D" $'-\x66'` is quiet where the literal `rm -r "$D" -f` asks, and `rm "$X" $(true)` stays an operand); a word that may arrive as no word or as several — `rm $F "$X"`, an unquoted `$(cmd)`, and a brace list, `rm {-r,-f} "$X"` and `rm {-rf,} "$X"` (#2412) — is skipped like the `$E` above: it ends nothing and selects nothing; `rmdir` selects neither option, whatever the word (the `rm` and `rmdir` rows now declare every option GNU and BSD take: `optionsComplete`; their remaining switches — `-d`, `-i`, `-I`, `-v`, BSD `-P` / `-W` / `-x`, the long names — are read as the switches they are, so `rm -i x` is read as `rm -f x` already was: its operands are words rm removes, not reads, and a cluster's letters are no candidate). The program's arguments begin after the program word the program position names, not after the first word spelled like it: a launcher option value spelled like the program (`env -u rm rm -r$F "$X"`, `sudo -u rm rm …`) is no longer read as an operand of `rm`, for every reader of a program's arguments (this one, the write targets, the unplaced-option probes) — a narrowing, open to veto: such a line asks as its literal `-rf` twin does where it ran. The read station's acceptEdits license reads the same predicate (an operand rm refuses covers no write root), so acceptEdits accepts `rm -rf .` — and `rm` refuses it — while `rm -rf <root>` and `rm -r <root>/` still ask. `rm -rf .`, `rm -rf ./`, `rm -rf ..`, `rm -rf ../` and `rmdir ..` no longer ask; `rm -rf ./*` and `rm -rf *` (the pattern names every entry of the working directory), `rm -rf "$PWD"`, `rm -rf ../<ws>` and `rm -rf <ws>/` still ask; `rm -rf .*` is judged by what its matches reach — the `.` and `..` it expands to are refused by `rm`, and a dotfile inside the workspace is no protected location. The reference (Claude Code 2.1.296) resolves `.` against the working directory and asks; this reads what `rm` does. *Who is hurt:* a reviewer who relied on the ask for `rm -rf .` or `rm -rf ..` — `rm` refuses those operands and removes nothing there; and a command that builds an `rm` (or an unknown program's) option word the shell rewrites from a value the text does not settle and spells with a dash (`rm -$FLAGS "$DIR"`, `rm -r$(opt) "$DIR"`, `$T -$OPTS "$DIR"`, `rm $E -r$F "$DIR"`, `env -u rm rm -r$F "$DIR"`) over a target the text does not settle, which ran at no question under bypassPermissions where `rm -rf "$DIR"` was asked, and now asks as it does (refused with no approver) — the word may be `f` and the target is not known. *How to migrate:* nothing for `.` / `..`; for the option word, settle the target in the text (`DIR=./build; rm -$FLAGS "$DIR"` is judged where it lands) or point it below a root the shell provides (`"$TMPDIR/build"`), as for the literal `rm -rf`. *Why:* one planner for the literal and the unknown program, reading `rm`'s real effect, so `$PYTHON -m pytest .` and `$EDITOR .` stop getting bypass-proof asks.
|
|
67
|
+
- **#1628 — a child shell inherits the parent's exported functions.** *What changed:* a function exported with `export -f NAME` / `declare -fx NAME` is inherited by a child `bash -c` as a function, so a call to the name there is the function, not the builtin: `declare(){ :; }; export -f declare; bash -c 'declare X=…; rm -rf "$X/usr"'` no longer models the child's `declare X=…` as the builtin binding, so `$X` is unknown and the removal target is unverifiable (ask) where it was silent; with no function exported the child's `declare` is the builtin, as before. Carried through the plan start: the binding carrier's function keys mark each exported name `defined` in the child, and `PlanStart.exportedFunctions` hands every child text the shell builds each inherited name with whether its BODY was read (the two carriers are one fold's work, #2371); in-shell `source` and a local function definition already shadowed. A body the text holds is read where it is defined — its segments are judged there (`f(){ rm -rf <ws>; }; export -f f; bash -c f` is the dangerous removal) — so calling it in a child is no unreadable text (`f(){ echo hi; }; export -f f; bash -c f`, `declare -fx f; sh -c f` run as they did in 7.55.0; the first cut of this entry asked about every exported name). A body is read where it is defined, not where it is called: a relative target that names another place at the call site (`f(){ rm -rf proj; }; export -f f; bash -c 'cd ..; f'` in a workspace named `proj`) is judged at the definition, for a child's call as for a same-shell one (`cd ..; f`) — KL-1628-c, as in 7.55.0. A body nobody read is: a function delivered on a child's environment (`env 'BASH_FUNC_f%%=() { … }' bash -c f`), or exported with no definition the text holds (`source x.sh; export -f f; bash -c f`), is an inherited function whose body the engine never read, so a call to its name in the child is unverifiable shell text (ask / refuse). The inherited set reaches the text of an `eval`, a launcher or a command substitution (`eval "bash -c '…'"`, `$(bash -c '…')`), where it was dropped. A name the shell rewrites is not tracked: `export -f "$n"` is asked about all the same (measured, both trees), `env "BASH_FUNC_$n%%=…"` is not (KL-1628-a, as in 7.55.0). *Who is hurt:* a command that exports a function shadowing a declaration builtin and runs it in a child — directly, through `env`, or inside eval / substitution text — to set a removal target now asks. *How to migrate:* nothing — the ask is the honest reading.
|
|
68
|
+
- **Out of scope.** #1611 closes as premise-falsified (its `BOUND_SPELLINGS_MAX` fold cap was retired by #1498; the cells' residual weakness — a loop-bound program verb — is #2298's law, applied above). #2299 (IFS custom-separator field split, A7 / A11) is a separate lexer-semantics ticket.
|
|
69
|
+
|
|
70
|
+
### The session list is one answer: `SessionStore.list(query?)` reads the store's rows — a restarted store lists them — pages them newest-first by the `(lastActiveAt, sessionId)` keyset, filters by the row's owner, and reads its query through one reading, `readSessionListQuery` (#1468 S3; behavior change, open to veto: `TtlSessionStore.list` lists persisted sessions, not its cache, orders them by the activity the repo records (a touch is recorded; `noteTaskRun` and a bare acquire no longer reorder), and a call without `limit` reads the first 50; hard-breaking: 1 — `TtlSessionStore.touch` answers a promise (ruled C-R732 note 2, open to veto): who is hurt — a subclass that overrides `touch` with a `void` return no longer type-checks (a `SessionStore` implementer is not affected); how to migrate — the override returns `Promise<void>` and awaits the parent; why — the S3 record law lands the activity on the row before `done` is pushed, so `touch` awaits `SessionRepo.touch?` (item (6)); closed sets: none; exports: +5 (2 runtime: `readSessionListQuery`, `SESSION_LIST_LIMITS`; 3 types: `SessionListQuery`, `SessionListQueryReading`, `SessionRow`); members: `SessionStore` gains type parameters `S` / `Q` (defaulted), `SessionStore.list` takes an optional query, `SessionRepo.list` answers `SessionRow[]`, +1 optional `SessionRepo.touch?`, `TtlSessionStore.touch` answers a promise) (@server @cli)
|
|
71
|
+
|
|
72
|
+
- **Before.** Three answers to "which sessions are there": `TtlSessionStore.list()` listed its live cache (a session evicted, forgotten or released under `evict: "forget"`, and every session after a restart, was missing — so `/resume` over a File root forgot every conversation on restart); `SessionRepo.list()` listed persisted metadata with no activity time and no owner (the File repo replayed every session's whole log to answer it); and a server backend answered with its own `listSessions` (owner filter, keyset cursor, limit) beside the core face. The kit's list case said "each live session".
|
|
73
|
+
- **Now.** `SessionStore.list?(query?: Q): Promise<S[]>` is the one list: every session ROW the store holds that the query admits, newest-first by `(lastActiveAt, sessionId)` (both descending; equal times order by id), at most `limit` rows after the `after` cursor; placed sessions are never listed. `SessionListQuery` = `{ owner?, includeUnowned?, after?: { lastActiveAt, sessionId }, limit? }` — no query, or `{}`, is the unfiltered list (every owner's rows, unowned included), first page; `owner` filters by the row's owner cell (exact match; the owner must be a principal name — a blank string, or one holding a control character, a line or paragraph separator or a format character other than the two joiners, is refused `config.invalid_argument`, #2265), `includeUnowned` (only with `owner`) adds the unowned rows; `limit` is an integer in `1..SESSION_LIST_LIMITS.max` (1000), absent = `SESSION_LIST_LIMITS.default` (50). `readSessionListQuery(raw, extensions?)` is the one reading every store's `list` takes its query through: the receipt law (a plain object, each member read once), each member judged, the default filled in; a bad value — a limit out of range or not an integer, a member of the wrong type, `includeUnowned` without `owner`, a malformed cursor, a member the store does not read — is refused `config.invalid_argument`, never mapped to a default. `SessionStore<S, Q>` takes the store's own summary and query: a backend whose list carries more per row, or filters by more (a text search), extends `SessionStoreSummary` / `SessionListQuery`, names its extension members to `readSessionListQuery`, and applies its filters before the page is cut — it is still assignable wherever a `SessionStore` is taken. `TtlSessionStore.list` reads its repo's rows (`SessionRepo.list`, now `SessionRow[]` = the metadata plus `lastActiveAt?`, the newest activity the repo RECORDS for the row) and orders by that time alone — never by what this process saw, so a dropped cache, a restart or another replica pages the same order and a cursor is never overtaken by a row whose time moved back. Activity is what is recorded: a row's writes, and a touch, which `TtlSessionStore.touch` now records through the new optional `SessionRepo.touch?(id)` (the File repo sets the log's modification time to now, the bytes untouched; the in-memory repo keeps a per-row time); over a repo without `touch` the order is what its writes record, else `createdAt` — the one reading, with no fallback to the cache. The cached view adds only what it alone holds, the run it recorded (`lastTaskId` / `lastTaskRunId`; after a restart a row lists without them), and only while the listed row names the view's occupant (`compareOccupants` — whatever head read the repo has). The File repo answers each row from its log's header and its modification time, one header read per session file, never a replay; the placed listing reads the same rows (its age = the same time).
|
|
74
|
+
- **A repo that names no occupant keeps its cached view's run on the list.** Whether a cached view still stands for a row is one judgement, read by both the list (does it lend the view's `lastTaskId` / `lastTaskRunId`) and `acquire` (does it serve the cached session): a row naming the view's occupant stands, a row naming another does not (nor does a row whose owner differs from the view's), and a row that names none (a token-less repo) stands over a repo with no head read — the cached view is its only evidence — but is dropped over a repo with a head read, where the row can be read again. Over the bundled Pg reference a real Runner's A, B, A lists both rows with their last run, as the list did before it read rows; the order is the creation order, since that repo records no activity time. Limit, and what a head-less repo's external writers can do: `KL-1468-s3-a` in `docs/KNOWN-LIMITS.md` (open to veto).
|
|
75
|
+
- **Behavior change (open to veto).** (1) **What changed:** `TtlSessionStore.list()` lists the sessions its repo holds, not its cache: after a restart, after `forget`, and after a `release` under `evict: "forget"` (the default over a durable repo), a session is still listed; under `evict: "delete"` (the default over the in-memory repo) a released session is gone, as before. A shell's `/resume` over a long-used File root shows the old sessions it used to forget. A session a background subagent wrote without a placement (a delegation runner mounted with no agent-row store) is now listed as an ordinary session — it is one: nothing marks it as a subagent's. (2) A call with no `limit` reads at most 50 rows (before: the whole cache, which a restart emptied); a caller that wants every row pages with `after` — the last row's `lastActiveAt` and `sessionId` — and asks for one row more than it shows to learn whether another page exists. (3) A File-root list costs one header read per session file on every page (the order needs every row's time); a root of tens of thousands of sessions pays that per call. (4) The File lane's activity time is the log's modification time, so a copy that does not preserve it (a plain `cp`, some sync tools) reorders the list — the risk the placed listing's age reading already had. (5) The order is the recorded activity — writes and touches — the reading server SQL stores already take (`updated_at`, bumped by `touch` and by every leaf move): `noteTaskRun`, and an acquire that writes nothing, no longer move a session up the list (a run moves it by what it writes, and by the touch the Runner makes as it ends); the kit's `noteTaskRun` case no longer asserts that it is activity — a narrowing of what a store must do, the same one-carrier reason as the rest of this entry. (6) `TtlSessionStore.touch` now answers a promise and rejects when its repo cannot record the touch (the File repo: a `SessionError("storage")`); the Runner's end-of-run touch already routes a rejection to `onError` and goes on (`run-terminal-adoption.ts`). A subclass of `TtlSessionStore` that overrides `touch` returning `void` stops type-checking (return a `Promise<void>`); an implementer of the `SessionStore` interface is unaffected, its `touch` is `void | Promise<void>`. Who is hurt: a host that relied on the list being the live set (it is the persisted set), on an unbounded `list()`, on `noteTaskRun` or a bare acquire reordering the list, on `touch` being synchronous (a subclass overriding it, above), and an author of a repo with no `touch` and no head read (the bundled Pg reference adapter, and anything shaped like it): its list is in creation order, and the list lends a cached run to a row on the cached view's own evidence (the paragraph above, KL-1468-s3-a).
|
|
76
|
+
- **How to migrate.** *cli* (`/resume`): read the first page, page with `after` when the user scrolls; nothing else changes. A `SessionRepo` that can record a touch implements `touch?`; one that cannot keeps its write-time order. *server* (stage two of the store boundary): the SQL and local stores implement `SessionStore<ServerSessionSummary, ServerListQuery>.list` and retire the parallel `listSessions` face (`security.ts:209`, `caching-session-store.ts:75`, `pg-session-storage.ts` / the TiDB twin's `listSessions`, `local-session-store.ts` `listSessions`): read the query with `readSessionListQuery(q, ["q"])` (the text search is the extension member, applied before the page), keep `updated_at DESC, session_id DESC` (the same order: `touch` and every leaf move bump `updated_at`), and move the cursor from an ISO `lastActivityAt` string to the epoch-ms `lastActiveAt`; the wire `GET /v1/sessions` projection (`lastTaskId` → `lastRunId`, ISO times) stays at the route. The local lane's own `lastActivity` sidecar retires: its rows come from `FileSessionRepo.list`, whose `lastActiveAt` is the log's modification time. A route that passed `includeUnowned` without an owner must not (it is refused).
|
|
77
|
+
- **Pins.** `test/backlog1468-s3-list.test.ts` (L1 the real loading path: a backend booted after a restart lists the sessions written before it, ordered by each log's modification time, and the placed listing ages by the same time; L2 the projection never invents — run ids absent after a restart, a cached view of a replaced occupant lends nothing (with and without a head read on the repo), a cursor taken before a restart pages the restarted store without a repeat, release under each evict policy; L3 the File repo lists by header — an interior corrupt record is the replay's disclosure, not the listing's; L4 the query reading — defaults, bounds, receipt law, extension members, identity; L5 a store with its own summary and filter is a `SessionStore` and filters before the page; L6 nine list mutants, each reddening exactly the kit cases that pin its law; L7 the repo kit's listing case reds a File repo whose touch records nothing or whose time a write moved back; L8 the one judgement of "the cached view stands for this row": a real Runner's A, B, A over the Pg reference lists both rows with their last run, the list and `acquire` answer alike in five cells of repo shape × row evidence, and a repo that keeps an owner but names no occupant lends nothing to a row created again under another owner or none); the kit (`sessionStoreContract`) gains eight list cases — rows not cache (a reopened store lists them), a touch recorded across a restart, a replaced occupant's run not lent, paging across a dropped cache, the owner filter, keyset paging with ties, the default page, a bad query refused — its list case is renamed "each session row once" and its `noteTaskRun` case judges the run seats only; the repo kit (`sessionRepoContract`) gains a listing case (each row once, its owner cell, a recorded time a later write or touch never moves back).
|
|
78
|
+
|
|
79
|
+
### The approval record's option form gets its one shape reading, `readRuleOffer2`, and the consent record check reads offers through it (#2264; behavior narrowing, open to veto; hard-breaking: none; closed sets: none (the reading answers two of `RULE_OFFER_SHAPE_PROBLEMS`' words); exports: +2 (`readRuleOffer2`, type `RuleOffer2Reading`)) (@server)
|
|
80
|
+
|
|
81
|
+
- **Before.** A record option (`RuleOffer2`: a candidate index, or a batch of them with three optional card seats) had three shape readings: the consent record check's inline clauses (an object, a kind word, a batch with members, an integer index, the card seats' types — it read an accessor member once per word it compared, and ignored members the form does not declare), and the server's two zod transcriptions, one per approval-record store (`permission-rule-store-file.ts:91`, `permission-rule-store-sql.ts:269`), which refused an undeclared member and accepted a batch with no members.
|
|
82
|
+
- **Now.** `readRuleOffer2(raw)` answers `{ offer }` (a fresh object of the members it judged) or `{ problem: "malformed" | "unknown_word" }`: not a plain record, a member the form does not declare, an index that is not a non-negative safe integer, a batch with no members, a `segments` that is not a list of strings, a count that is not a non-negative safe integer, a reason row that is not exactly a segment and a reason ⇒ `malformed`; a kind or a reason that is a string outside its closed set ⇒ `unknown_word`. Each member is read once, a list by position with its length read once. Which seats a record of which kind must carry, and whether the indices name its candidates, stay the record check's — which now reads every option through this reading first.
|
|
83
|
+
- **Behavior narrowing (open to veto).** (1) A record whose option carries a member the form does not declare (a foreign writer's row, e.g. a stored rule text beside the index) is refused `record_malformed`; before it was read without that member. The record's `schema` stamp is the version gate, so a same-stamp row carrying one was not written by this form. (2) The refusal sentence for an off-form option is one sentence naming the reading's word — `offer N is not a record option this engine reads (malformed|unknown_word): …` — where it was one of four ("is not an offer object", "has an unknown kind", "batch offer N has no members", "reason row N is not a segment/reason row from the closed reason set"); a non-integer index is no longer echoed into it at all. (3) A store that hands back accessors: the kind and each reason are read once, so a value that answers differently on later reads is judged by its first answer. Who is hurt: a test pinning one of the four old sentences.
|
|
84
|
+
- **How to migrate.** *server*: both approval-record stores read each stored option with `readRuleOffer2` and refuse the row (their fail-closed replay) on a problem, retiring `RuleOffer2Schema`; a batch with no members, which the zod schema accepted and the core record check refused, is now refused at the read.
|
|
85
|
+
- **Pins.** `test/backlog1468-s3-list.test.ts` (R1 every option the engine mints reads back; every off-form value refused with its word; one read per member; the server transcriptions' acceptance, the empty batch named; the record check refusing an undeclared member); `test/backlog2102-offer-kind-denial-bound-vocab.test.ts`, `test/backlog1468-k1-vocabulary-twins.test.ts`, `test/design375-batch-consent.test.ts`, `test/permission-rule-consent.test.ts` re-pinned to the one sentence and the one read.
|
|
86
|
+
|
|
87
|
+
### A filesystem glob is matched by one engine whose work is bounded by the pattern's length times the path's: the Glob and Grep tools' patterns, `.gitignore` lines and the shell read station's expansion of a command word no longer slow down with their number of stars (#2267; behavior change, open to veto: `**` stands for any run of characters, a line terminator in a path name included — the retired expression's `.*` stopped at one; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
88
|
+
|
|
89
|
+
- **Before.** Each `*` of such a glob was compiled to a `[^/]*` of a regular expression built from the glob's text, so the work of one test grew with the number of stars like a binomial of the name's length: a pattern of eight stars against a 32-character name took about 0.7 s, ten about 4 s, sixteen minutes — on the Glob tool's `pattern`, the Grep tool's `glob` (both model input), a `.gitignore` line (repository input), and a glob word in a shell command the read station expands to see what it would read (model input again). The permission-rule name glob (`wildcardMatch`) and the read-deny segments were already linear; these two compilers were the ones left. A `.gitignore` line of 65 536 characters did not slow the walk, it ended it: the expression was too large for the engine and the walk threw an invalid-regular-expression error.
|
|
90
|
+
- **Now.** `src/tools/fs/fs-glob.ts` is the one matcher of the filesystem dialect (`*` any run but `/`, `**` any run, `?`, `[class]`, `{a,b}`, anchored at the start or at any path segment). A site parses its own spelling into tokens; the engine compiles them to a small program and walks the path once, holding the set of instructions live at each position (an instruction at most once), so one test costs at most the program's length times the path's length plus one, whatever the arrangement of the stars. The scratch is two sets of the program's length, kept between tests for a program of up to 65 536 instructions: a long rule that dies at its first character costs what it touched, not a table of program × path cells. A program past that size (a pattern of more than 64 K literal characters, or of more than 16 K `*a` units — a star and its follower cost four instructions, so the size follows the pattern's shape, not its length alone) makes its scratch for each value that gets past the two checks below and drops it after the test: about 1.1 MB per test at 70 000 instructions, whatever the path (before, such a `.gitignore` line threw and ended the walk). Before the walk, two checks that can only say "cannot match": a value that lacks a literal every match holds, or does not end with the literal the pattern ends on, is refused; inside the walk, a program that opens on a literal starts a thread only where that literal stands; a run of stars is compiled as one star. No expression is built from the pattern's text (a `[class]` is one character wide, its own small expression cannot backtrack) and no cap on stars was added: sixteen or forty stars against 32 characters return at once on all four paths. The permission-rule name glob keeps its own engine (`wildcardMatch`): its `*` crosses `/` and it has no `?`, class or alternative — a different grammar, not a second reading of this one.
|
|
91
|
+
- **What stays.** The language each site spoke: an answer-for-answer comparison against the retired builders over every glob of up to three pieces and a seeded sample of longer ones agrees (the `.gitignore` lines' negation, trailing-slash and basename rules stay in `gitignoreMatcher`; the shell's leading-dot rule stays in `globSegmentMatches`). `?` and a class stay one UTF-16 code unit wide, an unterminated `[` or `{` still reads as the literal and still makes the Grep tool refuse the token, a pattern the shell leg cannot model is still `unestablishable`.
|
|
92
|
+
- **Behavior change (open to veto).** `**` was `.*` and did not cross `\n`, `\r`, U+2028 or U+2029; it now crosses them, as `?` and `*` already did. **Who is hurt:** a path name that contains a line terminator and is named by a `**` glob; that name is now matched (before it was silently missed). **How to migrate:** nothing.
|
|
93
|
+
- **Cost.** A gitignore-sized walk (30 rules, 30 000 paths) measured about 13 µs per path before and 10–13 µs after. The worst case stays the product of the two lengths, and it does not take a long path: a ring of empty alternatives (`*{,}` repeated 1 000 times, a 4 KB pattern) keeps every instruction live at every place and enters 397 318 (instruction, position) pairs against a 79-character path (16 KB: 1 588 318; 8 to 25 ms and 18 to 110 ms per test, measured on two machines), and a pattern of 2 500 alternating `*a` units against a 2 000-character path enters about 8 million (80 to 400 ms); the retired expression took 79 s to fail on a ring of only six such units against the same 79 characters, and did not return on the `*a` shape. Only the Glob tool's `pattern` and the Grep tool's `glob` can spell the first (a `.gitignore` line takes no `{}` and the shell station reads `{` as a letter), and both are model input with no length limit (KL-2267-a).
|
|
94
|
+
- **Pins.** `test/backlog2267-fs-glob-engine.test.ts` (§1 the engine's visit count stays within `size × (length + 1)` on sixteen stars and on globstars with stars inside alternatives, what one test leaves in the shared scratch does not reach the next, a run of stars is one star, a long rule rejected at its first character allocates nothing per test while a program past the retained size makes its scratch for each test, and a ring of 1 000 empty alternatives enters over 90% of the product against a 79-character path (the limit as stated above); §2 each of the four paths answers through the engine within a count bound and still finds the file that does match; §3 the language against the retired builders, about 4.0 M and 1.0 M comparisons; §4 `**` and a line terminator, `?` and an astral character); no pin reads a clock.
|
|
95
|
+
|
|
96
|
+
### A call's safety axes are read under the names it answers to and nowhere else: a caller tool's irreversibility tier, reversibility probe and egress mark stay under its own name, an alias carries no copy of them, and a tie between two of the call's names goes to the tool that runs, so a call made under an alias is judged by that tool's own probe (#2266; two safety narrowings and two widenings, all open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli)
|
|
97
|
+
|
|
98
|
+
- **Before (v7.55.0).** The prepare safety scan copied each caller tool's tier, probe, egress mark and mark sources onto its aliases (the first claimer's), and the roster builder moved an alias's copy to the member the alias resolved to after a displacing mount or a removal — a second resolution of who answers to the alias, beside the one every reader already made (the gate resolves the model's spelling to the wire name of the tool it dispatches to, and the call's face lists that name). The copy went stale when a mount and the leg's selector moved the alias in two steps. Two caller tools sharing an alias, `Read{irreversibility: "maybe", aliases: ["X"]}` with a probe that never vouches and `Charge{irreversibility: "maybe", aliases: ["X"]}` with one that always does, on a full-hands leg with `allowTools: ["X"]`: the engine's `Read` mounted later and took the name, so `X`'s copy moved to `Charge`; the selector then unmounted the engine instance (it does not answer to `X`) and `X` answered to the caller's `Read` again, but its copy stayed `Charge`'s. The tier seat reads a call's names spelling first and keeps the earlier name on a tie, so a call by `X` was judged by `Charge`'s probe. Measured with a real Runner under `bypassPermissions`: the call ran `Read` unasked and consulted `Charge`'s probe (0 asks, `Read` ran once); the same call by `Read`, or the same tools on a leg with no hands, asked once on `Read`'s probe. The approval card of the same shape, with `Charge` also marked `egress`, said `egress: true` for `Read`.
|
|
99
|
+
- **Now.** A tool's marks live under the names they were laid on: a caller tool's own declaration under its own name (every caller tool of that name, as before), the engine shell seat under the shell's name, the MCP / A2A protocol fold under the server tool's minted name. A call is read under every name it answers to — the model's spelling, the wire name of the tool it dispatches to, that tool's other aliases — the gate's existing reading (`ruleNamesOf`, the call's face), unchanged. The scan writes no alias entry, and the roster builder no longer touches the axis tables (`RosterBuilder`, not a package export, takes the effect map alone). The one resolution of "which tool does this alias answer to" is the roster's, at read time. Every reader of the five tables was checked for a read under a spelling that skips the wire name: none — each reads through a name-readings port fed the gate-resolved wire name, or reads a mounted tool's own name (its roster row, the shell seat, the wiring manifest's lists).
|
|
100
|
+
- **Safety narrowing (open to veto).** (1) **What changed:** in the shape above a call by the alias is judged by the tool that runs — its own tier and probe, its own egress — so the call asks (1 ask, `Read` does not run, `Charge`'s probe is not consulted) and the card says `irreversible: true` without `egress`. (2) **Who is hurt:** a deployment that relied on another tool's probe vouching for a call that runs a different tool; none intended. (3) **How to migrate:** nothing; give each tool the marks that describe it.
|
|
101
|
+
- **Safety narrowing, the tie (open to veto).** (1) **What changed:** the tier seat — the one comparator the gate, the rule offer, the park's risk descriptor and the ask's risk axes read — takes the strictest tier among the call's names, and on a tie the call's wire name now keeps the seat (before: the call's earlier name, the model's spelling first), so the tool that runs judges its own call with its own probe. Before, a host tool `MyShell{irreversibility: "maybe", aliases: ["Bash"]}` with a probe that never vouches, kept by `allowTools: ["MyShell"]` on a full-hands leg, called as `Bash` with `ls`: the engine's shell seat had been installed under `Bash` (`maybe` with the shell shape probe) before the selector unmounted the engine shell, the two `maybe` tiers tied, and the shell shape probe vouched `ls` — measured, 0 asks, `MyShell` ran and its own probe was never consulted. Now 1 ask on `MyShell`'s probe, and it does not run. The tie changes whose probe judges the call and nothing else about it: whether the ask is read as the shell gate's (`shellGated`) does not move, because a host tool that declares `maybe` or `always` is marked as a deployment tool under its own name, so the call was never read as shell-gated under either seat (the same reading on both trees). A strictly higher tier under any of the call's names still wins (`MyShell{irreversibility: "always"}` asks either way). (2) **Who is hurt:** a host whose tool answers to an engine tool's name by alias and relied on the engine seat's probe vouching for its calls; the server documents `MyShell{aliases: ["Bash"]}` as a host shape. (3) **How to migrate:** give the tool the probe that describes it, or no tier. Not changed here: the shell seat is installed under the shell's names before the selector runs, so its tier still reaches a tool that answers `Bash` by alias after the engine shell is unmounted (over-careful: a tool declaring no tier, or a lower one, is asked on the shell's probe — in the modes that ask: measured with `MyShell` declaring no tier, under `default` `touch gone.txt` and `rm -rf gone` ask and `ls` does not, while under `bypassPermissions` that ask is an ordinary one and is let through, 0 asks) — a candidate for the next design car.
|
|
102
|
+
- **Widening, the other side of the tie (open to veto).** (1) **What changed:** the same shape with a host probe that vouches where the shell shape probe would not: `MyShell{irreversibility: "maybe", aliases: ["Bash"]}` whose probe always vouches, called as `Bash` with `rm -rf gone` — measured, 1 ask before (the shell shape probe under `Bash` kept the seat and refused it), 0 asks now (`MyShell`'s own probe vouched and it ran). (2) **Who is hurt:** a host that relied on the engine shell's shape judgment reaching its own shell tool through the alias while the engine shell is unmounted. Until the shell seat follows the engine shell's mount (the candidate above), the outcome is not monotonic in the host's declaration: the same tool declaring no tier is still asked on the shell's probe in a mode that asks, and runs unasked under `bypassPermissions` (KL-2266-a). (3) **How to migrate:** give the tool a probe that judges its commands, or `irreversibility: "always"`, or keep the engine shell mounted beside it.
|
|
103
|
+
- **Widening (open to veto).** (1) **What changed:** the reverse shape, `Read{aliases: ["X"]}` declaring no marks and `Charge{irreversibility: "always", aliases: ["X"]}` (or `egress: true`), same leg and selector: a call by `X` runs `Read` and is read on `Read`'s marks, so it is not asked under `bypassPermissions` (1 ask → 0; `Read` runs). `Charge` called by its own name still asks. (2) **Who is hurt:** a deployment that relied on one tool's marks reaching a different tool through a shared alias. (3) **How to migrate:** put the mark on the tool it describes. Which of two caller tools sharing an alias answers to it is the roster's order (the exact name, then the first claimer); refusing such a shared alias at the door is #2161's question.
|
|
104
|
+
- **Unchanged.** A caller tool NAMED exactly like an engine tool still marks that name: its marks reach the engine tool's calls (the designed channel; KL-2105-a). The shell seat, the protocol fold, the roster rows and the wake reconcile's effect reading (the scan still records each caller tool's effect under its aliases there).
|
|
105
|
+
- **Consumers.** *server* / *cli*: no wire key, row member, type or closed set changes; a call's ask and its card read the marks of the tool that runs.
|
|
106
|
+
- **Pins.** `test/backlog2266-axes-follow-answering-member.test.ts` (F1 the fail-open shape; F2 the same call by the tool's own name and on a leg with no displacement; F3 the widening shape and its controls; F4 the ticket's shape `Read{always, aliases: ["X"]}`, a defineTool product called by its alias, the first claimer's egress; F5 the scan writes no alias entry; F6 every gate-shaped call lists its wire name, and a request with no face reads the wire name alone; F7 the card's risk axes; F8 the tie: `MyShell{maybe, aliases: ["Bash"]}` judged by its own probe — a refusing probe asks, a vouching probe runs `rm -rf gone` unasked — the seat chosen on the tie with the marks a scan writes for a declared tier, and the `always` control). Re-pointed: `test/backlog126-definetool-axis-carry.test.ts` (the product-form scan pin reads no alias copy); `test/name-readings-behavior.test.ts` §1.3 (a tie goes to the wire name, then to the earlier name).
|
|
107
|
+
|
|
108
|
+
### (#2269) Security fix, in part: a `defineTool` product cannot write an engine contract id by hand, or be dressed in one afterwards — the roster door that refuses a spec writing a `core.` contract id by hand now judges the descriptor a product carries with the same judge; an engine definition's contract object read off the definition and worn on a tool of the host's own still passes (see Not closed here) (#2269; security; behavior narrowing, open to veto: a product — or a plain object spread from one — whose descriptor claims `core.` identity the engine did not mint, or that no longer describes the object it rides on (an engine product renamed or given another schema), is refused at prepare with `config.tool_face_invalid`; behavior change: an engine definition's contract object and the descriptor the factory attaches for it are frozen; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli @client-core)
|
|
109
|
+
|
|
110
|
+
- **Before.** #2229 closed one door: a `TaskSpec.tools` *spec* declaring a `core.` contract id its catalog did not mint was refused. The other shape a roster entry takes was never asked. The public `defineTool` labelled any `core.` id it was given `origin: "core"`, the roster read that label to give the entry `source: "builtin"` and the card namespace `builtin:<name>`, and a `defineTool` product carrying a hand-written `core.ask_user_question@1` mounted as the built-in it was not — one declaration, two answers by the object's shape. The same held for a product dressed after the fact with the public `attachToolContract(product, { origin: "core", … })`, and for a product whose schema was changed afterwards (the descriptor downgrades to `legacy:` but keeps its origin). A built-in row reads its legs from the declaration, not from the tool's effect, so a write tool dressed this way could state `boundaryLegs: []` and be read by it.
|
|
111
|
+
- **Now.** One judge, two readings. The engine's mint is an object, not a string: a catalog contract object is frozen and every declaration copy shares it by reference, and the descriptor the factory attaches for one is frozen and recorded as the engine's own (`attachEngineToolContract`, internal); the public `attachToolContract` never records one, whatever `origin` it states. `callerEngineContractProblem` reads a roster entry's claim from its `contract` member (a spec) **or** from the descriptor it carries (a product, or a spread of one) and refuses the claim when the object it rests on is not the engine's: `config.tool_face_invalid`, the sentence the spec arm always had (`tool "<name>" declares the engine contract "<id>", which no engine definition minted for it — …`). A carried descriptor must also still describe the face it rides on — its wire name is the face's name and its structural schema digest (`computeShapeDigest`, the reading `getToolContract` already applies) is the face's schema's — or the face is no longer the engine's tool and is refused the same way; this is one rule for every face, not a case for products. An engine product a host mounts by hand (`createAskUserQuestionTool()`) still mounts as the built-in it is, and a plain-object spread of one that only re-renders its description is not refused — it mounts as the caller's own tool with a `legacy:` contract, as it always did.
|
|
112
|
+
- **Who is hurt.** A host that wrote a `core.<name>@<n>` id on its own `defineTool` product (or attached one with `attachToolContract`) and mounted it through `TaskSpec.tools`: the run now fails at prepare with `config.tool_face_invalid`, where it used to mount as a built-in. A host that spreads an engine product into a plain object and renames it or gives it another schema (it used to mount as the host's own tool with a `legacy:` contract) now has the run refused — it is no longer the engine's tool, so mount it as a tool of your own: build it with `defineTool` from your own spec. A host that changes an engine product's `parameters` in place after the factory built it is refused for the same reason. A host that deep-copies an engine product into plain data (a clone of its members, descriptor included, that does not carry the brand — a `cloneDeep` of the product, say) now has the run refused with `config.tool_face_invalid` as well: the copied descriptor is not the engine's object, and before this version the copy mounted as the host's own tool (source `caller`, a `legacy:` contract). Nothing else changes for a readable roster. `getToolContract(product)` still labels a hand-written `core.` id `origin: "core"` — the label is by prefix; what the door asks is whether the engine minted it. A host that mutated an engine factory's `contract` object, or the alias list of an engine product's descriptor, in place now meets a frozen object.
|
|
113
|
+
- **How to migrate.** Declare an id of your own, or none. To mount an engine definition, call its factory; do not write its id. Do not copy an engine product's members into a new object: mount the factory's product itself (a spread that only re-renders the description still is the engine's tool), or build a tool of your own with `defineTool` from your own spec. Nothing for a deployment that only mounts engine factories and its own tools.
|
|
114
|
+
- **Not closed here.** The proof is an object, and the engine hands the object out: an engine definition's contract object is the public `contract` member of its `ToolSpec` (`createTodoWriteTool().contract` — a root-exported factory and a typed member, no reflection and no cast), and the descriptor of an engine product rides on the product. Any host can read one off and wear it on a tool of its own, of any name and any schema — as the `contract` of a `ToolSpec`, of a spread of an engine spec whose name, schema and `execute` are replaced, or of a `defineTool` product — and the door accepts it: the row reads `source: "builtin"`, the card `builtin:<name>` and the legs the host's declaration states (`[]` for a write tool that says `boundaryLegs: []`). A real `prepareTask` gives the same rows before this version and after it; what this version removed is the hand-written id (on a product, or attached to one afterwards), the engine's descriptor carried onto an object it does not describe, and the rewriting of the engine's objects in place (they are frozen). The hazard #2269 was opened for — a host's write tool read as a built-in without boundary legs — is therefore still open for a host that lifts the object off an engine definition. Closing it is a design question (the proof cannot be an object the factories hand out: a mint token only the definition module holds, or a door that compares the definition itself — its `execute` or its brand — and not its contract object), tracked as a follow-up ticket. Also not closed: a host that changes an engine product's other declaration members in place after the factory built it (`effect`, `boundaryLegs`, the rule face — the descriptor describes name and schema only) keeps the built-in source.
|
|
115
|
+
- **Pins.** `test/backlog2269-product-engine-contract.test.ts` (A1–A3: the forged product refused with the spec door's code and sentence, on the real Runner lane as well, and the factory records no mint for it; B1–B3 controls: an engine product, a caller's own id, no contract; C1–C7: a spread of the forged product, a product dressed with the public attach (an engine id under origin `core`, an id of its own under origin `core`, the engine's id under origin `caller`), a schema swapped after the factory, a spread of an engine product, and the frozen objects; D1–D3: a descriptor lifted onto another object, an engine product's schema changed in place, a spread renamed; E1: a value under the carrier symbol that is no descriptor object — `null` — reads as a product that carries none, the same row as a product with none, no `TypeError`, on the prepare lane and on the real Runner); `test/backlog2229-rule-store-grammar-stamp.test.ts` L9 re-pinned to an engine definition the host mounts by hand (the seat reads who mounted it); `test/design469-r3-boundary-legs.test.ts` re-pinned the same way.
|
|
116
|
+
|
|
117
|
+
### `@sema-agent/core-stores-sql` — the shell of core's SQL storage package (private, no stores yet), and the multi-package release base it needs: per-package wire manifests, package legs in the pre-tag check, the post lint and the stranger install, one bump for every version cell (#1468 N1, #1751; core public face: unchanged; hard-breaking: none; release procedure: one more manifest per published package from 7.56.0)
|
|
118
|
+
|
|
119
|
+
- **The package.** `packages/core-stores-sql/` is `@sema-agent/core-stores-sql`, cut at the root's version with `@sema-agent/core` as its exact peer, `private: true` (it is not published while it carries no stores). Its public face is neutral factories and neutral types only — `createSqlStorageBackend`, `createSqlDriver`, `baselineDdl`, `publishedColumns`, and `SqlStorageBackend` / `SqlStorageBackendOptions` / `RetentionCascadeHooks` / `SqlDriver` / `SqlTxConn` / `PgPoolLike` / `MysqlPoolLike` / `PublishedColumn` / `SqlDialect` (`"pg" | "mysql"`) / `SqlEngine` (`"pg" | "innodb" | "tidb"`); no store class, dialect class, statement list or ensure leg is exported (frozen in `packages/core-stores-sql/test/export-surface.snapshot.json`). Both factories first refuse a resolved core whose `CONTRACT_KIT_ENGINE_VERSION` is not the package's own version, then read every option loudly (a dialect word outside the two — `"tidb"` included — a pool of the other dialect, a retention word core's own declaration reader refuses, a hook that is not a function, a key this version has no option for), then refuse: this version returns no backend and no driver. `baselineDdl` runs the package's ensure roster against a recording pool (empty roster ⇒ `[]`; the recording admits only CREATE / INSERT), `publishedColumns` lists the columns a deployment may read in place (none yet). Package tests live in the root `test/` (mods-kernel's precedent).
|
|
120
|
+
- **How the repo resolves `@sema-agent/core` for a package that imports it.** One mapping: the root `tsconfig.json` maps `@sema-agent/core` to `./src/index.ts` (`compilerOptions.paths`) and includes `packages/*/src`, so the root typecheck compiles the package against core's source; `vitest.config.ts` reads the same `paths` entry as its exact alias. Alone, at build time, the package's own tsconfig sees core's built declarations (`../../dist/index.d.ts`), the consumer's view. Side effect: in-repo programs that import core by its package name (`eval/opt0-profiler`) now type-check against the source instead of `dist/`, so the root typecheck no longer needs a built tree. The root `.npmrc` (newly tracked; every other `.npmrc` stays ignored) carries one key, `legacy-peer-deps=true`: without it npm satisfies the package's exact peer by fetching the published `@sema-agent/core` into the lockfile, and fails with `ETARGET` at a bumped, not-yet-published version (both measured). A pin refuses any other key in that file.
|
|
121
|
+
- **`gate:packages` / `gate:packages:pack`.** A package that takes core is compiled in the repo by the root typecheck (the gate checks the root program holds every one of its files) and its surface is derived there; it compiles alone only at build, which is refused by name while the root's `dist/index.d.ts` is absent. Its stranger install puts the root's own tarball from the same tree beside it. Every package's smoke table must list every runtime export it has (the root's A-006.5 freshness as a whole-surface rule; mods-kernel already did). `docs/LAYERING.json` gains the package's row (`@sema-agent/core`, `node:fs`).
|
|
122
|
+
- **Wire manifests per package.** `node scripts/wire-changes.mjs <prevTag> <RC> --package <dir> [--version X.Y.Z --to-tag vX.Y.Z]` writes `wire-changes/<dir>@<version>.json` with the root manifest's own reader (the package's compiled files and frozen surface; `package` names it; a package new in the range is all additions; no criteria join). From 7.56.0 every published package (today: mods-kernel) must have one in the RC tree, based on the previous tag and current for its own inputs (its tsconfig, its snapshot, its source directory) — judged by `release:preflight` before the tag and by `ship-post-lint`'s WIRE leg after it — and the post's BREAKING lines answer the root's and the packages' entries together (each package manifest is first judged whole — a list it lacks is refused, never read as empty; `WIRE: none` must hold for all of them). Generation now refuses a `--to-tag` that names another version than the manifest's (the two-stage form computes before the bump, so `--version` is required there — this applies to the root manifest too).
|
|
123
|
+
- **Release scripts.** `scripts/bump-version.mjs <x.y.z>` sets the root version, each package's version and each exact core peer in one step and refreshes `package-lock.json` (`npm version --workspaces` leaves the peer at the old number — measured). `scripts/verify-fresh-install.mjs <version>` installs every published package after the root, anonymously, and runs its smoke table. The claim reader behind "which packages does this tree publish" is one function (`publishClaimsOfTree`, over a `{ listDirs, readFile }` tree), now shared by `verify-published-tags.mjs`, the post lint and the stranger install; the first two read a git ref's tree through `treeAtRef`, the stranger install reads its own checkout. `docs/RELEASE-TIERS.md`'s multi-package section is rewritten to match, with its untested npm claims replaced by readings (a local `npm pack` of mods-kernel reproduces the registry's `@sema-agent/mods-kernel@7.55.0` shasum byte for byte).
|
|
124
|
+
|
|
125
|
+
### The prompt's model axis is the model's declared traits — `Model.promptTraits` replaces `promptGuidance` and the fable id reading, `TaskSpec.promptLean` replaces `promptProfile`, tool description variants are chosen by the same traits, and the task layer's identity digests its text (#1663, #546, #1793; hard-breaking: three; behavior change, open to veto; closed sets: `PromptTrait` 2 → 14; exports: +3 runtime (`PROMPT_TRAITS`, `PROMPT_TRAIT_BUNDLES`, `isPromptTraitName`), +3 types (`PromptTraitName`, `PromptTraitBundle`, `ToolDescriptionVariant`); members: −`Model.promptGuidance` +`Model.promptTraits`, −`TaskSpec.promptProfile` +`TaskSpec.promptLean`, −`ToolSpec.descriptionClassic` / `Tool.descriptionClassic` +`descriptionVariants`, −`ToolExecuteContext.promptProfile` +`promptLean`, −`RunWorkflowToolDeps.parentPromptProfile` +`parentPromptLean`, −`RunInternals.promptProfile`, −`CacheIdentityElements.policyDigest`, −`RawPrefixInputs.promptProfile` / `.fableMitigations`, −`PromptRuntimeFacts.promptProfile` / `.fableMitigations`, −`SectionRenderInputs.modelGuidance`; renderer `model-guidance` (so `PromptRendererId` loses that word) and section `core/behavior.model-guidance` retired) (@sdk @client-core @cli @server @settings-schema @web-admin @web-client @test)
|
|
126
|
+
|
|
127
|
+
- **Before.** Which prompt text a model received had three answers. The task's `promptProfile` (`simple` / `classic`) picked the short or long shape; the engine recognized the fable family from `model.id` (a boundary-aware regex on its last path segment) and lit the fable sections; and `Model.promptGuidance` appended free text at rank 450 for any model that carried it. A tool's long-form text sat in a second field (`ToolSpec.descriptionClassic`) that the mount swapped in when the profile was `classic`. The task layer entered the prompt identity as `(op, target, chars)`, so replacing the task's role text with another of the same length left the identity and the session's epoch record unchanged (KL-1604m1b-c).
|
|
128
|
+
- **Now.** (1) A model declares the traits it would light in the reference — `Model.promptTraits`, an exact set of names read through one predicate, `isPromptTraitName`: the reference's capability names that gate prompt text (`PROMPT_TRAITS`, fourteen; cli296.js B[7451052,7451700) `fvo` minus the effort / thinking / context / fast-mode / refusal-fallback / org-lock bits), its three bundles (`PROMPT_TRAIT_BUNDLES`; a declared bundle lights its members), or a deployment's own `x-<namespace>-<name>` (≤ 64 characters; the engine checks the spelling and gives it no meaning). Absent ⇒ `["lean_prompt"]` (today's default shape, bytes unchanged); an empty list is a declaration of no traits — the exact set of nothing — and takes the classic shape, so a form that submits `[]` for "none chosen" flips the model's shape (send the field absent for the default). A name the predicate refuses, or a value that is not a list, refuses the task (`config.prompt_trait_unknown`). (2) `TaskSpec.promptLean?: boolean` is the task's one switch: `true` adds `lean_prompt`, `false` removes it (the long, classic shape), absent leaves the model's answer; it moves no other trait. It rides every engine-built child (both delegation lanes, the observer sidecar, the verifier leg) and every workflow agent unless that spec writes one; a value that is not a boolean refuses the task (`config.prompt_selector_invalid`). (3) The effective trait set (`effectiveTraitsOf`) is the one input of the selector's `traits` dimension: the catalog's variants, every overlay's `when.traits` (center, deploy and task alike) and each tool's description variants read it through one function (`traitsHold`). No model id is read to choose text. (4) `fable5Model()` declares `promptTraits: ["lean_prompt", "fable_5_mitigations"]` (the reference registry's entry for the model). (5) Per-model guidance is an overlay: a deployment's model entry declares a trait (`x-acme-verbose`), and a center artifact's `add` to the `behavior` band — or a `prompt.compose` answer — carries `when: { traits: { allOf: ["x-acme-verbose"] } }`; it composes exactly for the models that declare it. An artifact's `requires.traits` names such a trait like any other (the reference's names keep their vocabulary order, a deployment's follow by text, so an artifact built before this release verifies unchanged). (6) A tool card's description is `description` (the unconditional text) plus `descriptionVariants`: at mount, the first variant whose `traits` condition holds replaces it. The four built-in tools that had a long form (Read, Glob, TodoWrite, Agent) declare it as `{ id: "classic", traits: { noneOf: ["lean_prompt"] }, text }`; their bytes are unchanged on every shape. The tool's `cardId` reports the variant: `builtin:<wireName>` for the unconditional text, `builtin:<wireName>#<id>` for a chosen variant (`legacy:` likewise). A malformed table — an id outside `[A-Za-z0-9._-]{1,64}` or repeated, an empty or unknown-trait condition, a blank text — refuses the task (`config.tool_face_invalid`). (7) The task layer's identity projection carries `text: "sid-sha256:" + sha256(session id, NUL, text)` instead of `chars`: the text never enters the record, the digest is the same in every process the session runs in, and a same-length replacement is a new record (`code_change`, `changed: ["task"]`). KL-1604m1b-c retires.
|
|
129
|
+
- **Hard-breaking (open to veto): the prompt shape is one boolean.** **What changed:** `TaskSpec.promptProfile` is retired for `TaskSpec.promptLean` (`"classic"` ⇒ `false`; `"simple"` ⇒ `true` or nothing); along the same chain `ToolExecuteContext.promptProfile` → `promptLean` (absent when no spec wrote one — never the parent's effective traits), `RunWorkflowToolDeps.parentPromptProfile` → `parentPromptLean`, and `RunInternals.promptProfile` is gone (no engine path wrote it). A spec still carrying `promptProfile` refuses the task by name (`config.spec_retired_key`, also before a resume's claim); a workflow tool wired with `parentPromptProfile` refuses at construction (`config.deps_retired_key`). **Who is hurt:** every request-key chain that carries the profile — sdk `TaskRequest.promptProfile`, client-core's `promptProfileWireCaps.ts` and the `request/taskRequest.ts` field matrix, cli's `SEMA_PROMPT_PROFILE` A/B lanes (`seamQuery.ts`, `seamQueryEngine.ts`), server's admission check, `spec-fields.ts` `promptProfileFromBody`, `resolve-spec.ts` and `TASK_BODY_DECLARED_KEYS`, web-client's turn-request ledger (`ui/lib/engine/turn-request-ledger.ts` lists the key), test's cli adapters that forward `SEMA_PROMPT_PROFILE` (read in the neighbours' origin/main, not run) — and a library host that writes the field. **How to migrate:** send `promptLean: false` where `promptProfile: "classic"` was sent; drop `promptProfile: "simple"` (or send `promptLean: true`). The keys rename in one wave (sdk → client-core → cli → server): an old client's `promptProfile` reaching a new engine is refused, never served lean.
|
|
130
|
+
- **Hard-breaking (open to veto): the model axis is the model's declaration.** **What changed:** `Model.promptGuidance` is retired (a model carrying it refuses the task by name, `config.deps_retired_key` — its text would otherwise silently not reach the model), with the `model-guidance` renderer and the `core/behavior.model-guidance` section (rank 450, block `model.guidance`); the engine no longer reads `model.id` to light the fable sections (`isFableFamilyModelId` and `resolveModelPromptTraits` are gone; the tool-registration model gate still reads ids — another axis); the internal `FABLE_5_PROMPT_GUIDANCE` constant is gone (its three lines are catalog text, `core/behavior.fable-classic.*`). **Who is hurt:** a deployment whose model entry carries `promptGuidance` (settings-schema `ModelEntry.promptGuidance`, the web-admin model form, server's `config-provider.ts` CLOSED projection and `web-admin-client/apply-effective.ts`) — every task on such a model now fails until the entry moves to traits; a deployment that runs a fable-family model id through a model entry that declares no traits — its runs lose the fable sections and take the plain lean shape (the 2×2: lean × `fable5Model()` and classic × `fable5Model()` unchanged; a model declaring `fable_5_mitigations` without `lean_prompt` gets the classic shape, by the exact-set rule); a reader of the manifest's `model.guidance` block. **How to migrate:** declare `promptTraits` on each model entry (the reference registry's capability list per model — the settings-schema preset table the design names; `fable5Model()` already declares its own); move each `promptGuidance` text into a center or deployment `add` to the `behavior` band whose `when.traits` names a trait the model declares (an `x-<namespace>-<name>` for a deployment's own guidance).
|
|
131
|
+
- **Hard-breaking (open to veto): a tool's long form is a description variant.** **What changed:** `ToolSpec.descriptionClassic` and `Tool.descriptionClassic` are retired for `descriptionVariants?: readonly ToolDescriptionVariant[]` (`{ id, traits, text }`); a spec still carrying `descriptionClassic` is refused by name (`config.deps_retired_key`) — where `defineTool` reads it, and at a run's roster snapshot for a raw `TaskSpec.tools` entry (the object a tool's `withAgents` returns included), which asks the live entry once with ordinary property lookup, so an own member, a non-enumerable one, a class tool's prototype getter and a Proxy's trap are one refusal with one sentence (a getter that throws on the key refuses the entry as unreadable, `config.tool_mount_denied`; a class tool without the key mounts as before); `ToolContractDescriptor.cardId` (and the manifest's `tools[].cardId`) gains `#<variant id>` when a variant was chosen. **Who is hurt:** a caller tool that declared `descriptionClassic`; a reader of `cardId` that compared it to `builtin:<wireName>` on a run without `lean_prompt`. **How to migrate:** write `descriptionVariants: [{ id: "classic", traits: { noneOf: ["lean_prompt"] }, text: <the old classic text> }]`; read `cardId` up to `#` for the card.
|
|
132
|
+
- **Behavior change (open to veto): the task layer's identity and two records it writes.** **Privacy:** whoever holds a session's exported records and a candidate text can now test whether that was the task's role text (an equality check, not a recovery; before, the record carried the text's length). **One extra record:** a session forked or imported under a new id records `code_change` naming `task` on its first leg (the digest is keyed by the session id). **On upgrade:** every session's first leg on this version records `code_change` naming `catalog` and `renderers` (the model-guidance section left the catalog and its renderer left the renderer vector) and, when the task layer has entries, `task` (the projection's member changed) — once.
|
|
133
|
+
- **Where the refusals land on a resume.** A re-supplied tool's card declarations — a `descriptionVariants` table the judge refuses, a retired `descriptionClassic` — are judged before the claim (`assertToolCardDeclarations`; the row stays pending and the corrected resume redeems the same token); the screen reads plain data and runs no caller code, so a retired `descriptionClassic` that only a lookup finds (a class tool's prototype getter, a non-enumerable member) is left to the roster snapshot's door and refuses after the claim with the same code and sentence (the roster-screen limit already stated for `boundaryLegs`); a `descriptionVariants` table that a class tool or a getter holds is not plain data to the screen either, and is judged at the mount, after the claim (KL-469-i). The model's `promptTraits` and a retired `promptGuidance` are model doors and refuse after the claim like the other model doors (KL-1950-a: judging them before would be a second model resolution against a catalog `swapDeps` may replace in between) — a store without `reopen` keeps that row consumed.
|
|
134
|
+
- **Wire.** The turn snapshot's cache identity is eight elements: `policyDigest` is gone (`prompt.assembled` `snapshot.elements`, `prompt.snapshot_changed`) — the traits are selector values, already digested by the identity's binding element inside `artifactDigest`. The selector record (`prompt.assembled` `selectors.traits`, the `prompt.compose` event) lists the effective set: declared names, bundle members, a deployment's own names. No neighbor reads `policyDigest` (sdk / client-core / cli / server origin/main, grep).
|
|
135
|
+
- **Not in this release.** The design's full M2b — every built-in tool card as catalog data, overlays on the tool-card band, `tool.describe` → overlay, the catalog-literal gate over `src/tools/**` — waits for M3's typed message parameters (two of the four long forms here interpolate mount facts). A second cache breakpoint at the end of the byte-stable prefix (A-K10: measured offline, +9 249 bytes reusable on a new session's first request; provider caching not measured) is its own change (#2281). `delivering_work` / `corrections` still compose on `fable_5_mitigations` rather than the reference's `bison_cairn` / `larch_cistern` (KL-1604m0-c; #1607's to decide, now that models can declare those traits).
|
|
136
|
+
- **Pins.** `test/backlog1663-prompt-traits.test.ts` (the trait-name reading's accept / refuse grid; the model door — unknown name, misspelled `x-`, non-list, retired `promptGuidance`; the manifest's selector record; the lean door and the retired `promptProfile`; the D24 path through a real center artifact; a bundle member reaching `when.traits`; the artifact requirement order; Read's card lean / classic with `cardId`; a caller tool's first-match variants; six malformed tables; a retired `descriptionClassic` under six readings of the key on the real Runner, one sentence with the factory's, an unreadable getter, the controls; #1793 same-length replacement, reproducible digest, fork's one record; a fable id with no traits). `test/prompt-golden-matrix.test.ts` refrozen: the 32 groups × 2 048 combinations equal the pre-change tree's matrix with the guidance input removed, group for group (the one declared byte change is the retired section). `test/tool-face-controls.test.ts` (variants by traits, child inheritance, `resolvePromptTraits`'s exact set and switch, no id read), `test/prompt-fable-preset-dedupe.test.ts` (the 2×2 and the exact-set cell), `test/prompt-golden-freeze.test.ts` (the guidance case is now a model-selected overlay, declared refreeze).
|
|
137
|
+
|
|
138
|
+
### Three bounded query faces on `CheckpointStore` — `listOverdue`, `listBySession`, `deleteSessionCheckpoints` — with one reading of each query, and the file store's `delete` ledger event (#1468; additive exports: +18 (8 runtime: `readCheckpointOverdueQuery`, `readCheckpointSessionQuery`, `readCheckpointDeleteGuard`, `selectOverduePage`, `selectSessionPage`, `CHECKPOINT_LIST_LIMITS`, `checkpointDeadlinePassed`, `deleteGuardAdmits`; 10 types: `CheckpointDeleteGuard`, `CheckpointFacet`, `CheckpointOverdueCursor`, `CheckpointOverdueQuery`, `CheckpointOverdueQueryReading`, `CheckpointPage`, `CheckpointRowRead`, `CheckpointSessionCursor`, `CheckpointSessionQuery`, `CheckpointSessionQueryReading`); members: `CheckpointStore` gains three optional faces; a store-format forward change)
|
|
139
|
+
|
|
140
|
+
- **Before.** A deployment's operator half (the approval queue, the deadline sweeps, the decide and resume legs, the reconciler, the session delete) asked a checkpoint store questions the `CheckpointStore` interface had no face for — "the overdue pending rows of every scope, by deadline semantic", "the newest pending row of this session on these gate kinds", "the rows that parked this tool call, in any status", "does this session hold an expired row", "delete this session's rows if its owner is X" — so a backend answered them with methods of its own class (fourteen on the server's SQL store, with a second implementation of each on its file twin, which kept two side indexes — a scope registry and a session→token index — because the core file store could not enumerate across scopes, by session or across statuses). The two `reap` verbs each wrote the deadline rule for an uncomparable deadline inline.
|
|
141
|
+
- **Now.** (1) `listOverdue(query)`: the PENDING rows of every scope whose deadline has passed `cutoff` and whose gate reads as `facet` through `facetOf` (a question parked under an approval gate kind is `B`), ordered (deadline, token), keyset-paged — `{ rows, next? }` — at most `limit` rows (1..500, default 100); a candidate row whose shape the face cannot read, or whose summary cannot be projected, is left out and disclosed, and the page fills on from the next candidate (`next` is present when a candidate remains after the page's last row). (2) `listBySession(query)`: the rows of ONE session in any status, narrowed by `status` / `gateKinds` (an empty list matches none) / `toolCallId` / `scope` / `createdSince`, ordered (createdAt, token) `oldest` or `newest` first, at most `limit` rows (1..100, default 100); each row answers `{ kind: "row", checkpoint }`, or `{ kind: "unreadable", token, scope, sessionId, status, createdAt, problem }` (the token = the key the store files it under; `scope` `null` when it is not a string) for a row of the session whose stored shape the face cannot read — a filter excludes a row only on a member it can read — answered rather than skipped (an unreadable row is not an absent one) and rather than failing the page (one damaged row does not hide its readable siblings), and disclosed through the store's seat; the bundled file and in-memory stores produce it today for a hand-edited or later-engine row among the rows they hold — the file store's restart replay drops a row whose token, scope or status is not a string before any face can answer it (disclosed once, at the replay), so across a restart such a row is neither listed nor counted by the delete; a row whose session id is not a string belongs to no session, and the listing leaves it out and discloses it. (3) `deleteSessionCheckpoints(sessionId, { owner })`: the session's checkpoint rows that exist when the call starts (taken before the guard's head read, so a row another owner's new session parks while it is awaited is never deleted), in any status and every scope, only when the session's row exists and its owner equals the guard's (null-safe); otherwise `0`. Present exactly when the store was built with a session head read (`sessionHeadOf`, a session store's `headOf`); `FileStorageBackend` wires its own. (4) One reading per query (`readCheckpointOverdueQuery` / `readCheckpointSessionQuery` / `readCheckpointDeleteGuard`): a bad value or an unknown member is refused `config.invalid_argument`, never defaulted. The page selectors the first-party stores run (`selectOverduePage` / `selectSessionPage`), the bounds (`CHECKPOINT_LIST_LIMITS`), the deadline rule (`checkpointDeadlinePassed`, now read by both `reap` verbs too) and the guard's judgment (`deleteGuardAdmits`) are exported for a backend that answers the same faces. All three faces are optional and probed by presence; the store-contract kit judges each when a backend offers it (`checkpointStoreContract(make, runner, { sessionHeads })`).
|
|
142
|
+
- **Store-format forward change (open to veto).** (1) **What changed:** `FileCheckpointStore.deleteSessionCheckpoints` journals a new ledger event kind, `delete`. (2) **Who is hurt:** a deployment that rolls its binary back to 7.55.0 or earlier while a `delete` event is still in the live checkpoint ledger — that binary refuses the replay (`checkpoint.unsupported_version`, `reason: "ledger_event_unknown"`), loudly, for the whole data root; after a compaction has folded the event away the directory reads on the older binary again. (3) **How to migrate:** before rolling back, compact the checkpoint ledger (`FileCheckpointStore.compactNow()`, or let the store compact itself at its `compactEvery` count) so no `delete` event remains live, or stay on this version; a deployment that never calls `deleteSessionCheckpoints` writes no such event.
|
|
143
|
+
- **Who else is touched.** A store wrapper that forwards every member now forwards the new faces too, and the kit holds it to them; a store-contract binding whose backend drops the gate's approval card on the way back now also fails the kit's overdue case (the card decides the facet).
|
|
144
|
+
- **How to migrate (consumers).** *server* (stage three of the store boundary): compose the operator half on these faces and the existing ones — `findPendingTokenBySession` / `peekPendingScope` = `listBySession({ status: "pending", gateKinds, order, limit: 1 })`; `findCheckpointCandidatesForAsk` = `listBySession({ scope, toolCallId, createdSince })` read to the last page, an `unreadable` row or a row above `MAX_SUPPORTED_CHECKPOINT_VERSION` read as an unparseable candidate; `findDecidedApprovalsForBinding` = `listBySession({ status: "resolved", toolCallId, limit: 8 })`; `hasExpiredBySession` = `listBySession({ status: "expired", limit: 1 })`; the deadline sweeps = `listOverdue({ facet })` + `expire` / a denying resume; `listPending` = `listScopes()` × `listByScope(scope)` × `get(token)`; `peekScopeByToken` = `get(token)?.scope`; the session delete leg composes on the new face `deleteSessionCheckpoints(sessionId, { owner })`. Your own `deleteBySession(sessionId, owner)` keeps its contract and needs no change: it is not a member of the interface, the face's name is deliberately a different one, and the kit neither probes nor calls it (a store that carries it and not the new face is judged as not offering the face). The kit's `sessionHeads` option is a map the kit writes the heads of its delete case into, for a store that reads its session heads from memory; a store that judges the guard inside its own statement (an SQL store would) binds the kit by mirroring each entry the kit writes there into its session rows before the delete is asked. The checkpoint ledger's side indexes of the file twin then have nothing left to answer. One summary-projection gap stays (the approval queue's `boundInputHash` / `riskDescriptor` / `ruleOffers` / `ruleOffersAbsence` / `askClass` are not on `CheckpointSummary`, so the queue reads them with `get`): a candidate ticket, not a face.
|
|
145
|
+
- **Pins.** `test/backlog1468-p2-faces.test.ts` (the kit's three cases on both stores, each face offered; presence; the delete case judges a face that deletes nothing; the face's name is its own — a store carrying `deleteBySession(sessionId, owner)` and no new face compiles as a `CheckpointStore` and is judged as not offering the face, nothing probed and nothing called; the file store's restart and compaction; the unreadable arm on both stores and across a restart; the bundled backend's wiring; one reading per query; the five session reads composed against the SQL predicates they replace, with the two known differences named; the `get`-based scope read; the review's reproductions — a delete racing the id's re-creation by another owner, an overdue row whose summary cannot be projected, a row whose token member is not its key, a guard member supplied by `Object.prototype`, gate kinds read without calling the caller's array, a row whose scope is damaged, the page cut before any copy); the kit's three cases in `checkpointStoreContract`.
|
|
146
|
+
|
|
147
|
+
### The mods kernel, wave two: the shell's stations, `call` and `budgetMs` columns, one side lock, answers received as data, the seat-trust law, `ClassicEventMap`, `prompt.mention` on the engine's bus, and `tool.call` between the gate's allow and the tool's body with `$.tool.call` — an extension's own call through the run's own per-call pipeline (#1933 slices K + M + S1, #1787, #1922, #1925; security; behavior narrowing and behavior change, open to veto; hard-breaking: 5, below; closed sets: `MOD_EVENT_TABLE` +25 stations and `prompt.mention` side surface → engine, `MOD_EVENT_TABLE` +1 station (`tool.call`, callable), `MOD_FAILURE_ANSWERS` / `ModFailureAnswer` +`refuse_until_run`, `ExtensionRequester.kind` new (`extension`), `UserMessageAttachment.notes` +1 word; wire: `tool_start.requestedBy`, `tool_end.requestedBy`, `tool_end.answeredBy`, `AskRequest.requestedBy` new optional members, `tool_end.notExecuted` one more source; exports: root +4 types (slices K + M) and +2 types (slice S1: `ExtensionRequester`, `ToolCallAnswer`), `@sema-agent/mods-kernel` +2 functions +9 types; `KERNEL_CONTRACT_VERSION` 2 → 3, once for both slices; slice S1 is 24 source files (four of them a type or an export line: `types.ts`, `index.ts`, `task-event.ts`, `tool-policy.ts`), above the 15-file slice guidance: the station and its call are one event, and splitting them would ship a station with no call — a transitional arm) (@server @cli @client-core)
|
|
148
|
+
|
|
149
|
+
- **The table.** The shell's events have stations in `MOD_EVENT_TABLE` (`ui.render`, `ui.resolve`, `ui.press`, `ui.input`, `ui.select`, `ui.scroll`, `ui.focus`, `prompt.fill`, `prompt.suggest`, `prompt.edit`, `command.run`, `command.describe`, `config.set`, `config.describe`, with CC 2.1.296's pinned keys), and ten surface methods (`ui.toast`, `ui.log`, `ui.notice`, `ui.invalidate`, `ui.selection`, `ui.copy`, `prompt.read`, `command.list`, `command.register`, `config.list`). Two columns: `budgetMs` — an event's own ceiling on every clock a handler runs under on it (`prompt.edit` 50 ms; a registration never widens it) — and `call` — the event is callable through `$`: the call reaches the host's entry for it (`ModBusInput.entries`), which raises the event once at its station under the caller's frames, its answer being the call's (a `{ deny }` is returned). A declared name the bus cannot call in this version throws, naming it (before: no member, a bare `TypeError`).
|
|
150
|
+
- **One side lock.** `uses.events`, `uses.calls` and `on()` read one predicate (`expandOnSide`): an exact name of the other side is refused (`config.mods_invalid`, naming the side); a wildcard selects its bus's names and must select one. Before, a surface name in `uses.calls` was dropped silently and an exact surface name in `uses.events` was admitted.
|
|
151
|
+
- **Answers are data (#1787).** Every handler answer is received once as data — a primitive, or a plain record or array copied whole and frozen; a function, a class instance, a symbol, a bigint or a cycle the handler built is a site violation. A sealed object — frozen data all the way down — of the dispatch's own `e`, or of what the handler's `next` answered (taken the moment it came up, before the handler could touch it), is kept as itself; anything else is received like the handler's own value, so an object it built and planted inside a mutable member of what came up is still refused. What a chain's bottom answers — a station's core, a `$` method's seat value — is received by the same receipt before any handler sees it: frozen all the way down (an object of another kind handed as it is), so a handler that writes into it throws, its failure is reported like any other, and what it tried to plant never reaches the station or the caller. Before, a class answer was handed to the station as it was, so a `tool.check` answer whose `decision` getter read `deny` for the check and `allow` after was taken as `allow`, and `classic.UserPromptSubmit` read a `block` that emptied after its check. `prompt.section`'s own data check is retired (the bus's is the one); `prompt.compose` and the `classic.PreToolUse` decision receipt meet a non-data answer as the bus's violation sentence (`answered a value that is not data at …`).
|
|
152
|
+
- **The seat-trust law (#1925).** A dispatch of an event whose failure column refuses names its answer ranks (`ModDispatchOptions.ranks`: 0 passes, `top` refuses outright — the engine's three judging stations, `prompt.mention` and `plugin.register` do; a dispatch that names none is not run). A less trusted seat's answer never stands over a more trusted seat's stricter one: a task-seat handler that answered after its `next` may not refuse less than what the deployment's handlers below it answered (a deployment handler may overrule another below it; the floor travels up with the answer); one answering without its `next` having answered — never called, or still running — while a deployment handler below it never got to judge stands only with an outright refusal; anything else is a violation and the station refuses. A station's ranks read everything that makes an answer stricter: `classic.PreToolUse` ranks deny 3, an ask a person must answer (`requiresRealApproval`) 2, a plain ask 1 — so a task handler that calls `next` and answers a plain ask over a deployment's mandated ask is refused, as it is without `next`. Before, a task `user` handler answering `allow` without `next` passed over a deployment `append` guard's deny (`tool.check`, `classic.PreToolUse`, `classic.UserPromptSubmit`). No tier is special — the law reads seats. Stricter than CC 2.1.296, which has no task seat (its managed hooks sit at prepend and merge with what is below them).
|
|
153
|
+
- **Re-entry.** On a refusing event, a handler above the raiser that judges and has no `.catch` is a guard that cannot answer: the dispatch refuses for it (`failed`, cause `reentry`, the guard named), so a nested `$` call never passes a guard of the chain it was made from. Skip events pass it over as before. (Reached only through a callable event's entry; no wave-one path re-enters a refusing event.)
|
|
154
|
+
- **The dispatch's record.** A station's answer check reads `run.bottomStarted` (`ModDispatchRun`); a cancel after the bottom started is `{ kind: "aborted", bottomStarted: true }`.
|
|
155
|
+
- **`ClassicEventMap` (#1922).** Each `classic.*` event a station raises with its `e` and its answer form (keys held to the table's `classic.*` rows with a station; `ClassicEventName`, `ObservedAnswer`); `dispatchClassic` / `observeClassic` / `screenUserPrompt` construct their `e` against it, and `RunnerDeps.mods` / `TaskSpec.mods` hold `EngineModRegistration`s, whose `on` types a classic handler by the map (kernel: `MappedOn`, `ModTypedNext`, `ModTypedHandler`, `ModEventPair`, `ModEventMap`).
|
|
156
|
+
- **`prompt.mention` (slice M).** Raised for each declared attachment before the engine reads it (§10.9): `e = { mention, agentId?, path?, offset?, limit? }` (`mention`, `agentId` pinned); the chain's bottom is the engine's read of what is handed down (a path entry's path and line window may be re-spelled; the read rules judge what is read, and a guard above a re-spelling handler sees only the declared path — a guard that must judge the file that will be read hangs at `append`, below every handler that may re-spell: the seat-trust law governs answers, not what a handler hands down); a handler answers `next(e)`, `{ deny }` or `{ type: null }` (not attached, the frame naming the extension) and never supplies contents; a failing handler refuses (not attached). The frame's record carries the note `extension_declined`. All three supplies (`path`, `content_text`, `content_image`) are raised alike; no handler, no dispatch.
|
|
157
|
+
- **Hard-breaking (open to veto).**
|
|
158
|
+
1. *`KERNEL_CONTRACT_VERSION` 2 → 3.* What changed: the event table's stations and columns, the side lock, `$` callable events, the answer receipt, the seat-trust law, the re-entered guard and `prompt.mention`'s side (above). Who is hurt: a host that loads a kernel copy beside the engine and compares the number — none today (server, cli and client-core load no kernel copy). How to migrate: take the kernel from the same core version.
|
|
159
|
+
2. *A refusing event's dispatch must name its ranks.* What changed: `ModBus.dispatch` of an event whose failure column refuses, with no `ranks`, is not run (`failed` by the engine, reported). Who is hurt: a host raising `classic.PreToolUse`, `classic.UserPromptSubmit`, `tool.check`, `prompt.mention` or `config.set` on a bus of its own. How to migrate: pass `ranks: { of, top }` — 0 for an answer that passes the event on, `top` for an outright refusal.
|
|
160
|
+
3. *Other-side names and non-data answers are refused.* What changed: an exact other-side name in `uses.events` / `uses.calls` (or `on()`) refuses the prepare; a handler answer that is not data is a site violation — on a refusing station the dispatch fails and the station refuses (the call, the prompt, the attachment, the candidate), on a skip station the handler is skipped and the skip is reported on `onError`. `plugin.register` is a refusing station: a gatekeeper that fails — a throw, an overrun, or a refusal carrying a member that is not data (`{ refuse, cause: new Error() }`, `{ refuse, at: new Date() }`, a frozen class instance) — refuses the candidate (`mods.registration_refused`), never admits it by a skip. Who is hurt: a registration declaring a name its bus never reads (it was never usable), a handler answering a class instance or carrying a function, a gatekeeper that throws (its candidates are now refused, not admitted). How to migrate: drop the other side's names (or use a wildcard); answer plain records and arrays.
|
|
161
|
+
4. *The seat-trust law.* What changed: a task-seat answer less strict than a deployment answer below it, or one given without `next` over a deployment handler that did not judge, refuses on the judging stations. Who is hurt: a task registration that allowed (or added context) without `next` in a deployment with `append`-tier guards. How to migrate: call `next(e)` first and answer at least as strictly as what it returned, or refuse outright.
|
|
162
|
+
5. *The failure column's closed set gains `refuse_until_run` (slice S1).* What changed: `MOD_FAILURE_ANSWERS` / `ModFailureAnswer` is `refuse`, `skip`, `refuse_until_run`. Who is hurt: a host that switches over the column with two arms (a TypeScript exhaustiveness check fails) or tests `=== "refuse"` (it reads `tool.call` as a skip event). How to migrate: read `failureRefuses(failure)`, the kernel's one reading.
|
|
163
|
+
- **Signatures (the wire tool lists both as signature changes; neither hurts an implementer).** `ModAnswerCheck` gains a fifth argument `run: ModDispatchRun` — a check written with four still fits; a host that itself CALLS a check it holds passes the run. `ModBus.dispatch` takes any object as `e` (was `ModEventPayload`) — a wider parameter; a bus a host implements still fits the method. `ModDispatchOutcome`'s `aborted` arm gains an optional `bottomStarted`.
|
|
164
|
+
- **Not hard-breaking, named.** `TaskSpec.mods` / `RunnerDeps.mods` are typed `EngineModRegistration[]`: a `ModRegistration<EngineDollar>` still fits; an inline handler of a `classic.*` event is now typed by `ClassicEventMap` (a TypeScript host that read a member outside it annotates `e: ModEventPayload`).
|
|
165
|
+
- **Consumers (the shell's stations, the bus, `prompt.mention`).** @cli — L-1109: the shell's stations, the `call` column, entries and the event ceiling are in the table (the shell raises them; config.set dispatches name ranks); `prompt.mention` is the engine's (W2C2 drops its mention piece; the three supplies are sent as today); render `extension_declined` on a frame that was not attached. @client-core — re-export the kernel at this version; the side per name and the station columns are read off `MOD_EVENT_TABLE` (never copied); `UserMessageAttachment.notes` may carry `extension_declined`. @server — `TaskSpec.mods` / `RunnerDeps.mods` element type; a host-built dispatch of a refusing event names its ranks; publishing the contract number on a handshake face is the server's design (`KERNEL_CONTRACT_VERSION` from the core it packs).
|
|
166
|
+
|
|
167
|
+
- **`tool.call` (slice S1; INTEGRATION-CORE §10.10).** Raised on the leg's bus for every execution of a call the gate let go — the live loop's lanes through one new execution seat (`AgentLoopConfig.executeTool`), the resumed leg's approved call through the same function; no handler, no dispatch. `e = { tool, tool_use_id, agentId?, input }`, all four pinned. A handler answers `next(e)` (what comes up is a frozen view `{ result: { content, isError } }` of the real result), `{ deny }` before the body (not executed; `extension "<name>" refused this call …`), or `{ result }` in place of the body — taken only when the call's latest gate record is an allow no one settled, released or cleared, and the body has not started; otherwise a violation. A deny and a result are answers in place of `next` (the `$` methods' law): given while the handler's own `next(e)` still runs, they are a violation and the call is refused before anything below can start the body. A new failure answer `refuse_until_run`: a failure while the body has not started refuses the call; once it has, the body's real result stands (a late deny or a late result is such a violation, the handler reported on `onError`). The start is one fact the dispatch records, read the same for a deny as for a result: a deny given after the body started is a violation whether the body's result came up through the handler's `next(e)` or the body threw out of it — a handler that catches the body's throw and answers `{ deny }` (or whose `.catch` does) does not turn a call that ran into one that did not: the body's own outcome stands, `tool_end` carries no `notExecuted`, the model reads the tool's own error, the verification turn is armed, the call stays in `classic.PostToolBatch`, and `$.tool.call` answers the tool's own result rather than `{ deny }` (open to veto: before, a call whose write had happened was reported to the model as "refused … before it ran"). A `.catch` that gives no answer a station takes over what the run below threw — one that answers nothing, answers a form the site does not take, replays only `next`, or throws itself — is now said on `onError` (on every event; the throw still stands) — it was dropped without a line; a host that counts `onError` lines sees one more for each such `.catch`. A cancel before the body ends the call `Operation aborted`. Its ranks: a deny refuses outright (the seat-trust law, #1925, holds here as on the other judging stations). Whatever an extension answered in place of the body, the call's post-execution stations stay silent; `tool_end` carries `notExecuted: true` and `answeredBy`.
|
|
168
|
+
- **`$.tool.call({ tool, ...input })`.** The event called (`uses.calls: ["tool.call"]`): the engine's entry runs the loop's own per-call pipeline for the named mounted tool — one function the sequential lane now runs too (`runToolCall`), through `AgentHarness.callTool` under the running prompt's config: `tool_start`, the whole gate (rules, mode, `classic.PreToolUse`, `tool.check`, the ask), `tool.call`, the body, the post-execution stations, `tool_end`. It runs as the run's principal; the registration name is only an origin. It is not part of the conversation (no tool-result message, no `classic.PostToolBatch` batch). Every event its pipeline raises is raised under the caller's frames and origin, carried by one async-scoped record of the call that is live only while the call runs — a dispatch from a timer or a detached promise the call started, after it settled, is refused on the leg bus and reported. It answers `{ result: { content, isError } }` or `{ deny }` (returned). It throws on a missing `tool`, a name the run mounts no tool for, outside a running prompt (a station raised before the loop, the objective's `classic.UserPromptSubmit` screen included), and for the engine's `Agent`, `AskUserQuestion` and `Workflow` — known by their catalog contract, whatever name they are mounted under — naming each one's own door. It never shares the run with a call that has the run to itself: judged at the gate's allow exit on the arguments the gate lets go, with the loop's own predicate (`isBatchSafeToolCall`) — one that is not batch-safe is let go only when no other call is in flight, a batch-safe one only while no call that is not batch-safe has been let go; otherwise the hook layer refuses it there (two made at once may both be refused, never both run). It has no park lane: an ask for it is put in-stream or not at all, and the run never parks for it.
|
|
169
|
+
- **What a call that never ran leaves behind (behavior change, open to veto).** A call the engine refused before it was let go, or one an extension answered in place of its body, no longer arms the final-verification turn (`TaskSpec.finalVerification`: a write that never ran produced no deliverable), live or seeded from a resumed session's rows (`details.notExecuted`), and never joins a `classic.PostToolBatch` batch — read off the occurrence's evidence, not off a marker the post-execution station consumes.
|
|
170
|
+
- **Wire.** `tool_start` / `tool_end` and the approval card an ask for an extension's call puts to the person carry `requestedBy: { kind: "extension", name }` (`ExtensionRequester`, a display fact; the call's occurrence is its one record). `tool_end.answeredBy` names the extension that answered in place of the body; `tool_end.notExecuted` has one more source (an extension answered in place of the body). The persisted `toolResult` row repeats `notExecuted` but not who answered.
|
|
171
|
+
- **Against the reference (CC 2.1.296; ids registered in the divergence book).** `input` is pinned (CC lets `tool.call` rewrite it — a rewrite belongs to `classic.PreToolUse`); a deny or a result after the body started is a violation (CC's `isLateRefusal` only warns); a result is taken only on a call no one was asked about and before the body started (CC takes it at any time); CC's `{ result, context }` `context` is not taken (context is `classic.PostToolUse`'s); `$.tool.call` answers the station's own view, not CC's `{ result, text }`; the in-flight rule and the absent park lane have no CC counterpart (CC neither batches by `cd` nor parks). The three doors are CC's (`j2e`, cli296.js:249224).
|
|
172
|
+
- **Consumers (`tool.call`, `$.tool.call`).** @server — pass `requestedBy` through on `tool_start` / `tool_end` and the approval card, and project it in `trace/project`; `answeredBy` and the extension source of `notExecuted` (a call an extension answered never ran — a reader that counts `notExecuted` as replayable, or bills by it, meets one more source). @cli — the card's line of origin ("extension X wants to run …") and the transcript row's; a call an extension answered is not drawn as run. @client-core — project `requestedBy` and `answeredBy`; `ExtensionRequester.kind` is a closed set of one (`extension`); `MOD_FAILURE_ANSWERS` is read off the kernel at this version.
|
|
173
|
+
|
|
174
|
+
### One deployment host floor — `RunnerDeps.hostFloor = { deny?, only? }` — read by the gate and by the engine executors that connect on the Runner that declares it (a delegated child prepared by another Runner reads that Runner's deps, KL-2212-b): the fetch tool on every hop, task images, each MCP http dial before a pooled connection is leased, every A2A request and redirect hop, WebSearch's SearXNG backend on its request and every redirect hop; `WebFetchConfig.allowHosts` and `RunnerDeps.allowImageUrl` retire into it (#2212, design/1489 car B; security; behavior narrowing, open to veto; hard-breaking: H1 `WebFetchConfig.allowHosts` removed, H2 `RunnerDeps.allowImageUrl` removed, both open to veto; closed sets: `CHECKPOINT_CONFIG_REFUSAL_CODES` 44 → 45 (+`config.host_floor_invalid`); new codes `config.host_floor_invalid` and `config.host_floor_refused` (open `config.*` family); exports: +5 (`compileHostFloor`; types `HostFloor`, `HostFloorDeclaration`, `HostFloorPosture`, `HostFloorReach`); members: +1 `RunnerDeps.hostFloor`, −1 `RunnerDeps.allowImageUrl`, −1 `WebFetchConfig.allowHosts`, +1 optional `ToolExecuteContext.hostFloor`, +1 optional `WiringManifest.hostFloor`; signatures: `materializeMcpTools` and `materializeA2aTools` gain a trailing optional `hostFloor`) (@server @cli @client-core @connectors @test)
|
|
175
|
+
|
|
176
|
+
- **Before.** "This deployment never reaches X" had no seat. WebFetch carried its own host list as a factory option (`allowHosts`, which only whoever built the tool could pass — the deployment's deps could not), task images had a predicate that *replaced* the default image guard (`allowImageUrl`), and neither an MCP http endpoint nor an A2A peer was bounded by anything: an A2A request without declared headers followed every redirect (`redirect: "follow"`), so a peer's 307 took the request to a host no declaration named. A deployment that needed a bound gave up the fetch tool (server's multi-tenant arm removes WebFetch).
|
|
177
|
+
- **Now.** One declaration in the host rule's spelling (`example.com`, `*.example.com`; read by the rule side's one host-pattern reader, `readHostPattern`, and matched by its one matcher, `hostPatternAdmits` — the rule parser and `domainRuleAdmits` call them too), compiled once in the config doors (`compileHostFloor`). `deny` entries are never reached; `only` present bounds everything else (`only: []` = nothing the six readers below reach); an `only` entry naming a private address or `localhost` lifts the private-network floor for that host. One hop judge (`hopRefusal`, `src/core/host-floor.ts` — `isPrivateHost` moved there, its one home) is read by six readers: **the gate**, a deny-only narrowing layer core owns, leading the block — a call whose URL slot names a host the floor does not admit, or no host it can read, is denied (`decisionReason: "rule"`) before any rule, mode (`bypassPermissions` included) or approver, and the rewrite re-check and the resume re-check read it like the other narrowing layers; **the fetch tool** on its first URL and every redirect target, read from the run's context (`ToolExecuteContext.hostFloor`), never a factory closure; **task images** (https, the floor, the private-network floor; still no redirects); **MCP http** at every dial, before `McpConnectionPool.acquire` — a refused server is skipped `failed` / `invalid_config`, never contacted; **A2A** on every request and redirect hop; **WebSearch's SearXNG backend** on its request and every redirect target, read from the run's context like the fetch tool's (the tool hands it in `opts.hostFloor`), a refused hop never requested (#2325). The fetch tool, A2A and the SearXNG backend follow redirects through one loop (`fetchFollowing`): each hop judged before it is requested, the method of a followed hop decided once (303, or 301 / 302 answering a POST → GET with no body; 307 / 308 keep both; a GET or HEAD request keeps its method); what each takes is its own law (the fetch tool: same-host only, others handed back; A2A: none with declared headers, any judged hop without, up to 20 as `fetch` did; the SearXNG backend: any judged hop, up to 20). The wiring manifest reports the posture (`hostFloor: { onlyMode, denyCount, onlyCount }`, never the entries) iff declared.
|
|
178
|
+
- **H1 — `WebFetchConfig.allowHosts` removed (open to veto).** *What changed:* WebFetch's host list is no longer a factory option; `webFetchToolSpec` / `createWebFetchTool` / `assembleCodeTools({ webFetch })` refuse an `allowHosts` member by name (`config.deps_retired_key`), and the tool reads `RunnerDeps.hostFloor` from the run on every hop. *Who is hurt:* a deployment or a harness that passes `allowHosts`; it now fails at the factory. In the neighbouring repos' origin/main (read, not run): server, cli, client-core and sdk pass none; **@test** does — `repro/webfetch-ssrf-internal-address-boundary/repro.mjs:180,274` (`allowHosts: ["127.0.0.1"]`, the reachable control) and seven more repro scripts (`core548-g1g5-criteria/g2-construct.mjs`, the `webfetch-summarization-gap` / `webfetch-tool-error-isError-blind` repros, two `.negative-scratch` copies; `core7230-G-20260918/g1*.mjs` pass `allowImageUrl`, H2). *How to migrate:* move the list to `RunnerDeps.hostFloor.only`. This is a capability change in two ways: the same list now bounds task images, MCP http and A2A — list those endpoints' hosts too or they are refused — and an entry that names a private address (`127.0.0.1` for a local MCP server) lifts the fetch tool's private-network floor for that host on every port; to keep the fetch tool narrower than the floor, add rule-layer tightening (`WebFetch(domain:127.0.0.1)` deny, or an ask on `WebFetch` plus `RunnerDeps.allowedTools: ["WebFetch(domain:docs.test)"]`).
|
|
179
|
+
- **H2 — `RunnerDeps.allowImageUrl` removed (open to veto).** *What changed:* a task image URL is judged by the one guard (https, the floor, the private-network floor); no predicate replaces it. The seat is refused by name at construction and at the deps door (`config.deps_retired_key`, row in the retired-seat table). *Who is hurt:* a deployment that used the predicate for a CDN list or a resolve-and-pin check (none in the neighbouring repos' origin/main — read). *How to migrate:* a CDN list → `RunnerDeps.hostFloor.only` (it bounds every structured egress); a resolve-and-pin check has no seat in this version (the connection layer is design/1489 car D) — send such images inline.
|
|
180
|
+
- **H3 — a host the floor does not admit is refused at every reader (behavior narrowing, open to veto).** *What changed:* with a floor declared, the six readers above refuse; a refusal is not cleared by any rule, mode or approval. *Who is hurt:* a deployment that declares a floor and still names MCP / A2A endpoints, image hosts or fetch targets outside it. *How to migrate:* list them in `only` (or take them out of `deny`).
|
|
181
|
+
- **H4 — the private-network floor's exception is the deployment's `only` entry (behavior change, open to veto).** *What changed:* a private host is reachable by the fetch tool and by task images only when an `only` entry names it (before: an `allowHosts` entry for the fetch tool; nothing for images unless the predicate replaced the guard). *Who is hurt:* nobody who migrated by H1 / H2. *How to migrate:* as H1.
|
|
182
|
+
- **A host has no empty label, and a pattern's `*` is judged on the host as read (behavior narrowing, open to veto).** The one host reader (`readSpelledHost`, `src/core/host-target.ts`) refuses a spelling with an empty label once its trailing dots are read off — `.evil.test`, `a..test`, `*..test` — where it read one: such a pattern matched only a URL spelled with the same empty label, never the host it was meant to name (7.55.0 accepted `WebFetch(domain:.evil.test)`, and it did not reach `evil.test`). A pattern's `*` is its operator only as the leading ASCII `*.`; the host after it is read by the URL parser and holds no `*` as read, so a full-width or small asterisk the parser folds (`WebFetch(domain:*.evil.test)`) is refused (`unsupported.wildcard`) where 7.55.0 minted the exact host `*.evil.test` under the text `WebFetch(domain:*.evil.test)`, its text and matcher fields disagreeing. A floor entry so written refuses the compile (`config.host_floor_invalid`, the sentence naming `*.<domain>` for "every host below"), a rule so written refuses to parse (`invalid.grammar` / `unsupported.wildcard`), and a URL naming an empty label (`https://a..test/`) names no host the rules can read. `evil.test`, `*.evil.test` and a trailing dot read as before.
|
|
183
|
+
- **A stored rule row whose text this grammar reads as no rule is a row the rule lane cannot judge a call against (behavior narrowing, open to veto).** (1) **What changed:** the person's rule lane reads the text of each stored row a judgment of the call may read — a row of a name the call is judged under, and every path row when the path grammar judges it — with the rule reader (`parseRuleText`). A row whose text it reads as no rule (an earlier grammar's rule this one refuses: a 7.55.0 empty-label row) is a row the lane cannot judge the call against, its existing law's call form: as a deny or an ask that is eligible for the call it still tightens by its stored fields, and a call none of the rows refuses is put to a person (a real approval, class `rule_unreadable`, bypass-immune; the reason names the row and the reader's refusal: "the stored deny rule WebFetch(domain:.evil.test) is no rule this engine reads (… names a host with an empty label …)"); as a grant it grants nothing in the lane's judgment (the run's path tables outside the lane still read a stored path row by its fields, KL-2212-c). Before, the lane judged such a row by its fields alone — the empty-label deny reached nothing, so the call ran. The session partition keeps such a deny or ask of its session for the lane (it dropped it, disclosed); it still drops such a grant and every row whose stored fields its text does not spell. A row whose text reads keeps its reading — the 7.55.0 full-width-star rows (text `WebFetch(domain:*.evil.test)`, fields the exact host `*.evil.test`) included: their fields, not their text, are what the lane matches, as before (KL-2212-c: one screened view of the person's rows is the closing shape). (2) **Who is hurt:** a person whose store holds such a deny or ask: every call of its tool that no deny refuses asks; calls of other tools are untouched; such a grant stops applying. A durable row stays removable (`removePersistedRule` by its text); a session row ends with its session, as session rows always do (the persisted-store removal refuses a session scope). A sync round still moves such a local row into quarantine (the round's result names it), after which its calls are judged without it; the 7.55.0 reach of an empty-label deny (only a URL with that empty label) stays refused by the fetch tool, which refuses such a URL. A checkpoint whose session rules hold such a row refuses its restore whole (the restore's law), and a session's rules row holding one in `toolDeny` (stamped by 7.55.0 or by this version) cannot be read, so its rules refuse every call. (3) **How to migrate:** rewrite `.evil.test` as the two patterns `evil.test` and `*.evil.test`, and a full-width `*.` as `*.`; then remove the old durable row. **Cost:** a call reads the text of each such row once more (measured on a loaded host: about 150 µs for a shell row, 7–17 µs for a path, name or host row).
|
|
184
|
+
- **Smaller changes (open to veto).** One hop judge for MCP, A2A and images means a declared MCP http endpoint, an A2A URL or an image URL with credentials in it, or whose host does not read back as one host (`https://2130706433../`), is refused before it is sent even with no floor declared (before: `fetch` refused the first, the second was dialed); a declared MCP endpoint that is not http(s) (`ws:`) is now classified `invalid_config` with `config.host_floor_refused` where it was `unknown` ("fetch failed"). A2A follows its redirects by hand with `fetch`'s 20-hop bound, its redirect set and its method rule. A redirect, for the fetch tool too, is a 301, 302, 303, 307 or 308 (the fetch standard's set and the reference fetch tool's); a 300, 304, 305 or 306 carrying a Location is now the answer (before, the fetch tool followed or handed back any 3xx with a Location). The declaration is read once, at `new Runner`, through the engine's declaration receipt and kept as a frozen copy (a Proxy, a getter that throws, a non-plain object refuse there, `config.host_floor_invalid`), so a resume's pre-claim screen and its prepare judge one declaration. The fetch tool's description line now reads "Private/internal hosts are refused unless this deployment names them, and the deployment may limit which hosts are reachable" (model-visible). Its refusal and the image guard's are reworded around the floor (`host "x" is denied by this deployment's host floor (hostFloor.deny)` / `is outside the hosts this deployment reaches (hostFloor.only)`; `image url not fetched — …`).
|
|
185
|
+
- **Upgrade (fail-closed).** A WebFetch approval parked on 7.55.0 and resumed on a Runner whose floor excludes its host is denied by the resume's narrowing re-check — not executed, the approval spent, no `resume.target_moved` (the parked row is unchanged). Declare the floor after draining parked fetch approvals, or accept that refusal once.
|
|
186
|
+
- **What the floor is not.** The six readers' structured egress only (a search backend a deployment injects itself, such as a provider's search API, holds its own egress, #2325), and a shell command's network is the deployment sandbox's; a host is judged by its spelling, not its resolved address (KL-2212-a; design/1489 car D). It is the declaring Runner's: a delegated child prepared by another Runner (a deployment's split child-execution runner) compiles that Runner's own `hostFloor`, and one whose deps carry none has no floor (KL-2212-b; the carry is the next car's: the floor becomes a row of the tighten-only clamp table). **@server** puts Agent children on `subRunner` (`src/boot/stage-06-runners.ts:~433`, read in its origin/main): put `hostFloor` in `createSharedRunnerDeps` so both Runners carry one declaration — until the clamp-table row lands, a sub-runner without the floor has unbounded egress. The rule layer's `WebFetch(domain:…)` rows are not projected into it (a rule answers "ask about this call?", the floor "can this deployment reach it?"; Claude Code projects them into its sandbox allowlist, divergence candidate DV1).
|
|
187
|
+
- **Pins.** `test/backlog2212-host-floor.test.ts` (A the compile: posture, one spelling, reach, lifts, sixteen bad values and two near misses, the prepare and the resume refusals, the two retired seats; R1 the gate: deny / only / bypassPermissions / allow-all / unreadable / a caller's rewrite / the manifest; R2 every hop: a redirect into a denied host never requested, a same-host hop followed, a named private address, a rebound `defineTool` product, outside a Runner; R3 images: denied, a named private https host, http still refused; R4 MCP: never dialed and the pool never asked, through the Runner; R5 A2A: a denied peer, a 307 into a denied host never sent with its no-floor control followed, declared headers follow none; the redirect loop's method rule and hop bound; a parked fetch approval resumed under the floor). Re-pinned on the new seat: `backlog1489-host-grammar` G2d, `security-fixes`, `tools-data-coverage`, `known-issues-2026-06-15`, `full-body`, `webfetch-*`, `tool-use-result-details`, `backlog1074`, `backlog1950` (recipe), `public-knob-liveness`. The empty label and the stored-row screen (same file, two FIX1 blocks: the floor compile, the rule parse and the URL; the folded star on the rule and the floor; a session's rules row stamped 4 and 5; the session door; the real lane on the durable and the session door under default and bypass, the other tool's call, the readable deny beside; every grammar directly — the grant, the deny's fields, the path row through the read face, the minted controls) — seven mutants, each red.
|
|
188
|
+
|
|
189
|
+
### A principal's name has one judge, `principalNameOf`: a rule owner, a session owner, an adoption target, a sync round and a provider's bucket read a name through it, and a blank name is refused at every entry that needs one (#2265; behavior narrowing, open to veto; hard-breaking: none (the type surface is unchanged; the two store contract kits are stricter, see below); closed sets: none; exports: +1 runtime (`principalNameOf`); members: none) (@server @cli @client-core)
|
|
190
|
+
|
|
191
|
+
- **Before (v7.55.0).** "Is this string a principal's name" was read three ways. The rule owner's principal arm (`readRuleOwner`) refused the empty and the whitespace-only string. The sync entry, `adoptFilePermissionRuleStore`, `adoptLocalDataRoot`, the rule-directory adoption marker's reader, the file provider's `forPrincipal`, `ownerStoreOf` and the lanes' three copies of "is this task anonymous" refused the empty string only, so a whitespace-only principal was a bucket of its own (a durable file named by the hash of the spaces), a sync target and an adoption target. The session row's owner cell — `ownerCell` under every bundled repo's `create` / `fork` / `importEntries`, `SessionStore.register` and `fork`, `readOwnerCell` reading a stored cell, `SessionListQuery.owner`, `CheckpointDeleteGuard.owner` — and the data-root adoption marker's reader (`toPrincipal`) took any string, the empty one included: `register(id, "")` recorded an owner of `""`, and a fork made for a task whose principal was `""` recorded it too. And the three persisted-rule entries — `addPersistedRule`, `removePersistedRule` and `effectivePermissionRules` (through `resolveOwnerStore`) — took their target as given (`structuredClone` of whatever object came): a blank name, a class instance and a record whose `kind` is outside the closed set were each a bucket, so an add wrote a rule row into the bucket of `" "` (or of a bogus-kind owner's `principal`) and the read entry served it back as a rule row (the 7.55.0 blackbox's G10.a, measured on 7.55.0).
|
|
192
|
+
- **Now.** One judge, `principalNameOf(raw)` (`src/core/principal-name.ts`, a leaf with no import of its own; package root): a string with visible content (something is left once white space and every Unicode format character are taken away) that holds no control character (U+0000–U+001F, U+007F–U+009F), no line or paragraph separator (U+2028, U+2029) and no format character (category Cf: the zero-width space, the bidirectional controls, the byte order mark) other than the two joiners U+200C / U+200D, kept exactly as written, never stripped or normalized (` alice ` is a name, and not `alice`; `陈` and `kit-alice` are names); the empty string, a blank string, a string holding one of those characters and every value that is not a string name nobody. The five faces read through it: the rule owner's principal arm; the session owner cell (every bundled repo's `create` / `fork` / `importEntries` through `ownerCell`, `SessionStore.register` and `fork`, a stored cell through `readOwnerCell`, the list query's `owner`, the delete guard's `owner`); the adoption target (both entries and the three markers that record it); the sync round's principal; the provider's bucket (`ownerStoreOf`, the file provider's `forPrincipal`, the lanes' and the person-path reader's "anonymous" test, and the owner a fork row is minted for from a task's principal). An entry that needs a name refuses anything that is not one; a face that tolerates having no principal reads only ABSENCE (`undefined`, and the empty string) as none and refuses a value that was provided and is no name (the task's principal, below) — and no face reads a blank string as a name. The target of `addPersistedRule`, `removePersistedRule` and `effectivePermissionRules` is read by the owner reading, whose principal arm is the same judge. Three readings, six spellings of the test, become one function.
|
|
193
|
+
- **Behavior narrowing, the shape of a name (open to veto).** (1) **What changed:** a string holding a control character (U+0000–U+001F, U+007F–U+009F), a line or paragraph separator (U+2028, U+2029) or a Unicode format character other than the two joiners (the zero-width space `"\u200b"`, the bidirectional controls such as `"\u202e"`, the byte order mark `"\ufeff"`) anywhere in it — `"ali\u0000ce"`, `"ali\u0007ce"` among them — is no name, and neither is a string with no visible content (white space and format characters taken away leaves nothing: a name made only of joiners, zero-width spaces or byte order marks is blank); refused, not stripped or normalized, at every entry the blank refusal above uses and with the same sentence family (`session owner must be a principal name (…)`, `failed (committed: no)`, `adoption_bad_request`, `corrupt_session` for a stored cell); before, each of them was a name, a bucket and an owner of its own, and `readRuleOwner` answered an owner for it. The joiners U+200C and U+200D are allowed, as the names of people need them between letters (Persian, Arabic and several Indic scripts: `"می\u200cخواهم"`, `"a\u200db"` pass; `"\u200c\u200d"` alone does not); the judge does not test where a joiner stands, so a joiner at the start or the end of a name with other visible content is a name too, and joiners are kept as written. (2) **Who is hurt:** a host whose principals carry one of the refused characters — an invisible one pasted from a directory or a ticket — and a deployment already holding a session row or a rule bucket named that way (the row reads as `corrupt_session`; the bucket is no longer reached). (3) **How to migrate:** pass the principal as the identity provider spells it without those characters; repair a stored cell as for a blank one.
|
|
194
|
+
- **Behavior narrowing, session owners (open to veto).** (1) **What changed:** a blank string (`""`, `" "`, a tab) is no owner: a repo's `create` / `fork` / `importEntries`, `SessionStore.register` (first write), `SessionStore.fork`, `SessionListQuery.owner` and `CheckpointDeleteGuard.owner` refuse it with a `TypeError` / `config.invalid_argument`, before any row is laid down; and a row already holding a blank owner cell is read as `corrupt_session` (the file listing skips it with a disclosure, `open` and `headOf` refuse it) — it names nobody, and reading it as anonymous would admit every principal. (2) **Who is hurt:** a host that registers or forks a session under a principal that can be blank (a missing variable spelled `""`), and a deployment whose store already holds a row created that way — a fork made for a task whose principal was `""` was one. (3) **How to migrate:** register the real name, or `null` for an anonymous session; repair an existing row by writing its header's `owner` cell to a real name or `null` (file: the first `meta` line; a SQL store: the owner column). A fork made for a task whose principal is no name now records no named owner, so it takes the source row's — the same as `null`.
|
|
195
|
+
- **Behavior narrowing, adoption and sync (open to veto).** (1) **What changed:** `syncPermissionRules`, `adoptFilePermissionRuleStore` and `adoptLocalDataRoot` refuse a whitespace-only principal / target as they refused `""` — before any transport call or write; the rule-directory marker reader and the data-root marker reader (which accepted `""` as a target) read a marker whose target is no name as corrupt. (2) **Who is hurt:** a host that synced or adopted toward a whitespace-only principal, and a root already adopted to one (its marker now reads as corrupt and refuses the boot gate). (3) **How to migrate:** pass the verified principal; repair such a marker by hand to the real name.
|
|
196
|
+
- **Behavior narrowing, the task's principal (open to veto).** (1) **What changed:** `TaskSpec.principal` is ABSENCE (omitted, or the empty string, as the engine has always read it) or a principal name; a principal that was PROVIDED and is no name (white space alone, a string holding an invisible or control character, a non-string) is refused at prepare with `config.invalid_argument` before any bucket is read — on a resume, in the re-supplied config too — and `ownerStoreOf`, the file provider's `forPrincipal` and `resolveOwnerStore` refuse it the same way; before, a whitespace-only principal was a bucket of its own and a non-string reached the zero-rule floor by accident. (2) **Who is hurt:** a host that forwards an unvalidated principal (a trailing newline from a file, white space from a form, a value with a pasted zero-width space) — its task now fails at prepare instead of running with no rules. (3) **How to migrate:** pass the verified principal as the identity provider spells it, or omit the key.
|
|
197
|
+
- **Security note: only absence is no principal.** On a deployment that declared local-owner rules (`RunnerDeps.localOwnerRules: true`) a task with NO principal reads the machine owner's standing approvals — that is the declaration. A principal that was provided and is no name must therefore never be read as "no principal": were it, a caller who appends one invisible character to the principal a host forwards (`bob` plus U+200B, `bob` plus NUL, U+202E before `bob`, white space) would read the owner's approvals, and the offer door would mint "always" rules into the owner's bucket. The judge therefore reads absence, a name and a bad value apart (`readPrincipal`, `src/core/principal-name.ts`); the task door and every bucket resolver refuse the third; and the pin `h1` runs a real Runner on a local-owner deployment (the forged principals are refused before any bucket is read and run nothing; omitted and `""` are still the owner; `bob` is still his own bucket). Measured with the review leg's probe: 7.55.0 ran none of the forged tasks, an intermediate form of this change ran all of them, this form refuses all of them (`executed=0`, no bucket asked).
|
|
198
|
+
- **Behavior narrowing, the persisted-rule entries (open to veto).** (1) **What changed:** `addPersistedRule` and `removePersistedRule` read their `principal` argument by the owner reading — a bare string is a name (a blank one is no name), anything else must be a plain-record owner (a class instance, a blank principal, a `kind` outside `RULE_OWNER_KINDS` and a missing `kind` are no owner) — and answer `{ status: "failed", committed: "no" }` before any bucket is asked for; `effectivePermissionRules` / `resolveOwnerStore` throw for an `owner` that is no owner (a bare string included) and for a `principal` that was provided and is no name (the empty string is absence, as everywhere: the no-principal bucket, as before). (2) **Who is hurt:** a host that passed a class instance as the owner (its members were read, its prototype ignored), a blank name, or a bogus-kind owner to these entries — its rule landed in, or was read from, a bucket no verified principal selects —. (3) **How to migrate:** pass the verified principal as a string, an owner as a plain `{ kind: "principal", principal }` / `{ kind: "local-owner" }` record, or omit the principal for the floor; a rule row an earlier add wrote into such a bucket stays in the store and is no longer reachable through these entries (the SQL twin below).
|
|
199
|
+
- **Contract kits.** `sessionStoreContract`'s first-`register` case now also refuses `""` and a blank string, and `sessionRepoContract` gains one case (a blank owner on `create` and `fork` is refused and leaves no row). A store that implements the interface itself (a SQL store) runs red on them until it reads its owner through the judge.
|
|
200
|
+
- **Not changed.** The consent entries (`resolveCallerOwner`) keep 7.55.0's behavior for every string: a principal that was provided beside an owner is STATED — it contradicts a different owner, and a provided value that is no name (`bob` plus a newline or an invisible character) contradicts too, never "not stated"; with no owner it is refused; only the empty string and an omitted principal are absence. The record reader still asks whether a stored approval record's `principal` field is PRESENT with `!== ""` and then judges it with the name reading (a stored blank owns nothing). The session-rule stores key a row by `principal ?? null`, so the empty string keys a row of its own there, not the anonymous row — as before this version (a follow-up). The engine-wide `principal || undefined` reads on a resume's identity continuity compare strings and are not name judgments (the task door above admits only absence or a name). `syncPermissionRules`' own dispatch on a typed `owner` is not changed here (an owner whose `kind` it does not know still takes the principal path there when its `principal` agrees with the round's).
|
|
201
|
+
- **Consumers.** *server*: pick up `principalNameOf` (blank AND shape: a name holding a control, format or separator character other than the joiners is refused, not stripped) for the SQL permission-rule store's `forPrincipal` and the session stores' owner writes (`register` / `fork` / create). The SQL `forPrincipal` (its `principal === ""` test, the write side's twin of the file provider's) keeps `undefined` and `""` as the zero-rule floor and must REFUSE a provided value that is no name instead of giving it a bucket (or the floor); a route that forwards a request's principal to a task, to `addPersistedRule`, `removePersistedRule` or `effectivePermissionRules` gets the refusals above for a malformed one. Until the SQL stores read through the judge they keep a blank-string bucket and a blank owner that the file lane now refuses, and fail the stricter kit cases; existing blank-owner rows need the repair above, and a bucket a blank name selected on a SQL store keeps the rule rows an earlier add wrote there (delete them by hand; the core entries no longer reach that bucket). *cli*, *client-core*: no wire key, member or closed set changes; a task whose principal is provided and is no name now fails at prepare with `config.invalid_argument`.
|
|
202
|
+
- **Pins.** `test/backlog2265-principal-name-one-judge.test.ts` (J the judge (blank, the shape half: zero-width space, NUL, BEL, with `kit-alice` and `陈` as controls), its root export and its leaf form; a the rule owner; b the session owner on both bundled repos, through the store, a repo that judges nothing, a stored blank cell on the file lane, the list query and the delete guard, and the Agent tool's fork arm; c both adoption entries and the three marker readers; d sync; e `ownerStoreOf`, the file provider, the rule lane, the offer door and the person-path reader; h the task door on a real Runner (the forged principals refused before any bucket is read; absence still the owner; the resume screen); g the persisted-rule entries: a blank string, a class instance and a bogus-kind owner at `addPersistedRule` / `removePersistedRule` / `effectivePermissionRules`, controls for a name, an owner and the local owner; g4 the consent entries beside an owner; S a source scan that no `principal === ""` / `typeof principal !== "string"` spelling grows back). Re-pointed: `test/backlog1468-s3-list.test.ts` L4a (an `owner: "o"` filter, no longer `owner: ""`).
|
|
203
|
+
|
|
204
|
+
### A caller's tool named `AskUserQuestion` is an ordinary caller tool on the content-question lane and, on the resume path, for a person's edit (see the #2300 resume-path section below): both read whose tool a call is off its face — the engine's own mount of the question definition — never off its name; its card, the org layer's question exemption and a decide's `answer` guards still read the name (#2300; security; behavior change, a widening, open to veto; hard-breaking: one, open to veto; closed sets: none; exports: none; members: none) (@test @server)
|
|
205
|
+
|
|
206
|
+
- **The rule.** One judge answers "is this call the engine's question tool?": one of the engine's own mount sites mounted the tool the call dispatches to (`ToolCallFace.engineMounted`) and its contract is the catalog's question contract (`core.ask_user_question@1`) — the judge the persisted-rule lane's content-routing seat already read. The content-question ask class (origin `content_question`, with its bypass immunity and its classifier, sandbox and allow-rule exclusions), the content-ask route to `resolveContentAsk`, the durable park's sync-first decline, an ancestor's question float and `createDurableQuestionPolicy()` read it; the two name sets, the four name comparisons and the park's card-by-name read they used before are gone. The refusal "AskUserQuestion could not be parked … the question was NOT shown to anyone" is said only for the engine's own question tool, when its park truly failed.
|
|
207
|
+
- **Behavior change, a widening (open to veto).** What changed: a caller's tool named `AskUserQuestion` — a `TaskSpec.tools` entry, or a `createAskUserQuestionTool()` product a host mounts itself (a host's mount is not an engine mount) — takes the ordinary caller lane: with an approver present and the person's rule bucket unreadable it is put to the approver (origin `rule_store_unavailable`) instead of being refused with the parking sentence and a notice that the approver reported nobody reachable (nobody had been asked); with a durable park armed and a live approver it is put to that approver in-stream instead of being parked as a question; an allow rule naming it clears its ask, `bypassPermissions` releases it, auto's classifier judges it and `createDurableQuestionPolicy()` no longer asks about it — as for the same tool named `AskHuman`. The same holds for a caller's tool that wears `AskUserQuestion` as an alias (`{ name: "MyQuestion", aliases: ["AskUserQuestion"] }`, called under either name): #1194 held such a tool as the question under both of its names on these two seats, and now it is an ordinary caller tool on them — `bypassPermissions` releases a policy ask on it and the durable question policy lets it run; the engine's own question tool stays bypass-immune. Who is affected: a deployment that mounts a tool of its own under the reserved name: an ask about it used to end in that refusal (or a question park), and now it is asked about, cleared or refused like any caller tool. Why: the 7.55.0 criteria (⑦ G7.a) state that a caller's same-named tool is asked; the lane read the name while the rule lane's seat read the face — two readings of one fact.
|
|
208
|
+
- **Security — the ancestor's settlement.** An inherited constraint hands an ask on the engine's question tool back unsettled (a "float"), so that the delegated leg's own content lane routes the question; that float now reads the same judge. Without that line, this change would have let a caller's same-named tool in a delegated leg skip its ancestor's mandate. The floated ask, no longer a content question at the leg's gate, would have reached the leg's own approver instead of the ancestor's: measured, the ancestor's refusing approver was not consulted, the leg's allowing approver was, and the tool ran. Now a caller's `AskUserQuestion` under an ancestor is settled by that ancestor, as `AskHuman` is; the engine's own question still floats to the leg's question face.
|
|
209
|
+
- **Hard-breaking (open to veto).** What changed: `runToolGate` treats a call as the engine's question tool only when its request carries a face (`ToolGateInput.toolFaceOf`) with `engineMounted` and the question contract; a bare `AskUserQuestion` request is an ordinary call — its ask goes to `resolveAsk`, not `resolveContentAsk`, inside a declared sandbox a sandbox-local fold ask on it is admitted like any other — and `createDurableQuestionPolicy()` answers `allow` for a bare request. Who is hurt: a host that drives `runToolGate`, or calls the durable question policy, itself — outside a Runner — for the engine's question tool without stamping a face. Inside a Runner every request the gate and its policies judge carries the live roster's face (the resume's pre-claim re-check, which runs before a roster exists, carries the face its parked row recorded, mount site included — the #2300 resume-path section below), so the engine's own question tool reads as before; server's question gates (`createDurableQuestionGate`, the leader leg's `createDurableQuestionPolicy()`) are Runner policy seats judging those requests and see no change for it. How to migrate: pass `toolFaceOf` answering the face of the leg's question tool — `toolCallFaceOf(roster, "AskUserQuestion")` over the leg's `ToolRoster` (the engine's own mount row carries `source: "builtin"`) — or a `ToolCallFace` written with `engineMounted: true` and `contractId: "core.ask_user_question@1"`. Why: a bare name cannot tell the engine's question tool from a caller's tool that took the name, and the content lane, which never asks the permission approver, must not be entered on a guess.
|
|
210
|
+
- **Consumers.** @test — re-test G7.a (7.55.0 criteria ⑦) on this build: the caller's `AskUserQuestion` is asked, as `AskHuman` is. @server — `createDurableQuestionGate`'s live-context leg still reads the name; composing `createDurableQuestionPolicy()`'s answer (an `ask` there means the engine's question tool) keeps the one judge with no new import. @cli — no wiring: the parking-sentence refusal no longer appears for a caller's same-named tool, and its ask comes with the card the shell already renders.
|
|
211
|
+
- **Not changed here.** The org layer's question exemption, the reserved approval card and the resume path's `answer` guards still read the name (a person's edit of the parked call no longer does: the #2300 resume-path section below). A caller's tool named `AskUserQuestion` still renders the question card on its permission ask. When such a tool parks (a durable park armed, no live approver), it is resumed as a question: an allow without an `answer` is refused with `checkpoint.invalid_outcome` unless the resuming leg has a live question face — fail-closed. Moving these reads to the same judge needs the parked row to record whose tool it was; the pending action's identity group carries no mount site. The deny notice for the engine's own question tool, when no question face is wired or a forced durable gate's park failed, still says the approver reported nobody reachable: the content-ask outcome does not record whether a question face was consulted.
|
|
212
|
+
|
|
213
|
+
### The read table says what the programs do: valgrind, setpriv, chroot and prlimit read by their options, `tar -C` an ordered base, a long name's prefix read per grammar, a `git -c` setting git runs later a code body, a name too long for the filesystem an absence; an unreadable-text card offers a way out only where it is true, and the `maybe` tier's ask says what it found (#2141, #1667, #1833, #1847, #922, #2142; behavior widenings and narrowings, each open to veto; model-visible text; hard-breaking: none; closed sets: `FileErrorCode` 14 → 15, +`name_too_long` (registered in `docs/CLOSED-SETS.md`); exports: none by name (`FileErrorCode` gains a member); members: none) (@server @cli @client-core @test)
|
|
214
|
+
|
|
215
|
+
- **valgrind, setpriv, chroot, prlimit (#2141; widening, open to veto).** What changed: the four launcher rows declare the options their real binaries were run with (valgrind 3.19's core and Memcheck options and each tool's output file, values only after `=`, `--` stepped, a long name only whole; util-linux setpriv and prlimit 2.38; GNU coreutils 9.1 and macOS chroot), so `valgrind --version`, `valgrind --leak-check=full ./a.out`, `setpriv --reuid=65534 ls` and `prlimit --nofile=1024 ls` read the program behind the options instead of refusing the program position — in bypass they run, where they were `unverifiable_shell_text` and denied (TB: 36 refusals over 26 tasks). Who is hurt: a deployment that relied on these launchers' options being refused; an undeclared option (`valgrind --log-socket=…`, setpriv's newer `--landlock-*`) still refuses, and the files valgrind writes (`--log-file=`, `--xml-file=`, a tool's out file) are judged as its write targets — a name carrying valgrind's own `%q{NAME}` environment substitution is no place the text settles, and is asked. A permission rule reads through the same options, because the rule reader's wrapper table is the projection of these rows: `Bash(rm:*)` denies `valgrind --leak-check=full rm -rf x`, `setpriv --reuid=65534 rm -rf x`, `prlimit --nofile=1024 rm -rf x` and `chroot --userspec=1:1 / rm -rf x` as the rule (they asked before as calls that "could not be read against the permission rule"), and a call through them that runs another program is no longer that ask under every Bash content rule. The rule reader also reads a wrapper's options only before the operands it owes, as the program-position reader does (getopt's "+" in GNU chroot / flock / timeout, POSIX getopt in BSD chroot): `chroot / -u root rm -rf x` runs `-u` and is read so (with chroot's options declared it would have read `rm`), and `timeout 5 -s KILL rm -rf x` / `flock FILE -n rm -rf x` read `-s` / `-n` as the program where they read `rm` — the read station refuses all three as an unreadable program position, so no call that ran before runs now. How to migrate: nothing; a deny or ask rule on the program applies through these launchers (KL-2141-a lists the launcher rows still without a grammar).
|
|
216
|
+
- **The way out on an unreadable-text card (#2141; model-visible text).** What changed: `Unverifiable shell text (fail-closed unless cleared): …` keeps "write the text into the command itself (a `-c` string, a here-document or a here-string)" only on a text built when the command runs, where writing it out is read; a text the lexer could not read says it was not read and offers nothing (written into a `-c` string it is the same text), and a segment whose program the walk could not name carries the program-position reader's own sentence (`"valgrind" is given the option "--log-socket=…" where the program it runs would be — which program runs is not readable without that word's option table — not auto-allowed`), the one sentence the read station gives the same segment; after a launcher's bare `--`, a word spelled like an option is named for what it is (`"env --" ends the options, and "-i" after it is a name spelled like an option where the program it runs would be — which program runs is not read from such a word — not auto-allowed`), where both faces called it the launcher's option before. Who is hurt: a reader that matched the old sentence of the last two; the model is no longer told to do what is refused again. How to migrate: match the class (`unverifiable_shell_text`), not the sentence.
|
|
217
|
+
- **The `maybe` tier's ask (#1667; model-visible and person-visible text for every Bash ask the shape check does not clear, not only launchers).** What changed: an ask raised because a `maybe`-tier tool's probe did not show the call reversible says `tool "<name>" was not shown to be reversible — explicit approval required`; `performs an irreversible action` stays the `always` tier's alone (a real irreversibility mark). The built-in shell probe's `reason` — the card's `probeReason`, never the message the auto-mode classifier is shown — is the compound classifier's own sentence, verbatim (`command "nice" is not in the read-only allowlist`). A launcher in front of a read-only program still asks in default mode: CC 2.1.296 asks too (its read-only check strips only `command` / `builtin` / `noglob`; KL-1667-a). Who is hurt: a surface or a test that matched `performs an irreversible action` on a Bash ask — this tree re-pins `backlog239-probe-reason`, `backlog482-compound-readonly-e2e`, `backlog696-read-boundary-any-doctrine`, `backlog7340rg-headless-denial-sentence`, `inherited-gate-rb40-review-gaps`, `backlog768-bash-canonical-recheck` (a shape-tier card now carries this account: the cells read its class), `poll-loop-classifier` and `backlog502-out-of-root-read-mandate` (the probe's not-reversible verdict now carries `reason`) and the shape-probe cells of `backlog2141-read-table`. How to migrate: read the class, not the sentence; @cli: render `probeReason` beside the message — it is now present on every such Bash card; @test: fixtures that hold the old sentence take the new one.
|
|
218
|
+
- **`tar -C DIR` (#1833; widening and narrowing, open to veto).** What changed: `-C DIR` / `--directory DIR` is the ordered base of the operands written after it (`OptionSpec.rebases`, bsdtar 3.5.3 and GNU tar 1.34 measured): `tar -C docs -cf out.tar file` reads `docs/file` alone; a second `-C` resolves against the first (one naming its own place starts over); the operands before it, the archive, a `-T` list and an `-X` file are read where the command stands; DIR itself is a place it names, no root — except where an operand after it is one the text does not settle (a pattern the shell expands where the command stands, a rewritten value): DIR is then the root that operand is judged by, as before. Who is hurt: a deployment whose rows under DIR stopped a `tar -C DIR <other member>` (it no longer does: tar opens only the member it is given) — and, the other way, a member now judged where tar opens it. How to migrate: nothing; KL-1826-a is closed.
|
|
219
|
+
- **A long name's prefix (#1847; narrowing).** What changed: the grammars whose every measured binary refuses `--versio` (perl, ruby, python, node, php, jq, sqlite, bun, git, docker / podman, GNU find, valgrind; macOS and Debian) read a prefix of a declared long name as a name they do not declare, with the existing doubt; where one release or implementation takes a prefix (curl 7.88 against curl 8, GNU bc / dc, gawk) the prefix stays the option — the reading that misses no value. The binary gate carries one cell per grammar (`--versio` against `--version`): a grammar that says "none" fails where its binary takes one. Who is hurt: nobody running a real binary — those programs refuse the prefix. How to migrate: nothing; KL-1836-a is closed.
|
|
220
|
+
- **`git -c KEY=VALUE` (#922; narrowing).** What changed: a setting git hands to a shell — or runs as a program — later (git-config(1), gitcredentials(7), git-send-email(1) and gitattributes(5): `core.pager`, `core.editor`, `sequence.editor`, `pager.<cmd>` unless boolean, an `alias.*` led by `!`, every credential helper in its three forms (`!` snippet, absolute path, `git credential-<value>`), the diff / merge / filter drivers, `difftool` / `mergetool` commands, `core.sshCommand`, `gpg.program`, send-email's `toCmd` / `ccCmd` / `headerCmd` / `sendmailCmd` / `smtpServer`, …; `OptionSpec.runsLater`) is a code body of git's — given by `-c KEY=VALUE`, or by `--config-env KEY=VAR`, whose value the environment holds (asked: the text is not in the command): the segment re-enters the shell as a `trap` action does, its text a nested plan whose deny hits refuse. Under a deny row on `deny`, the read station refuses `git -c 'core.pager=cat deny' log`, `git -c 'core.editor=cat deny' commit` and `git -c 'alias.x=!cat deny' x` (it vouched them before); without one, it asks about such a setting as a read it cannot establish (`probe`), which a whole-tool allow (`allowedTools: ["Bash"]`) no longer clears — `git -c core.pager=cat log` was allowed under that allow and asks now. `git -c core.autocrlf=false log` keeps the station's vouch; in default mode it asks as it did, because any global `-c`, `--config-env` or `--exec-path` makes a git line not read-only (the reference's rule; unchanged here). Who is hurt: a session that allows Bash wholesale and sets one of these keys for a harmless text (`git -c core.pager=cat log`) — it asks now. How to migrate: set the pager by `--no-pager`, or answer the ask. The same keys given through `GIT_CONFIG_*` / `GIT_PAGER` environment words are not read (KL-922-a). An empty value names no command (git launches no pager for `core.pager=`, `credential.helper=` resets the helper list): `git -c core.pager= log` and `git -c credential.helper= push` stay what they were.
|
|
221
|
+
- **`fc` running a stored history line (#922, C-R727 note 12; narrowing, open to veto).** What changed: bare `fc`, `fc -s [pat=rep] [first]`, `fc -e EDITOR` and `fc -e -` run a line of the shell's history list — which a non-interactive shell keeps too (`history -s 'echo X'; fc -s` prints X under bash 5.3) — so the line they run is one no word of the command spells, read as a text built when the command runs, the same reading as `eval "$X"`: `history -s 'rm -rf ~'; fc -s` is `unverifiable_shell_text` (bypass-proof) where it ran under bypass with no card. Each form is read as bash 5.3 runs it: `-s` and `-e -` RUN the line and win over `-l` (`fc -l -s`, `fc -ls`, `fc -s -l`, `fc -le -`, `fc -e - -l`, `fc -lr -s`); `-e EDITOR` edits then runs without `-l`, and beside `-l` only lists (`fc -e vi -l`). The builtin's options end at `--` and at its first operand, which bash takes as the leading text of a history entry: `fc -l -- -s` lists from the entry that begins `-s`, `fc -- -l` runs the entry that begins `-l` — with no such entry bash reports `no command found` and runs nothing (bash 5.3.15; 3.2 says `history specification out of range`), so `fc -- -l` is read as running a stored line (refused / asked) even where history holds no such entry, the stricter side (the permission-rule reader, which reads every word for a command-running option without fc's precedence, reads `fc -l -- -s` and `fc -e vi -l` under a Bash content rule as running one — stricter, that reader only). That reader now finds a short command-running option in a cluster or with its value attached (`fc -ls`, `fc -e-`, `compgen -CV …`, `complete -C=V …`, `mapfile -C'cmd' …`), so such a call is unreadable against a Bash content rule as its spaced spelling already was (narrowing). `fc -l` alone lists: `history -s` (storing), `fc -l` / `fc -ln` (listing without `-s` / `-e`) and `history -p` (expanding to standard output) run nothing and are unchanged in every mode, a Bash content rule included (the permission-rule reader reads `fc` as itself; its `-e` names the editor it runs). Who is hurt: a command that re-runs history through `fc` — it is refused in bypass and asks in default mode. How to migrate: write the command itself instead of re-running it from history.
|
|
222
|
+
- **`name_too_long` (#2142; widening, open to veto; additive closed-set member).** What changed: `FileErrorCode` gains `name_too_long` — a name the filesystem cannot hold (POSIX `ENAMETOOLONG` for a path component past `NAME_MAX`) — and NodeExecutionEnv answers it where it answered `unknown` (a path past `PATH_MAX` whose components are short stays `unknown`: such a file may exist, reached from a directory below it); the shell read station reads it as an absence, as `not_found`. The TB cause: the station hands an inline program text (`python3 -c "…"`) to the deny judge as a spelling, and placing it asked the filesystem about a first component of ≥ 256 bytes; the `unknown` answer was read as a lookup that did not answer, so the command was the environment's silence (`unverifiable_shell_text`, mandated, bypass-immune) — denied in bypass (TB: 15 of 102 multi-line `python3 -c`; the predicate "first segment ≥ 256 bytes, no unresolved `$`" separates them 15 / 0, test's readings). Who is hurt: an `ExecutionEnv` consumer that switches exhaustively on `FileErrorCode`; @server: `RemoteExecutionEnv` (`remote-env.ts`) maps ENAMETOOLONG to `name_too_long` — until it does, a remote env answers `unknown` and keeps today's bypass denial (KL-2142-a); @client-core: the error-code table gains the word. How to migrate: add the member to a table keyed by `FileErrorCode`.
|
|
223
|
+
- **One reading each.** The decline sentence of a segment whose program the walk could not name has one home (`declinedProgramSentence`, the read station and the removal guard); the station's lookups read an absence through one predicate (`isAbsence`: `not_found`, `not_directory`, `name_too_long`).
|
|
224
|
+
|
|
225
|
+
### A person's edit of a parked call runs as written unless the resumed leg's roster says the call is the engine's own question tool — never the call's name — and the pre-claim edit re-check judges every call's edit under the face its parked row records, the tool's mount site now among the members (#2300 resume path; security; behavior change: a widening for a caller's tool named `AskUserQuestion` and a narrowing for the engine's own question tool, both open to veto; hard-breaking: one — the parked row gains a member and a requirement word, a store-format forward change, open to veto; closed sets: `RequirementWord` 31 → 32 (+`mount_site`, registered in `docs/CLOSED-SETS.md`); exports: none; members: +1 optional `engineMounted` on the parked tool-approval row) (@server @cli @dgx @test)
|
|
226
|
+
|
|
227
|
+
- **Before (measured through the real Runner).** A caller's tool named `AskUserQuestion` (a `TaskSpec.tools` entry; the engine's own question tool not mounted) parked headless with a durable park armed, then resumed `allow` with the person's edit `{ path: "/edited" }` — with an `answer`, or with a live question face on the resume: the model's bytes `{ path: "/model" }` ran and the edit was dropped; the same tool named `AskHuman` ran `/edited`. Two resume reads compared the parked call's name: the resolver (which dropped the edit) and the pre-claim edit re-check (which skipped the frozen ancestors and the resume-edit base policy for an edit on a call of that name).
|
|
228
|
+
- **Now, one judge.** The resolver drops a person's edit only when the face of the parked call on the resumed leg's roster says one of the engine's own mount sites mounted the question definition (`isEngineQuestionFace`, the judge the gate lane's seven stations read); a caller's tool of that name — a `TaskSpec.tools` entry, or a `createAskUserQuestionTool()` product a host mounts itself — runs the person's edit as written, as `AskHuman` does. The pre-claim re-check runs before any roster exists, so it judges an edit under a face rebuilt from the parked row; it no longer skips by name and judges every call's edit — the frozen ancestors, the resume-edit base policy, the rewrite re-judgment — and the row records the mount site the call parked with (below), so the face it judges under says of the mount site what the face the gate judged the call under said. Two name readings retired (`resume.questionArgsShown`, `resume.editRecheckQuestionSkip`); none added.
|
|
229
|
+
- **The parked row records the mount site (the same release; fixes a regression of the line above).** Before: with the name skip gone and no mount site on the row, a face rebuilt from the row was the engine's for no call, so a deployment policy built the way `createDurableQuestionPolicy()`'s documentation says (return its `ask` for the engine's question tool, otherwise refuse) answered the re-check of the engine's own question tool with its "otherwise" arm — measured through the real Runner: the question parked, and a person's `allow` + `answer` + `updatedInput` (even the shown bytes) was refused before the claim with `resume.constraint_rejected` ("this agent may only ask questions"), where the release before resolved the row; with no `updatedInput` both resolved. The row held a capability, a contract and a shape digest, none of which tells the engine's mount of a definition from a host's mount of the same definition (`callerMountSource` reads a host-mounted engine product as `builtin` too), so the identity could not be derived from what the row held. Now: the park writes the roster entry's mount site (the fact a live face's `engineMounted` carries; read off the entry like the identity members beside it, never off the request's face, which the policy judging the call can write) onto `pendingAction.engineMounted`, and `parkedCallFaceOf` puts it back on the face the re-check judges; the same measured shapes resolve, an edit that changes the bytes is still judged (a policy that refuses the changed batch refuses it, the row pending), a caller's tool of any name and a host-mounted engine definition record none. Still true after this: the face rebuilt from a row is not the whole live face — it carries no rule name values and no approval card, which a live face derives from the roster (#2406) — and the resume's approval-object comparison does not compare the mount site (#2405), so a definition with the same identity that a host re-mounts between the park and the resume is judged on its live face by the post-claim belt, not refused before the claim.
|
|
230
|
+
- **Hard-breaking (open to veto) — the parked row gains a member and a requirement word.** What changed: every parked tool-approval row of a tool the engine mounted (every built-in, the question tool among them) carries `pendingAction.engineMounted: true` and the row lists the requirement word `mount_site` (`REQUIREMENT_WORDS` 31 → 32; the row digest holds the member); a row of a caller's or a protocol tool carries neither. Who is hurt: an engine release before this one that reads such a row — a rolling deploy where a worker of this release parks and an older worker resumes, or a rollback with parked approvals (a person who downgrades a single-machine shell, @cli, whose engine derives the durable approval domain on over its local store, and a deployment of @dgx rolled back, meet it the same way) — refuses it before the claim as a newer row (`checkpoint.unsupported_version`, `detail.reason` `version_newer`, `detail.requires` lists `mount_site`; the row stays pending — read from `judgeCheckpointRequirements` and the resume screen, not run on an older binary); a host's own `CheckpointStore` that stores the row member by member (a whitelisting serializer) and drops `engineMounted` — the store contract kit's approval-members cell now round-trips it, and a row that comes back from such a store lists `mount_site` with the member gone, so the resume refuses it before the claim as damaged (`checkpoint.invalid_outcome`, `detail.reason` `requirements_damaged`; the row stays pending and no worker can resume it); and a row parked by an earlier release has no member, so a person's edit on its engine question tool is judged as an ordinary tool's by the pre-claim re-check (the measured refusal above, under such a deployment policy). How to migrate: resume or drain parked approvals before moving a worker back; roll workers forward together; a store implementation carries the member (run the kit); for a row parked by an earlier release, decide the engine question without `updatedInput` — the row is still pending and decidable.
|
|
231
|
+
- **Behavior change, a widening (open to veto).** What changed: a person's edit on a parked caller's tool named `AskUserQuestion` now runs (it was dropped and the model's bytes ran), after the same pre-claim and post-claim re-checks any caller tool's edit meets (the frozen ancestors, the resume-edit base policy, the PreToolUse re-screen). Who is affected: a deployment whose tool wears the reserved name and whose approver edits its parked calls. How to migrate: nothing to do for a person who edits to change what runs; a host that sent `updatedInput` on such a decide to be ignored stops sending it.
|
|
232
|
+
- **Behavior change, a narrowing (open to veto).** What changed: the engine's own question tool resumed with an `updatedInput` that the deployment's resume-edit policy denies is refused before the claim (`resume.constraint_rejected`, the row stays pending); it used to execute the shown questions and ignore the edit. The two ancestor arms of the same re-check (a frozen ancestor that denies the edit; an opaque inherited chain, `resume.constraint_unprojectable`) now reach it the same way — read from the code, not measured. Measured: with a policy refusing the edited batch, before `{ row: "resolved", terminal: "completed", the shown questions answered }`, now `{ row: "pending", thrown: "resume.constraint_rejected" }`; with no edit, with an edit the policy accepts, and with a policy-decided edit the readings are unchanged. Who is hurt: a client that sends `updatedInput` on a question approval's decide (the resolver still ignores it). How to migrate: send the decide without `updatedInput` — the row is still pending and decidable. Why: an edit is judged before the claim under the face its parked row records (the mount site among the members, below), and judging every edit is the reading that cannot widen anything; the resolver, which holds the roster, drops the edit.
|
|
233
|
+
- **Consumers.** *server*: do not forward `updatedInput` on a content-ask decide; an edit on a caller-named `AskUserQuestion` now runs and can be refused pre-claim like any caller tool's. *test*: re-run the edit shapes of the 7.55.0 criteria ⑦ (G7.a) on a caller's `AskUserQuestion` with `answer` and with a live question face — the person's bytes run, as for `AskHuman`.
|
|
234
|
+
- **Not changed here.** A decide's `answer` guards still read the name (`pendingContentAskCallId`): a caller's `AskUserQuestion` is still resumed as a question — an `answer` on it is accepted where `AskHuman` refuses it, and an allow with neither an `answer` nor a live question face is refused with `checkpoint.invalid_outcome` — as are the reserved approval card and the org layer's question exemption; the row now records whose tool it was, and moving those readers onto it is the ticket that holds them.
|
|
235
|
+
|
|
236
|
+
### What a tool's `execute` returns is received once, at the tool door: `defineTool` copies the content blocks and `details` (and a thrown error's `details`) into the engine's own data, so no later reader runs the tool's code again (#960 slice one; behavior change, open to veto; hard-breaking: five, open to veto; closed sets: none; exports: none index-exported — internal readers only: `appendOwn` / `dataMember` in the receipt module, `notStartedResult` / `carryNotStartedReceipt` / `isNotStartedDelegationResult` in `src/core/delegation-not-started.ts`, the last moved from `src/agents/subagent.ts`; members: none) (@server @cli @client-core)
|
|
237
|
+
|
|
238
|
+
- **The rule — the door reads what the wire reads.** The content array and `details` (and a thrown error's `details`) are received once by the engine's one receipt of a caller's object, read with the members the JSON wire carries, recursively, and running none of the tool's serializers (`ReceiptMode.wire`, `others: "copy"`): every object keeps its own enumerable members (a getter called once; a non-enumerable member is never read; the prototype is not kept) and a list its own indices; an object that would be serialized through a `toJSON` — found off property descriptors up its prototype chain, never run — is absent, its members with it, except the platform's own `Date.prototype.toJSON` (by identity): a `Date` arrives as its ISO string (`null` for an invalid date), read off its own time value, never through a method the tool could override. What JSON carries no value for (a function, a symbol, a bigint), a cycle, a Proxy and a getter that throws are left out. The door and `JSON.stringify` differ on three things: a `NaN` or an `Infinity` stays a number in the copy and becomes `null` only where the wire writes it; a boxed primitive (`new String("hi")`, `new Number(5)`, `new Boolean(false)`) is read as an object by its own enumerable members (`{ "0": "h", "1": "i" }`, `{}`, `{}`) where the wire writes the primitive, and a Proxy as a whole value is absent where the wire would write its target (run through its traps, which the door does not do). There is no structured clone at the door; the receipt's leaf law (`ReceiptMode.leaves`) serves the mods observer copy alone. So nothing of the tool's runs after its getters are read once — no `toJSON`, no `toString` — and nothing a tool kept off the wire reaches the copy. Nor does anything a prototype holds: each member is asked own before it is read (a hole is absent, never looked up), and every copy is filled on no prototype and handed its ordinary one when full, so an index accessor planted on `Array.prototype`, a `type` / `text` accessor on `Object.prototype` or a `Number.prototype.toJSON` is never run by the door. A `text` / `image` / `document` block is then rebuilt from its own fields. The card reducer and the `tool_end` error-arm lifts read that data — a list by its own indices — instead of guarding each read.
|
|
239
|
+
- **Behavior change (open to veto).** What changed: a block whose `type` answers `text` and then `image` reaches the model and `tool_end.output` as its text (it was refused as undecodable media); a `text` getter that raises on its second read no longer turns a successful call into `isError`; a `details` getter that raises on its second read no longer drops the `tool_end.structured` card, and a `details.type` that answers differently per read is one card everywhere; a `details` member whose getter throws at its first read is left out and the call succeeds with no notice (before, the whole call came back `isError` with the getter's message — KL-960-a names the absence, this is its other side). Who is affected: a tool whose returned values change between reads — every reader now sees the first answer.
|
|
240
|
+
- **Hard-breaking (open to veto) — no reference.** What changed: a result's content blocks, its `details` and a thrown error's `details` are the door's copies in their wire form, never the objects the tool returned; a contract block carries only its contract fields; a class-instance `details` arrives as a plain record of its own enumerable members (an `Error` as its enumerable members — `message` and `stack` are not); a function in `details` is left out. Who is hurt: a host or test that compares them by identity with the tool's objects, mutates those objects after returning them, reads a non-contract field off a content block, or relies on a `details` prototype accessor or a function in `details`; and a tool that returns a Proxy as its `details` (the card it drew, `tool_end.structured` and the persisted row go with it: before, the Proxy's target was read through its traps). How to migrate: compare by value; return a plain record, not a Proxy; carry extra data in `details` as own enumerable members of plain objects. Why: a reference lets the tool's code run again in every later reader (the defect this closes).
|
|
241
|
+
- **Hard-breaking (open to veto) — a value carrying a `toJSON` other than `Date`'s, a user's or the platform's, is absent; a `Date` is its ISO string in process too.** What changed: an object in `details` or content (or a thrown error's `details`) that would be serialized through a `toJSON` other than the platform's `Date` one — a class with a `toJSON` method, a record with a `toJSON` function or accessor, a `Date` whose `toJSON` was replaced — is absent at the door, members included — the members its answer carried on the wire before included (a `toJSON` returning `this` no longer carries the record's public members), and a `Buffer` (its `toJSON` is Node's) and a `URL` (its `toJSON` is the platform's: the href the wire wrote is gone; a `URLSearchParams` has none and arrives as `{}`) among them; its `toJSON` never runs. A `Date` arrives as its ISO string, as the wire always carried it (a `Date` made in another realm carries that realm's `toJSON`, not this platform's, and is absent); an in-process consumer that read a `Date` object off `details` reads a string. Who is hurt: a tool that relies on a `toJSON` to shape its `details`, or puts a `URL` there (the shaped value no longer reaches `tool_end.structured` or the transcript); a host that called `Date` methods on a `details` member in process. How to migrate: put plain data in `details` (the shape the `toJSON` would have answered); parse the ISO string (`new Date(s)`). Why: the door runs none of the tool's code (a thrown value's own code never runs, #1553) and keeps off the wire whatever a `toJSON` would have kept off (C-R746 note 2).
|
|
242
|
+
- **Hard-breaking (open to veto) — a `Map` or a typed array in `details` is what the wire carried.** What changed: a `Map` (or `Set`) arrives as `{}` plus its own enumerable members and a typed array as its index record (`{ "0": 7 }`, every number itself: a `NaN` or an `Infinity` element stays a number in process and is written `null` where the wire writes it, as in any list) — exactly what `tool_end.structured` and the persisted row always carried (a `Buffer` carries a `toJSON` of Node's and is absent, above). **The door's cost** (7.55.0 handed `details` on by reference, so all of it is new): linear in the size of `details`, with no size bound. A dense list or a record is read live, in order, as `JSON.stringify` reads it — its member names fixed when it is met, each member asked own just before it is read and walked to its end before the next, a primitive assigned where it is read, no property descriptor and no definition per member (a list that proves sparse is read off its own members instead — past 64 holes, one whose holes are more than half the indices read so far; KL-960-b). So a getter that changes an earlier member's insides or removes a later member is read as the wire reads it (an earlier state of this release read every member off descriptors captured before any getter ran, and copied a member a getter had already removed); a typed array's elements are copied natively into an engine-owned `Float64Array` and assigned off that copy. Measured on one machine at load 4–8, the door against a native `JSON.stringify` of the same value: a list of 1M numbers 17 ms vs 27 ms, 1 000 × 1 536 embedding numbers 37 vs 115 ms, a 1M `Float32Array` 44 vs 131 ms, a 3M `Uint8Array` 175 vs 238 ms — at or below the JSON write; many small records cost more per member, 100 000 records of two members 79 vs 8 ms (≈ 10×), 1 000 records of 30 members 3 vs 1 ms; the factor grows with machine load. Through a whole Runner call a typed array's index record also costs memory the by-reference hand-off of 7.55.0 did not: a 1M `Float32Array` added about 350–390 MB of heap and 0.8–1.4 s on one machine. A bigint typed array has no JSON form and is `{}`; an in-process consumer that read the `Map` or the typed array out of `details` reads the record. Who is hurt: an in-process reducer that read a `Map` / typed array out of `details`. How to migrate: put the data in plain records and lists. Why: a clone of such an object at the door would be a second form beside the wire's (and a class-instance clone carried members the wire never read — C-R746 note 1).
|
|
243
|
+
- **Hard-breaking (open to veto) — a block is its own enumerable fields.** What changed: a content element whose `type` / `text` are a class's prototype accessors is no block: it is received as the wire reads it (its own enumerable members, here `{}`), and the model gets the `[invalid content block normalized to text]` salvage text where it read the accessors through the prototype before (`tool_end.output` already showed `{}`). A class whose fields are own members is a block like any record. Who is hurt: a tool that builds content blocks from a class with getter fields. How to migrate: return `{ type, text }` records (or own fields). Why: one reading for the model and the wire, the one the `TextContent` / `ImageContent` / `DocumentContent` types describe (own data fields).
|
|
244
|
+
- **Members a tool kept off the wire stay off (C-R746).** A non-enumerable `details` member and a list's non-index member — absent on the wire before this change — and every member of a `toJSON` carrier (at the top, inside a class instance, or reached through another carrier; the answer that rode the wire before no longer rides) are absent at the door, on `tool_end.structured` and on the transcript row; no `toJSON` of the tool's is called on content, `details` or a thrown error's `details`.
|
|
245
|
+
- **Hard-breaking (open to veto) — unreadable content is one answer.** What changed: a `content` that cannot be read (a revoked proxy) returns the structured refusal "content must be a string or an array of content blocks" (`isError`) instead of the thrown TypeError's text; a content element whose `type` cannot be read is received as data instead of failing the call; an absent element (a hole, `undefined`, an index that throws) is left out instead of failing the call. Who is hurt: a test pinning the old failure. How to migrate: pin the refusal or the result.
|
|
246
|
+
- **The delegation tool's "no child ran" is the engine's receipt, and it crosses the door with the object (unchanged from 7.55.0; a narrowing of an earlier state of this release, open to veto).** What it is: the `Agent` tool's refusals before any child ran (an unknown type, an empty prompt, worktree isolation unavailable, a cap, a revive it cannot make) are minted in one place, which records the refusal's `details` object in a table only engine code can write; every copy the engine makes of a recorded object is recorded too — the tool door's copy of what a tool returned, and the `details` the large-result offload rebuilds around a spilled string — so the receipt travels with the object, whatever name the tool is mounted under (two doors in a row keep it), and the memory content-origin wrap exempts a delegation result from the exposure mark only when its `details` carries that receipt. A result that merely says no child ran is a child's report: a caller's delegation adapter (a tool declaring `agentToolFaces` / `agentListing`) returning `details.childRan: false` — with or without an `attestation`, the refusal's own words included — is judged delivered and marks as any report does. Relative to an earlier state of this release, which carried the fact as the data member `details.childRan: false` and trusted it from any tool that declared a delegation face (a tool could skip the mark by saying so): that member is gone and the statement buys nothing. Relative to 7.55.0 the refusals' `details` read as they did (`{ error, code }`) and only an engine-minted refusal is exempt; one widening (open to veto): a refusal whose `details` carries a string past the offload threshold (a fork whose session store threw a long error) lost its exemption on 7.55.0 when the offload rebuilt `details`, and marked the session for a call in which no child ran — it is exempt now. Who is affected: against 7.55.0, a deployment whose delegation refusals carry such strings (its session is no longer marked by them); against the earlier state, a caller's delegation tool that wrote `childRan: false` (it now marks) and a consumer that read the member (it is absent). Not closed here: a host's own code that calls the delegation tool, keeps the refusal's `details` object and returns it under different content — or writes the child's text into that very object — is exempt, as in 7.55.0 (the receipt names the object, not the content beside it or what is written into it).
|
|
247
|
+
- **Fixed — the one receipt never asks `Array.prototype` (every mode).** Before: the receipt's own bookkeeping (its stack of containers, its path of names, the copies it filled) pushed onto and assigned into ordinary lists and records, so an index accessor someone planted on `Array.prototype` (or a setter on `Object.prototype`) ran inside every receipt — the deps `new Runner` reads, a declaration, the org rules, a mods payload or answer — and the receipt then threw a `TypeError` instead of answering (v7.55.0 measured: the mods payload mode and a configuration mode both threw, the planted accessor called twice). The same held for an inherited `get` / `set` on `Object.prototype`: the receipt's own property descriptors inherited it, so a member's definition read it and failed as an invalid descriptor. Now its lists have no prototype, every copy is filled on none and every descriptor it writes has none, so the planted code is never called and the receipt answers as it would without it. Who is affected: nobody whose process leaves the built-in prototypes alone.
|
|
248
|
+
- **Not closed here.** Content a PostToolUse hook rewrites is not rebuilt by the door's block law, so the content-side guarded readers stay; a `details` member the door cannot read is absent with no notice (KL-960-a); a built-in function or the array iterator replaced in the process after the engine loaded (`Array.prototype.slice`, `Object.keys`, `Array.prototype[Symbol.iterator]`) is still called by the door (KL-960-d).
|
|
249
|
+
|
|
250
|
+
### The release post lint's three ledger legs read the marker a ship writes, not the version string: CAPABILITY wants a row beginning `| <post> | core <version>`, PLANS a `SHIPPED(` row of the release that cites the post, FEATS an `anchor-core` that says `shipped v<version>`; `--comms-root <dir>` names the comms checkout (#2272; release tooling only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
251
|
+
|
|
252
|
+
- **Before.** Each leg passed when its ledger file merely contained the version number, and a release's number is in the ledgers long before it ships. For 7.55.0 the CAPABILITY file was satisfied by the server's own 7.55.0 release section and by core's 7.54.0 row (its sentence `… 7.55.0 起改普通 clone`), the PLANS leg by the window row of 7.55.0 (status still `OPEN(`), the FEATS leg by two anchors that said `已并 7.55.0 stage`. A gate that cannot be red on the day it matters is not a gate.
|
|
253
|
+
- **Now.** (1) CAPABILITY: a line of `audits/CAPABILITY-CONSUMPTION.md` that begins `| <post> | core <version>` (or the post says `CAPABILITY: none`, unchanged). (2) PLANS: a row of the `## core` section whose window cell begins with the version, whose status cell begins `SHIPPED(` and whose line cites `[<post>]`. (3) FEATS: at least one cited feature's `anchor-core:` front-matter field contains `shipped v<version>`; AUDITS is unchanged. The ledgers are read as the comms tools read them: a table row is cut at every pipe that is not escaped (spaced or not), a front-matter field is one `key: value` line, a header counts only with both fences. A version is a whole token (`7.55.0.1`, `7.55.01`, `7.55.0x`, `7.55.0+build.1`, `7.55.0-rc.1` are other numbers). `<post>` is the `id:` in the draft's front matter, so the draft to lint is the claimed `posts/NNNNN-core.md`; a draft without it is refused once, by name. `--comms-root <dir>` names the comms checkout for these legs and the bugs leg (default unchanged: `sema-comms-core`, then `sema-comms`; the rulings leg keeps its own search). Each marker was taken from the rows the real ledgers carry, and replayed on them: on the comms state one commit before the 7.55.0 ship record the old legs read green and the new ones read red on all three; on the state after it, green on all three; the seven CAPABILITY rows since 7.49.1 and twenty PLANS `SHIPPED(` rows read as ships.
|
|
254
|
+
- **Who is hurt.** The publisher, once: the three ship rows are written after the post number is claimed and before the lint runs; a row written earlier no longer satisfies a leg. So is anyone who keeps a `capSectionFile` template for `scripts/post-ship.mjs` in the old `## core 发布:` section shape (next bullet). Nothing at runtime: `src/` is untouched and nothing in the package changes.
|
|
255
|
+
- **`scripts/post-ship.mjs` step ③ writes the row the leg reads (release tooling only; hard-breaking for the template file, open to veto).** What changed: step ③ used to insert a `## core 发布:<version>(…)` section (the registration shape of 7.39.0–7.48.0) before the first such heading; the registrations since 7.49.1 are one `| <post> | core <version>(minor;…):… | <consumers> |` row each, which is what the CAPABILITY leg reads, so two shapes answered one question. Now the `capSectionFile` template (the key keeps its name, so an old manifest does not silently skip the step) is ONE row, its `⟨PLACEHOLDER⟩` tokens are filled, the lint's own predicate `capabilityRegistrationProblems` must accept it for this version and ship post, and it is inserted after the numbered-row run that holds the last core registration row. Who is hurt: whoever keeps a template in the section shape (or a row keyed by another post or version) — the run stops with `capSectionFile must hold ONE registration row` / `is not the registration the ship lint reads` before anything is written, dry run included. How to migrate: make the template one line `| ⟨SHIP⟩ | core ⟨VERSION⟩(minor;提货单 [⟨PICKUP⟩] · 判据帖 [⟨CRIT⟩];tag v⟨VERSION⟩ `⟨R⟩`):<capabilities> | <consumers> |`; `insertCapSection` is gone, `insertCapRow` and `requireCapRow` are the two new exports (tooling only).
|
|
256
|
+
- **Pins.** `test/ship-post-lint-ship-time-legs.test.ts`: per leg, three readings that must pass and three or more that must not, quoted from the real rows (CAPABILITY `CAP-P1`–`P3` / `N1`–`N3`, PLANS `PLANS-P1`–`P4` / `N1`–`N6`, FEATS `FEATS-P1`–`P3` / `N1`–`N3`); the legs against a throwaway comms checkout (opt-out, a draft without an id, a missing ledger file); the CLI's `--comms-root`; `CAP-N4` is the old section shape alone, refused. `test/post-ship-script.test.ts`: the row goes at the end of the numbered run holding the last core row (two runs, a text-first row, no anchor), the template is refused unless it is one row the lint accepts (section, several lines, another post, another version, another repository), and a `run()` on a temp comms root leaves ledgers whose CAPABILITY and PLANS legs both pass. Per-fixture mutation tables (34 for the lint, 10 for post-ship, none survived) are in the car's receipt.
|
|
257
|
+
|
|
258
|
+
### A named agent runs in the background, where its name reaches it: `Agent({ name })` takes the background lane whatever `run_in_background` says, and the synchronous report no longer claims `resumable:` (#1697; behavior change, open to veto; hard-breaking: one, open to veto; model-visible text; closed sets: none; exports: none; members: none) (@cli @client-core @test @server)
|
|
259
|
+
|
|
260
|
+
- **The rule.** A name is an address. On a mount with a background lane, a call that names its agent takes that lane — a third OR term beside `run_in_background` and a definition's `background: true`, the shape of the reference's teammate branch, which runs a named spawn in the background and never reads `run_in_background` (CC 2.1.296). The background lane is the one lane that keeps an agent addressable after it settles: by name, SendMessage delivers to it while it runs and continues it after it settled (retained session or durable record). A named synchronous child was a name that addressed nothing — its report said `resumable: true`, and the next SendMessage by that name was refused "a completed foreground agent is not resumable" (test [10335] E3).
|
|
261
|
+
- **The `resumable:` word means one thing.** On every model-facing face it answers "will the next SendMessage continue this agent?". A synchronous child has no task row for SendMessage to resolve, so the synchronous report carries no `resumable:` line; a retained synchronous child is continued by the deployment's resume handle (`SubagentSteerHandle.resume`), which the report's footer states and the handle carries. The background lane's completion frame keeps its `resumable` field (unchanged).
|
|
262
|
+
- **Hard-breaking (open to veto).** What changed: on a mount with a background lane, `Agent({ name, run_in_background: false })` launches in the background — its receipt is the async card and its result arrives as a completion notification (or through `TaskOutput`), not inline; a named child refused at its own prepare is reported as a failed background run, not as the call's refusal. Who is hurt: a model, host or test that named an agent and expected its result inline; a reader of the synchronous report's `resumable: true` line (none found in the server, cli or client-core trees — they read the notification frame's structured `resumable`). How to migrate: to have the result inline, do not name the agent (an unnamed `run_in_background: false` run is unchanged); to keep it addressable, name it and wait for its notification. A deployment continuing a synchronous child keeps using the steer handle.
|
|
263
|
+
- **Model-visible text.** The `name` parameter reads "Where the SendMessage tool is mounted, the name makes the agent addressable via SendMessage({to: name}); either way a named agent always runs in the background (omit name to run one synchronously)" on a mount with a background lane (the address claim keeps its mount qualifier; the lane fact holds whether or not SendMessage is mounted), and "a label only: agents on this mount run synchronously, so a name does not make one addressable" on one without; `run_in_background` adds "(a named agent always runs in the background)", and so does the tool description's "Pass `run_in_background: false` for a synchronous run …" sentence in both of its variants (one sentence, `SYNC_ESCAPE_SENTENCE`); the classic (non-lean) variant's continuation sentence "Use SendMessage with a completed agent's ID to continue it with its context intact" — unqualified while the lean variant named both conditions — is now the lean variant's own sentence (where the SendMessage tool is mounted, and when session retention or durable named-agent revival is enabled; one sentence for both variants, `continuationSentence`); SendMessage's no-match note reads "a completed foreground agent is not addressable (nothing retains its transcript) — name an agent or run it in the background to keep it addressable"; and the async launch card's `task_id` line, which taught "Use SendMessage with to: '<id>', summary: … to continue this agent" whether or not SendMessage is mounted, now says "Where the SendMessage tool is mounted, use it with to: '<id>', summary: … to continue this agent" (the card is built by a factory that cannot see the mount, as the description is; the reference's card is unqualified because it always mounts SendMessage).
|
|
264
|
+
- **Consumers.** @cli: a named agent's card is the async card — render its row from the background lane's frames (`task_started` / completion), as for any background agent; the shell's own list keeping a finished named agent visible while it is continuable is the shell half of #1699 (open). @client-core: no wire key, member or closed set changes. @test: re-run [10335] E3 on this build — the spawn receipt is the async card and SendMessage by the name continues the agent. @server: no wiring; a host that drove `run_in_background: false` with a name to get an inline result now gets the async card.
|
|
265
|
+
- **Not changed here.** Re-using a name still resolves to the newest agent under it (#1696), and ListAgents does not yet state how long a finished agent stays continuable (#1699): both need one reading of "can SendMessage reach this agent", which today lives inside SendMessage's delivery ladder only (design/1697 §4). On a mount with no background lane a named spawn runs synchronously as before. A named fork still takes the fork lane's background branch (not continuable, as before).
|
|
266
|
+
- **Pins.** `test/backlog1697-named-agent-background.test.ts` (the E3 shape on a real Runner: async card, then SendMessage by the name continues it; an unnamed foreground child stays synchronous with no `resumable:` line; a mount with no background lane; the schema texts — all four red on the base tree); re-pinned `test/rb253-256-agents-receipt-footer.test.ts` (the retained synchronous report carries no `resumable:`; the launch card's mount-qualified follow-up line, also in `test/rb288-295-agents-face.test.ts`), `test/backlog1110-agent-child-inherits-scenario-surface.test.ts` (its rig waits by the lane rule; a named teammate pinned for both asks), `test/prompt-golden-1302.json` (the `name` text on a mount with no background lane). The measurement behind #1698 rides `test/backlog1698-one-notification-block.test.ts` (one block per completion on every engine path; two only when the host wakes the session with its own copy while the engine drains its parked one — KL-1698-a; the one-mint-point fix belongs to the wake layer, @client-core @cli).
|
|
267
|
+
|
|
268
|
+
### The person whose run drives a tool shell is told which variables of the engine's environment the credential scrub and the own-credential rule withhold from that shell — names only, never a value, and the way out for each: `inherit_env.names_withheld`, read off the execution environment's own answer `ExecutionEnv.withheldEnv` (#1963; ruled C-R744; user-visible; differs from the reference implementation, which says nothing, open to veto; hard-breaking: two, open to veto; behavior change: one, open to veto; closed sets: `ENGINE_NOTICE_CODES` 100 → 101, +`inherit_env.names_withheld` (audience `user`, minted by the engine); exports: none (the split is the engine's own); members: +1 required `ExecutionEnv.withheldEnv`) (@server @cli @client-core @test)
|
|
269
|
+
|
|
270
|
+
- **The rule.** Which entries a tool shell's child does not receive is decided by one composition — `inheritedHostEnv(policy, findings)` and the environment's explicit grants — and the execution environment hands that answer over as the read `ExecutionEnv.withheldEnv` (`src/engine/harness/types.ts:720`): the findings, names and rules, never a value; `[]` when nothing is withheld (or nothing is inherited, a shell environment built rather than inherited from this process); `"unknown"` when the adapter cannot say. `NodeExecutionEnv` reads it off the same merge its `homeDir` and `cdPath` read, announcing nothing. As each call to the engine's own shell tool starts on a root leg, the run reads the answer and tells the session's person, once per session per name set (the session's announced record; a second call or a second message of the session says nothing, a newly withheld name is told again). It is read at each call, not once at the start, because the answer grows during a run: a credential the engine takes in when it first routes a model (a model entry's own credential header), dials a peer or builds a seat is withheld from the next child, and a shell call always follows a model request. The notice says: `detail.state: "withheld"` with `names` and, when any, `ownCredentials`; `"unknown"` with no names, saying the engine could not determine them. An answer that is neither a list of findings nor `"unknown"` — absent included — is never read as `"unknown"`: it is the operator's `config.execution_env_capability_invalid` (`detail.key: "withheldEnv"`) and the person is told nothing. The model is never told a withheld name (it would point it at the engine process's own environment, KL-1753-a; the way out is the person's).
|
|
271
|
+
- **One split for both audiences.** One split answers `{ names, ownCredentials }`: `ownCredentials` are the withheld entries that are this deployment's own credential — a declared name, or a value equal to a declared or engine-held credential value — by the same judgment the own-credentials subtraction withholds them by, whichever rule withheld them first. The rest are the scrub's: `inheritEnv: "all"`, or an allow list naming them, hands them on; no policy hands on the own half. `config.secret_env_scrubbed` and the person's notice read it, so they never disagree about which names are the deployment's own (pinned).
|
|
272
|
+
- **Behavior change (open to veto).** What changed: `config.secret_env_scrubbed`'s `detail.ownCredentials` names every withheld entry that is this deployment's own credential; under `"scrub"` a credential-shaped declared name (`SERVICE_AUTH_TOKENS`, `MODEL_API_KEY`) used to count as the scrub's only, so the list was short of exactly those. Who is hurt: a reader treating `ownCredentials` as "what the shape rules missed". How to migrate: that reading is the findings' `kind` (`own-credential`), which is unchanged — as are the findings themselves and any count keyed by their kind.
|
|
273
|
+
- **Hard-breaking (open to veto) — `ExecutionEnv.withheldEnv`.** What changed: `ExecutionEnv` gains a required read-only member `withheldEnv: readonly SecretEnvFinding[] | "unknown"` (`src/engine/harness/types.ts:720`, the shape of `cdPath`). Who is hurt: every `ExecutionEnv` implementation fails to compile until it answers — @server's seven adapters (`RemoteHostExecutionEnv`, `RemoteContainerExecutionEnv`, `RemoteK8sExecutionEnv`, `RemoteSshExecutionEnv`, `RemoteLocalDockerExecutionEnv`, `RemoteDeviceExecutionEnv`, `RemoteAdbExecutionEnv`) and any host's own adapter or test double. How to migrate: a host-lane adapter returns the findings of the composition its shell child reads (`composeHostSubprocessEnv({ policy, allow: [] }, findings)` on the server's host lane), less the keys its explicit grants re-supply; an adapter whose shell environment is built rather than inherited returns `[]`; one that cannot say returns the literal `"unknown"`. Why required: an optional member would let a host lane that never answers stay silent forever, which is the defect this change closes.
|
|
274
|
+
- **Hard-breaking (open to veto) — the notice catalog.** What changed: `ENGINE_NOTICE_CODES` gains `inherit_env.names_withheld` (audience `user`; `detail: { state, names?, ownCredentials?, sessionId, runId? }`; it always carries `sessionId`). Who is hurt: a consumer switching exhaustively over the catalog, or holding an audience or whitelist table that must account for every user code — this was the one way to learn of a withheld variable (the operator's line), it is now one of two. How to migrate: route it to the session's stream and render the two halves; or refuse it with a written reason.
|
|
275
|
+
- **Consumers.** @server (S-1679): answer `withheldEnv` on the seven adapters (the host lane through the same composition its `cdPath` reads); enter the code in the session-stream whitelist (its dedupe unit is the session) — the transit then carries it; no server-side sentence is needed. @cli: render by code, the two halves apart; `-p` has no way to send `inheritEnv: "all"` today (the shell sends it on the interactive lane only), so the scrub half's way out is the shell's to word — a switch, or a sentence saying the interactive lane hands them on. @client-core: the code is a new user row in a table keyed by code. @test: `-p` with a credential-shaped name and a declared own name exported, and a prompt that makes the model run a command ⇒ one sentence per session, at the first shell call, the halves split right, no value anywhere; a second call or message ⇒ none; a run whose model calls no shell ⇒ none; `"all"` ⇒ the own half only.
|
|
276
|
+
- **Not changed here.** A per-exec `env` that re-supplies a withheld name still has it listed (the read has no exec); a delegated child is not told (the person reads the root leg), even on an environment of its own — so a session whose only shell calls are a delegated child's is never told — and neither is a session whose only shell is the Monitor tool, though its spawn reads the same shell environment (the notice is read where the engine's shell tool starts); under an allow-list `inheritEnv` the variables outside the list are not inherited either and are not findings, so "does not receive N variable(s)" counts the scrub's and the own-credential withholds, not every variable the list leaves out; a host that wires no `onNotice` gets the notice's text on the console (`console.warn`, names only), once per session per name set where the operator's console line was once per process; the split reads the declaration as it stands when the notice is made; on a host lane shared by several people, each is shown the names (never the values) of the engine's credential-shaped variables, which that lane's shell can already reach (KL-1753-a; KL-1963-a).
|
|
277
|
+
|
|
278
|
+
### The gate-record screen reads two more facts a reader of a persisted row used to judge by hand: `settlement.when` is a finite number (I10), and a winner's `decision` beside its record names the direction the record names (I11, `winnerDecision`) (#2271; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none (one optional key on the existing `screenGateOutcome` options); members: none) (@server)
|
|
279
|
+
|
|
280
|
+
- **Before (v7.55.0).** `screenGateOutcome` read the shape and the closed-set words of a `GateOutcome` (I1–I9) and nothing about two facts a row's reader needs beside them. (1) `settlement.when`: any value passed, so a reader that orders or shows the moment a wait ended judged "is it a finite number" at its own site, while another reader of the same record put the raw value on its wire — one bad moment, two answers. (2) The direction of a durable row's winner: `ResolvedOutcome.decision` (`allow` / `approve` / `edit` | `deny` / `reject`) and the record it carries (`gateOutcome`: the settlement kind's direction, the disposition's) could say opposite things and each reader took whichever it looked at.
|
|
281
|
+
- **Now.** One screen, two more invariants (`src/core/gate-outcome.ts`). **I10**: `settlement.when`, when a settlement is present, is a finite number (`typeof` number and `Number.isFinite`) — `NaN`, `±Infinity`, `null` (what JSON writes for a non-finite number), an absent member and text are each one `I10: settlement.when … is not a finite number (epoch ms)` sentence, in both readings. **I11**: a caller reading a durable row's winner states the winner's word beside the record — `screenGateOutcome(winner.gateOutcome, { reading, winnerDecision: winner.decision })`, the key present whatever it holds, read as `reading` is read (an options object that inherits it states it too) — and the screen reads it: the word is one a winner holds (`allow`, `approve`, `edit`, `deny`, `reject`; one `I11: winnerDecision … is none of the words a winner holds (…)` sentence otherwise), its direction (allow: `allow` / `approve` / `edit`; refusal: `deny` / `reject`) is the direction of the settlement kind (`SETTLEMENT_IS_REFUSAL`; `I11: the winner's decision "x" is the allow direction but the settlement kind human_refused ended the wait as a refusal`) and of the disposition (`allowed` / `denied`; `I11: … but the disposition reads denied`). A record with no settlement is not judged for its absence (the settlement arm reads a settlement kind that is there); without the key nothing is read. Same sentences, same invariant numbers, in the `archive` and the `producer` reading.
|
|
282
|
+
- **Behavior narrowing (open to veto).** (1) **What changed:** a record whose `settlement.when` is not a finite number now has a defect where it had none, in both readings. In the engine this is the gate's own composition: the producer reading refuses at the mint (`the tool gate composed an outcome that violates its own record: I10: …`), and a tool-gate result whose outcome carries one is reported and its frame carries no outcome. The one engine source of a moment other than `Date.now()` is `RunnerDeps.now` (the `approver_unavailable` settlement, `src/core/runner/prepare-ask-lane.ts:247`). (2) **Who is hurt:** a deployment whose injected `RunnerDeps.now` answers a non-finite number (that settlement is then refused at the mint instead of filed with a bad moment); a reader of archived rows that passed a non-finite `when` through the screen (a row the file backend wrote after a `NaN` reads back with `null`) — it now gets a defect for it, and a reader that withholds a record on any defect (the server's trace projection) withholds it. (3) **How to migrate:** a deployment's `now` answers epoch milliseconds (`Date.now` is the default); a reader that handled a bad moment itself can drop its own check and read the screen's.
|
|
283
|
+
- **Consumers.** @server (S-1959) — `src/approval-gate-currency.ts:144` (`Number.isFinite(settlement.when)`) retires: the screen at `:140` now reads it; pass the winner's word at the same call, `screenGateOutcome(recorded, { reading: "archive", winnerDecision: winner?.decision })`, and the direction the module header at `:30-33` lists as unjudged is judged there (the reading stays the row's own: a winner/record disagreement is a defect, so `unknown`); `src/trace/project.ts:319` (`screenGateOutcome(v)`, which puts `settlement.when` on the wire at `:461`) gets I10 with no change — a non-finite moment now takes its existing report-and-withhold path. The kind × party pairing stays unjudged (KL-1265-b). The key is optional: a caller that does not pass it sees I10 and nothing else new.
|
|
284
|
+
- **Not changed.** Every other invariant, the retired-member registry (a winner's words are not a gate record's closed set: none has left), the exports (`screenGateOutcome`'s options type gains `winnerDecision`; no new runtime name), the producer sites (the gate's exit and its station screen read I10 through the same screen; neither has a winner beside the record).
|
|
285
|
+
- **Pins.** `test/backlog2271-gate-screen-when-and-direction.test.ts` (I10 per value over both readings, every settlement kind, the JSON `null`, the mint, a NaN clock; I11 per word over every approval / refusal kind, each arm alone, a word outside the set per value, an absent key reading nothing; the real durable lanes — a tool approval decided by a person (allow, deny) and by the host's SLA sweep, a plan review approved, edited, rejected — read clean against their own decision and are named by the arm a corrupted row breaks).
|
|
286
|
+
|
|
287
|
+
### WebSearch's SearXNG backend is the host floor's sixth reader: `RunnerDeps.hostFloor` judges its request and every redirect target as it judges the fetch tool's (#2325; security; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `hostFloor` in the `opts` argument of `WebSearchConfig.search`) (@server)
|
|
288
|
+
|
|
289
|
+
- **Before.** `createSearxngSearchBackend` sent its request with the platform's own redirect following and read no floor: under `hostFloor: { only: [] }` a WebSearch still dialed the instance, and under `deny: ["127.0.0.1", "localhost"]` it dialed 127.0.0.1 and followed a 302 to `localhost` while the same run's WebFetch of the same URL was refused; nothing judged a redirect on its way to `169.254.169.254`. The floor's notes in this release said the floor binds "every engine executor that connects" and that `only: []` leaves "no structured egress" — a claim this one executor did not meet.
|
|
290
|
+
- **Now.** The backend sends every request through the one redirect loop (`fetchFollowing`) under the one hop judge (`hopRefusal`): its URL and each redirect target are judged before they are requested, and a refused hop is never contacted. The search rejects with `config.host_floor_refused` and the judge's own sentence — `SearXNG request is not sent — host "localhost" is denied by this deployment's host floor (hostFloor.deny); refused`, or `… is outside the hosts this deployment reaches (hostFloor.only); refused` — the words the fetch tool's refusal carries; the tool returns it as any backend failure (`Error (WebSearch): the search backend failed.`, the text fenced) — with the generic retry advice, because the failure classifier reads the message text and not the error's `code`, where the fetch tool says no rule, mode or approval clears a floor refusal. The floor is the run's: `createWebSearchTool` hands it to the backend in `opts.hostFloor` (`ToolExecuteContext.hostFloor`; the key is present only when a floor is declared), never a factory closure, so one backend under two Runners answers each Runner's own floor. The loop follows every judged redirect (301, 302, 303, 307, 308) that carries a Location, up to 20 — `fetch`'s own set and bound; with no floor a local instance answers and a 302 to localhost is followed as before (three differences below). A redirect's response that the loop follows or refuses is released — its body cancelled — for the fetch tool's and A2A's use of the loop too. This reader holds no private-network floor: the instance URL is the deployment's own declaration (a local instance is the usual one), as an MCP endpoint and an A2A peer are, so a redirect onto a private address is stopped only by a floor that does not admit it.
|
|
291
|
+
- **Behavior narrowing (open to veto).** (1) **What changed:** with a floor declared, a WebSearch whose SearXNG host — or the host of a redirect it answers with — the floor does not admit is an error result; and a redirect target the hop judge cannot read (credentials in the URL, a host that does not read back as one host, a scheme other than http(s)) is refused with or without a floor, as the fetch tool and A2A refuse one. (2) **Who is hurt:** a deployment that declares a floor and mounts the SearXNG backend without admitting the instance's host (a local instance: `127.0.0.1`). *server*: `src/plugins/web-search.ts` (`searxngSearch`, read in its checkout at 3ff6dec7, not run) passes the tool's `opts` to this backend unchanged, so the floor reaches the searxng leg of its search plugin the moment the Runner declares one; its brave and tavily legs are its own `fetchImpl` calls and read no floor. (3) **How to migrate:** add the instance's host to `hostFloor.only` (an entry naming a private address also lifts the private-network floor for it, which this reader does not hold), or take it out of `deny`.
|
|
292
|
+
- **Three differences with no floor (open to veto).** (1) The injected `fetchImpl` is called once per hop: the platform used to follow inside one call. An implementation that adds a credential header (`Authorization`, `Cookie`) now adds it on a cross-origin hop too, where the platform's own following dropped `Authorization` — scope such a header to the origin it is for (KL-2325-b); the default backend sends `Accept` alone. (2) An empty `Location` header is read as no header, so the 3xx is the answer, where `fetch` would follow it to the same URL (KL-2325-a; the fetch tool and A2A read it so already). (3) `fetchImpl`'s first argument is the URL's string, not a `URL` object (`typeof fetch` allows both).
|
|
293
|
+
- **Not changed.** A search backend a deployment injects itself — server's brave and tavily legs, a provider-native search — holds its own egress and may ignore `opts.hostFloor`: the floor does not bind it. `probeSearchBackend` (an onboarding connectivity check that runs outside any Runner) calls the backend without `opts`, so it dials the instance whatever floor a Runner declares: the floor is a Runner's deps. The result-side `allowed_domains` / `blocked_domains` filters are the model's, not the deployment's, and are unchanged.
|
|
294
|
+
- **Pins.** `test/backlog2325-websearch-floor.test.ts` (D1–D8 the backend itself: `only: []` refused at the first hop with nothing requested; a 302 into a denied host refused on the second hop with the target never requested; a denied base; the metadata address under `only`; a chain of admitted hops, relative and absolute, followed; the no-floor control; every hop requested by the one loop with `redirect: "manual"`; no private-network floor; the loop's other refusals; an unchanged HTTP error; D9 the loop cancels the body of a redirect it follows or refuses and leaves a handed-back one and the answer alone. L1–L6 on a real Runner: the sentences, the controls, one backend under two Runners, `opts.hostFloor` present only when declared, one sentence with the fetch tool's).
|
|
295
|
+
|
|
296
|
+
### A call runs, and is judged and shown as, the instance it dispatches to: an approved resumed call runs the tool its roster row was minted from, a deferred tool's placeholder stands for one mounted instance and runs that one, and the approval card's preview and a per-call row's boundary legs are read off that same instance (#2283, #2284; ruled C-R745; security; four narrowings, one widening and two behavior changes — the first of those two moves both ways: it widens one call shape and narrows another — all open to veto; hard-breaking: none; closed sets: none; exports: none; members: none on the package surface) (@server @cli)
|
|
297
|
+
|
|
298
|
+
- **The rule.** Which tool object a call is about is fixed where the call is judged and carried to its execution; nothing on the call's own path looks the name up again a second way (one reader outside it does: the `$.tool.call` door that tells the delegation tool by its bound `execute` takes the first caller tool of that name, `specOf` in `prepare-task.ts`, so with two caller tools under one name the door may judge another instance than the one the harness runs). A roster row is minted from the instance the dispatcher resolves its name to (the later mount under a shared name, the harness's yield); a resumed approval's two stations judge the row under the parked wire name, and the call runs the instance that row was minted from. A deferred tool's placeholder is minted for one mounted instance; the harness resolves a call to its placeholder before the gate, as it resolves every call to its tool, so the call carries that instance to its execution. The approval card's preview and the cross-boundary seat's per-call legs resolver are read off the instance the dispatcher resolves the call's name to. Retired: four first-match lookups — the resumed call's over the mount array, the placeholder's when it executes, the preview's by name or alias, the per-call resolver's by name. Claude Code 2.1.296 resolves a tool use's tool once, when the use starts (`l2`, `cli296.js:249209`: `fn(s.options.tools, …)`), and hands that object to permission and execution (`runToolUse({ tool: M, … })`); it has no durable resume and no placeholder object.
|
|
299
|
+
- **Security narrowing, #2283 (open to veto).** What changed: a parked approval whose wire name two mounted instances share — a caller tool and the engine tool that shadows it (the hands band mounts after `TaskSpec.tools`: `Write`, `Bash`, …), or two caller tools under one name — runs, once approved, the instance the live loop dispatches the name to and the stations judged, the later mount. It used to run the first one mounted, which the approval card did not describe: a caller's `Write` ran in place of the approved engine `Write` and the file was not written; a caller's `Bash` ran in place of the engine's `touch`. Who is hurt: a deployment whose resumed approvals ran its own shadowed tool — that tool no longer runs on resume, as it never ran live (prepare already warns that the built-in wins). How to migrate: give the caller tool a name of its own.
|
|
300
|
+
- **Security narrowing, #2284 (open to veto).** What changed: a deferred tool called directly whose instance a host re-dial (`reconnectMcpServer`) or a refresh replaces while the call is being judged — an approver's card up, say — runs the instance the call was resolved to; an MCP instance whose server now lists the tool differently is refused before it is sent (`mcp.tool_declaration_changed`), as a call to the same tool not deferred already was. It used to look its name up again when it ran and send the newly listed instance, which nobody judged: measured, a re-listing that declared the tool destructive while the card was up was sent and run on the new connection. A re-dial that withdraws the tool while its card is up now answers with the same refusal instead of the placeholder's "not active yet, call ToolSearch" teaching. Who is hurt: a host that changes a deferred tool's declaration while one of its calls is being judged and expected the call to run anyway — the model calls again and that call is judged on the new declaration. How to migrate: nothing.
|
|
301
|
+
- **The two lanes now agree, including their shared gap (behavior change, both ways: a widening for the stale-shape call, a narrowing for the new-shape call; open to veto).** A deferred call and a call to the same tool not deferred now read alike: the instance is bound when the call is judged, and the MCP listing check it meets before sending compares the raw name, the safety axis, the read-only and "a person must answer" marks and the task-support refusal — not the input schema. So a schema-only re-listing while a card is up is not re-judged on either lane: the bound instance runs and validates the arguments against the schema it was judged with (KL-2284-b). Measured: a re-listing that adds a required parameter, the call lacking it — not deferred, sent on the base and on this build; deferred, not sent on the base (its late lookup validated against the new schema) and sent on this build; the reverse, a call carrying a parameter only the new schema allows — deferred, sent on the base and refused on this build with the old schema inlined in the invalid-arguments answer. Who is hurt: a server that re-lists a tool with a stricter schema while one of its calls waits on an approver, and relies on the engine to refuse the stale shape (the server's own validation still applies). How to migrate: re-list with a name or safety-axis change, or have the model re-issue the call after the re-listing.
|
|
302
|
+
- **Security narrowing, the approval card's preview (open to veto).** What changed: the preview an ask and a parked row carry (`AskRequest.preview`) is the projection of the tool the call dispatches to; two tools under one name showed the first one's preview, and a caller tool shadowed by an engine tool (a caller `Write` beside the hands band) lent the engine call its own preview — the person saw the caller tool's account of a call the engine tool ran. Who is hurt: a host whose shadowed tool's preview appeared on the engine tool's card. How to migrate: nothing.
|
|
303
|
+
- **Security narrowing, a per-call row's boundary legs (open to veto).** What changed: the cross-boundary seat reads a `"per_call"` row's legs resolver off the row's own instance, the later mount; it read the first tool under the name, so a caller tool sharing the name of an engine per-call tool (`TaskStop`, `TaskOutput`, `Artifact`) had its own `boundaryLegsFor` judge the engine tool's calls (a caller tool's declared legs never make its own row per-call). Who is hurt: nobody known (pinned at component level: the lane and a real roster builder). How to migrate: nothing.
|
|
304
|
+
- **Widening, the resumed engine ToolSearch (open to veto).** What changed: a parked `ToolSearch` call (a policy that asks about it), approved, runs on the resumed leg. It used to miss its tool there — the search tool is a roster row but not in the mount array the old lookup read — and failed with `resume.tool_unavailable`, reopening the row for a decision that could never run. Who is hurt: nobody known; a deployment that asks about ToolSearch and relied on the approval never running. How to migrate: deny it instead of asking.
|
|
305
|
+
- **Behavior change (open to veto).** A placeholder schedules as the instance it stands for (`executionMode`) and counts repeated invalid shapes per instance: a refresh's new instance gets a placeholder of its own, read off that instance, where one placeholder per name kept the scheduling of the instance mounted at prepare and its count across refreshes (under the `static` materialize strategy, where a placeholder stays after an invalid call, a model that alternates invalid deferred calls with refreshes that replace that tool's instance starts the count again at each refresh — a refresh that keeps the instance keeps the count, and under the default `swap` the first invalid call replaces the placeholder with the real tool; a run with no turn or budget ceiling has no other end: KL-2284-d).
|
|
306
|
+
- **Not closed here.** A call is fixed to its instance when the harness resolves it, a moment before the gate reads its row; a refresh landing between the two has the gate judge the new row while the call runs the instance it was resolved to (KL-2284-a: only the MCP lane replaces instances during a leg, and those refuse a changed declaration — though not a changed schema, KL-2284-b — before anything is sent). The preview and the per-call resolver read the dispatcher's resolution over the live mount array, the selection the row is minted by, not the row's recorded instance (KL-2284-c, a transitional form ruled C-R745 note 2): the two differ while a re-dial has replaced instances and not yet re-minted the roster, and on an engine-lane row (ToolSearch), which the mount array does not hold; by today's declarations no instance a re-dial replaces carries a preview or per-call legs (MCP tools are minted with neither), and the equivalence is pinned (E1). A parked MCP call resumed after its server re-listed the tool with other annotations (`destructiveHint`) runs on the resumed leg's fresh instance: the approval's tool identity (capability, contract, shape, implementation revision) does not hold the declaration the MCP lane's own check compares (measured on the base and on this build; a candidate ticket).
|
|
307
|
+
- **Consumers.** @server — a durable resume through the decide route runs the tool its parked row names as the engine dispatches it; a deployment mounting a caller tool under a hands or MCP name gets the engine instance on resume (the warning it already logs at prepare names the collision); the `reconnectMcpServer` route: a deferred call being judged across a re-dial is refused if the declaration changed, and a schema-only change is not re-judged on either lane (KL-2284-b). @cli — the refusal is the existing `mcp.tool_declaration_changed` tool result (`details.code`, `delivered: "no"`); the preview an approval card renders may differ for a shadowed tool (it is the running tool's now); no new frame, member or code.
|
|
308
|
+
- **Pins.** `test/backlog2283-2284-judged-instance-runs.test.ts` (R1 caller `Write` + the band's `Write`, parked and approved: the file is written, the caller's tool does not run; R2 two caller tools under one name: the later runs; R3 controls: the live loop runs the later instance, one tool runs on resume; R4 a parked name held only as another tool's alias misses loudly; P1 the preview of two same-named tools is the later one's, and a shadowed caller `Write` lends the engine call none; B1 a per-call row's legs come from the row's member (component); E1 the mint's selector answers every row's recorded instance by wire name and alias, the engine-lane row pinned as the one difference; D1 a deferred MCP tool re-listed destructive during its ask: refused before it is sent; D2 the same not deferred, refused on both trees; D3 an unchanged re-listing runs; D4 the next call runs under the new declaration; D5 a withdrawn tool: refused, not taught). Re-pinned: `test/prepare-tool-disclosure-mount.test.ts` (the placeholder holds its instance after a replacement; one placeholder per instance), `test/b2b-directcall-invalid-args-activation.test.ts` PIN ⑦ (the withdrawn-name arm is gone; the unrenderable arm stays), and the placeholder fixtures of `rb422-deferred-directcall-hardening` / `design388-write-fences` (`real` in place of `resolveReal`).
|
|
309
|
+
|
|
310
|
+
### A leg's end record carries what its session is still owed: every unfinished background task that the leg's `{ owner, scope, sessionId }` access reaches (the reach the TaskOutput mount reads, whether or not that tool is mounted) is read as the poll the session's next leg would make of it, and a leg's end is judged once — and the registry has one reading of a leg's live background tasks, every one (#2277, #2160; security; behavior narrowing, open to veto; fix, user-visible: false orphan notices; hard-breaking: none; closed sets: none; exports: none by name; members: +2 `TaskRegistry.liveTasks`, `TaskRegistry.owedDeliveries`; the session record's shape is unchanged) (@server @client-core @cli @test)
|
|
311
|
+
|
|
312
|
+
- **The rule.** A leg's end (the settle, a suspend before its row) writes the device lane's session record as the tree stands joined with what the session is still OWED: each task the leg's access (`{ owner, scope, sessionId }`, the reach a TaskOutput mount would read; a deployment without that tool owes the same) reaches that has not finished — pending, running or parked — delivers into a later leg of the session, another tree, so it is judged as that leg's poll of it (`TaskRegistry.owedDeliveries`, the poll's own `boundaryLegsOf(…, "output", …)`, then the one obligation table): a background agent or workflow is content from beyond the device, a background shell travels its own environment's leg (on the target: nothing; off it: an outside read). What that owes — the off-target-read bit — joins the record, not the tree (a lane still on the tree is not put to a person for it). A reading that throws reads as one delivery from beyond the device and is reported on `onError`; the lane's reports are contained against a synchronous throw, so an `onError` that throws undoes no reading and stops no leg end (behavior change: a throwing `onError` no longer rejects the lane's prepare or a station's reading either); an `onError` that returns a rejecting promise still leaves that rejection unhandled, once per report — the same leak as before, with one more report point (a host callback is contained in several ways across the engine, and this lane has its own try / catch). A tree that never armed reads nothing.
|
|
313
|
+
- **A leg's end is judged once.** The leg end writes the state of that moment joined with what is owed, at most once; it no longer writes again for what other lanes of the tree grow while it waits on the store (their content is theirs; what is delivered to this session is owed and already joined). A prepare's and an exit's writes keep their judged-again form.
|
|
314
|
+
- **Behavior narrowing (open to veto).** *What changed:* on a device lane, a turn that ends with background work in flight (an agent or workflow running, pending or parked, or a background shell off the target) writes the off-target-read bit into the session's record; the session's next leg reads it. *Who is hurt:* `multi_tenant` deployments with a device lane — after such a turn every later device call of that session asks a person (the bit is monotone); a task-scoped background agent the same teardown aborts counts too (its notification can carry what it produced before the stop), and so does a background agent that a foreground child started, when the child's teardown aborted it and its delivery went to the child's session — the root session's access reaches it, so after the root's turn ends the root's later device calls ask, though that agent never read beyond the device. `single_user` deployments see no change (the bit is recorded, no judgment reads it). *How to migrate:* no configuration change; to avoid the cards, let background work finish within the turn (a same-tree `TaskOutput` poll moves nothing across the boundary) or do not launch background tasks in governed device sessions.
|
|
315
|
+
- **The notice's catch-up sentence is retired.** `config.cross_boundary_record_unwritten` no longer has the "an earlier record of this write landed … Until #2160 lands …" sentence: a leg's end writes one record and never a catch-up, so a failed leg-end write on a leg that wrote nothing before is a failed first write and the governed sentence ("the next leg … reads the content after its newest record as off-target, so its device calls are put to a person") is true of it. A parked leg wrote its suspend record before its settle's: if only the settle's append fails, the newest record is the suspend's, which already holds everything the session was owed, so the resumed leg reads no content after its newest record as off-target — its device calls are put to a person only if the suspend record itself carries the owed bit (an unfinished background agent at the suspend does), not because of the failed settle — and the sentence, said for `phase: "settle"`, over-warns there (nothing the session was owed goes unjudged). A governed **prepare**'s failure now says what is true there (it said more): the leg's next write — an exit before its call runs, a suspend before its row, its end — owes the record again, and only if none lands does the next leg read the content as off-target. `detail` is unchanged.
|
|
316
|
+
- **One reading of a leg's live background tasks (fix, user-visible).** `TaskRegistry.liveTasks(access)` answers every pending, running or parked task the access reaches, newest first, with no display cap; the leg end's owed deliveries, the post-compaction listing, the resume's orphan diff, the stop hook's `backgroundTasks` view and the suspend's snapshot read it (the snapshot leaves the parked out, as before: a parked child waits on its own checkpoint row). The two readings it replaces went through `list()`, which caps at 50 rows before it filters by status: with more than 50 background tasks alive, a resume after an approval pause announced live tasks as "were running and are now stopped" (measured: 60 live at the pause and 5 more before the resume ⇒ 5 false orphans; 40 + 15 ⇒ 5; now 0), and the pause's snapshot held 50 of 60. The post-compaction listing keeps its 50-row cap where it is rendered and now says how many it left out: `(+N more background tasks, not listed here)`. `list()` keeps its cap (a display listing).
|
|
317
|
+
- **Closed and left.** KNOWN-LIMITS KL-469-c is closed. Left, each with a ticket: KL-2160-a — a host-lane parent whose device child has not finished preparing when the parent's turn ends: the next turn's write beyond the device runs with no person (unchanged since 7.55.0; #2278); KL-2160-b — a revived child that finishes between two leg ends before its notification is delivered, and a revival in another process (#2279); KL-2160-c — a workflow or background shell aborted between its launch and its registration (inferred; #2279); KL-2160-d — a delegation tool given a registry other than the default one (#2280).
|
|
318
|
+
- **Consumers.** *server*: the record's `data` keeps its three members, so the SQL session-entry twin needs nothing; no server code reads the retired sentence; behavior: more `cross_boundary` cards on `multi_tenant` device lanes after a turn that ends with background work in flight. The leg end reads exactly what the TaskOutput mount reads — the default registry under `{ owner, scope, sessionId }`: a host that wires its own registry into the delegation tool is not covered (KL-2160-d). *client-core* / *cli*: the card class (`cross_boundary`) and its rendering are unchanged, it appears more often; the catch-up sentence disappears; the post-compaction listing may end with the `+N more` line. *test*: J1 and J2 below; g2c#inject flips green in this version (expected-red since 7.55.0); a judgment cell pinned on the sentence "not been written" / "#2160" is re-pinned (the arm is retired).
|
|
319
|
+
- **Judgment cells (black box).** **J1 — g2c#inject is closed:** `multi_tenant`, a device environment; the parent leg launches a background agent and ends; the child reads beyond the device while the parent's leg-end record append is held in the store; any later append of that leg fails (inject) or lands (control). Criterion: in both arms the next leg's device `Bash` raises a `cross_boundary` ask (refused ⇒ not executed), and no notice carries "not been written" / "#2160". Positive control: posture `single_user` ⇒ zero cards in both arms. **J2 — a host-lane parent with a device child does not loosen:** the parent's environment declares no target, the background child's (minted per child session by the factory) does, and the child finishes preparing before the parent's turn ends; the next leg calls a write beyond the device (effect `write`). Criterion: that write raises a `cross_boundary` ask (as in 7.55.0 — this cell is the counterexample of "do not write the leg-end record"). Positive control: the child's environment declares no target either ⇒ zero cards. Read-only cells: a parked or a task-scoped background child counts as owed; a background shell on the target does not; with more than 50 live tasks a resume after a pause names no live task as stopped. Expected-red: KL-2160-a (the parent's turn ends before the child prepares) — the next leg's write runs, #2278.
|
|
320
|
+
- **Pins.** `test/backlog2159-cross-boundary-bit-on-run.test.ts` (J1 inject / control / a session-scoped child with a task id that is not the session id / single_user; J2 and its control; the lane: settle and suspend carry an owed bit into the record and not the tree, a shell on and off the target, a reading that throws, single_user, a host-lane tree reads nothing; the registry: pending / running / parked under the owner, session and root arms, a finished task and another owner's excluded, 60 live read in full, each read as another tree's poll; more than 50 live tasks across a pause and a resume; the listing's `+N more`), `test/design469-r3-launch-gate.test.ts` (a leg's end writes once: a same-tree lane's read landing during its wait is not chased, an owed delivery is in the record), `test/design469-r3-boundary-legs.test.ts` (the notice: no catch-up sentence in any phase, the governed prepare and settle sentences).
|
|
321
|
+
|
|
322
|
+
### One reader of a rule's tool name, and one reading of a `*` in it: the persisted rule validator and the approval policy's lists read a name as the rule syntax spells it, a durable partition serving a behavior word outside the closed set is read as one that could not be read, and a `*` is a glob over the whole name on a list that restricts and names no scope on a list that grants — the lone `*` included, the entries beside it keeping their reading (#1879, #1911, #1715, #1902, #1807, #1918; ruled C-R722; behavior narrowings and widenings, each open to veto; hard-breaking: two root exports renamed, one exported type member renamed, the session rule grammar 4 → 5, `isCoveringRuleName`'s behavior required, a padded org snapshot row refusing every call; closed sets: `wildcard_all` stays, allow-side only, and the earlier-grammar notice's reader word gains `grammar_revised`; exports: −2 +2 renamed, no count change; members: +1 optional `RuleReject.respelling` on the exported `RuleReject` type) (@server @cli @client-core @sdk @settings-schema @test)
|
|
323
|
+
|
|
324
|
+
- **Before (v7.55.0).** A tool name in a rule or a list had ten readings (measured on the base tree through the real lanes): the persisted rule validator `parseRuleText` trimmed a name before its `(`, so `" Bash(rm:*)"` and `"Bash\t(rm:*)"` were stored, snapshotted and edited as `Bash(rm:*)` while every list refused that spelling; `createApprovalPolicy` read its three lists as written — `requireApproval: ["Read(*)"]`, `["Bash(ps:*)"]` or `["Re*d"]` let the tool run without asking, `deny: ["Read(*)"]` refused nothing, and its projection carried `Read(*)`, which a resume's read-back refused; a durable partition serving a row whose `behavior` was `"maybe"` had that row skipped in silence; a `*` inside a name (`Re*d`, `Read*`) was a literal name every list kept and nothing ever matched, a leading `*` (`*ead`) was refused by some readers and kept by others, a peer glob (`mcp__git*`) was refused, inert or silently kept by list, the lone `*` was refused on a name-set deny and in a rule, read as nothing on an entry deny list, refused on a run's denied list (`config.tool_face_control`: "a denied list has no wildcard — the entry would hide nothing"), and on an allow-entry list (a skill's `allowed-tools`, `allowedTools`, a session's `toolAllow`) it voided the whole list.
|
|
325
|
+
- **Now — the name head (#1879, #1911).** `parseRuleText` reads the name with the rule syntax's own test (`ruleSpellingOf`): a padded or invisible-charactered head is `invalid.grammar` ("… is not a rule spelling …") at every door that hands it raw text — the store's `addPersistedRule`, the org snapshot, the consent edit (`precheckEditedRuleText`), rule sync. `createApprovalPolicy` reads `requireApproval` / `deny` / `autoAllow` through the screen `createAllowDenyPolicy` reads its lists with (one function): `Read(*)` / `Read()` are `Read`, held once; the projection holds the bare names; an entry no tool name can have is refused at construction (`config.invalid_tool_name_set`, `issues[].list` naming the list).
|
|
326
|
+
- **Now — the durable partition (#1715).** A served rule or tombstone row whose `behavior` is no word of `deny` / `ask` / `allow` makes the durable read one that could not be read, the row named in `unreadable`: every call is asked of a person (class `rule_unreadable`, bypass-immune) and the run gets `permission.rule_store_unavailable` once — the answer the bundled file backend already gave such a bucket.
|
|
327
|
+
- **Now — the `*` (#1902, #1807, ruled C-R722; CC 2.1.296: the deny / ask lookups match a rule's tool name with `globMatching` on, the allow lookup does not, and the settings validator says "Deny and ask rules accept wildcards anywhere").** One reader answers which tools a spelling reaches on a list (`isCoveringRuleName` / `ruleNameCovers`): on a list that RESTRICTS — a rule policy's deny / ask, a name set's `deny`, `requireApproval`, a session's `toolDeny`, a skill's `disallowed-tools`, an agent's `denyTools`, a run's `excludeTools` — a `*` anywhere in a name is a glob over the whole name (`Re*d`, `mcp__git*`, `*__get_x`; `*` alone every tool); on a list that GRANTS — `allowedTools`, a skill's `allowed-tools`, a name set's `allow`, `autoAllow`, a session's `toolAllow`, an agent's `allowTools` — a `*` is read only in the tool segment after a literal `<ns>__<peer>__`, and any other `*` names no scope: refused at a constructor, read as nothing in a read list (`wildcard_all` for the lone `*`, `namespace_wildcard` for a peer glob, `grammar` otherwise), the entries beside it keeping their reading. The selector's allowed list keeps the lone `*` as "no allowlist" — one test, `namesEveryTool`, which the spec tightener, the definition's note (#1918) and the workflow agentType lane now ask too. In a rule (`createPermissionRulePolicy`) `X()` / `X(*)` are the bare `X` for a covering name too (`*(*)` is `*`, where it was `invalid.tool_name`; `a2a__peer(*)` is `a2a__peer`, where it was `unsupported.covering_paren`), as the lists read them; a covering name with a content stays refused, and an MCP name with any parenthesis — `mcp__srv(*)` included — stays refused in a rule (`unsupported.mcp_paren`, as in 7.55.0) while a list reads it as `mcp__srv`.
|
|
328
|
+
- **Behavior narrowing (open to veto) — padded rule names.** (1) **What changed:** `" Bash(rm:*)"`, `"Bash\t(rm:*)"`, `"Bash (rm:*)"` are refused by the store, a consent edit and rule sync, where they were read as `Bash(rm:*)` (an org snapshot row is read as unreadable — its own hard-breaking bullet below); the refusal names the spelling the text is a blank away from (`RuleReject.respelling`, "spelled as a rule it is \"Bash(rm:*)\""). (2) **Who is hurt:** a host that stored or submitted such a spelling — a person's typed edit with a leading blank included; the reference's editor trims what a person typed before it validates, so a surface that hands the bytes on unchanged meets the refusal. (3) **How to migrate:** spell the rule as written (`Bash(rm:*)`); a surface trims what a person typed before it submits the edit.
|
|
329
|
+
- **Behavior narrowing (open to veto) — the approval policy's lists.** (1) **What changed:** `createApprovalPolicy` throws `config.invalid_tool_name_set` for an entry it used to keep as written and never match (a content form, a padded name, a peer glob on an allowlist), and reads `Read(*)` as `Read` — so `requireApproval: ["Read(*)"]` now asks, where the tool ran unasked. (2) **Who is hurt:** a host that wrote such an entry. (3) **How to migrate:** name the tool (`Read`); route a content narrowing through `createPermissionRulePolicy`.
|
|
330
|
+
- **Behavior narrowing (open to veto) — an off-set behavior word.** (1) **What changed:** a custom durable backend that serves a row with a behavior outside the closed set gets every call asked of a person, where the row was skipped. (2) **Who is hurt:** a backend that stores a word of its own. (3) **How to migrate:** store `deny` / `ask` / `allow` only (the file backend already refused the bucket).
|
|
331
|
+
- **Behavior narrowing (open to veto) — a `*` on a restricting list.** (1) **What changed:** `Re*d`, `*ead`, `mcp__git*` and the lone `*` reach every tool their glob matches on every restricting list; the lone `*` was refused on a name-set deny and in a rule (7.52.0, #1889 / car-1886 E — "it names no tool"), read as nothing on an entry deny list and refused on a run's denied list. The refusal's reason — a deny that reads protective and denies nothing — no longer holds: the glob denies every tool, so a run whose denied list holds `*` — refused at prepare before — now starts with every tool hidden. A session row stored before this grammar keeps its writer's meaning (the stored-row bullet below): its `toolDeny: ["*"]` still names no tool, and the row is announced. A delegated leg whose own denied list holds `*` is refused before it starts (`protocol_denied`: it denies the tools it relies on). (2) **Who is hurt:** a host that wrote `*` or a partial glob on a deny list expecting nothing. (3) **How to migrate:** drop the entry, or name the tools.
|
|
332
|
+
- **Widening (open to veto) — a `*` on an allow-entry list (#1807, stated [11266]).** (1) **What changed:** an allow-entry list holding `*` grants its other entries (`["Read", "*"]` grants `Read`), where the whole list was voided; the `*` itself grants nothing and is announced. A skill's `allowed-tools` reads it the same way — wider than the reference's skill reader, which collapses such a list to `["*"]` and so grants nothing (read from its frontmatter parser; the scope of the mangled name is inferred). (2) **Who is hurt:** a host that relied on `*` to void a list. (3) **How to migrate:** remove the entries the list should not grant. A session row stamped in grammars 1–4 keeps its voided list (below).
|
|
333
|
+
- **Widening (open to veto) — the workflow agentType lane (#1918).** (1) **What changed:** a workflow step's `agentType` whose definition's `allowTools` holds the selector's wildcard (`["*"]`, `["Read", "*"]`, `["*(*)"]`) sets no allowlist on the step's policy, as the Agent lane's selector has always read it — the workflow lane read `*` as a name nothing carries and admitted only the named tools. (2) **Who is hurt:** a workflow whose agent definition named tools beside `*` expecting the workflow lane to bound them. (3) **How to migrate:** drop the `*` to bound the step to the named tools (on both lanes).
|
|
334
|
+
- **Behavior narrowing (open to veto) — allow-side constructors and mounts.** (1) **What changed:** a name-set `allow`, an `autoAllow` and a `requireApproval` under `denyByDefault` refuse a lone `*` and a glob outside a protocol name's tool segment (`config.invalid_tool_name_set`), where the entry was kept and admitted nothing; a run's or an agent's `allowTools` that holds a glob outside a protocol name's tool segment (`["Read", "R*"]`) refuses the task (`config.tool_face_control`), where the entry was kept beside `Read` and granted nothing; a caller tool whose name or alias holds a `*` is refused at mount (`config.tool_name_invalid`), as the reference's mount spelling holds none; a skill list's unquoted `*<name>` — the key's own value, a flow item or a `- ` entry — is a YAML alias the reader does not resolve and refuses the skill (`no_frontmatter`; YAML would resolve it to an anchored node, so reading it as the glob `*name` could lose a deny entry), while a `*` that names no anchor (`allowed-tools: *`, `*, Read`) is the wildcard text as before. (2) **Who is hurt:** a host that wrote such an entry or mounted such a name. (3) **How to migrate:** name the tools; rename the tool; quote a glob entry (`"*Edit"`).
|
|
335
|
+
- **Hard-breaking (open to veto) — two root exports renamed.** (1) **What changed:** `isNamespacedCoveringRuleName` → `isCoveringRuleName`, `namespacedRuleNameCovers` → `ruleNameCovers`: the predicates now answer for a glob over the whole name too, which "namespaced" no longer names. (2) **Who is hurt:** an out-of-engine name-keyed lane that self-checks with them (server `src/approval.ts`). (3) **How to migrate:** import the new names; pass the list's behavior to `isCoveringRuleName` (a deny / ask list reads a glob anywhere).
|
|
336
|
+
- **Hard-breaking (open to veto) — `isCoveringRuleName`'s `behavior` is required.** (1) **What changed:** `isCoveringRuleName(name, behavior)` takes the list's behavior as a required argument, and a value that is no behavior word throws a `TypeError` — it used to default to the granting reading, so a deny-list caller that left it out read `Re*d` / `mcp__git*` as exact names (a deny that refuses nothing), and `names.filter(isCoveringRuleName)` passed the array index there. (2) **Who is hurt:** a caller that omits it, or hands the predicate to `filter` / `map` directly (a type error at build, a `TypeError` at run time) — server's `src/approval.ts` did both when this was written and now passes its table's behavior (`NAME_TABLE_BEHAVIOR`, `:321` in its checkout of 10-23), so only the rename reaches it. (3) **How to migrate:** pass the behavior of the list the names come from: `names.filter((n) => isCoveringRuleName(n, "deny"))` (`"ask"` for an approval list, `"allow"` for a granting one).
|
|
337
|
+
- **Hard-breaking (open to veto) — an org snapshot row that is no rule spelling only by a blank refuses every call.** (1) **What changed:** an org row like `" Bash(rm:*)"` or `"Bash (rm:*)"` — read as `Bash(rm:*)` before, refusing `rm` only — is no rule spelling under the one spelling reader, so it is a row the layer cannot read and stands at its strictest reading: a deny refuses every call of every tool, its verdict saying `row 1 of the organization snapshot, " Bash(rm:*)", is not a rule spelling (invalid.grammar); republish it as "Bash(rm:*)"` and the degraded line naming the same spelling. Why: one reader of a rule's spelling, and a deny that cannot be read is fail-closed — reading the row trimmed at the reach would be a second reading of the same text. (2) **Who is hurt:** an organization whose published snapshot carries such a row: after the upgrade every agent under it is refused every call. (3) **How to migrate:** a deploy prerequisite for whoever serves an organization snapshot — republish it with canonical spellings before upgrading the engine (`parseRuleText` names the canonical spelling of each such row in `reject.respelling`). sema-server (its checkout at `3ff6dec7`, read not run, `src/boot/stage-04-stores.ts`) wires only the durable partition and no org snapshot source, so no first-party deployment meets this today.
|
|
338
|
+
- **Hard-breaking (open to veto) — `CompiledAllowEntry` kind `namespace` → `covering`.** (1) **What changed:** the entry reader's covering row is `{ kind: "covering", pattern }` (a protocol peer, a tool-segment glob, and on a deny list any glob). (2) **Who is hurt:** a reader switching on `"namespace"` (none found in server / cli / client-core / sdk). (3) **How to migrate:** read `"covering"`.
|
|
339
|
+
- **Hard-breaking (open to veto) — session rule grammar 4 → 5.** (1) **What changed:** `SESSION_RULES_GRAMMAR` is 5; a row stamped 5 is refused whole by an engine of grammar 4, which would read a `*` deny as nothing. A row stamped 0–4 keeps the meaning its grammar gave its entries (the stored-row bullet below). (2) **Who is hurt:** a mixed fleet whose older readers meet rows a newer writer stamped; a store twin that stamps its own number. (3) **How to migrate:** upgrade readers first; a store twin stamps `SESSION_RULES_GRAMMAR` as before.
|
|
340
|
+
- **Now — a session row an earlier grammar wrote keeps its writer's meaning (ruled C-R722, note 2).** One rule over the documented grammar revisions, read in one place (`earlierGrammarReads`, one table): an entry keeps the meaning its writing grammar gave it, and an entry that grammar could not read (no rule, or no rule form) takes this engine's reading; an entry this grammar reads as no rule reads as no rule (`earlier_grammar`, as before); a row stamped this engine's grammar consults nothing. So a row stamped 0–4 keeps a `toolDeny` `*` and a `*` in a protocol peer (`mcp__git*`) as entries naming no tool and `Re*d` as the exact name `Re*d` (in `toolAllow` too), and an allow list grammars 1–4 voided stays voided — what 7.55.0 read; a row stamped 0–3 keeps reading `Agent(<type>)` and the Bash pattern form as 7.55.0 did (its grammar could not read them); the readings the releases since gave a grammar-0 deny rule form and a grammar-0–2 `mcp__*` deny stand. `compileSessionRuleBucket` refuses a key that is no rule-entry bucket (`SessionPolicyError` `invalid_shape`) rather than read it as the allow bucket.
|
|
341
|
+
- **Widening (open to veto) — a stored `toolDeny` name starting with `*`.** (1) **What changed:** a row stamped 0–4 whose `toolDeny` holds `*ead` — no rule in grammar 4, so those rules could not be read and every call was refused — reads it as the pattern now (it refuses the tools it matches), announced. (2) **Who is hurt:** an operator who relied on such a row refusing every call. (3) **How to migrate:** write the rules afresh, naming the tools to refuse.
|
|
342
|
+
- **Behavior change (open to veto) — a stored row whose entries two grammars read differently is announced.** (1) **What changed:** `config.session_rules_earlier_grammar` is also sent, once per bucket, for a row stamped below this grammar whose entries its writer's grammar and this one read differently — a grammar-0 `toolDeny` rule form, a grammar-0–2 `mcp__*` deny, a grammar-0–3 `Agent(<type>)` or Bash pattern, a grammar-0–4 `*` in a name — naming each entry, both meanings and which one the rules enforce; the reading itself is unchanged. `detail.entries[].reader` gains a third word, `grammar_revised`. (2) **Who is hurt:** a consumer that switches on `reader` with two words. (3) **How to migrate:** render the message (it carries the facts), or add the word.
|
|
343
|
+
- **The published safety-merge corpus moves in lockstep.** `SAFETY_MERGE_CONFORMANCE_CORPUS`: `inert-deny-entry-removed-safe` no longer holds `mcp__git*` (it is a real deny now), and a new vector `glob-deny-removed-loosened` pins that removing `mcp__git*` / `Re*d` from a deny list loosens; a center merger that runs the corpus reads the new answers.
|
|
344
|
+
- **Unchanged.** The closed set `ALLOW_ENTRY_INERT_REASONS` (7 words; `wildcard_all` is the allow-side lone `*`), every rule's content grammar, the projection replay's matcher (it reads the frozen names through the same predicate), a run selector's `*` sentinel.
|
|
345
|
+
- **Consumers.** *server*: `src/approval.ts` imports the renamed predicates; the SQL session-rule twin stamps grammar 5 (upgrade readers first); a host-written policy projection holding a non-bare name is still refused at resume, and a list it builds with `createAllowDenyPolicy` meets the allow-side refusals; the `skill.disallowedTools entry "*" reads as nothing (wildcard_all)` request refusal no longer arises from this engine (a deny `*` is a row); M17 is unaffected (no closed-set change). *cli*: the rule-ladder render and a typed rule edit (trim before submit); the allow notice's sentence is reworded; `config.session_rules_earlier_grammar` now also arrives for a stored row whose entries two grammars read differently — its message is the sentence to render, and `entries[].reader` may be `grammar_revised`. *server*: a session-rule SQL twin serves the row's stamp as before — the engine reads a stamped row's meaning, so a twin that restamps rows on read would erase it. *client-core*: `voids_allow_list` arm retires at core ≥ 7.56.0 ([11266]); `wildcard_all` unchanged. *sdk*: K-95 needs no major (closed set unchanged). *settings-schema*: `SkillSpec.allowedTools` / `disallowedTools` carry entries; the lexicon is this engine's (C-R471). *test*: the black-box cells for `*` on both list kinds.
|
|
346
|
+
- **Pins.** `test/backlog1879-rule-name-one-reader.test.ts` (N the name head at the store, the edit and the approval lists, the real lane; B the off-set behavior word, effective() and the real lane, default and bypass; G one `*` reading over 11 patterns × 8 names × 7 restricting consumers, the granting side, the real lane, a covering name's `X()` / `X(*)`, #1918), all 13 red on the base tree; F the stored row's meaning per grammar (16 cells: kept, moved, voided, no rule, this grammar's own), the announcement's two meanings, the real lane (a grammar-4 `toolDeny: ["*"]` refuses nothing and is announced once, the same row in grammar 5 refuses unannounced, a grammar-2 Bash pattern deny refuses its own calls only), a key that is no entry bucket refused — seven mutants each red; the re-read pins of the earlier cars (`backlog1303*`, `1327*`, `1355b`, `1358`, `1372`, `1377`, `1385`, `1474`, `1484`, `1517-a`, `1623`, `1736`, `1790`, `1841`, `1845`, `1866*`, `1875`, `1876`, `1878`, `1886`, `1912`, `2150`, `7480`, `mcp-capability-and-rule-coverage`, `per-task-agents`, `permission-rule-consent`, `policy-name-audit`, `prepare-listings-transcript`).
|
|
347
|
+
|
|
348
|
+
### A rewrite of a call's arguments goes back to every judge that judged earlier bytes, and a person's edit never runs in another form (a caller's tool named `AskUserQuestion` included — the #2300 resume-path section below): `combinePolicies` asks its earlier layers once more about a later layer's rewrite, the PreToolUse screening judges a policy's rewrite once, and a hook's or a policy's rewrite of an approved edit is put back to the person as it stands; a person's rules are read once per judgment of a call's bytes (#1022, #1312, #2224, #2225; security; six safety narrowings and one behavior change, all open to veto; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `AskRequest.editRewritten`, +1 optional ask-arm `PermissionResult.editRewritten`, +1 optional `CheckpointError.detail.editRewritten`) (@server @cli @client-core)
|
|
349
|
+
|
|
350
|
+
- **Before (v7.55.0, measured through the real Runner).** `combinePolicies(guard, rewriter)` where `guard` refuses `/protected/*` and `rewriter` moves `/safe/x` to `/protected/x`: the call ran at `/protected/x` — the fold cascaded a rewrite only into the layers after it, so the guard never saw the bytes that ran (the engine's own fold re-ran the layers before the last rewriter only inside its caller-slot region, through a separate wrapper and an emitter index; a deployment's own composed policy got nothing). A PreToolUse hook that refuses `/protected/*` judged the model's bytes and a policy then rewrote onto `/protected/x`: it ran. In the approved-edit round a person edited `/safe/x` to `/human` and the hook (or the policy) rewrote `/human` to `/hook` (`/policy`): `/hook` ran with no second card on an egress-marked tool or a declared `requiresUserInteraction` tool, because only a passthrough-seat tool met the edit round's `unstated` question; on the durable lanes the same rewrite was refused pre-claim (the policy) or dropped so the person's bytes ran (the resumed leg's hook re-screen). A deny row a person wrote into their persisted rules while deciding refused their edit through the session-rule store but not through the person's store (that store was read once per call), so one shape had two answers.
|
|
351
|
+
- **Now, one rule (`src/core/rewrite-rejudge.ts`).** A verdict covers the bytes it was given. (1) Rewritten bytes go back, once, to every judge that judged earlier bytes of the attempt: `combinePolicies` asks each layer before the latest rewriter about the bytes that run, and that answer is its verdict (a deny refuses; a different form handed back refuses — "a rewrite is judged once, never chased"); the latest rewriter is not asked again. The PreToolUse screening, which judged the bytes before the policy, judges the policy's rewrite once in the gate's fold and in the approved-edit round — its verdict on those bytes replaces its first. The documented order "a policy's rewrite is applied last over a hook's" is the last-rewriter case of the same rule and stands. (2) A person's bytes are the ceiling: a judge looking at a person's edit may refuse it, never replace it. Where the person can still be asked (the gate's edit round, a delegated child's ancestor re-check), the judge's form is put back to the same approver as the round's question, carrying `AskRequest.editRewritten.acceptableForm` (the bytes the card presents) — approving it as it stands runs exactly those bytes, a further edit is a new round (three at most), no mode releases it and no classifier answers it. Where nobody can be asked, the refusal names the form: pre-claim `resume.constraint_rejected` with `detail.editRewritten` (the row stays pending; a decide carrying that form runs it), post-claim the result's `details.editRewritten`, and the gate's refusal when no approver took the outlet. "The same bytes" is one reading for a rewrite: both values as the tool reads them, canonically serialized — a rewrite to the same bytes is no rewrite (the park's storage-fidelity check keeps its own plain-value comparison, `samePlainValue`, for a different question — whether a checkpoint backend's codec moved the filed value).
|
|
352
|
+
- **Rules counted.** Retired: the engine fold's final-form re-check wrapper and its per-call emitter index (`prepare-policy-chain.ts`: 1,538 → 1,459 lines, −79; the ratchet ceiling banked 1,588 → 1,460), the trailing copies of the integrity guards (their only reason was rewrite escape), the edit round's passthrough-only `unstated` re-ask (the outlet covers every tool), the edit round's "rewritten since a human looked" flag and its post-loop write-protection re-judge (the write-protection judge now judges a judge's form inside the round, before its card — rule 1 — and refuses a table hit there), the durable belts' private equality (`deepJsonEqual`) for a rewrite, and #1294's "the person's bytes beat the re-screen's" order. Added: the one rule's reader and outlet in one module. One residue, named: a same-instance arbitration adapter judges another layer's verdict rather than bytes, so when a layer between the two rewrote, it arbitrates the caller slot's verdict on the bytes that run after the fold's second look (no second evaluation of the shared instance) — the narrowing layers keep their trailing pass for their own reason (they read their rules at each judgment, and a layer before them may wait on a person).
|
|
353
|
+
- **Safety narrowing, the composed fold (open to veto).** (1) **What changed:** a layer of `combinePolicies` that judged bytes a later layer rewrote is asked again about the rewritten bytes; it refuses them or asks about them, and a layer that rewrites again on that look refuses the call; an `updatedInput` that reads as the bytes it was handed is no rewrite and asks nobody again; and a layer's rewrite that is a person's edit (an ancestor approver's, licensed by that person's yes) is not replaced by a later layer — that layer's rewrite refuses the call. (2) **Who is hurt:** a composition whose earlier layer refuses (or asks about) what a later layer rewrites into — that call was running unjudged; two layers that rewrite back and forth (refused now, before the claim on a durable decide); a stateful or prompting earlier layer is consulted twice on a rewritten call (`check` must already be idempotent). (3) **How to migrate:** order a sanitizer before the layers that must judge its output, or make the earlier layer accept the sanitized form.
|
|
354
|
+
- **Behavior change, verdicts follow the bytes that run (open to veto).** An approval factory (`createApprovalPolicy`) composed before a rewriting layer asks its person again about the rewritten bytes, and that yes covers them — no `unstated` card for them (before: one); its person saying no there refuses. A real-approval question an earlier layer raised about bytes a later layer rewrote away does not stand: that layer's question (or none) about the bytes that run does. A same-instance ancestor no longer arbitrates (and so no longer prompts about) the caller slot's verdict on bytes that do not run; a shared policy that asked about the raw arguments and allows the rewritten ones puts no card (before: two). A PreToolUse hook's `allow` speaks for the bytes it was given: after a policy rewrite, its second look's `allow` is the allow layer's hook member for the bytes that run (it clears what that member may clear — before, any policy `updatedInput`, an identical one included, silenced the member; a hook that allows only the model's bytes still leaves the question standing).
|
|
355
|
+
- **Safety narrowing, the screening's second look (open to veto).** (1) **What changed:** when the policy rewrites the call, the PreToolUse screening is raised once more over the rewritten bytes — a deny refuses, a different form refuses, an ask is the hook's question about those bytes, and its first verdict (an allow, an ask) no longer stands for them — its `additionalContext` is not replaced: when the second look answers (an allow, an ask, no opinion) both looks' context reaches the result; a second look that refuses ends the call with no context of its own. (2) **Who is hurt:** a hook that refuses or rewrites the policy's output; a hook counting dispatches sees two on a rewritten call (one on any other); a hook that answers only the first dispatch of a `tool_use_id` is silent on the second look, so its own mandatory question is dropped on a rewritten call (it is dispatched before the person has decided, and again on the rewrite); and a hook whose ask was about the model's bytes only meets no question about the rewritten ones — for that shape the second look widens, not narrows (the behavior-change bullet above says the same of an allow). (3) **How to migrate:** nothing for a hook that accepts the policy's forms; one that rewrites should accept its own output unchanged; a hook that must ask about whatever runs should answer on every dispatch, not once per `tool_use_id`.
|
|
356
|
+
- **Safety narrowing, the live edit round (open to veto).** (1) **What changed:** a hook's or the policy's rewrite of an approved edit no longer runs: the same approver is asked again with the rewritten form (`editRewritten`), for every tool — before, only a passthrough-seat tool met a second card, and an egress-marked or `requiresUserInteraction` tool ran the judge's bytes unseen. (2) **Who is hurt:** a deployment whose hook or policy normalizes every edit to a different spelling: each such edit takes one more card; an approver that answers every card `true` keeps working. (3) **How to migrate:** render `editRewritten` as "approve this form", or have the judge accept the person's spelling as it is.
|
|
357
|
+
- **Safety narrowing, the write protection in the edit round (open to veto).** (1) **What changed:** when the re-check of an approved edit raises an ask that carries a rewrite of the person's edit (the policy's, or a hook's), the write-protection judge judges that rewritten form before the round puts it to the person on a card (an approval factory composed in the policy asks its own approver while the policy re-judges, which comes earlier): a table hit (a path the protection table names, such as `.git/…`), or a target the judge cannot place, refuses the call fail-closed (`deniedBy: "write_protection"`) with no card for a write no approval covers; before, the form went to the person as a second card, and a plain yes cleared the protection and ran the write onto the protected path (a rewrite carried on an allow, with no card, was already refused there). The person's own edit stays outside this check. (2) **Who is hurt:** a deployment whose policy re-check asks about an approved edit it has rewritten onto a protected path — the call is refused where it took a card an approver could say yes to. (3) **How to migrate:** re-submit the edited action directly (the ordinary write gate judges it), or have the judge leave the person's spelling alone.
|
|
358
|
+
- **Safety narrowing, the resumed leg (open to veto).** (1) **What changed:** on a parked approval with a person's edit, a PreToolUse re-screen that rewrites the edit refuses the call (the approval is spent, the form on `details.editRewritten`); before, the hook's rewrite was dropped and the person's bytes ran. (2) **Who is hurt:** a deployment whose hook rewrites edited approvals on resume; an unedited approval's rewrite still runs (the reference's form). (3) **How to migrate:** re-issue the call; to keep the person in the loop, decide with the hook's form.
|
|
359
|
+
- **Safety narrowing, the person's rules (open to veto).** (1) **What changed:** the person's persisted rows are read once per judgment of a call's bytes, so an approved edit (another judgment) takes a new read: a deny row written while the person decided refuses their edit, as a session-rule deny already did. (2) **Who is hurt:** nobody intended; a store pays one more read per approved edit. (3) **How to migrate:** nothing.
|
|
360
|
+
- **Unchanged (the reference's form, `KL-2225-a`).** An approval without an edit is not judged again: a deny written while the person decided reaches the next call, not the one they approved — the same in both stores. The reference takes the permission decision's input after a schema check alone (cli296.js:249217 "tn=no.decision;Rt=no.input", cli296.js:249218 "Rt=W_t(e.name,tn.updatedInput,Rt)") and judges a hook's `updatedInput` with its rules before the prompt (cli296.js:249209 "let he=await yk(n,V,{...s,toolUseId:b},{hookUpdatedInput:e.updatedInput})"); its hooks never see a person's edit — re-screening it is ours, and so is the ceiling. The ceiling covers the bytes the engine records as a person's edit (its approval record); the answer of a host's own approval screen is not in that record, so a later layer can still rewrite it unasked (`KL-1022-a`).
|
|
361
|
+
- **Cost.** A call nobody rewrote pays nothing. On a rewritten call: the layers before the latest rewriter are evaluated twice; one extra PreToolUse dispatch when the policy rewrote; one more session-rule read when the caller slot rewrote; one more person-store read per approved edit.
|
|
362
|
+
- **Consumers.** *cli* / *client-core*: an approval request may carry `editRewritten: { acceptableForm }` — the card is the round's question about the form it presents; offer "approve this form". *server*: the durable refusal keeps its code `resume.constraint_rejected`; `detail.editRewritten.acceptableForm` is new and present only when the resume-edit policy rewrote the edit — a decide carrying that form as `updatedInput` runs it; a resumed leg's hook rewrite of an edit now refuses (post-claim, `details.editRewritten`).
|
|
363
|
+
- **Pins.** `test/backlog1022-rewrite-recheck.test.ts` (R1–R6 and R17 rule 1: the guard meets the rewrite, never chased, same bytes are no rewrite and keep a required question, the cost counts, the screening's second look and its dispatch count; R7–R9 and R16 the live outlet card, the policy's form judged by the screening first, the refusal's details, no mode release; R18–R19 an ancestor's approver: the outlet under `bypassPermissions`, a later ancestor layer does not replace the edit; R10–R12 the durable lanes and the reference's control; R13–R15 a deny written while the person decides, both stores, and the unedited approval). Re-pointed: `test/backlog1294-approved-call-hooks.test.ts` ③d (a re-screen rewrite of an edit refuses; the same bytes run), `test/backlog1065-mcp-default-ask.test.ts` ⑰a / ⑭c (the outlet card: `editRewritten`, the round's class), `test/inherited-gate-rewrite-ordering.test.ts` (the intermediate-rewrite cell, both readings), `test/backlog1020-approve-accepts-ask-outcome.test.ts` P8b and `test/coarse-command-name-policy.test.ts` (the crossing shapes are refused), `test/backlog1359-allow-layer-only-home.test.ts` D7 / D7c / G5 and `test/backlog595-settlement-attestation.test.ts` E3b / E3c (the second answer is the record), `test/hardening-permission-core.test.ts` I1 (the real-approval demand follows the bytes that run), `test/hooks.test.ts` / `test/defectscan-probe-final-args.test.ts` / `test/security-fixes.test.ts` R-3 (a judge that accepts the later form lets it stand; one that insists on its own refuses), `test/backlog1226-decision-receipt.test.ts` ⑬.2 (the copy discipline read off the outlet's presented bytes), `test/backlog1974b-keyless-and-unreadable-resume.test.ts` ⑥ (a back-and-forth composition is refused before the claim).
|
|
364
|
+
|
|
365
|
+
### A checkpoint row's generation has one reading, `checkpointRevOf`, exported from the package root beside the optimistic-concurrency judge `checkpointOccMatches` (#2391; additive exports; behavior change: none; hard-breaking: none; closed sets: none; exports: +2 (2 runtime: `checkpointRevOf`, `checkpointOccMatches`; 0 types); members: none; signatures: none) (@server)
|
|
366
|
+
|
|
367
|
+
- **Before.** "An absent `rev` reads as 0" was written inline eight times in core (the OCC judge, the in-memory loss answer, the resolve and reopen bumps of both bundled stores, and the resume's expectation, twice) and again in server's approval-gate reads. `checkpointOccMatches` was not a root export, so a host that compares two reads of one row (did a resolve or reopen land between them?) wrote the generation rule itself.
|
|
368
|
+
- **Now.** `checkpointRevOf(cp)` is the one place that says it: `cp.rev ?? 0`. The OCC judge, the loss answer, both stores' bumps and the resume's expectation read the generation through it; `checkpointOccMatches(cp, expect?)` is unchanged in answer (no expectation opts out; otherwise the row's generation must equal `expect.rev`) and is exported with it. No behavior change on any row.
|
|
369
|
+
- **Who is hurt.** Nobody: two added runtime exports, no existing name or shape touched.
|
|
370
|
+
- **How to migrate.** *server*: delete `revisionOf` and read the generation through `checkpointRevOf`, and ask the OCC question with `checkpointOccMatches(again, { rev: checkpointRevOf(row) })`. The server lines that collapse (server `int-7135` at `3ddb462e`, line numbers move): `approval-gate-currency.ts:78-80` (`revisionOf` itself) and `:104` (`revisionOf(again) === revisionOf(row)`), and `approval-sweeps.ts:152` (`cp.rev ?? 0`) and `:202` (`(cp.rev ?? 0) !== observedRev`, which is `!checkpointOccMatches(cp, { rev: observedRev })`) — and a fifth spelling of the rule, the SQL checkpoint store's `put` bind `cp.rev ?? 0` (`src/plugins/checkpoint-store-sql.ts:1270`), which reads the generation through `checkpointRevOf` as well (its SQL half, `COALESCE(rev, 0)`, stays). Server deletes its `revisionOf` copy on arrival.
|
|
371
|
+
- **Pins.** `test/checkpoint-rev-reader.test.ts` (the root exports are the internal functions; the reader on an absent, zero and present `rev`; a host's two-read comparison; S1–S8 one cell per core site, each on a row minted without `rev`, which go red together when the reader's default is changed); `test/export-surface.snapshot.json` (+2, both `advanced`).
|
|
372
|
+
- **Unchanged.** `checkpointRowMatches` (the row half of the CAS predicate) is not exported; the SQL twin keeps its native `COALESCE(rev, 0)`.
|
|
373
|
+
|
|
374
|
+
### A model's capability declarations are read in three states through one reading: `Model.reasoning` and `Model.input` are optional, what an undeclared capability means is said once, and the person is told — once per conversation, per model — when the images they supplied are not sent to the model (#1830 slice S1, #1926; ruled C-R762 and its note 1; user-visible; behavior change, open to veto; differs from the reference implementation, which never declares a model text-only and tells no one, open to veto; hard-breaking: five, open to veto; closed sets: `ENGINE_NOTICE_CODES` 101 → 102, +`model.capability_downgraded` (audience `user`, minted by the engine); exports: none at the package root (the reading lives in `src/brain/model-capability.ts`; its root export ships with slice S2); members: `Model.reasoning` and `Model.input` become optional) (@cli @server @web-admin @client-core @sdk @settings-schema @test)
|
|
375
|
+
|
|
376
|
+
- **The rule.** A model declares image input (`input` lists `"image"`, or a list without it) and reasoning (`reasoning: true | false`), or does not declare them (the key is absent). One predicate reads a declaration — `capabilityOf(model, cap)` answers `declared_on`, `declared_off` or `unknown` — and one table says what an undeclared capability means (`UNKNOWN_CAPABILITY_READS`: image input is used, reasoning is not), read by `capabilityHeld`. Every reader of the two bits reads through them: the image row of the three message assemblers (`brain/media-degrade.ts`), the thinking entry gate and the off / effort-tier faces (`brain/reasoning.ts`), the explicit-off arms and the `reasoning_content` field of the three lanes, the compaction summary's thinking forward and output floor, the Read tool's PDF profile and `selectModel`. Before this, an absent `input` meant "sees images" in the assemblers and the PDF profile and "does not" in `selectModel`, and the PDF profile read a `"document"` member the type does not allow.
|
|
377
|
+
- **What a request carries (unchanged except as listed below).** A declared-off capability is never asked for: images become the one-line placeholder the model already saw, no thinking keys go out. A declared-on capability is sent as before. An undeclared image capability is sent as before; an undeclared reasoning capability carries no thinking keys, as before. The rules that already shape a request stay (the anthropic budget lane's cap-wins skip, an effort tier the lane cannot spell, media stripped from an error tool result), and earlier reasoning in the transcript is replayed by each lane's own replay rule, not by this reading.
|
|
378
|
+
- **The person is told (user-visible, open to veto).** When a session's request replaces images with the placeholder because the model's entry declares no image input, the person gets `model.capability_downgraded` once per conversation per model (the conversation is the session, and what remembers it is the session's announced record: a child leg that starts from a record already holding the digest — a fork of a session whose notice has flushed — says nothing more, and one that starts without it, a child spawned before its parent's record flushed, can say it again for the same images): `detail: { model, capability: "image", status: "declared_off", sessionId, runId }`, and a message naming the model, why, and the two ways out (declare `input: ["text","image"]` if the model reads images, or switch models). The operator trace frame `vision.placeholder` is written from the same report, per request, as before. A reasoning capability a model declares off is never reported here — its face is the reasoning resolution's drop arm (`TaskResult.effectiveReasoning.dropped`). The account rides a leg-wide sink, so the end-of-task compaction reports to its own leg and a child leg reports to its own session, while `Runner.sideQuery` (sessionless; a tool's utility query) is shielded from it — an image a utility model does not take is that query's fact, not the conversation's; the once-per-conversation record is best effort (a first report after the leg's record is flushed, or a lost append, is said again by the next run).
|
|
379
|
+
- **Disclosed gap until slice S2.** An undeclared capability is "tried" only for images; an undeclared reasoning capability is not tried yet. And a provider that refuses a capability it was sent (an undeclared image on a text-only endpoint) fails the request loudly, as today: the retry without the capability, the per-endpoint memory of the refusal and the `downgraded` status arrive together with the reasoning flip in S2.
|
|
380
|
+
- **Hard-breaking (open to veto) — the two members are optional.** What changed: `Model.reasoning?: boolean` and `Model.input?: ("text" | "image")[]`; absent means "not declared". Who is hurt: TypeScript code that reads `model.reasoning` as a `boolean` or calls `model.input.includes(…)` without a guard stops compiling; @server's `mintCapabilityKeys` cast (`src/model-select.ts`) is no longer needed. How to migrate: handle `undefined` as "not declared"; writers change nothing.
|
|
381
|
+
- **Hard-breaking (open to veto) — a declaration of the wrong shape is refused.** What changed: a `reasoning` that is not a boolean, or an `input` that is not a list of strings, throws a `TypeError` naming the model and the key, instead of being read by truthiness (`reasoning: 1` was on; `input: "image"` was on). The task's own model is judged at the task's model door, beside its `extraBody`, before the session is acquired or an environment is minted, so the run fails with those words and holds nothing; any other model (a degrade target, the compaction model, a role's model) is judged where it is first read — a request's build fails with the words; a model a `classifier` role names for the auto-mode classifier is first read inside its lanes, whose fail-closed answers (`role_unresolvable` / `unavailable`) carry no words; a model the engine defaults the classifier seat to when no role names one (the tier below the main model, the `summarize` → `default` role chain) is first read where the default is announced, at arming, so a wrong shape there fails the run's preparation with the words, before any request. Who is hurt: untyped JavaScript callers that pass such values; and a catalog holding one entry of the wrong shape — a `select` reads, for each entry, the declarations its needs ask for, up to the first need the entry does not meet (`matches` in `select-model.ts`), so an entry whose `input` is the wrong shape makes every selection with a `vision` need throw its `TypeError` (and one whose `reasoning` is the wrong shape every selection with a `reasoning` need that reaches it; an entry that fails an earlier need is never read for a later one), and a role whose `select.needs` names such a need fails to resolve. How to migrate: pass `true` / `false` or omit; pass a list.
|
|
382
|
+
- **Hard-breaking (open to veto) — the `"document"` member is not read.** What changed: the Read tool's PDF profile grants native document blocks on the anthropic lane when image input is held, and nowhere else; an `input` list containing `"document"` grants nothing more. Who is hurt: only a caller of `pdfModelCapabilitiesOf` passing that member (the `Model` type never allowed it). How to migrate: none on the anthropic lane; elsewhere documents were never sent.
|
|
383
|
+
- **Hard-breaking (open to veto) — the notice catalog.** What changed: `ENGINE_NOTICE_CODES` gains `model.capability_downgraded` (audience `user`, minted by the engine; it always carries `sessionId`). Who is hurt: a consumer switching exhaustively over the catalog or holding a whitelist of user codes — no row of audience `user` was about media before this (`tool_result.media_refused` is the operator's), and this is the first. How to migrate: add the row; render the message by code.
|
|
384
|
+
- **Hard-breaking (open to veto) — selection reads the same table.** What changed: `selectModel` (and a `{ select: { needs: ["vision"] } }` role) matches an entry without `input` for a `vision` need, because an undeclared image capability is held. Who is hurt: a catalog that leaves `input` off a text-only entry and relied on selection skipping it. How to migrate: declare `input: ["text"]` on that entry.
|
|
385
|
+
- **Not changed here.** `contextWindow` stays a required number and is never read optimistically (the deployment's fallback is the server's). Reasoning replay across a model switch. The document placeholder telemetry is still traced under `vision.placeholder` (a candidate). The auto-mode classifier's sentence about `compat.thinkingFormat` (#1823) is slice S3.
|
|
386
|
+
- **Consumers.** @cli (L-1852): render `model.capability_downgraded` as one line by code — it is the engine's signal for an image the model did not get; the family table that wrote `MODEL_VISION=false` for `deepseek*` is the shell's own (L-1850). @server: S-1661 — drop the `mintCapabilityKeys` return cast; S-1624 — `images_omitted_no_vision_model` and `images_omitted_total` in `boot/resolve-spec.ts` retire (the engine mints the fact; there is no second detector); enter the code in the session-stream whitelist (dedupe unit: the session); S-1625 — the window fallback stays yours. @web-admin: the notice names the catalog row whose declaration withheld the images — a write-back hint for the row's vision bit. @client-core (CC-778): render by code; `caps.vision` `unknown` keeps "send and tell". @sdk: the two optional members pass through. @settings-schema: a catalog entry of the wrong shape now throws at the engine's model door and in `select` (above); the schema that types `Model.reasoning` / `Model.input` and a role's `select.needs` is the place such a value should be refused first.
|
|
387
|
+
- **Pins.** `test/backlog1830-model-capability-tristate.test.ts` (P1 the predicate's three states, the unknown table, six malformed shapes refused with model and key named; P2 the three real assemblers: image × on / off / unknown, thinking keys × on / off / unknown, explicit off × the three; P3 selection, the PDF profile, the effort-tier and off faces, the compaction summary; P4 a real Runner over the completions brain: one notice with its detail and way out, none more on the session's next run, one more for another model, none for undeclared / declared-on images, none for a reasoning drop, a malformed declaration refused before an environment is minted, a tool's side query to a text-only utility model told to no one; P5 the closed sets); `test/backlog1279-child-undrained-to-parent.test.ts` (catalog count 102).
|
|
388
|
+
|
|
389
|
+
### One reasoning wire applier — `applyReasoningToRequest({ api, model, level, body })` writes the thinking keys a tier needs on each wire family, and the headers those keys need, from the one dialect table the three brains now build their requests from; a host that sends its own request to the same endpoints calls it instead of copying a dialect (#2163; additive, the engine's own requests are byte-identical except the two repairs named under "Two repairs" below; hard-breaking: none; closed sets: none; exports: +10 — `applyReasoningToRequest`, `mergeReasoningHeaders`, `mintEffortWireValue`, `lowestReasoningSeat`, `AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS` and the types `ApplyReasoningInput`, `AppliedReasoning`, `ReasoningBudgetKnobs`, `ReasoningRequestModel`, `ReasoningSeat`) (@server)
|
|
390
|
+
|
|
391
|
+
- **What changed.** The completions brain's `applyThinking`, the Messages brain's thinking / effort / beta arms and the Responses brain's `resolveWireEffort` were three private copies of "which keys does this model take for this tier". They are one function in `brain/reasoning-request.ts` now, and each brain calls it. The chat-completions dialects (`thinkingFormat`) are one table; the Messages family's budget form, adaptive form, `output_config.effort`, `context_management` and the three betas are one arm; the Responses family's `reasoning.effort` is one arm. Nothing about a request the engine sends changes, except the two repairs named under "Two repairs" below: 1 818 cells (wire family × model declaration × compat shape × requested tier × output-cap lane × header bag, including a `thinkingFormat` that is an array or an object and a cap an untyped config spelled as a numeric string), each driven through the real brain with a fetch stub, send the same body bytes and the same ordered headers before and after — 1 814 of them byte for byte, the other 4 (a user-declared `anthropic-beta` list on a model that asks for no thinking, see the first repair) differing only in the spelling of that header's NAME (`test/fixtures/reasoning-wire-matrix.golden.json`, captured on the tree before the change; those 4 rows re-captured).
|
|
392
|
+
- **What the exports answer.** `applyReasoningToRequest({ api, model, level, body, budget? })` → `{ body, headers, effort, wireFacts }`: a pure function — the input body is not mutated, `body` is a copy with the thinking keys written (Messages: `temperature` dropped while thinking is on, the cap raised when the budget form needs the room and the cap is not a hard bound), `headers` is the patch the keys need (`anthropic-beta`: the effort beta, the context-management beta, the interleaved-thinking beta — whichever the model entry declares; `{}` elsewhere), `effort` is the effort value the request carries, `wireFacts` the output-cap facts the Messages budget form weighed. `api` is the model entry's `api`; absence is meaningful — an undeclared or declared-off model, a `level` that is `undefined` or not a tier, a tier the wire cannot carry and a `thinkingFormat` that is not a recognised string write nothing. `mergeReasoningHeaders(headers, patch)` folds the patch into the request's own headers: the beta list is unioned with what the request already sends under that name, in any letter case and any number of spellings (existing entries first, as given, then each new entry not already there; entries are trimmed, and a spelling that holds no string is claimed and contributes no entry — a value that is not a string is dropped without a word, and a list left with no entry at all is still written, as an empty `anthropic-beta` header). The Messages brain folds its own header layers through the same function. `mintEffortWireValue(level, model, allowed)` is the clamp + `thinkingLevelMap` translation behind every effort value the engine writes (`declaredOffSpelling`, `thinkingOffSpelled` and `resolveEffort` were already exported). `lowestReasoningSeat(model)` is `off` where the wire spells off, else `low` — the tier an answer-only request takes; its output cap needs `AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS` more room on the `low` seat (`AUTO_MODE_CLASSIFIER_MAX_TOKENS` was already exported).
|
|
393
|
+
- **One rule, not two (C-R767; open to veto).** The classifier's reasoning seat (`classifierRungSeat`) now reads `lowestReasoningSeat` for its tier and adds only its own cap; it stays internal — the verdict is a model-capability fact, not a classifier fact, so the root carries the general name.
|
|
394
|
+
- **New refusal, external callers only.** `applyReasoningToRequest` on the Messages budget form throws a `TypeError` when the body has no `max_tokens` (`undefined` or `null`; the budget is sized from it); the brain always sets one, so no engine request meets this. A cap of any other kind that reads as a finite number — including a numeric string an untyped config produced — is read exactly as the brain always read it; one that does not is the refusal under "Two repairs".
|
|
395
|
+
- **Two repairs (both open to veto; the first moves 4 cells of the equivalence matrix in the spelling of a header name only, the second none).** **The beta list has one reading (widening).** What changed: the Messages brain read the `anthropic-beta` header through the plain header merge — one spelling, the last layer replacing the earlier ones whole — while `mergeReasoningHeaders` unions every spelling, so a list the model entry or the deployment declared was silently dropped when the call also sent one. The brain now rebuilds the list on every request through that same function — every spelling of the model entry's list, the deployment's (`headers` at construction) and the call's, in that order, each entry once and trimmed — and the thinking keys' own betas join it the same way; the list is always written under the lowercase name `anthropic-beta`. Who is hurt: a deployment that declared a list on more than one layer and counted on the later layer replacing the earlier — it now sends the union; and anything that matched the header NAME case-sensitively on a single-layer bag — a list declared as `Anthropic-Beta` or `ANTHROPIC-BETA` on the model entry or the call, on a request where no thinking key adds a beta, used to leave under that spelling and now leaves as `anthropic-beta` (HTTP header names are case-insensitive, and the value of a tidy list is the same bytes; an untidy value loses its blank and empty entries). How to migrate: nothing, if the lists were meant to add up and the name is matched case-insensitively; to send one layer's list alone, declare it on that layer only. **A budget that cannot be sized is refused, not sent as `null` (narrowing).** What changed: on the Messages budget form a `max_tokens` that does not read as a finite number (`NaN`, `Infinity`, a non-numeric string), or a share or fixed budget that makes the budget `NaN`, used to put `"max_tokens": null` / `"budget_tokens": null` on the wire (`JSON.stringify` writes a non-finite number as `null`); `applyReasoningToRequest` and the Messages brain now throw a `TypeError` naming `body.max_tokens`, `budget.share` and `budget.tokens` with the values as read, before any request is made. The same non-finite cap on a request that sizes no budget — thinking off, no tier, or a hard cap of `-Infinity` (which skips thinking) — is not read by this refusal and still reaches the wire as `"max_tokens": null`, as before. Who is hurt: only a caller or a configuration whose value was already unusable — its request carried a null cap or budget, and now fails at the build with its words; numeric strings from an untyped config (`"4096"`), a hard cap under the floor (thinking skipped) and an infinite share or budget the cap clamps are accepted as before. How to migrate: correct the value; a title generator or side query that passes a cap from its own configuration should check it is a finite number.
|
|
396
|
+
- **Not changed.** `reasoningRequestCarried`, `resolveReasoning`, `reasoningEffortLevelsOf` and `thinkingOffSpelled` read the same predicates as before; the reporting resolver and the wire still cannot drift because both read them. `Model.reasoning` stays read through `capabilityOf` / `capabilityHeld`: a wrong-shape declaration throws from the applier on every wire family.
|
|
397
|
+
- **Consumers.** @server (S-1904, the titler in `hookLlm`): delete the hand-copied dialect; build the body, call `applyReasoningToRequest({ api: model.api, model, level: lowestReasoningSeat(model), body })`, send `result.body`, and fold `result.headers` into the request headers with `mergeReasoningHeaders` — the Messages effort arm needs `anthropic-beta: effort-2025-11-24`, so merging the body alone is not enough, and assigning the patch over your headers would drop a beta the request already carries. Size the answer cap with the seat: on the `low` seat add `AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS` to it (a hard cap below the Messages budget form's floor skips thinking — `result.body.thinking` is absent then).
|
|
398
|
+
- **Pins.** `test/backlog2163-reasoning-applier.test.ts` (E1 the 1 818-cell equivalence against the golden; E2 the host's call writes the keys the brain's own request carries, for every cell; S1 the seat verdict and the classifier's seat reading it; A1 purity and a title generator's forms; A2 bad values; A3 the header union; A4 root identity and no dialect key left in the brains' code; F1 the beta list through the brain's three header layers and past a spelling that holds no string, the budget refusal on the host's call and on the brain's path, the role-defaulted line reading the seat and a defaulted seat read at arming); `test/backlog2163-seat-announce.test.ts` (the seat's verdict replaced by one that disagrees with the predicate: the seat and the role-defaulted line both follow it); `test/export-surface.snapshot.json` +10; two ratchets banked down for the comments and the compatibility words that left the brain bucket with the code (`comment-ratio-ratchet` `src/brain` 211 → 190, `legacy-residue-gate` `src/brain` 9 → 7), and the `thinkingBudget` exemption of `defectscan-truncation-invariant-surface` names the function's new file; `test/backlog1549-extra-body-shape.test.ts` reads the first argument of `applyExtraBody` by any name (the brains hand it the applier's returned body).
|
|
399
|
+
|
|
400
|
+
### The model is told when a declared MCP server did not connect — on the leg's first request, by default, in its own frame `mcp_unavailable` that no longer waits on `toolsDelta` (#1551 slice 1; user-visible; behavior change, open to veto; differs from the reference implementation, whose frame needs tool search active, open to veto; hard-breaking: two, open to veto; closed sets: the `steering_injected` `source` union 19 → 20 (+`mcp_unavailable`), `AttachmentSource` 16 → 17; exports: none; members: +1 optional `TaskSpec.attachments.mcpUnavailable`) (@server @cli @client-core @dgx @test)
|
|
401
|
+
|
|
402
|
+
- **Before.** A declared server that failed to connect was told to the model only as the failed arm of the `tools_delta` frame: off unless `attachments.toolsDelta: true`, and carried only by a turn boundary — so a model that answered its first request in text (no tool turn, no boundary) never saw it, whatever the switch. The sentence also said the tools were unavailable "for this session", while the next run dials every declared server again.
|
|
403
|
+
- **Now.** One frame, `mcp_unavailable`: default ON (`attachments.mcpUnavailable: false` turns it off, for the request's engine-built child legs too; a value that is not a boolean is refused at the task's door — `config.invalid_argument`, naming the key and the kind it received, never the value — rather than read as either state), riding every leg's first request — a fresh leg's first user message beside the date / listing / output-style frames, a resumed leg's continuation beside its git / posture / output-style frames — once per session per (server, error) as before: the `announced_listing.mcpFailed` record advances on the receipt of the message that carried it, so a prompt a hook kept back, or a continuation never sent, records nothing and the next run tells it. No turn boundary carries it. Text: "…are unavailable for now:" + the entries + "Each is tried again when the next message arrives; whether it connects then depends on the error shown." + the reference's closing sentences. `tools_delta` keeps its added / readded / removed arms, and `Prepared.toolsDeltaRef` is minted only when something is deferred.
|
|
404
|
+
- **Hard-breaking (open to veto) — the notice's source.** What changed: the failed-server notice is a `steering_injected` event with `source: "mcp_unavailable"`, never `tools_delta`. Who is hurt: a reader or a test that finds the notice under `tools_delta`, or a closed switch over the source union. How to migrate: read `mcp_unavailable` (client-core's open label table falls to its default label until a row is added).
|
|
405
|
+
- **Hard-breaking (open to veto) — default on.** What changed: a host that never turned `toolsDelta` on now gets the frame when a server fails, on the first request. Who is hurt: a probe pinning "no frame without opt-in"; a host whose "all attachments off" switch lists only the earlier default-on keys. How to migrate: send `attachments.mcpUnavailable: false` once on the request — the switch rides the parent spec seat, so the request's engine-built child legs are switched off with it — and server's request intake (`src/spec-fields.ts`) has to pass that boolean through, and client-core's `SEMA_ATTACHMENTS=off` expansion has to learn the key, or a deployment's "off" does not cover this frame.
|
|
406
|
+
- **Behavior change (open to veto) — the attachments declaration rides the parent spec seat.** What changed: `ParentSpecSeatFields` / `ParentSpecSeat` carry `attachments` whole (an ordinary member of the seat's fill law, one more row of its table), so an engine-built child leg — the Agent tool's, the observer sidecar's, a workflow node's — is switched the way its parent request is; a child spec that authors `attachments` at all keeps exactly what it authored, never half to the parent. Who is hurt: a host that opted a lane in for the request (`todoReminder`, `changedFiles`, `toolsDelta`, `mcpInstructions`, `budgetUsd`, `totalTokensReminder`) and relied on its delegates not carrying it — they now carry the same frames — or that switched `agentListing`, `skillsListing`, `backgroundTasks` or `mcpUnavailable` off and relied on its delegates keeping them on. How to migrate: declare a lane on the request only if its delegates should carry it too; a workflow node or host-run child spec that authors its own `attachments` keeps it.
|
|
407
|
+
- **Behavior change — a switch nobody can read is refused.** What changed: `attachments.mcpUnavailable` that is not a boolean (`0`, `null`, `"off"`, an object, a list) refuses the task at the attachments door with `config.invalid_argument` (`detail.field` `TaskSpec.attachments.mcpUnavailable`), before any server is dialed or model request made, on a fresh run and a resume's re-supplied config alike; it used to be read as the default (on), which a deployment that meant "off" took for off. Who is hurt: no released spec — the key is new in this release; only a host sending it as a string or number. How to migrate: send `true` or `false`, or omit the key. The other attachment switches read as before.
|
|
408
|
+
- **Differs from the reference (open to veto).** The reference produces its deferred-tools frame only while tool search is active (cli296.js:250049); this engine tells the model whenever a declared server failed, deferred tools or not — the safe direction, since otherwise a missing tool reads as a missing capability.
|
|
409
|
+
- **Not changed here.** The operator line `mcp.server_unavailable`; the status record and the wiring manifest; which servers are dialed; a failed server's tools still leave the tool block (keeping them listed from the server's last listing is slice 2's opt-in). A host cannot drop one server's entry, and the station that disposes of server instructions (`prompt.section`) is not asked: an availability fact the engine authors is not the server's text.
|
|
410
|
+
- **Consumers.** @server: pass `attachments.mcpUnavailable` (boolean) through the request intake — a non-boolean is refused by the engine — and note that the request's attachments now reach its delegates (open to veto). @client-core: add `mcpUnavailable` to the default-on key tables so "off" stays all off; optionally a label for the source; a lane the request turns on now shows on its delegates' legs too (open to veto). @cli: render `mcp_unavailable` like the other model-facing system frames. @dgx (ha-brain): nothing to turn on — the frame arrives with the core that carries this release.
|
|
411
|
+
- **Pins.** `test/backlog1551-mcp-unavailable-lane.test.ts` (real Runner, a spawn-failing stdio server, a brain that answers its first request in text: L1 the first request carries the notice ahead of the objective, one echo, the session records it; L2 `false` turns it off; L3 with `toolsDelta` on, tools_delta carries none of it; L4 once per session, a fresh session told again; L5 the sentence; L6 no server ⇒ no frame; L7 a server that dies across an approval pause is told on the resumed continuation, answered in text; L8 the switch's reading, a value it cannot read refused by name; L9 a first prompt a `userPromptSubmit` hook kept back records nothing and the next run is told; L10 a parent that switched the notice off switches it off for the child it delegates to, with the default control; L11 five unreadable values refuse the task with no model request and no frame, the good spellings run; L12 a lane the parent opted into — `changedFiles` — reaches the child's legs, with a no-declaration control; L13 the seat's fill law for `attachments`: a child's own declaration wins whole, an unset one gets a copy); `test/backlog1950-resume-config-doors-preclaim.test.ts` gains the unreadable-switch row (a resume's re-supplied config is refused before the claim); re-pinned to the new source: `rb309-tools-delta-mcp-arms-e2e`, `backlog560-announce-once-per-session` (its two stop / halt pins now probe the boundary-only dropped-tools once-arm), `turn-attachments-g1-tools-delta`, `backlog818-intact-lane` (the new frame's bound), `prepare-tool-disclosure-mount(-transcript)`, and the registry / source-union tables (`prompt-assembly-event-registry` — the fifth default-on kind, argued there —, `event-registry-completeness`, `backlog1255-output-style`).
|
|
412
|
+
|
|
413
|
+
### The allow layer's `hook` member has one clear and one record whichever event speaks it: a `tool.check` `allow` that clears an ask is recorded as the hook member's clear (`clearedBy`), as a `classic.PreToolUse` `allow` already was, and the member's disclosure is one record naming every registration that spoke (#1406; behavior change, open to veto; wire: `tool_end.gate.disposition.clearedBy` one more presence case, `AskRequest.allowRuleShadowed` one `hook` record where there were several)
|
|
414
|
+
|
|
415
|
+
- **Before.** Two events speak "this call needs no asking" for an extension: a `classic.PreToolUse` `allow`, read as the allow layer's `hook` member at the head of the layer, and a `tool.check` `allow`, read behind the read-only arm over the verdict the layer left standing. Both cleared through the same predicate, but only the first wrote the allowed disposition's `clearedBy: { member: "hook", entries: [<registration>] }` — the gate record of a call a `tool.check` extension cleared read like a call nobody was asked about, so `tool.call` took an extension's `{ result }` in place of the body there and refused it on a call a PreToolUse extension had cleared. A mandated ask both events answered `allow` over carried two `member: "hook"` disclosure records (more with inherited installations), where the layer's rule is one record per member.
|
|
416
|
+
- **Now.** One mint for the hook member's clear (`extension "<name>" allowed this call`), its `clearedBy` written from both places; one record per member in `allowRuleShadowed` — its entries are every speaking in reading order (two registrations whose shown names read alike stay two entries; a registration that speaks in two installations is listed twice), five shown and the rest counted. Positions are unchanged: a PreToolUse `allow` is still the allow layer's first member — the rule store is still read once before it, for its deny and ask rows, but a call it clears is not put down to a stored allow row (that row's sink is not told) and the read-only reader does not run — and `tool.check` is still raised over the verdict the layer left standing, as the reference engine reads its two events at two entry points. Which calls run and which are asked about is unchanged; neither clear touches the classifier's denial streak.
|
|
417
|
+
- **Who is hurt.** A reader that took an absent `clearedBy` as "nobody was asked" on a call a `tool.check` extension cleared (it now reads `member: "hook"`); a `tool.call` `{ result }` on such a call is refused now — the tool's body does not run either way, the model reads the refusal instead of the extension's output (`tool.call` is new in this release: no released host relied on the old reading); a card or a view that counted `hook` records (one now, every speaking in `entries`).
|
|
418
|
+
- **Consumers.** @server (the trace projection of `clearedBy`; pass-through) · @cli (the card shows one hook row) · @client-core (one record per member in the view).
|
|
419
|
+
- **Pins.** `test/backlog1406-extension-clear-one-reading.test.ts` (X1–X6 and X9–X15: the clearance record from both events, one hook record beside the other members' — inherited installations and the approved-edit round included, two look-alike names kept apart, the count past five exact — a real Runner's `tool.call` substitute refused after either clear, and the two positions — a PreToolUse clear ahead of the read-only arm and a stored allow row with the store read once and a stored deny row still refusing, a stored row ahead of `tool.check`); `test/mods-tool-check.test.ts` G3 re-pinned to the one record.
|
|
420
|
+
|
|
421
|
+
### A persisted-rule lane's answer the gate cannot read is the lane's `unreadable` word, never "no rule" — `normalizePersistedRuleHit` answers `{ unreadable: true, reason }` (the reason names the host's lane `persistedRules.adjudicate` and the failed field) for every malformed answer, and `{}` only for the lane's own no-hit words (#2302; security fix and behavior narrowing, open to veto; hard-breaking: none — return type unchanged; closed sets: none; exports: none) (@server @cli)
|
|
422
|
+
|
|
423
|
+
- **Before.** `normalizePersistedRuleHit` (the one screen every consumer of a persisted-rule lane reads through: the live gate's lane station, the synthetic Read / write judge, the resumed-call belt) answered `{}` — "no rule speaks" — for every answer it could not read: a `behavior` outside the closed set (`Deny`, `42`, absent), `rules` absent / not an array / empty, a member that is not a record or whose `rule` is not a string, a `null` / string / number answer, an object that is none of a hit, an unreadable report or a coverage table (`{}`, the retired `{ rule }`). Its own `unreadable` arm was therefore unreachable from a malformed answer, and a host whose lane misspelled a deny's behavior had that deny silently fail: the default mode handed the call to the classifier and `bypassPermissions` ran it. The org lane read the same garbage as `unavailable` (fail closed) — two answers, in two places, to "the lane gave an answer nobody can read".
|
|
424
|
+
- **Now.** One reading. `{}` is only the lane's own no-hit words: `undefined` (the store was read and no rule speaks) and a coverage answer (`{ segmentCoverage }`, whose malformed table is advisory and reads `{}`). Every other answer the lane gives that the screen cannot read is `{ unreadable: true, reason }` — the call form: the gate puts a person on the call (a real-approval ask of class `rule_unreadable`, origin `rule_store_unavailable`, the evidence `unavailable`), no mode releases it (`bypassPermissions` keeps it for a person — the same row the org lane's `unavailable` reads), a run with no approval face refuses the call, the synthetic judge answers `unanswerable` (`call_unreadable`) and the resumed-call belt spends the approval. The ask's reason names the seat and the first field that failed — `` `behavior` is not one of deny / ask / allow ``, `` `rules[1].rule` is not a string ``, `` `rules` is empty ``, `` the answer is `null` `` — from a fixed vocabulary and a row index; a lane's own values never ride it. A readable deny, ask or allow keeps its reading; the lane's own `{ unreadable: true[, reason] }` is carried as it was. No new code, class, origin, notice or form: the arm that was unreachable is now reached, and the helper that builds it is the only new line of reading.
|
|
425
|
+
- **What reads unreadable that read "no rule" (open to veto).** `null` (the contract's no-hit word is `undefined`; a lane bridged over JSON that answers `null` for a miss is now asked about), an empty `rules` (the `PersistedRuleHit` note said "not a hit"; a lane that builds `{ behavior, rules: matches }` and hands over an empty `matches` for a miss is now asked about), a bare string, the retired `{ rule, dots? }` object and a `{ rules }` hit without its behavior (the retired shapes the notes already said the screen does not accept), and any carrier that is not a plain own-data record. The engine's own lane never answers any of these (its answers are `undefined`, a hit with at least one rule, the unreadable report and the coverage answer), so a deployment on the bundled rule store sees no change.
|
|
426
|
+
- **Who is hurt.** A host that drives `runToolGate({ persistedRules })` directly, or calls `normalizePersistedRuleHit`, with a lane that answers one of the shapes above: its calls on the lane's tools now ask a person (default and bypass) or are refused (`dontAsk`, or no approval face) instead of running as "no rule" — loudly, naming the field. The Runner mints its lanes from `permissionRuleStore`, so a Runner-hosted deployment cannot hit this.
|
|
427
|
+
- **How to migrate.** A lane with no rule for the call answers `undefined`; a hit answers `{ behavior: "deny" | "ask" | "allow", rules: [{ rule, dots? }, …] }` with at least one member; a lane that cannot read its store answers `{ unreadable: true }`. *server*, *cli*: `ToolGateInput.persistedRules` is not wired in either checkout as read here (inferred from a text search of `src`, not run: your `persistedRules` wire key is the approval-response echo of the engine's own lane, a different seat) — confirm by search; nothing to change if so.
|
|
428
|
+
- **A hit the screen cannot inspect is not a coverage answer.** An answer whose `behavior` / `rules` (or `unreadable`) are accessors — never invoked — beside a `segmentCoverage` table read as the coverage no-hit; it is now unreadable (the coverage reading applies only to an answer that bears none of `rules`, `behavior`, `unreadable`), and a `rules` array whose `length` is not one positive integer, read once, is unreadable.
|
|
429
|
+
- **Pins.** `test/backlog2302-lane-answer-unreadable.test.ts` (S1–S19 on the normalizer, on the resumed-call belt's composition, on the synthetic Read judge and on a real Runner under default, bypass, `dontAsk` and no approval face with the lane's answer injected where a host's lane would sit; the org lane's garbage and the persisted lane's garbage read the same class and disposition; well-formed deny / allow / ask and the no-hit words keep their readings; mutations M1–M8, per-fixture table in the receipt), and the `design252-ask-rule-evidence` cells re-pinned to the new reading (`unavailable`, not `no_match`).
|
|
430
|
+
|
|
431
|
+
### The sentence a user reads after a fail-closed refusal nobody could be asked about offers the approver and no longer teaches `bypassPermissions`; the release rides the `delegation.ask_unresolvable` notice as `detail.remedies`, a closed-set member a host renders or leaves (#2422; user-visible wording change, open to veto; additive member; hard-breaking: none; closed sets: `ApprovalRemedyKind` new, `approver` / `permission_mode`; exports: `APPROVAL_REMEDY_KINDS`, `ApprovalRemedy`, `ApprovalRemedyKind`) (@server @cli @client-core)
|
|
432
|
+
|
|
433
|
+
- **Before.** An ask that reached its final deny with nobody to answer it (no approver wired, a wired one that answered `unavailable`, or a run that declared nobody on it can be asked) ended its tool text, and the notice's `detail.remedy` and `message` ("To let it run: …"), with one sentence naming the way out. On two arms that sentence said "run with `permissionMode: "bypassPermissions"` to release this ask (the release is disclosed on the call), or" and then the approver clause: for a tool that states no permission of its own (the clause stood between the allow entry and the approver), and for every ask no other arm names — a Bash call under `default` that needs approval is one. A user read that beside a client's own way out ("upgrade"): two ways out on one screen, one of which teaches opening every permission.
|
|
434
|
+
- **Now.** On those two arms the sentence is the approver clause alone, in the clause of the seat's own state (`wire an approver (`onAsk`) to put it to a person`; `make a person reachable through the approver that is wired (it answered `unavailable` for this ask)`; the declared-nobody form). The arms that name `acceptEdits`, `additionalDirectories`, an allow entry, leaving plan mode or an immunity no mode lifts keep their sentences. The release is data: `delegation.ask_unresolvable` `detail.remedies` — a non-empty list over the closed set `APPROVAL_REMEDY_KINDS` — carries `{ kind: "approver" }` on every arm that carries a remedy and, on exactly the two arms above, `{ kind: "permission_mode", value: "bypassPermissions", disclosedOn: "gate.disposition.released" }`; `disclosedOn` is where that release is disclosed (the allowed disposition's `released` member on the call's `tool_end.gate`), the note the sentence used to carry in parentheses. `remedies` rides exactly when `detail.remedy` is the headless remedy's sentence (a binding's own sentence and `mandate_unreconstructible` carry none); a notice minted without it is byte-identical to one minted before. The notice mint throws a `TypeError` for a `remedies` that is empty, outside the closed set, of another shape, or has no remedy sentence beside it. One decision gives both: `headlessApprovalRemedy` returns `{ sentence, remedies }`, and the gate's exit hands the notice the pair it read.
|
|
435
|
+
- **Who is hurt (wording change, open to veto).** A reader that matched "run with `permissionMode: "bypassPermissions"` to release this ask" or "the release is disclosed on the call" in a refusal's tool text or a notice's message (a probe, a test, a prompt that quotes it): those words are gone from both. As read in the neighbor checkouts (inferred from a text search of `src` and tests, not run): *server* matches only the approver clause (`test/s654-unstated-statements.test.ts`, unchanged by this) and names the old words in a `run-local.ts` comment alone; *cli* renders the engine's `message` whole (`upstreamBridge.ts`, the 400-character budget; its fallback line reads `detail.remedy`); *client-core* reads `detail.remedy` as an opaque string and has no `remedies` member yet — none matches the removed words. An embedder that learned the release from the sentence reads it from `remedies`, or from `docs/INTEGRATION-CORE.md`.
|
|
436
|
+
- **How to migrate.** Nothing breaks: the member is additive and an older host ignores it. *server*: pass `detail.remedies` through on `engine_notice` and render nothing. *cli*, *client-core*: render by `kind` or leave it unrendered — `approver` is what the sentence already says; `permission_mode` is an offer a host makes only where its operator allows the mode, with the disclosure `disclosedOn` names beside it; a `kind` outside `APPROVAL_REMEDY_KINDS` belongs to a newer engine and is ignored.
|
|
437
|
+
- **What the member does not list.** The sentences of the arms above that stay name `acceptEdits`, `additionalDirectories`, an allow entry or leaving plan mode; those are narrower than the release the sentence stopped teaching and are not in `remedies`, which today lists the approver and that one release.
|
|
438
|
+
- **Pins.** `test/backlog2422-refusal-remedies.test.ts` (a real Runner: a headless Bash call under `default` and under a wired approver that answers `unavailable` — the tool text has no `bypassPermissions`, the notice carries the approver and the release; the refusals that never offered the release carry the approver alone; a matrix over every mode, class, seat and covered-write form for the sentence and for exactly where the release is a member; the notice mint; mutations M1–M6, per-fixture table in the receipt), and the `backlog1137` / `backlog1314` sentences re-pinned.
|
|
439
|
+
|
|
440
|
+
### An engine write to a memory store file changes only the name it writes: a file that also has another name (a hard link) gets a new file under this name instead of a refusal, and the staging name a restore stopped between its `link` and its `unlink` left beside a seat is removed by the next write that meets it (#1105, design/1105; ruled C-R774; behavior widening — a refusal becomes a performed write, open to veto; fix, user-visible: a copy-out mount over such a seat failed on every session and now mounts; hard-breaking: none; closed sets: none; exports: none; members: none; wire: none — the uncoded `<path> has N names (a hard link) … not written` text is gone) (@server @dgx)
|
|
441
|
+
|
|
442
|
+
- **What changed.** `writeFileNoFollow` — every engine write at a store path that follows an earlier read: the containment and read-only-layer restores, the copy-out projection and its write-back, the seat repair, the id strips, the memory index writes — opens the path no-follow for writing and counts its names. One name: rewritten in place, as before (its inode and mode stay). More names: first, every staging name an engine minted beside the seat that is the same file is removed (the interrupted restore's last step; it loses nothing the write does not overwrite); a file that still has another name is not written in place — this name gets a new file, written the way the store commits a file (`atomicWriteFileSync`: a sibling created exclusively and no-follow, 0o600, renamed over this name), and the other names keep their file, its bytes and its mode. This is the answer the store's other three writers already gave (the File backend's commit and its inbound-gate restore rename a new file into place; a host's Write tool stages and renames) and the reference implementation's (its file writer renames a new file over the target; an in-place fallback refuses a hard link only for a caller that asks).
|
|
443
|
+
- **Who is hurt.** A deployment that hard-links a store file somewhere else and relied on the engine's restores or projections reaching the other name (the commits never did); the name the engine gives a new file is 0o600 and owned by the engine's process. A consumer or test that matched the old refusal text (none found in server, cli or dgx).
|
|
444
|
+
- **How to migrate.** Nothing to read: keep two copies in sync with a copy or a symlink outside the store, not a hard link (a symlink at a store path is still refused at the open). A directory the new file cannot be created in fails the write with its errno (`EACCES` / `EPERM` with the existing permission remedy inside the store, `EROFS`, `ENOSPC`, `EEXIST`) — never a fallback to the in-place write.
|
|
445
|
+
- **Not changed.** A symlink at the name is refused at the open (`ELOOP`); a file whose own bits refuse the write is refused at the open (`EACCES`) — the new file is only ever made for a file this process could have written in place; a single-name file keeps its inode and mode (the reasons an earlier attempt to route every such write through the rename, which replaced symlinks, bypassed write protection and changed every inode, was not taken).
|
|
446
|
+
- **The engine's own leftover.** A staging name in the engine's one staging grammar for its two owners — an instruction hold (`hold-<ms>-<12 hex>.staging-<ms>-<pid>-<seq>`) and a crash-residue capture (`<ms>-residue-<id>.md.staging-<ms>-<pid>-<seq>`, and that containment's `<ms>-residue-<id>.md.staging` of 7.55.0 and earlier), any `.claim-<pid>-<seq>` suffixes — beside the seat, that is the same file (device and inode). A name that only looks like a staging name (`backup.staging-1-2-3`) is not the engine's and stays. The crash-residue containment now mints its staging name through the same minter as the instruction hold. The hold reconciliation retires such a generation into custody like any other but no longer announces "could not be restored onto the plane" while the seat still holds those bytes.
|
|
447
|
+
- **Fix: the committed copy an UPDATE-form hold puts back lands only in the seat the hold vacated.** When an instruction-form file's bytes were replaced between the harvest's read and the hold's move, the hold puts the newer bytes back on the seat (or a third writer re-created the seat first); the UPDATE form then wrote the committed copy over them. It now writes no-replace (an exclusive create): a writer who holds the seat keeps it, the next harvest judges those bytes, and `report.restored` names only a restore that landed.
|
|
448
|
+
- **Atomic writes.** `atomicWriteFileSync` creates its temporary sibling exclusively and no-follow: a name already standing there is an `EEXIST` refusal, never a file it truncates or writes through (every caller: the control plane's sidecars, the File backend's journal replay and inbound restore, and now the detached write).
|
|
449
|
+
- **Known limits.** A path replaced by another file between the open and the rename is replaced again — the window every rename-based store write has (KL-1105-a). A leftover staging name no write meets stays beside its seat as a non-entry file (KL-1105-b). KL-1094-g is closed.
|
|
450
|
+
- **Pins.** `test/backlog1105-hardlink-second-name.test.ts` (the engine's own leftover removed and the write in place; the old residue spelling; a minted-grammar file that is a different file left alone; a planted link to a file outside — new file here, the outside file's bytes and mode untouched; a host's hard-link copy kept; a symlink still refused; write protection not bypassed; a directory that refuses the new file fails with its errno and writes nothing through; the exclusive temporary sibling; a copy-out mount over an interrupted restore and over a `cp -al` snapshot; the hold reconciliation's disclosure and its control; a host backup spelled like a staging name kept; the written path never taken for a leftover; the minter and the UPDATE-form no-replace restore in the source; end to end on a File store, a writer who takes the seat an UPDATE-form hold vacated keeps it, and its control); `test/a005-memory-engine-nofollow.test.ts` site table: the UPDATE-form restore is the exclusive create; `test/backlog1094-memory-target-realpath.test.ts` ⑳a / ⑳b / ⑳c re-pinned from the refusal to the new file.
|
|
451
|
+
|
|
452
|
+
### `Runner.rewindFiles({ sessionId, to, dryRun?, signal? })` rewinds a session's files outside any run — the code-only rewind with no model call and no session entry — and is the same history operation as `rewindFilesTo`; every `refused[]` row of a restore names its class, and the file-history `conflict` code means one law (#1130 / #2177, design/1130; behavior widening — a new verb; hard-breaking for a file-history backend outside this repo: two store-contract shapes; closed sets: +2 refusal codes from the new verb only, `rewind.session_busy` / `rewind.files_env_unsupported`, and a new closed set `FILE_HISTORY_REFUSAL_CLASSES` (3); exports: +`annulProofFailure`, +`FILE_HISTORY_REFUSAL_CLASSES`, +types `RewindFilesRequest` / `RewindFilesResult` / `FileHistoryRefusalClass` / `FileHistoryMintProof`) (@server @client-core @cli)
|
|
453
|
+
|
|
454
|
+
- **What changed — the verb.** `runner.rewindFiles({ sessionId, to })` converges the session's agent-edited files to the boundary of turn `to` (the entry id of the turn's FIRST user entry — the id space `TaskSpec.rewindFilesTo` addresses; an entry with no boundary of its own refuses, exactly as there) and answers `{ dryRun: false, restore }`, the store's ledger as it answered (`complete`, or `partial` with every refused and failed file). `dryRun: true` answers `{ dryRun: true, preview }` from the store's optional `diffStats` and writes nothing to the working tree; `preview: null` means no preview for this request (the backend has no `diffStats`, or answered none) and is distinct from an empty answer. It reuses what a `rewindFilesTo` run uses — the deployment door (`onTrackFailure` / `fileHistoryStore` / `fileHistoryCapture`), the env and initial root (a factory-minted env is minted for the rewind and destroyed after it), the history coordinates, the fork carry, and one restore reader — so `fileHistoryCapture: "off"` still restores, and there is no `TaskSpec.fileHistory` outside a run. There is no `acceptPartialRestore`: a run refuses a partial restore because it would go on working on a mixed tree; the rewind ends, and its caller reads the ledger.
|
|
455
|
+
- **What changed — admission and refusals.** In order: a bad request (`rewind.invalid_spec`: a request that is not a plain data object, judged before any member is read — a Proxy of any kind, even a transparent or revoked one, and an object whose prototype is neither `Object.prototype` nor `null`, an object built in another realm (a `vm` context) included, since it carries that realm's `Object.prototype`; `sessionId` / `to` not non-empty strings, `dryRun` not a boolean, `signal` not an AbortSignal, an unknown key, a member that cannot be read — a getter member is refused, never called). The request is taken as data once, so a Proxy that tells the caller `dryRun: true` can never become a real rewind, and no trap of the request runs; a host that wraps its request in a Proxy, or builds it in another realm, passes a plain copy (`{ sessionId, to, dryRun }` written out in its own realm); the deployment door's `config.*` codes and `rewind.store_unconfigured`; **`rewind.session_busy`** — a leg of THIS Runner is running or queued on the session (otherwise the rewind holds the session's lock until it ends, so a leg that arrives meanwhile waits for it); `resume.session_not_found`; **`rewind.files_env_unsupported`** — no filesystem-capable env (a run with a restore request notes the same fact as `files_env_unsupported` and runs on); `rewind_snapshot.unresolvable` (a preview's sentence says "no usable boundary": its probe cannot tell a boundary never made from one that cannot be read); `rewind.restore_failed` with `detail`. All are thrown `Error`s carrying `code`.
|
|
456
|
+
- **What changed — the read state.** The paths a restore moved go to the Runner's session read-state seats, and the session's next leg checks each against the disk after its seeding rungs (the agreement check a run's own restore always had), settling the batch only once its map is adopted — so after `rewindFiles`, the next turn's Edit on a restored file is refused as unread instead of "modified since read, either by the user or by a linter". The run's own restore uses the same channel, which also covers a partial restore the run refused (its written files used to keep their stale read records).
|
|
457
|
+
- **What changed — the ledger and the proof read.** `FileHistoryRestoreResult.refused[]` rows carry a required `class`: `link_safety` (a symlink or other non-regular object at the path, a parent chain that moved, a guarded write's precondition — the reference's `skippedLinks`), `base_unavailable`, `key_unresolvable`; damage to the store's own records is a `failed` row in every backend. `worldStillMatchesMint` answers `{ verdict: "unchanged" | "kept" | "unjudged" }`: a proof read that could not re-read the path judged nothing, so `annulTrack` answers `track_failed` (`aborted` when the signal fired) there, no longer `conflict`; `annulProofFailure` is the one mapping. `FileHistoryError.code`'s `conflict` is documented as the one history-preservation law it is (the empty-target law of `adoptScope` / `importHistory`, and `annulTrack` of a record that is no longer just the caller's mint).
|
|
458
|
+
- **Who is hurt.** A file-history backend outside this repo (server's `SqlFileHistoryStore`): its `restore` must put a `class` on every refused row — copy `resolved.class` / `applied.class` from the shared helpers it already calls and write `"base_unavailable"` on its own DV-14 arm; a refusal for a corrupt mode record is store damage and belongs in `failed`. Its `annulTrack` reads `proof.verdict` instead of `proof.unchanged`, and an unread proof is no longer `conflict`. A host that counted `conflict` from a retraction as "the law said no" for an unreadable path now sees `track_failed` / `aborted` there. Nothing changes for a run, its codes or its answers.
|
|
459
|
+
- **How to migrate.** Backend: `ledger.refused.push({ path, reason, class })` at each refused arm; `const proof = await worldStillMatchesMint(...); if (proof !== undefined && proof.verdict !== "unchanged") return annulProofFailure(proof, absPath);`. Host (server route / sdk verb): call `runner.rewindFiles` and pass its codes through; the reference's `canRewind` ⇔ the call did not throw, `filesChanged` / `insertions` / `deletions` = `preview`, `skippedLinks` = `restore.refused.filter((r) => r.class === "link_safety").length`. A deployment with several Runners over one session store routes the rewind to the Runner that runs the session (the busy check sees only its own lock).
|
|
460
|
+
- **Known limits.** The busy check sees this Runner's session lock: a same-tree delegated child recording into the session's history scope under its own session id — a background child that outlives its parent's leg — does not make the rewind refuse (KL-1130-a). A parked session's rewind addresses a fresh workspace and a snapshot-backed resume can put the parked snapshot back over it — as a `rewindFilesTo` run on a parked session does today (KL-1130-b). The read-state handoff lives in this Runner's process and covers the next leg that adopts the session's read state; a later replay of an older source (a conversation-only `resumeAt` branch, a checkpoint cut before the rewind) can bring a pre-rewind read record back, as after a run's own restore (KL-1130-c).
|
|
461
|
+
- **Pins.** `test/backlog1130-rewind-files-verb.test.ts` (restore and preview with no brain call and no entry; preview null both ways; no boundary for both; the fork carry; the deployment door, capture off, a bad onTrackFailure; missing session, stub env; twelve bad requests including a hostile `dryRun` getter; a request that is a Proxy (one whose `get` tells the caller `dryRun: true`, one whose `ownKeys` / descriptor traps add it, a transparent one, a revoked one — no trap runs, nothing is rewound), an object that inherits `dryRun`, and an object of another realm refused, with the frozen, non-enumerable-`dryRun`, `dryRun: undefined` and null-prototype plain forms still admitted; factory env minted and destroyed once per call on both paths; busy refusal and the lock held during the rewind; the out-of-run read-state handoff and the refused-partial run's handoff; the channel's settle; the partial ledger handed back; the classes; the proof read's three verdicts; the code doc's one law); the file-history kit: link-safety and base-unavailable classes, and the unread / aborted proof read (`track_failed` / `aborted`, no permanence, the record kept).
|
|
462
|
+
|
|
463
|
+
### A peer session's directory row says where it runs — `PeerSessionRecord.where` is `this_machine` or `hosted`, the members that name a local process (`pid`, `procStartMs`, `sockPath`) ride a `this_machine` row only, and ListAgents and the ambiguity error say where from one table; a cross-session notice's typed notice is read off the frame's provenance record, where it already rides (#971 S1, #1090; behavior narrowing of the row contract, open to veto; hard-breaking: `PeerSessionRecord.where` is required; closed sets: a new one, `PEER_SESSION_WHERE_VALUES` = `this_machine` / `hosted`; exports: +2 runtime (`PEER_SESSION_WHERE_VALUES`, `PEER_SESSION_WHERE_LABELS`) and the type `PeerSessionWhere`) (@server @client-core @cli)
|
|
464
|
+
|
|
465
|
+
- **Before.** A directory row had no locality member. Whether `pid` / `sockPath` described a process on the reading machine was assumed (the socket lane dials `sockPath`; the liveness probe signals `pid`), and a server's session rows left them out by convention only. ListAgents rendered every peer as `peer session`, with nothing saying where it runs. Separately, a host that wanted a delivery receipt's state or an idle notice's `finishedAt` read the box row, because the type comment said the frame's provenance carried `peerMeta` on the message lane only.
|
|
466
|
+
- **Now.** `PeerSessionRecord.where` is required and closed. `this_machine` = a process on the reading session's machine, stated only by a directory that reads that machine (`createCcPeerDirectory` stamps it on every row). `hosted` = a session a hosting service runs. `readPeerSessionRecord` refuses a row without `where` (`field_invalid: where`) and a non-`this_machine` row that carries `pid`, `procStartMs` or `sockPath` (`field_invalid` naming the member). Every terminal now reads a row through that reader. The engine reads a host's directory through one function, `listPeerDirectory`, at the three places it lists (ListAgents, SendMessage's resolution and its post-append re-read, the drain's own-row lookup). A row the reader refuses faults the whole listing; it is never dropped, since refs are minted and names resolved over the full set. Each place's existing fault arm then speaks: ListAgents says `peer-session directory unreadable: …` and names the row and the reason, SendMessage refuses with nothing sent, and the drain falls back to its own label. So a socket is dialed and a pid probed for a local process and nothing else: the exported `isPeerSessionProcessAlive` takes `where` too and does not signal a pid that is not this machine's. `peerDirectoryContract` gains a fifth reading: every listed row is one the reader accepts. ListAgents carries `details.rows[i].where` on peer rows and renders the kind column as `peer session on this machine` / `peer session hosted by the service` (`PEER_SESSION_WHERE_LABELS`; its description reads the same table). The ambiguity error's candidates say the same. ListAgents' peer state table drops its own-key guard: a row's `tempo` reaches it only through the reader. What a send promises still follows the lane, not `where`: receipts, idle notices and durable parking hold for every store-lane peer wherever it runs. The notice frame: no new member. Its typed notice has ridden `_sema_provenance.peerMeta.notice` since 7.4.0, the box record's `MailboxPeerNoticeMeta` copied whole. The type comments now say so (four carriers, two box-record provenance kinds), and `crossSessionNotice` stays `{ kind, text }`, the render discriminator and the line.
|
|
467
|
+
- **Hard break (open to veto). What changed:** `PeerSessionRecord.where` is a required member. `readPeerSessionRecord`, and so `createInMemoryPeerDirectory().upsert`, refuses a row without it, or a `hosted` row with `pid` / `procStartMs` / `sockPath`. The engine reads every listed row through it, and one row it refuses faults that listing. **Who is hurt:** every host that builds `PeerSessionRecord` objects. A server directory projection fails `tsc` at upgrade, and an `upsert` caller whose rows lack `where` is refused. A host whose directory hands back a row the reader refuses, for any member, used to have its other rows used as-is; now that host's peer lane says "directory unreadable" and sends nothing until the row is fixed. **How to migrate:** add `where` to each row your directory hands back: `"hosted"` for sessions your service runs, `"this_machine"` only if your directory reads the reader's own machine. Keep the three process members off `hosted` rows, and run `peerDirectoryContract` against your directory.
|
|
468
|
+
- **Consumers.** @server: the peer-directory projection's two row literals add `where: "hosted"` (its rows already carry no pid, procStartMs or sockPath). For S-709, `semaProvenanceOrAbsent` projects kind `cross_session_notice` with `peerMeta.notice`: the typed bits are already on the frame, so no new core member is needed and none is coming. @client-core: the cross-session notice card reads `_sema_provenance.peerMeta.notice` and does not wait for a carrier member; peer rows in ListAgents' `details.rows` carry `where`. @cli: the closed set and its two labels are what the cross-machine half will be written against; nothing to do in this version.
|
|
469
|
+
- **Not in this slice.** A session on another machine listed through a relay (a `remote` value, which needs the reader's identity on `PeerDirectoryAccess`), a per-source incomplete-listing seat, and a cross-machine approval setting are later slices of the same design.
|
|
470
|
+
- **Pins.** `test/backlog971-session-where.test.ts`: W1 the reader's closed set and the three machine-bound members on each value; W2 the in-memory write path; W3 the reference-registry reader stamps `this_machine`; W4 the real ListAgents tool's rows, kind column and description; W5 the ambiguity error; W6 a host that lists a refused row (the listing faults whole on ListAgents and SendMessage, nothing appended); W7 the kit's five readings and two broken projections that fail only the reader reading; W8 the liveness probe never signals a hosted row's pid. `backlog940-peer-row-state`'s hostile-tempo cell now reads the fault. `test/backlog1090-notice-typed-seat.test.ts`: T1 a real run's `task_notification` events carry each record's typed notice at `_sema_provenance.peerMeta.notice`, equal to the box record, with the carrier `{ kind, text }` only; T2 the drain alone, every typed member, both kinds. Re-pinned for the new kind column: `backlog1024-list-agents-fresh`, `design385-slice2b-agents` (whose A3 liveness rows are now `this_machine`). Fixtures gained `where` in six more files, and `backlog969-peer-directory-contract` now counts five readings.
|
|
471
|
+
|
|
472
|
+
### A run's result names the committed messages it was read from — `TaskResult.resultEntryIds`, in conversation order, the ids `message_committed` carries — so a consumer replaces those messages' text rows with `result` instead of inferring which message on screen the answer was (#1966; additive; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `TaskResult.resultEntryIds`) (@client-core @server @test)
|
|
473
|
+
|
|
474
|
+
- **Before.** `done.result.result` was a plain string with no provenance. It is read from the run's final assistant message merged with the trailing chain of cut-and-continued messages (a `length` stop or a promoted mid-stream partial, each followed by a continue nudge), and a committed message can differ from what streamed (engine notes placed when the turn finalizes, a degenerate cut trimmed, an opt-in text tool-call repair). A consumer comparing `result` with "the message on screen now" misreads the chain shape by construction: on screen is the chain's last message, `result` is the whole chain — a normal continued answer was reported as diverging from what was shown.
|
|
475
|
+
- **Now.** `resultEntryIds` lists the entry ids of the committed assistant messages the engine selected as the answer's source: one for an ordinary answer, every member of the continuation chain when an answer was continued. The chain walk that merges the text names its members, so the merge and the names cannot disagree. A member may contribute no text (a tool-calling message a park or a stop ended on, a thinking-only cut head), so a non-empty list may sit beside an empty `result`. `[]` = read from no committed message (refused before any turn, a run with no assistant message, and every result the engine writes in its own words: a workflow call's refusal or failure row, its `park_not_pending` settlement, the journal's oversize tombstone, a cascade that dispatched no rung, a repair loop's unprotected or exhausted-before-dispatch result). Absent = the provenance is unknown: a record an older engine wrote, a result rebuilt from a checkpoint row alone, or an answer a leg read from a message committed before it began (whose append that leg never saw — no Runner shape reaching it was found; it is stated as unknown rather than as `[]`). Every other result a Runner assembles carries it, and so does the stream's settle backstop. The harness binds an answer's sources to the answer object it returns, so a prompt that rejects after its answer was committed, or one that overlaps another on the same harness, leaves no name for a later reader to pick up.
|
|
476
|
+
- **Consumer rule.** At `done`, replace the text rows of the named messages (bound through `message_committed`, in stream order) with `result` — no comparison, no suffix arithmetic. A consumer that cannot bind an id (it lost that `message_committed`) reads the named entries from the session instead of guessing. *client-core*: where the field is present, the six-cell reconciliation in the adapter's result arm (render whole / nothing / trim-equal / append suffix / result-is-a-prefix / `result_text_diverged`) and its "current message, else the previous one" left value reduce to this rule; your existing path stays for an absent field. *server*: the key reaches your per-key result table (`observability/run-terminal.ts`) — it is an entry id the engine mints (not model text, not a secret); your ledger does not keep `message_committed`, so a consumer reading a replayed stream cannot bind ids and takes the read-the-entries branch. *cli*: `-p` prints `result`, unchanged.
|
|
477
|
+
- **Not yet byte-equal.** `result` alone drops inline `<think>…</think>` blocks and is trimmed; the named messages keep both. Moving those normalizations to where the message is produced is a change of its own.
|
|
478
|
+
- **Not closed here.** A sub-agent's forwarded pane carries no `message_committed`, so its rows cannot be bound. The `text_end.content` contract sentence said the segment text is byte-equal to its deltas; it now says what it is — the block as the brain closed it, which the committed block can still differ from.
|
|
479
|
+
- **Pins.** `test/backlog1966-result-entry-ids.test.ts` (P-A the continuation chain over the real OpenAI-protocol brain fed SSE fixtures — both members named in order, each a real assistant entry of the session tree; P-B one answer; P-C tool then answer, only the final message; P-D refused before any turn, `[]`; P-F park then resume, each leg its own messages, the parked tool-calling message named beside an empty `result`; P-I a thinking-only cut head named; P-K the sources ride the answer object — an absent answer, a look-alike and a later prompt's empty ending name nothing while the earlier answer keeps its own; P-H the think-tag gap as it stands), and one `[]` cell each in `backlog652-park-truth-from-store`, `backlog672-journal-oversize-refusal`, `cascade`, `repair-loop` (two), `rb201-workflow-spawn-review` (two), `backlog383-workflow-budget-gate-timing` (two) and `workflow-resilience`; mutations M1–M19, per-fixture table in the receipt.
|
|
480
|
+
|
|
481
|
+
### A skill carries its own hooks: `SkillSpec.mods` registrations are admitted with the leg that loads the skill and take part in that leg's raises only while the skill is active there — from its activation to the run's end, across a park and its resume — and never in what the leg hands its children (#1627 slice S1; design/1627 r2; capability word `mods.skill_seat`; behavior change, open to veto; differs from the reference implementation on two points, open to veto (below); hard-breaking: one — the parked row gains a member and a requirement word, a store-format forward change, open to veto; closed sets: `EngineCapability` 2 → 3 (+`mods.skill_seat`), `RequirementWord` 32 → 33 (+`skill_scopes`), `CheckpointErrorReason` +`skill_scopes_damaged` (all registered in `docs/CLOSED-SETS.md`); exports: none by name (`ENGINE_CAPABILITIES` gains a word); members: +1 optional `SkillSpec.mods`, +1 optional `CheckpointState.skillScopes`) (@server @client-core @cli @sdk @test)
|
|
482
|
+
|
|
483
|
+
- **What changed.** A skill may carry `mods` — `EngineModRegistration`s in the `TaskSpec.mods` form. They are admitted at the
|
|
484
|
+
prepare of every leg that loads the skill (it passes the load gate), on the task seat after `TaskSpec.mods`, through the
|
|
485
|
+
same screen. Their handlers take part only while the skill is active on that leg: from the `Skill` tool's successful
|
|
486
|
+
activation (or prepare, for a skill in `userInvokedSkills`) to the end of the run; a refused activation opens nothing. A
|
|
487
|
+
skill registration hangs `classic.*` events only (`config.mods_invalid`, naming the skill and the event). They are the
|
|
488
|
+
leg's own: absent from the standing chain the leg hands its children (its screening constraint, its installation
|
|
489
|
+
identity), and absent from "an effect-aware gate covers every call" (the ungated-write warning). A park records the active
|
|
490
|
+
hook-carrying skills (`state.skillScopes`) and the resumed leg reopens them; a section that is not a list of names refuses
|
|
491
|
+
the resume pre-claim (`checkpoint.invalid_outcome`, `reason: "skill_scopes_damaged"`). Before, a `mods` member on a skill
|
|
492
|
+
was dropped by the skills seat's copy, without a word.
|
|
493
|
+
- **Hard-breaking (store format; open to veto).** What changed: a park of a run whose hook-carrying skill was active writes
|
|
494
|
+
`state.skillScopes` and the requirement word `skill_scopes`. Who is hurt: a worker older than 7.56.0 that meets such a row
|
|
495
|
+
refuses it `version_newer` (rows of runs without such a skill are unchanged). How to migrate: finish those rows on a 7.56.0
|
|
496
|
+
worker; roll workers forward before routing skill hooks to them.
|
|
497
|
+
- **Differs from the reference (open to veto).** (1) The reference registers a skill's hooks under the session, so a skill a
|
|
498
|
+
subagent activates governs the main thread; here a skill's hooks govern the leg that activated it. (2) The reference keeps a
|
|
499
|
+
skill's hooks for the session; here they last for the run, as its `allowed-tools` grants do.
|
|
500
|
+
- **Consumers.** @server: `SkillSpec` gains a key (the `Record<keyof SkillSpec, …>` field table reds by design) — the wire
|
|
501
|
+
keeps `hooks` (frontmatter shape) and compiles it into `mods`; one registration object is admitted on every leg that loads
|
|
502
|
+
the skill, so a `once` entry is keyed by the leg (`e.identity`), and nothing of it is carried across a park; the engine's
|
|
503
|
+
word reds `ENGINE_CAPABILITY_FACTS` (the fact: this process compiles skill hooks on this lane). @client-core / @cli:
|
|
504
|
+
project skills' frontmatter hooks into the request when the server's key is true; a skill the engine activates is the
|
|
505
|
+
engine's to run. @sdk: `skills[].hooks` on the request type. @test: black-box cells in the pins below.
|
|
506
|
+
- **Not in this slice.** `AgentDefinition.mods` (the agent seat, `mods.agent_seat`) — design/1627 §3.5, next slice.
|
|
507
|
+
- **Pins.** `test/backlog1627-skill-seat.test.ts` (① a guard fires only after the activation; ② a person's invocation opens it
|
|
508
|
+
at prepare; ③ an observer watches only after it; ④ a refused activation opens nothing; ⑤ a child is neither screened by nor
|
|
509
|
+
dispatches the parent's skill hooks, and a parent task-seat handler judges the child's call once; ⑥ the ungated-write
|
|
510
|
+
warning still fires; ⑦ the seat's refusals; ⑧ park → resume reopens, a plain skill is not recorded, a damaged section is
|
|
511
|
+
refused; ⑨ the word).
|
|
512
|
+
|
|
513
|
+
## 7.55.0 — 2026-10-21
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
> Rulings open to veto in this release that the sections above do not name by number: C-R676, C-R678, C-R682, C-R683, C-R684, C-R690, C-R712 (three notes), C-R713 — all in `sema-comms/audits/CLAY-RULINGS.md`; the ship post's veto table lists every ruling C-R674 → C-R717.
|
|
518
|
+
### One reading of a workflow journal row read back from a store — `readWorkflowJournalEntry` — and the bundled journals refuse a row it finds corrupt, and the file journal a ledger line that is not a record (#1468 T3; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: +1 function, +1 constant, +1 type, `WorkflowJournalIncompatibleError` re-homed with its name and code unchanged; members: +1 static `WorkflowJournalIncompatibleError.ledgerLine`) (@server @client-core)
|
|
519
|
+
|
|
520
|
+
- **Before.** Which arm a journal row is, and whether that arm is legal, had four readers at three strictness levels. `FileWorkflowJournalStore.load` took any `parked` member as the park arm without looking at its terminal (and handed a row with neither arm back result-less); its line floor skipped, with a per-line disclosure, every line that was not a record — one that did not parse, one that was not an object, one without a string `callKey` / `scope` or a numeric `ordinal`, one under another scope — so whatever such a line held looked never journaled; the engine's resume admission re-derived the arm from the entries a store returned and checked the paused kind and the token; the engine's read of an ORIGIN run's journal for re-park material (a chained resume) picked rows by the `parked` member alone; a SQL backend outside this package checked the kind only. So a hand-edited or damaged parked row whose terminal is not `paused` was refused at resume by the engine (`workflow.journal_incompatible`), but `load` itself answered it as an entry; a row carrying BOTH arms was read as the park arm by the file store and as a settled call by the admission's park bookkeeping; an origin journal's corrupt parked row whose token matched was re-parked on and copied into the successor's journal; and a skipped file line left its ordinal looking never journaled, so a resume ran that step again.
|
|
521
|
+
- **Now.** `readWorkflowJournalEntry(row)` answers `{ entry } | { corrupt, why }` (`corrupt` one of `CORRUPT_BODY_FORMS`; `why` a clause naming what is wrong, quoting no stored value) over a DECODED logical row — `callKey` plus exactly one of `result` / `parked` (an encoding's own members, such as the file ledger's `scope` / `ordinal` or a SQL envelope, are decoded first and are not read). It judges which arm the row is and whether the arm carries what a resume reads from it; the payload's other members (`sessionId`, `gate`, `stats`) are not judged. Corrupt: not an object; no `callKey` or a non-string one; neither arm; both arms; a `result` that is not an object; a `parked` that is not an object, whose result has no `terminal`, whose terminal is not `paused`, or whose paused terminal carries no non-empty `token`. Other members are not read and not carried: the entry is minted from `callKey` and its arm. `InMemoryWorkflowJournalStore.load` and `FileWorkflowJournalStore.load` read every ordinal's winning row through it, and a corrupt row refuses the whole load with `WorkflowJournalIncompatibleError` (code `workflow.journal_incompatible`, the entry's ordinal named, no disk location in the message; the file store discloses `{kind:"file", path, reason}` on `onCorruptRead` with the refusal). The file store's line floor judges only the ledger's own members (`scope`, `ordinal`), so a line that lost its call key reaches the reader and refuses the load too; and in a run's own ledger only the unterminated tail a crash leaves mid-write is uncommitted — any other line that is not one of the run's records (it does not parse, is not an object, lacks a string `scope` or a numeric `ordinal`, or carries another scope) refuses the load with `WorkflowJournalIncompatibleError.ledgerLine(runId, line, form)` (same code, `ordinal` `-1`, the 1-based line and its `CORRUPT_BODY_FORMS` form named, the line disclosed on `onCorruptRead` before the refusal). A cross-scope caller still reads an empty journal; a ledger in which no line is a record refuses every caller, since nothing establishes whose it is. The run's scope is read from the ledger's first record, so "another scope" means another than that record's: when the FIRST record's `scope` is itself damaged into another string, that string is taken for the run's, the owner's `load` answers an empty journal, and a resume runs every step again. That form is unchanged in this release; #2174 closes it by keying the ledger by scope in its file path (the shape the resume-claim file already has), so no line is read to learn whose the ledger is. Dropping a row instead would leave its ordinal looking never journaled, and a resume would run it again — a completed step's side effects twice, or a parked step beside the child its pending checkpoint pins. A row a later write to the same ordinal superseded is not part of the answer; `append` does not judge rows (it may re-read the ledger to learn its run's scope), so a run whose ledger carries a corrupt row stays writable. The engine reads the same judge at both of its journal reads and adds only what a resume needs on top — a completed arm's terminal cause: the admission refuses a corrupt row with the same error a judging store throws, and the origin-journal read for re-park material treats a corrupt row as a load that failed. `WorkflowJournalIncompatibleError` now lives with the journal contract (`src/core/workflow-journal-store.ts`, beside `JOURNAL_OVERSIZE_ERROR_CODE`), keeps its root export, its name and its code, takes an optional third argument (`entry`, the row as read: when the reader finds it corrupt the message carries the reader's fixed clause instead of "carries no terminal cause", so no caller text reaches the message), clamps the run id itself, and is reworded: `workflow run "<id>": journal entry #<n> <clause> — this journal cannot be resumed.`, followed by what repair costs (deleting the entry does not repair it; restoring it needs a copy of the ledger kept outside the engine; a fresh run executes every step again, side effects included). The kit (`workflowJournalStoreContract`) gains one case: a parked row whose payload is not a paused result with its token is refused by `append` (any code) or by the whole `load` with code `workflow.journal_incompatible`, never read back as an entry and never dropped. `gate:single-mint` gains a routing row: every class implementing `WorkflowJournalStore` reads its rows through `readWorkflowJournalEntry` in `load`.
|
|
522
|
+
- **Behavior narrowing (open to veto).** (1) **What changed:** `load` of the two bundled journals refuses a run whose winning row for an ordinal is corrupt, where it used to answer. A resume from that run was refused before too (by the engine, same code, zero live dispatches) for every corrupt form except the ones below; only the message now says what is wrong. (2) A row carrying both arms: before, read as the park arm by the file store, and — from a store returning it whole — admitted with the ordinal counted as settled by the park bookkeeping (measured: under a changed call the resume ran that ordinal live, where a parked row is refused); now refused. (3) An entry with a non-string `callKey` from a store outside this package: before, a `TypeError` at admission; now the coded refusal with ordinal `-1`. That `-1` returns to this code meaning only that the refusal names no entry ordinal — a row with no readable call key (core's own stores never produce one: they name the ledger's ordinal), or a ledger line that is not a record (the message then names the line) — and is not a store fault, the meaning its retired `-1` arm used to carry. The two refusals have the same members, so telling them apart today means reading the message; #2185 replaces the `-1` overload with a structured subject member. (4) A file ledger line that is not one of the run's records — call key absent or not a string, `ordinal` absent or not a number, `scope` not a string or another scope than the first record's, a newline-terminated line that does not parse, a line that is not an object: before, skipped and disclosed per line, and a resume ran that step again (a run whose ledger lost ONE line resumed everything else and re-ran that step); now the load and the resume are refused. Not covered: a FIRST record whose `scope` was damaged into another string still makes the owner read an empty journal (Now; #2174). A crash's unterminated last line is still dropped silently. (5) A parked row whose paused terminal carries no token: refused by the engine before (same code), now by the stores' `load` too. (6) A chained resume reads its park material from the origin run's journal: when that journal holds ANY corrupt row, at any ordinal and in any store (core's own included), every park whose material lives there re-parks from its checkpoint row alone — the successor's journal carries the checkpoint-only material (an empty result, zero stats, `usageMissing`) instead of the child's paused result, and the run log names the refused read; before, a corrupt row elsewhere in that journal did not touch the material, and a corrupt parked row whose token matched was re-parked on. (7) The in-memory store's `load` no longer carries members an entry was appended with beyond `callKey` and its arm. **Who is hurt:** only rows no core writer produces (hand edits, damage, a store that returns ill-typed entries) — a host that calls `load` directly on such a run (a journal viewer, a deployment's journal read face) now gets a refusal for the whole journal instead of an entry list. **How to migrate:** nothing for rows core wrote. Do NOT repair a refused journal by deleting the named line: a missing entry is an ordinal a resume runs again, live. Core keeps no copy of a ledger, so restoring the line needs a copy the host kept; otherwise settle the run's pending approvals through the deployment's approval channel and start a fresh run — which executes every step again, side effects included.
|
|
523
|
+
- **How to migrate (consumers).** A journal backend outside this package reads each decoded row through `readWorkflowJournalEntry` in `load` and refuses a corrupt verdict with `throw new WorkflowJournalIncompatibleError(runId, ordinal, row)`; the kit's new case asserts that code on a refused `load`. *server*: `readJournalRowBody` keeps the envelope decode (a JSON object; an own `parked` key selects the arm) and hands `{ callKey, parked }` / `{ callKey, result }` to the reader; its own paused check retires, and the reader also refuses a paused parked row with no token, which the server's reading admits today. Until then the SQL twins' kit binding fails the new case (their `load` refuses with the store-corrupt error, not this code, and admits the token-less row). The local lane's journal read face reads core's file store, so one corrupt row now refuses the whole request there, while the SQL lane stubs the row; answering the local refusal with the same per-row stub (or a coded response) keeps the endpoint one shape across backends. *client-core*: the park-truth projection gate reads `workflow.journal_incompatible` as a string out of `dist/orchestration/workflow.{js,d.ts}`; the class now lives in `dist/core/workflow-journal-store.{js,d.ts}`, so compare against the root-exported `JOURNAL_INCOMPATIBLE_ERROR_CODE` (beside `JOURNAL_OVERSIZE_ERROR_CODE`; the class's `code` is an instance field, not a static one) instead of grepping a build path. Imports of `WorkflowJournalIncompatibleError` from the package root are unchanged.
|
|
524
|
+
- **Pins.** `test/backlog1468-t3-journal-readback.test.ts` (J1 the judge's grid; J2 legal rows across a restart; J3 / J3b a bad row refuses the file load, the entry named, the path only on the seat, the run still writable, a superseding write answered; J4 / J4b a real `runWorkflow` resume refused with zero live dispatches; J5 the in-memory store, the run id clamped, a caller cannot put text in the message; J6 the engine judging a store that does not; J7 / J7b / J7c the kit case discriminates — a store answering the bad row, dropping it, or refusing under a code of its own fails it — and the bundled backends pass; J8 a chained resume over a corrupt origin row re-parks from the checkpoint row alone; J9 / J9b a file line whose call key is a number or absent refuses the load and the resume, zero live dispatches; J10–J10d a line with a string or missing ordinal, a number scope, or a terminated line that does not parse refuses the load and the resume, naming the line, zero live dispatches; J10e a crash's unterminated tail is still uncommitted; J10f the ledger-line refusal carries no caller text); `test/backlog1041-whole-file-corrupt-session.test.ts` and `test/backlog1013-jsonl-read-back-shape.test.ts` re-pinned: their journal cells now expect the refusal after the same disclosures; `test/backlog672-journal-oversize-refusal.test.ts` binds the kit (with its new case) over both backends.
|
|
525
|
+
|
|
526
|
+
### A checkpoint row's index facts, the card table the deadline facet reads, and the read-back judge of a stored rule-offer list, exported from the package root (#1468; additive exports; behavior change: none on a row the engine mints, see Out-of-contract rows; hard-breaking: none; closed sets: new `RULE_OFFER_SHAPE_PROBLEMS` (3 words; registered in `docs/CLOSED-SETS.md`, listed in INTEGRATION-CORE §15); exports: +9 (4 runtime: `checkpointIndexFacts`, `CARD_FACET_OVERRIDES`, `readRuleOffers`, `RULE_OFFER_SHAPE_PROBLEMS`; 5 types: `CheckpointIndexFacts`, `RuleOffersReading`, `RuleOfferRead`, `RuleOfferDropped`, `RuleOfferShapeProblem`); members: none; signatures: `summarizeCheckpoint` gains an optional second parameter `disclose`, `InMemoryCheckpointStore` an optional constructor argument `{ disclose }`, `riskSeverity` an `unknown` overload) (@server)
|
|
527
|
+
|
|
528
|
+
- **Before.** A backend that keeps columns beside a stored checkpoint row derived each one itself (server `checkpoint-store-sql.ts` `put`: the gate kind, the tool name / call id / bound hash read off the pending action without branching on its kind, the descriptor, the directional bit), and its reaper's facet predicate was a hand-written list of approval gate kinds; a parked question (an approval gate whose card is `question`, facet `B`) is invisible to such a list. `facetOf` answered the question card by a literal comparison inside `isQuestionGate`, a second reading of the gate's card beside `parkedApprovalCardOf`. The stored rule-offer list (`pendingAction.ruleOffers`, the same `RuleOffer[]` as `AskRequest.ruleOffers`) had no reader in core: the server judged it with its own schema (`RuleOfferRawSchema`) behind two pre-screens of its own (`backfillLegacyBatchMemberKind`, `screenUncoveredDetailSeat`).
|
|
529
|
+
- **Now.** (1) `checkpointIndexFacts(cp)` is the one derivation of a row's immutable, indexable facts: thirteen members, every one present, `null` = the row carries none — `token`, `scope`, `sessionId`, `version` (`checkpointVersionOf`), `gateKind`, `approvalCard`, `toolName`, `toolCallId`, `boundInputHash` (the three off a `tool_approval` action only), `deadline`, `createdAt`, `riskDescriptor` (an approval gate's, the row's own object), `hasBidiControls` (`true` or `null`). The card is read through `parkedApprovalCardOf` and kept AS STORED: a word of `TOOL_APPROVAL_CARDS`, or a string outside the set (kept, so a later engine that knows the word reads the column right); `null` for a non-string, no card, or a non-approval gate. Not in it: the deadline facet (a registry reading a stored copy would freeze), state the CAS faces move, a value the deployment mints (an abandonment backstop), a display projection the deployment bounds or redacts. Pure. `summarizeCheckpoint` reads the members it shares with it (token, scope, session, gate kind, deadline, createdAt, tool id and name, the descriptor behind `severity`, the row's own directional bit), so its severity line no longer spells the two approval kinds: the descriptor is an approval gate's by the registry, through `parkedApprovalCardOf`. (2) `CARD_FACET_OVERRIDES` (`{ question: "B" }`, frozen) is the data `facetOf` reads: a key of the table on an approval gate takes the key's facet, anything else the gate kind's registry facet. `isQuestionGate` reads `parkedApprovalCardOf`. A backend's facet predicate over a stored card column is generated from the table and the registry (`approval_card IN (<keys>)` takes the key's facet, any other word or NULL the kind's): row by row it equals `facetOf` (pinned over six gate kinds × fifteen card values). (3) `readRuleOffers(raw)` reads a stored offer list: `{ offers: [{ index, offer, uncoveredDetailDropped? }], dropped: [{ index, problem }] }`, or `{ problem: "not_an_array" }`; `undefined` reads as no offers. Per element, original index kept: additive keys stripped at every level; a word outside a closed set (`kind`, a batch member's `kind`, `match`) drops the whole element (`unknown_word`), so does a missing or mistyped member, an empty batch or a count that is not a non-negative safe integer (`malformed`); a batch with one unreadable member is dropped whole; siblings stand. A batch member with no `kind` and the four text members of a command member reads as `command` — a stored-row form from before members carried a `kind`, on retirement line `RL-2164-kindless-batch-member` (`docs/RETIREMENT-LINES.json`, last version allowed to ship it 7.57.0, #2164: once the backends holding parked rows confirm none carries it, the reading goes and such a member is `malformed`); an offer read through that form is marked `kindlessForm: true` on its reading, so a backend counts the rows that still carry it with the engine's reader rather than a copy of the predicate. A batch `uncoveredDetail` seat that is not one well-formed row per counted segment is left out alone and the reading says so. Each member is read once; the answer is a fresh object. Against the server's own reading of the same element (its schema behind its two pre-screens, same zod), 73 of 76 forms agree; the three that differ are the server's display cap of 32 detail rows (the cap stays the server's), an array carrying the four text members as properties and a class instance (neither is a plain record to core — the judge and the descriptor axes share one record reading, `isPlainRecord` — and no JSON value has either form). (4) The store-contract kit gains one case: a row's `checkpointIndexFacts` survive `put` → `get` (an approval gate with a card, a word outside the set, no card, a descriptor, a deadline, the directional bit; a tool-less park).
|
|
530
|
+
- **Out-of-contract rows (no row the engine mints; behavior change, ruled C-R674, open to veto).** `summarizeCheckpoint` now reads three members through the facts' `null`, so on a hand-written or damaged row: a stored `deadline: null` omits the key (before: `deadline: null`); a `tool_approval` row whose `toolName` / `toolCallId` is missing or `null` omits that key (before: the key present with `undefined` / `null`); an approval gate whose `riskDescriptor` is present but whose axes are damaged — not a plain record, or a mark (`egress` / `irreversible` / `shell`) that is neither absent nor a boolean — is disclosed once per listing, through `summarizeCheckpoint`'s new optional second argument `disclose(sentence)`. Axes that are not a plain record omit `severity`. For marks: a damaged mark contributes nothing; the valid marks give their floor; "nothing tripped" can't be claimed past damage — so `{ irreversible: true, egress: "true" }` reads 4 and is disclosed, `{ egress: "true" }` has no severity and is disclosed, and `{}` / `{ egress: false }` read 2. The sentence names the row by checkpoint id and session, each JSON-rendered (an absent id reads `null`), never by token, and the member by its shape, never its value. The file store hands it its `onCorruptRead` seat (`{ kind: "file", path: <its ledger>, reason: <the sentence> }`); `InMemoryCheckpointStore` takes the same seat under the same name (`new InMemoryCheckpointStore({ disclose })`; it has no path, so it forwards the sentence); the pg reference adapter has none and its header says so. So a damaged row does not sink below the graded rows of a severity-sorted inbox unannounced where a seat is wired. Cost, by design: the sentence repeats on every listing, so a polled inbox logs it once per poll until the row is repaired or resolved. Before: a non-object descriptor, `{}` and `{ axes: null }` threw a `TypeError` out of the projection, so out of `listByScope` for the whole scope; axes that were a string, a number, a boolean, an array or a class instance read a tier (2, or the instance's marks), and a mark that was not a boolean (`{ egress: "true" }`, `{ egress: 1 }`) read as unset, lowering the tier silently. A stored `riskDescriptor: null` reads as no descriptor: `severity` omitted, nothing disclosed (before: a `TypeError`). One rule, not an arm per form: `riskSeverity` is total over one reading of the axes (a plain record by `isPlainRecord`, the one the descriptor's own mint reads a call's arguments with; its marks read by the rule above) — what proves no tier has none (`undefined`), never a throw — through an added overload (`riskSeverity(axes: unknown)`), the typed signature unchanged. Every other row projects byte-for-byte as before (2 880 rows: six gate kinds × ten card values × four descriptors × four deadlines, the two approval kinds also × seven pending-action forms; `facetOf` / `isQuestionGate` identical on all of them).
|
|
531
|
+
- **Who is hurt.** Nobody at runtime on rows the engine mints. A store-contract binding whose backend changes a row's gate card, deadline, descriptor, directional bit or tool fields on the way back now fails the new kit case.
|
|
532
|
+
- **How to migrate.** *server* (stage two of the store boundary): write the checkpoint columns from `checkpointIndexFacts` instead of reading the row; add the `approval_card` column holding `facts.approvalCard`, filled for existing rows from the stored row through the same function, and turn the generated facet predicate on only once the deployment's own record of that one-time backfill says it finished; generate the reaper's facet predicate from `CARD_FACET_OVERRIDES` and `PAUSE_REGISTRY` and retire the hand-written approval-kind list; read the offer list with `readRuleOffers` on both legs (the durable read and the synchronous mint) and retire `RuleOfferRawSchema`, `backfillLegacyBatchMemberKind` and `screenUncoveredDetailSeat` — keep the display caps (`MAX_RULE_OFFERS`, the member pre-bound, the 32-row seat cap), the redaction and the clipping, and count a `dropped` element and an `uncoveredDetailDropped` reading where the pre-screens counted (and a `kindlessForm` reading toward the retirement of `RL-2164`); a listing that projects through `summarizeCheckpoint` passes its corrupt-read sink as the second argument. *cli* / *client-core*: nothing.
|
|
533
|
+
- **Internal (no behavior).** `samePlainValue` moved from `checkpoint-store.ts` into its own module (`src/core/same-plain-value.ts`); it is not exported.
|
|
534
|
+
- **Pins.** `test/backlog1468-p1-checkpoint-facts.test.ts` (R1 the facts: keys, the card as stored and as the one card reading reads it, the generated predicate against `facetOf`, tool fields and descriptor by kind, scalars, purity, two real Runner parks; R2 the summary reads the facts; R3 the table, `facetOf` and `isQuestionGate` over every kind × card value; R4 the kit reds a store that drops the card; R5 the offer judge on the server pins' fixtures, problems and indices, whole-batch drops, the detail seat, one read per member, every list `suggestRulesForCommand` mints reads back unchanged); `test/cross-backend-inmem-file.test.ts` / `test/pg-cross-backend.test.ts` (the kit case on the bundled stores); `test/export-surface.snapshot.json` (+9, all `advanced`).
|
|
535
|
+
|
|
536
|
+
### A file-history failure says whether retrying can work: `FileHistoryError.permanence`, the backend's judgment of a fault in the engine's one retry vocabulary (`GovernanceRetryClass`); every `onError({ phase: "rewind" })` delivery is a `FileHistoryStoreError` carrying the failure's `code` and `permanence`; the first-touch edit refusal's whole sentence reaches the model (#1468 stage one, T2 — the 7.55.0 theme; behavior change, open to veto: the first-touch edit refusal is reworded and reordered and a permanent failure tells the model not to retry; an import onto a target whose tracked set or boundary directory cannot be read answers `adopt_failed` instead of `conflict`; a rewind onto a boundary that is there but cannot be used fails with `rewind.restore_failed` instead of `rewind_snapshot.unresolvable` (ruled C-R664, open to veto); behavior change, ruled C-R672, open to veto: that rewind's terminal carries the store's judgment in `terminal.detail`; a retraction that cannot be judged answers `track_failed` instead of `conflict`; an adoption or import onto a target holding a boundary nobody can use is refused instead of publishing over it; an import whose source lacks a blob answers `adopt_failed` instead of `not_found`; a failure code outside the closed set reads as a non-result; and **an export of a scope holding one boundary nobody can use (torn, or unreadable) — or an unreadable tracked set — is a typed failure instead of the readable part, so a session sync that reads it carries none of that session's rewind history unless the host acts on the failure**; the contract kit is tightened to match; hard-breaking: `FileHistoryStore.exportHistory` returns `FileHistoryExportResult` instead of `FileHistoryExport | null` — a backend that implements it and a host that reads it must change (ruled C-R672, open to veto); closed sets: none new — `permanence` reuses `GovernanceRetryClass` (`transient` / `terminal`), ruled C-R663, open to veto; exports: +1 class, +1 type (`FileHistoryExportResult`); members: +1 optional `FileHistoryError.permanence`) (@server @client-core)
|
|
537
|
+
|
|
538
|
+
- **What changed.** (1) `FileHistoryError` gains `permanence?: GovernanceRetryClass` — the backend's judgment of a FAULT (it could not do what it was asked), in words defined once, on `GovernanceRetryClass` — whose definition (the file header's second one retired) now covers both readers: can repeating the same request succeed? `transient` = with nothing changed first (the cause clears by itself, with no stated horizon); `terminal` = not until something named changes first — a verdict (a policy, or a usage window until the `retryAfterMs` it states), the deployment, the store or the input. One membership reading (`isGovernanceRetryClass`, keyed by the type, module-level) serves the engine and the contract kit. Stated whenever the backend knows: from a caught error's code, or from a state it found itself (a damaged record — unparseable, the wrong shape, failing its own digest — is `terminal`). Absent when it does not know (a code it does not judge), and on an outcome that is not a fault (a boundary never made, the empty-target law's `conflict`, an abort, a spent capture lease). The vocabulary is the one the governance codes already answer in (ruled C-R663, open to veto); no second word list. Both bundled backends read a caught value one way, by its `code` member, never its message: `EACCES`, `EPERM`, `EROFS`, `ENAMETOOLONG`, `EFBIG`, `ENOTDIR`, `EISDIR` ⇒ `terminal`; `EAGAIN`, `EWOULDBLOCK`, `EBUSY`, `EINTR`, `ETIMEDOUT`, `EMFILE`, `ENFILE` ⇒ `transient`; any other code (or none) ⇒ absent — a full disk or quota (`ENOSPC`, `EDQUOT`) included, since whether anything frees it is not the store's to know. `InMemoryFileHistoryStore` states it at its nine catches; `FileFileHistoryStore` at its nine catches and at every answer built from a helper that met a fault — the tracked set (unreadable or damaged), the consumed-id ledger, a tombstone append, a boundary file that is there but cannot be used (restore answers `restore_failed` with the fault — `not_found` is only a boundary never made, at every verb, and never carries `permanence`; a snapshot that meets such a file at publish answers `snapshot_failed` with it), boundaries that cannot be judged — a directory that cannot be listed or a boundary file that cannot be used, read by ONE judge (unreadable ≠ empty: an adoption or import onto such a target and an adoption from such a source answer `adopt_failed` with the fault, an import before it fetches a single blob; a retraction that would need the set answers `track_failed` with it, record left in place; an export of such a scope is a typed failure, (4) below), a source blob a copied chain names that is missing or damaged (`adopt_failed`, `terminal`), and a target or source whose tracked set cannot be read (`adopt_failed`; a retraction over it, `track_failed`). An import whose source cannot supply a referenced blob answers `adopt_failed` with no `permanence` (the source's loss is not this store's to judge). (2) Every `onError({ phase: "rewind" })` delivery is a `FileHistoryStoreError` (package root; it carries `FileHistoryError`'s `code` and `permanence` — its `message` is the engine's sentence, not the store's text). A timed-out boundary capture is `aborted`; a backend that threw, rejected or answered something that is not a result — on any seat: capture, fork carry, first-touch record, retraction, read by one judge — is that verb's `*_failed` with no `permanence`, a thrown value as the standard `Error.cause`. That judge reads every answer once into a plain copy — a success (`ok`, and `trackEdit`'s `minted`) as much as a failure — so no member of the store's object runs again at a seat that must not throw: a failure whose code is outside the closed set is a non-result that names the word ("the history store answered a code outside the closed set ("…"): <store text>"), a `permanence` outside the two words reads as unjudged, a message that is not a string as "(the history store gave no message)" (ruled C-R674, open to veto). A failed rewind task (`rewind.restore_failed` from the restore's own failure) carries the store's `{ code, permanence }` in `terminal.detail` (`{ kind: "coded", detail }`, the existing coded-failure channel); a boundary never made carries none (ruled C-R672, open to veto). The result arms' messages are unchanged; the throw arms' messages name the verb and keep the thrown text. The error's `name` is `FileHistoryStoreError`, so `String(err)` begins with it instead of `Error`. (3) `fileHistoryStoreContract` checks the cell with one rule: a stated `permanence` is a `GovernanceRetryClass` word, on a code that names a fault (`*_failed`, `unsupported_format`) — never on `not_found`, `conflict` or `aborted`. Leaving a fault unjudged still conforms — the engine then keeps the retry wording. Two cases are tightened (ruled C-R672, open to veto): the absent case also runs beside a tracked, edited file and checks that a restore of a boundary never made touches nothing (a backend that writes first and then answers `not_found` is refused), and an import whose source lacks a blob must answer `adopt_failed`; the export case reads the typed result, and an empty scope must export `{ ok: true, history: null }`. (4) `exportHistory` answers a `FileHistoryExportResult` (package root): `{ ok: true, history }` with the whole graph, `{ ok: true, history: null }` for nothing to export (the one meaning of `null`), or `{ ok: false, error }` — `adopt_failed`, the adoption family's code, with the fault and a message naming what cannot be read ("exportHistory scope "…" has boundaries that cannot be used ("b2") — its history cannot be exported whole, so nothing was exported") — for an unreadable tracked set or boundaries that cannot be judged. Never a partial graph.
|
|
539
|
+
- **Behavior change (open to veto): the first-touch edit refusal.** Before: `Error (<Tool>): the edit was refused because its first-touch file-history backup could not be persisted (<code>: <store message>). Proceeding would leave "<path>" with no durable pristine record — after the history store recovers, a retried edit would mint already-modified bytes as the pristine baseline. Retry when the file-history store is healthy.` The Write / Edit / NotebookEdit lane bounds a refusal at 300 characters, and the head, the store's message and the path usually filled them: the model received the sentence cut inside the store's message, and the retry advice never reached it. Now: `Error (<Tool>): the edit was refused because its first-touch file-history backup could not be persisted (<code>). <lead> Proceeding would leave the file with no durable pristine record. <store message>`. `<lead>` is "The history store reports this failure as permanent: do not retry this edit until the store is fixed." when the store said `terminal`, and "Retry when the file-history store is healthy." otherwise. The path is no longer in the sentence (the call names it). The engine's whole sentence — code, lead and consequence — is at most 300 characters for every tool, code and lead (the longest, NotebookEdit × `unsupported_format` × the permanent lead, is 298), so only the store's text after it can be cut. A store whose `trackEdit` throws or answers something that is not a result is the same sentence (`track_failed`, the retry lead, "the history store threw: …" / "… answered something that is not a result"), where before it was a separate "the file-history first-touch backup failed (…) — nothing was written" sentence with no lead. The model now receives the code and the retry advice it did not receive before, and a permanent failure tells it not to retry.
|
|
540
|
+
- **Behavior change (open to veto): an import onto an unreadable target.** `importHistory` (and the publish step of `adoptScope`) onto a target whose tracked set cannot be read answered `conflict` — "the target gained history of its own while this import was being staged", which was not true. It now answers `adopt_failed`, naming what cannot be read (the tracked set, the boundary directory, or the boundary files that cannot be used), with the fault's `permanence`, and an import says so before it fetches a single blob. Before, a boundary directory that could not be listed read as "no boundaries", and a target holding only a boundary nobody could use read as EMPTY: the publish removed that record (tombstoning its id). Now such a target is refused and the record stays for an operator (ruled C-R672, open to veto).
|
|
541
|
+
- **Behavior change (ruled C-R672, open to veto): a retraction that cannot be judged, and an import whose source lacks a blob.** `annulTrack` over an unreadable tracked set, or over boundaries that cannot be judged (before or after its proof read), answered `conflict` — the refusal law's code, as if a boundary had frozen the record. It now answers `track_failed` with the fault, so `permanence` appears only on a code that names a fault. An `importHistory` whose source cannot supply a referenced blob answered `not_found` — the code that means "no such boundary". It now answers `adopt_failed` (bundled backends; a third-party backend's kit run asks the same).
|
|
542
|
+
- **Behavior change (ruled C-R664, open to veto): a rewind onto a boundary that is there but cannot be used.** Before: the File backend answered `not_found`, so `restoreFiles` / `rewindFilesTo` onto an unreadable or damaged boundary failed the task with `rewind_snapshot.unresolvable` and "no file-history boundary exists for entry …" — untrue, and with nothing for a host to tell it from a boundary never made. Now the backend answers `restore_failed` with the fault, and the task fails with `rewind.restore_failed` ("rewind-files restore failed (restore_failed): the history boundary for entry … is there but cannot be used …") and `terminal.detail` = `{ kind: "coded", detail: { code: "restore_failed", permanence } }` (that half ruled C-R672, open to veto), so a host can tell a transient read from a damaged record; a boundary never made keeps `rewind_snapshot.unresolvable`, with no detail. A "before" rewind no longer asks `canRestore` first: the restore's own answer decides it (one `restore` call on the target's own key, `not_found` = the refusal). `canRestore` is now documented as what the bundled backends answer — a USABLE boundary, `false` both for one never made and for one that cannot be read; `restore` says which, and its documentation now states that a boundary never made touches nothing.
|
|
543
|
+
- **Hard-breaking (ruled C-R672, open to veto): `exportHistory` answers a typed result.** `FileHistoryStore.exportHistory?(scope)` returns `Promise<FileHistoryExportResult>` (`{ ok: true; history: FileHistoryExport | null } | { ok: false; error: FileHistoryError }`) instead of `Promise<FileHistoryExport | null>`, and a scope whose history cannot be read whole — one boundary nobody can use (torn, or unreadable such as `EACCES`), a boundary directory that cannot be listed, or an unreadable tracked set — answers `{ ok: false, error: { code: "adopt_failed", … } }` where 7.54.0 exported the readable part (the graph minus that boundary) and the unreadable-tracked-set case answered `null`. A backend that implements `exportHistory` (the server's SQL store) no longer type-checks until it returns the result, a host that reads it (the server's session-sync manifest and blob route) must read `.ok` / `.history`, and a host that treats a failed export as "no history" now syncs a session with one damaged boundary with none of its rewind history — before, it lost that one turn. Read `const r = await store.exportHistory(scope)`: `r.ok` ⇒ `r.history` (an envelope, or `null` for nothing to export); `!r.ok` ⇒ `r.error` (`adopt_failed`, its `permanence`, a message naming what cannot be read), and what a sync does with it — report it, retry it, or proceed without history — is the host's to decide.
|
|
544
|
+
- **Who is hurt.** A test or host that pins the old refusal sentence, its order, or the path in it. A host that recovers the failure code from a rewind disclosure's text: the result arms keep the code word in the message, but the reading to use is `err.code`. A host that read the raw thrown value off the fork-carry or boundary-capture throw arms: it is now `err.cause`. A host that branched on `conflict` from an import onto an unreadable target. A host that maps the task code `rewind_snapshot.unresolvable` (the server answers 422 "the anchor is wrong"; client-core reads it as user-fixable): a damaged boundary now arrives as `rewind.restore_failed`, whose classification is the host's own — a host that answers it as an ordinary failed run (and caches it under an idempotency key) can now read `terminal.detail.detail.permanence` to tell a transient read from a damaged record. A host that read `conflict` off a retraction disclosure as "frozen by a boundary": a retraction that could not be judged is now `track_failed`. A host or backend that reads an import's `not_found` as "the source lacks a blob": it is `adopt_failed`; a third-party backend that still answers `not_found` there fails the tightened kit case. A fork whose own scope holds a boundary nobody can use: its carry is now refused (`adopt_failed`, disclosed once per session) instead of publishing over that record. A store whose retraction throws is now an `onError({ phase: "rewind" })` delivery (`track_failed`, the thrown value as cause) — before, the lane swallowed it with no delivery, so a host that counts disclosures by code sees one more kind. A backend that implements, or a host that reads, `exportHistory` — and a session sync that reads a scope holding one damaged boundary: see the hard-breaking entry above. Log lines built with `String(err)` show `FileHistoryStoreError:`. The server's file-history disclosure notes say the model hears "Retry when the file-history store is healthy"; before this release it did not.
|
|
545
|
+
- **How to migrate.** Read `err.code` / `err.permanence` on a rewind disclosure, and `terminal.detail` on a failed rewind task, instead of matching text; read `exportHistory`'s `.ok` / `.history` / `.error`, and return that shape from a backend's own `exportHistory`; the words mean what `GovernanceRetryClass` defines for every reader, and an absent cell is "not judged". A backend states `permanence` where it meets a fault (a size-cap error ⇒ `terminal`) instead of folding a code word into its message; a host's metrics read the result's `permanence`.
|
|
546
|
+
- **Known limits.** `KL-1468-t2-b` (a session-metadata read failure in the fork-carry seat is reported as the carry's `adopt_failed`), `KL-1468-t2-c` (an environment read failure while capturing a user's file carries no `permanence`).
|
|
547
|
+
- **Pins.** `test/backlog1468-t2-file-history-permanence.test.ts`: the one reading of a caught value (judged codes, unjudged codes leave no member, a code-less message that spells codes, a hostile `code` member); the File backend's track / snapshot / restore / adopt / import × {terminal (injected `EROFS`, a real read-only directory where the process is not root, a damaged record), transient (injected `EAGAIN`), unjudged (injected `EIO`), not a fault}; the helper arms (tracked set, consumed-id ledger, tombstone carry, unusable boundary file ⇒ `restore_failed`, unlistable boundary directory on adopt / retraction / export, missing or damaged source blob, unjudgeable source boundaries, unreadable import target); a full disk or quota is unjudged; a rewind onto a damaged boundary fails `rewind.restore_failed` at and before alike while a boundary never made stays `rewind_snapshot.unresolvable`; a `trackEdit` / retraction / capture that answers a non-result, and a failure answer read once (a Symbol or throwing message, a foreign code and word); the retry words defined once; a path whose name spells codes moves nothing; through a real Runner: the refusal text the model receives, exactly, for an injected store (terminal / transient / unstated) and for a throwing `trackEdit`, the File backend's sentence whole within the bound, the 300 bound over every tool × code × lead; every rewind delivery arm typed (answered failure, sync / async throw, non-result, tolerated first-touch failure, failed and thrown retraction, fork carry answered and thrown); the kit refuses a third word and a stamped non-fault (`not_found` alone included), and accepts a backend that states none; an answer whose `ok` reads true once and then false or throws is read once at the boundary, edit, retraction and carry seats (the tools all run); the four cannot-judge retraction arms answer `track_failed`; an unusable boundary file (torn, or `EACCES`) refuses an adoption onto its scope and a retraction it may reference; an import onto a target whose boundary directory cannot be listed names that directory and fetches nothing; a source's missing blob is `adopt_failed` in both backends; a failed rewind's `terminal.detail` carries the store's judgment (transient / terminal apart, a third word dropped); the kit refuses a backend that rewrites a tracked file before answering `not_found`, and one that still answers `not_found` for a missing blob; an export over one torn or `EACCES` boundary out of two is a typed failure naming it (never the readable subset, never `null`), an empty scope exports `{ ok: true, history: null }`, and a readable-again scope exports the whole graph. `test/export-surface.snapshot.json`: +2 (`FileHistoryStoreError`, class, and `FileHistoryExportResult`, type, both `advanced`). `test/backlog1050-file-history-capture-off.test.ts`: a refused "before" rewind makes one `restore` call, on the target's own key (it made none while `canRestore` was asked first). `test/file-history-file-store.test.ts`: a torn boundary restores as `restore_failed` / `terminal`; a target holding only a torn boundary is refused (adoption and import), and publishes once the record is removed; a retraction over a corrupt tracked set is `track_failed`. `test/file-history-retention.test.ts`: a scope holding only a boundary nobody can use is not empty (the record stays, its id is not handed back).
|
|
548
|
+
|
|
549
|
+
### The approval-card closed set gains `skill` and `workflow`, and the engine's own `Skill` and `Workflow` tools declare them (#1475; hard-breaking, open to veto: a type-level exhaustion of the set; closed sets: `TOOL_APPROVAL_CARDS` / `ToolApprovalCard` 8 → 10, +`skill` +`workflow`; exports: none; members: none; behavior change: the card word an ask, a parked gate and a pending row carry for these two tools) (@cli @client-core @server)
|
|
550
|
+
|
|
551
|
+
- **What changed.** `TOOL_APPROVAL_CARDS` (`file-edit`, `file-write`, `notebook-edit`, `shell`, `fetch`, `plan`, `question`, `generic`) gains `skill` and `workflow`. The `Skill` and `Workflow` catalog rows declare them (`renderHints.approvalCard`), so the approval a shell receives for the model's `Skill` call carries `approvalCard: "skill"` and for its `Workflow` call `approvalCard: "workflow"` — on the live ask (`AskRequest.approvalCard`), on the parked gate (`CheckpointGate.approvalCard`, both approval arms) and on the pending row (`CheckpointSummary.approvalCard`); before, both read `generic`. The reference implementation renders these two approvals under their own dialogs (the skill's name and description; the workflow's phases and script), which is why they are cards and not hints on `generic`; a read-only file approval is not one (its dialog is the generic fallback with a different title), so no read card is added. Both words are ordinary cards: a declared (MCP or caller) tool may name them, `skill` only on a schema that has a `skill` property (`APPROVAL_CARD_REQUIRED_KEYS`), `workflow` on any schema (a workflow call names its script one of three ways, so no single key is common to them). Neither is reserved to the engine's tool, and neither is read from a tool's name: a caller tool called `Skill` that declares no card still asks under `generic`.
|
|
552
|
+
- **Who is hurt.** (a) Type only: code that exhausts `ToolApprovalCard` — a `Record<ToolApprovalCard, …>`, or a `switch` ending in a `never` check — stops compiling until the two words have a row; the engine's own table, `APPROVAL_CARD_REQUIRED_KEYS`, is one (a downstream copy of it is another). (b) A shell that maps a card word to a dialog and shows a word it does not know as `generic` shows these two approvals exactly as before, until it adds the dialogs. (c) A shell that treats a word outside its table as a fault, and a rollback: a row parked with the new word and read by an engine of an earlier version reads as `unknown_word` (`parkedApprovalCardOf`); a row parked before this version keeps `generic` and reads the same. (d) Nobody at runtime inside the engine: no gate, rule or classifier reads the word.
|
|
553
|
+
- **How to migrate.** *cli*: delete the two face-based fallback arms that stood in for these cards and render a `skill` and a `workflow` approval from the card word (L-1724). *client-core*: regenerate the card-word table of the generated type surface. *server*: add the two words to any table or switch over the approval-card set (a card-word wire enum, a mirror of `TOOL_APPROVAL_CARDS`). The wire-change list records the closed-set growth at the next cut.
|
|
554
|
+
- **Pins.** `test/backlog1475-skill-workflow-approval-cards.test.ts` (the set and the required-keys table keyed by exactly it; the overlay door for each word; a real Runner for the model's `Skill` call and `Workflow` call asking under the new words; the control that a caller tool asks under `generic`); `test/tool-name-literal-gate.test.ts` (the two new `skill` string literals registered as engine identifiers: `src/core` 91 → 92, `src/core/runner` 32 → 33); the per-fixture mutation table is in the car receipt.
|
|
555
|
+
|
|
556
|
+
### The "The user answered:" echo names each chosen option by its place among the question's options (`[n] label`), so two options that differ only by a space character are told apart (#2153; behavior change, model-visible text, differs from the reference implementation's echo, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli)
|
|
557
|
+
|
|
558
|
+
- **Before.** The AskUserQuestion result echoed each chosen option as its label alone, folded to one line (`- Pick: Plan A`). The fold maps every whitespace class — the ideographic space U+3000, the no-break space U+00A0, the rest — to one ordinary space, so two options whose labels differ only by such a character (`Plan` + U+3000 + `A` and `Plan` + U+00A0 + `A`) echoed as the same text, and the model could not tell which of them the person took.
|
|
559
|
+
- **Now.** Each chosen label that is one of the question's options is echoed with its 1-based place in `options`: `- Pick: [2] Plan A`. A multi-select names each chosen label's own place, in the order chosen (`- Pick: [3] C, [1] A`). The fold is unchanged (it is what stops a label from forging a second answer line, a closing tag or a fence marker), and one function now produces the echo of a chosen label for every line it lands on: the trusted answer line, the duplicate-answer line and the discarded-extra-selection line (the two fenced lines carry the label's characters as they came, with the place in front). An off-list entry is not an option, has no place, and is echoed as it came inside the fence. Nothing else in the result changes: the lead sentence, the "no listed option selected" and "did not select any option" sentences, the fenced note and the details card.
|
|
560
|
+
- **Differs from the reference implementation (open to veto).** The reference implementation echoes the raw label characters on a `"<question>"="<answer>"` line, with no place and no fold. The engine already differed (folded labels, one bullet per question); the place is a second difference on the same line, taken because the fold is a safety guard that stays and the place is what restores the distinction the fold costs.
|
|
561
|
+
- **Who is hurt.** (a) A host, harness or eval that reads the answer line of the tool result by its old shape (`- <header>: <label>`) now meets `- <header>: [n] <label>`: match the label after an optional `[n] `. (b) Prompts or tests that quote the old line. Nobody else: the result text is the model's, the shell renders the question from its own card, and no wire member moves.
|
|
562
|
+
- **How to migrate.** *cli*: where the shell echoes or re-parses the model-visible answer text (L-1680), accept the leading `[n] `; the place is the option's 1-based index in the asked question, so a shell that holds the question can map it back to the option. Everyone else: nothing, unless (a) applies.
|
|
563
|
+
- **Pins.** `test/backlog2153-ask-answer-ordinal.test.ts` (look-alike labels told apart and the fold unmoved; multi-select places; the two fenced lines and the off-list entry; the fold still defusing a line break and a closing tag; a real Runner reading the result); `test/core-toolgate-coverage.test.ts` and `test/ask-question-continuation-card.test.ts` (the existing echo cells re-pinned to the new line); the per-fixture mutation table is in the car receipt.
|
|
564
|
+
|
|
565
|
+
### A release is published from an ordinary clone of its tag, so the registry document carries `gitHead`: `scripts/publish-release.sh <version>` is that publish, and `release-preflight --publish-checkout <dir>` judges the clone (#2154; release tooling only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
566
|
+
|
|
567
|
+
- **Before.** The 7.54.0 root package and `@sema-agent/mods-kernel` were published from a linked worktree of the tag, and their registry documents carry no `gitHead` (`npm view @sema-agent/core@7.54.0 gitHead` is empty; 7.53.0, published from an ordinary checkout, has it). npm fills `gitHead` at publish time by reading `<package dir>/.git/HEAD` (npm 11.3.0, `@npmcli/package-json` `normalize.js`: it finds the git root from the package directory and reads the `.git` inside it as a directory); a worktree's `.git` is a file, and a workspace package's directory has no `.git` at all. The third leg of the registry's three-way sameness (shasum, `gitHead`, tag) was gone for both packages.
|
|
568
|
+
- **Now.** (1) `docs/RELEASE-TIERS.md` (release steps 3 and 4) publishes from `git clone --branch v<ver> --depth 1 <repo> <dir>`, never a worktree, and writes the clone's HEAD into each workspace package's manifest for its publish only (`npm pkg set gitHead=…`, restored with `git checkout -- packages/<dir>/package.json`, never committed). (2) `scripts/publish-release.sh <version>` does the whole publish in one order that keeps the token last: `PUBLISH_GATE_DIST` (the `dist/` the RC gate ran on) must be named, the tag must exist, the tag is cloned and its root package and every non-private workspace package must be at `<version>`, the preflight leg runs over the clone, `dist/` is built there and must be byte-identical to the gated one — all of which refuse before anything is read or published — then the token goes into a private temporary npmrc (read from the shell profile's `NPM_TOKEN` export; mode 600; never printed, never on a command line, removed on exit), `npm whoami` must be `vivi-ai`, the root and then every non-private workspace package are published, and `npm view <name>@<version> gitHead` must be the tag's commit for each of them (exit 1, naming the package, otherwise). A package the registry already holds at the tag's commit is skipped, so a release that stopped half way (the root out, a workspace package failed) is finished by running the same command again; one the registry holds with another gitHead, or none, is refused. (3) `node scripts/release-preflight.mjs --rc <ref> --publish-checkout <dir>` adds a leg to the pre-tag check: `<dir>/.git` must be a directory and `git -C <dir> rev-parse HEAD` must be the RC commit; the verdict now splits `manifestProblems` from `checkoutProblems` (`problems` is both, in that order).
|
|
569
|
+
- **Not done here.** The script has not been run against the real registry or the real token: its first use is the 7.55.0 publish. It has been run end to end against a fake `npm` and a fake profile (git, node and the preflight real; the v7.54.0 tag as the release): the guards above, the order of the calls, the token only in the private npmrc, the workspace manifest dirty only for its own publish, the half-finished release finished by a second run. What that cannot show is the real `npm`'s own behavior (that it reads a gitHead from an ordinary clone's `.git` is read from npm 11.3.0's source, `@npmcli/package-json` `normalize.js`, not measured by a publish) and a byte-identical `dist/` from a real build (the comparison is `diff -rq` over a build the fake `npm` stands in for).
|
|
570
|
+
- **Who is hurt.** Nobody at runtime: `src/` is untouched and nothing in the package changes. The publisher uses `scripts/publish-release.sh` (or follows steps 3 and 4) instead of `npm publish` from the RC tree. `releasePreflight()`'s return gains `manifestProblems` and `checkoutProblems` (the existing `ok`, `problems`, `notes` keep their meaning).
|
|
571
|
+
- **Pins.** `test/backlog2154-publish-checkout.test.ts` (the leg on a throwaway repository: an ordinary clone clean; a worktree, a missing directory, a hollow `.git` and a clone at another commit each named; the leg through `releasePreflight` and the CLI; the script parses, refuses before the profile, orders its steps token-last; the release steps name the clone, the script and the leg); `test/backlog2154-publish-release-run.test.ts` (the script run against a fake `npm`: H1 no gate dist, H2 / H3 version disagreements, H4 a dist mismatch, H5 the whole release and its token hygiene, H6 / H7 a package already on the registry, H8 a half-finished release finished by a second run, H9 the wrong account); `test/backlog2068-release-preflight.test.ts` unchanged and green.
|
|
572
|
+
|
|
573
|
+
### An MCP server connection ends its session when the engine lets it go, follows a session the server dropped, and stops dialing when the run is aborted (#2147, #2148, #2149; behavior change, open to veto; hard-breaking: none; closed sets: none (two new tool-result codes, `mcp.tool_declaration_changed` and `mcp.tool_listing_unreached`, on the open `details.code` / `tool_end.errorCode` face); exports: `materializeMcpTools` gains a trailing optional `signal`, `classifyMcpFailure`'s request site gains an optional `session: true`; members: `McpLease` gains a required `listing` (the connection's listing record; the lease is not exported by name, it is what `McpConnectionPool.acquire` answers, so only a host that substitutes a lease object of its own must add it), and `McpConnectionPool.acquire`'s `dial` is handed a `stop: AbortSignal` and its options gain an optional `signal`) (@dgx @server @cli @client-core)
|
|
574
|
+
|
|
575
|
+
- **Before (v7.54.0), #2147.** Letting a Streamable HTTP connection go — a run's end, a re-dial replacing it, a dial that failed after its handshake — closed the transport, which aborts the local requests and nothing else: the server's session for it stayed standing until the server reaped it, one per run.
|
|
576
|
+
- **Now, #2147.** The transport the engine dials HTTP servers with ends its session in its own `close()` — one `DELETE` carrying the session id, then the close (`SessionEndingTransport`, `src/core/mcp-session-end.ts`) — so every way a connection is let go does it: a run's end, a re-dial (the session it replaces ends before the next opens — for a pooled connection, once no other leg of the run holds it), the pool's last release, a dial that failed or was stopped, and a handshake the MCP SDK rejects and closes itself. One `DELETE` however many closes ask. The `DELETE` is courtesy: a refusal (405), an unknown-session answer (404), any other status, a network failure, or no answer within 2 s (`MCP_SESSION_END_WAIT_MS`) changes nothing about the close — the transport closes regardless and aborts a request still in the air. A server that issued no session id is sent nothing; a stdio server has no session and its close is unchanged.
|
|
577
|
+
- **Who is hurt, #2147 (open to veto).** A server that answers a `DELETE` slowly or never: every let-go of one of its connections waits up to 2 s for it — the run's teardown (once per such server, in parallel with the others), a host `reconnectMcpServer` and the engine's own recovery (the old session's `DELETE` goes before the new handshake), a dial the run's abort stopped, and a host that disposes a materialization in its own request path (a status panel that awaits `dispose()` answers up to 2 s later). A proxy that drops or rejects `DELETE` on the MCP endpoint costs the same bounded wait at most. Nobody else: a server that does not track sessions sees one more request per connection and answers it as it likes.
|
|
578
|
+
- **Compensation, #2147.** None needed on the host side (no knob, no field). A server that cannot end sessions answers 405 promptly — the spec's own word — and costs nothing; one that tracks sessions should honor the `DELETE`.
|
|
579
|
+
- **Before (v7.54.0), #2148.** A tool call to a Streamable HTTP server whose session the server had dropped (a restart, a reaped session) was answered 404 by the endpoint and reached the model as `http_status` 404; only the host's `reconnectMcpServer` verb re-dialed, and every later call on the mounted tools failed the same way until it did.
|
|
580
|
+
- **Now, #2148 — a lost session reads one way (ruled C-R667, open to veto).** A request that carried the session id the server issued at the handshake and was answered 404 by the endpoint — the protocol's word for a session it no longer knows — is a lost session (`sessionGone(err, site)`, `src/core/mcp-failure.ts`; the site says whether the request carried a session, `requestSite`, `src/core/mcp-session-end.ts`, read before the request is sent). It is the server's report, not a proof that nothing ran: its record stays the table's for a 404 (`http_status`, `delivered: "unknown"`, as before — `"no"` keeps meaning "provably never sent"), and every face that renders the failure names it with one phrase (`LOST_SESSION`: "the server reported the session the request was sent on gone …") — the tool-call lane, the resource read and directory lanes, the resource listing and a refresh's failed entry. On the tool-call, resource-read and directory lanes the words also say whether the request was sent again — only a tool call ever is — and that a server that executed it before answering ran it (once, or twice after a resend that met a lost session too); the resource listing and a refresh's failed entry carry the phrase alone. Nothing else reads that way: a 5xx, a timeout and a lost pipe leave the outcome unknown, a JSON-RPC error and a tool's own `isError` mean the server processed the call, a 400 is the protocol's word for a missing session header, and a 404 on a connection that holds no session id is an endpoint answering.
|
|
581
|
+
- **Now, #2148 — a lost session is recovered once.** A tool call whose session is gone is sent again once, through the one rule below: the engine runs the re-dial `reconnectMcpServer` runs — this leg lets the old connection go (its session is ended once no other leg of the run holds it) and opens a new one — and repeats the request where the rule allows. The resend is capped at one: a second lost session fails the call with that failure, saying it was sent twice and may have run twice; a resend that ends any other way — it succeeds, the server's tool reports an error, a 5xx — answers with that outcome as any call's would and says nothing of the first attempt (KL-2148-e; a rule that every resent call says so, whatever its outcome, is #2202). A call that is not sent again — a re-dial that cannot be had (the server is then down as a failed host re-dial leaves it; the next call says "disconnected", not attempted), a declined one, a replacement whose listing mints the tool another gate or has not reached it (the next call is then refused before it is sent), or the call's own abort or the run's (which end the wait at once) — fails with its first attempt's failure, saying it was not sent again; the shared re-dial goes on for the calls still waiting, and a re-dial the run's abort stopped records nothing against the server (no `failed` row, no `mcp.server_unavailable` warning — the stop is the engine's own). Calls that find the session gone together share ONE re-dial; a call whose 404 lands after the connection it was sent on has been replaced is sent on the replacement without another re-dial, so a late 404 never closes the connection other calls have moved to; a host `reconnectMcpServer` that asks while a re-dial runs is refused (`outcome: "refused"`, "already in flight"), and a call that finds the host's re-dial running joins it. The recovery sends no `mcp.server_redialed` notice, and — like a host re-dial — re-queues the server's instructions for the model (one redundant re-announcement).
|
|
582
|
+
- **Now, #2148 — where a mounted tool may be sent.** One rule, asked on every call (`reach`, `src/core/mcp-server-link.ts`): a mounted tool is sent only on its server's live connection, and only while the latest listing taken on that connection — by this leg (its dial, re-dial or refresh) or, on a pooled connection, by another leg holding it; of two listings in the air the one opened later wins, and a refresh answering after it is discarded with `status: "failed"` — put through this leg's own intake, mints the tool the gate it was mounted with — the raw name it is sent under, its safety axis (the server's `destructiveHint` / `openWorldHint` folded with the caller's `toolAxes`), its plan-mode read-only reading (`readOnlyHint`), the server's "a person must answer this call" mark (`_meta["anthropic/requiresUserInteraction"]`), and its task-support refusal (`mcpToolGate`, `src/core/mcp-listing-drift.ts`). The connection's listing is one record per connection (`ConnectionListing`, `src/core/mcp-server-link.ts`) with the one reading of an incomplete walk the engine has: a walk that stopped early cannot prove a tool gone, so it answers only for the names it reached and every other name keeps its entry from the listing before it — on a re-dial's new connection, from the connection it replaced (the refresh's own merge retired into it). An entry vouches for a tool's gate only if a listing of that same connection reached it: one carried from the connection a re-dial replaced keeps its tool mounted but proves nothing about what the new session declares — a reconnect is when a declaration may have changed. No live connection ⇒ "disconnected", not attempted (`connection_closed`, `delivered: "no"`) — never the connection the tool was minted around, which another leg of a pooled run may still hold. Otherwise the call is refused before it is sent (`isError`, `delivered: "no"`), every call, until a listing of the connection reaches the tool (a host `reconnectMcpServer` or a refresh): another gate, or none (a listing that reached the tool's place and no longer lists it) ⇒ `details.code` `mcp.tool_declaration_changed`; no listing of this connection has reached the tool (a re-dial's walk that stopped before it) ⇒ `mcp.tool_listing_unreached`, an unchanged tool included, since whether it changed is unknown. Once a listing reaches the tool, it runs if its gate is the one it was mounted with. Who opened the re-dial that replaced the connection does not enter the answer, and neither does the direction of the change (tightened or relaxed). The connection itself is kept — the server's other tools, whose gates did not change, keep working, and the status row reads `connected`. A tool the restarted server lists that was never mounted changes nothing.
|
|
583
|
+
- **Reference implementation (CC 2.1.296), #2148.** The corpus carries the code (`cli296.js:281899` / `:282073`, two copies; `:282072` the judge; `:282077` the throw; `:238349` the error): the reference resends a tool call once after recovering a lost session, judges by the status (a 404 to a request that held a session id read at the call's start; the JSON-RPC body is not read), treats the stale 404 as not having reached the server's handling, and after the one retry reports only `MCP server "<name>" session expired`. This engine's resend-once and its judge are the reference's shape (parity). Three differences stay, each recorded: the reference also reads a 400 whose text says the session is unknown ("No valid session ID" and two other phrases, `:282072`) as expired — this engine reads structure, not words, ruled C-R668, #2178 (KL-2148-d); the reference also resends after an HTTP disconnect when no POST was in flight — #2179; and the reference sends the same mounted tool again without comparing the re-listed declaration, where this engine refuses a tool whose gate changed or whose re-listing did not reach it (above).
|
|
584
|
+
- **Who is hurt, #2148 (open to veto).** (a) A gateway that answers 404 to a request carrying a session id for a reason other than a lost session: each call re-dials once and fails as before, and the re-dial replaces a session that was healthy. (b) A call in flight on the old connection when another call recovers it dies with that connection as a lost connection (outcome unknown, never sent again). (c) A pooled run tree: each leg that finds its session gone re-dials its own connection, so the legs stop sharing one. (d) The resend rests on reading a 404 to a session-bearing request as the server's report of a lost session — the reference's own reading; the MCP spec mandates a new handshake there and does not itself say the request may be repeated. A server that runs a request and then answers 404 for its session runs the repeat too: when the repeat also meets a lost session, the record (`unknown`) and the words ("it ran twice") say so (ruled C-R667); when the repeat ends any other way (it succeeds, the server's tool reports an error, a 5xx), the result says nothing of the first attempt (KL-2148-e, #2202). (e) A server restarted with a tool declared differently keeps its connection; the 404'd call is not sent again and that tool is refused on every later call — `mcp.tool_declaration_changed` when the recovery's listing reached it, `mcp.tool_listing_unreached` when its walk stopped before it — until a listing of the connection reaches the tool (a host re-dial or a refresh takes one; one that stops before the tool leaves it refused); before, every call to the server failed with 404. (f) A consumer that lists the tool-result codes it knows: `mcp.tool_declaration_changed` and `mcp.tool_listing_unreached` are new codes. (g) A tool call or a refresh racing a re-fold: a tool object minted from the old listing that is still called after the connection's listing changed is refused rather than sent with its old gate, and a refresh that answers after a later-opened one has applied now reports `failed` instead of putting its older roster in place. (h) A pooled run tree: after another leg's listing — its dial, its refresh — takes in a changed declaration, this leg's tool of that declaration is refused on its next call; the listing is compared, not adopted: this leg's roster is not swapped, and its own refresh or re-dial takes the declaration in (KL-2148-c). An incomplete listing on a connection answers only for the names it reached, so it refuses nothing it did not reach that a listing of that connection had reached before. (i) A re-dial — the host's or the engine's — whose listing stops early now keeps the tools its walk did not reach (from the connection it replaced) instead of reporting them removed, as a refresh always did; those tools stay mounted but are refused before they are sent (`mcp.tool_listing_unreached`) until a listing of the new connection reaches them — an unchanged tool on a page the walk did not reach is refused too (before, v7.54.0, the re-dial reported it removed). A leg joining a pooled connection whose own first listing stops early likewise mounts the tools it did not reach from the connection's listing — those another leg's listing of that same connection reached, and which therefore run; the connection's record may also hold carried entries that no listing of this connection has reached, and the joining leg mounts those too — they are refused (`mcp.tool_listing_unreached`) like any carried entry. (j) A server whose restart answers the old session 400 "No valid session ID" (the MCP SDK's example servers) is not recovered; each call fails with that 400 until the host re-dials (ruled C-R668, #2178; KL-2148-d).
|
|
585
|
+
- **Compensation, #2148.** None needed on the host side for the recovery: the call that failed now succeeds. A host that sees `mcp.tool_declaration_changed` or `mcp.tool_listing_unreached` calls `reconnectMcpServer` (or refreshes the server's tools) to take the declaration in. An observable account of automatic recoveries — and of a recovery whose listing changed a mounted tool — is a follow-up: today the status row, the warnings and the notices say nothing about either.
|
|
586
|
+
- **Before (v7.54.0), #2149.** The handshake and the first `tools/list` of a dial carried the startup bound (the server's `startupTimeoutMs`, else `MCP_TIMEOUT`, else the SDK's 60 s) and no abort: a run the host aborted while a server held either of them ended only when the bound did, and the session's next message queued behind it.
|
|
587
|
+
- **Now, #2149.** The dial stops when the run is aborted. `materializeMcpTools(…, signal?)` takes a trailing signal; the Runner passes the host's `spec.signal` joined with the leg's own controller (the leg's controller follows `spec.signal` only once its harness exists, which is after the dial). When it fires, a dial in the air ends — the handshake owns its transport until it lands, so the abort closes it wherever it waits, a held `initialized` notification included; the first `tools/list` runs on a connection the pool may share, so the abort ends that one request — and the server is skipped like any server that did not connect (`mcp.server_unavailable` through `onError`, a `failed` row in the wiring manifest, the abort as the row's error and not as a request timeout, the half-open session ended as #2147 says), and the run ends. A signal that fired before the dial starts nothing — no request, no stdio child — and a re-dial under a fired signal neither dials nor lets the connection it would replace go. The listing follows the signal only while it is in flight: an abort after the dial sends the server nothing and leaves the connection alone. A pooled dial belongs to the pool (`McpConnectionPool.acquire` gains an optional `signal`): each leg waiting on it walks away on its own abort, leaving the dial to the legs still waiting; the dial stops only when the last has, and leaves the pool's table in the same step, so a leg that asks while the stopped dial is still letting its connection go dials afresh instead of failing with another leg's abort, and a dial nobody waits on leaves no connection behind (one that landed just as the last leg walked away is closed). A pool that is closed closes a connection nobody leased. An unfired or absent signal changes nothing.
|
|
588
|
+
- **Who is hurt, #2149 (open to veto).** Nobody who does not abort. A host that aborts a run while a server holds the handshake or the first listing now sees the run end as soon as the stopped dial has let its connection go — at once when the server answers the session's `DELETE`, up to 2 s later (`MCP_SESSION_END_WAIT_MS`) when it holds it — with that server `failed` and one `mcp.server_unavailable` error on `onError`, where it used to see neither until the bound elapsed. A server may see a `notifications/cancelled` for the listing request that was in the air when the abort fired, and the session's `DELETE`; it is never sent the cancellation of a request it answered.
|
|
589
|
+
- **Compensation, #2149.** None needed. A library caller of `materializeMcpTools` passes a signal to get the same; one that passes nothing is unchanged. `probeMcpServers` passes none (its own `signal` still stops its wait).
|
|
590
|
+
- **Pins.** `test/backlog2147-mcp-session-end.test.ts` (A–G: the one `DELETE` at dispose and none before; 405 / 404 / 500 / no answer / gone server; a re-dial's order; a dial that fails after its handshake; a pooled connection; the transport under a real client, a server with no session id, closes once; a real run; a handshake the SDK rejects), `test/backlog2148-mcp-session-gone.test.ts` (J the one judge and its record; V1–V16: a lost session recovered once and the mounted tool following it; a 5xx and a JSON-RPC error not sent again; the second 404 read as a lost session; three calls sharing one re-dial; a host re-dial refused while the engine's own runs; a call in the air when its connection is replaced; a pooled connection; a refused handshake; a real run; a stateless server's 404; a changed, relaxed, equal and extended listing), `test/backlog2148-mcp-recovery-gate.test.ts` (G1–G16: two pooled legs after a changed declaration, the unchanged tool still running; a leg with no connection sending nothing on the one its sibling holds; the interaction mark; a call joining the host's re-dial; a duplicated listing; the call's abort and the run's abort during a recovery; a late 404 after the re-dial; a stopped pooled dial leaving the table; two real runs, a changed declaration and a re-dial that cannot be had; the resource lane's lost session; a refresh re-folding the gate; a declined re-dial not resending on the lost connection; a raw name swapped under one minted name; two refreshes answering out of order; a pooled sibling's refresh checked, not adopted; G17–G20: an incomplete listing — a pooled sibling's dial, a recovery's re-listing, a real run, a refresh as control — refusing nothing it did not reach; a real run against a server that runs the call and then answers 404, read as `unknown` and "ran twice"; the resource listing and a refresh naming the lost session; a 400 unknown-session answer not read as a lost session), `test/backlog2149-mcp-startup-abort.test.ts` (U1–U9 and P1–P7: a held listing, a held handshake and a held `initialized` ended by the abort; a fired signal dials nothing, over http and stdio; an abort after the dial sends nothing and leaves the connection alone; a re-dial under a fired signal; a real run aborted by the host and the session's next message; the unfired signal as the negative control; legs waiting on one pooled dial, on the pool and over a real server; a dial abandoned just as it lands leaving no connection behind), over `test/mcp-http-session-stub.ts` (a Streamable HTTP stub with real sessions, holds and a request log).
|
|
591
|
+
|
|
592
|
+
### (#2140) A turn that ends the run is no longer reopened by an engine reminder: an advisory frame rides a request the run already owes and never makes one (#2140; behavior narrowing, open to veto: the model sees one turn fewer where a reminder fell due on the run's last boundary; behavior change, open to veto: on a resumed leg whose settled batch did not end the run, that batch's boundary frames (the budget / token readouts, reminders, …) now ride the leg's first request; hard-breaking: none; closed sets: none; exports: none; members: none on the package surface)
|
|
593
|
+
|
|
594
|
+
- **Before.** The turn boundary steered its engine frames (the reminder bundle — todo / task reminder, listings, changed files, budget and token readouts, output style, date, instructions and size-guideline changes —, a PostToolBatch observer's `additionalContext`, the limit-approach frame, the new-diagnostics block) wherever they fell due, and a steer makes the next provider request. On the boundary of a turn that ENDED the run the model was asked again, and its second answer became the result: a text-final stop on the boundary where the task reminder fell due gave one more model turn whose "No further action needed." replaced the real final answer in `TaskResult.result`, in the stream's `done` event and on every `-p` / SDK face that reads them (field: 7 of 89 benchmark trials). A batch that ends the run — `StructuredOutput`, `ReportBlocked` — was reopened the same way: the model could submit its structured output a second time, which overwrote the first, or end a blocked run with extra text. Two seats carried it: the boundary judged "a request is owed" from the message's content (any tool call), which is wrong for a batch that ends the run and was read by eight readers (six bundle lanes, the limit-approach frame, the direct-write argument) while the other lanes read nothing; and a bundle queued at an owed boundary behind a caller's steer (one steer per drain) drained at the next, text-final boundary and made the request there (the doubled output-style copy of KL-1284-a was the same root).
|
|
595
|
+
- **Now.** One rule, two readers of one fact. The loop hands the boundary its own continuation decision, `continues` (defined once, at `PrepareNextTurnContext.continues`: the turn's tool results go back to the model as the next request — its batch neither ended the run nor was cut by an interjection, and a recovery nudge is not such a request. In practice: a cut turn's results reach the model too, but it is the queued input it was cut for that continues the run; a nudge — the output cap, a cut stream, a malformed or thinking-only turn — is a request the loop makes on a turn with no tool results, so the boundary's frames wait; a resumed leg's settled batch states it by the same rule); the boundary collects the frames it steers only when that holds — one door for every lane whose collection consumes something (the bundle, the limit-approach frame, the new-diagnostics drain) instead of eight scattered readers and none on the rest; a PostToolBatch observer's context consumes nothing and is governed by the drain rule below alone. On a resumed leg whose settled batch did not end the run, that boundary owes the leg's first request, so its frames (budget / token readouts, reminders, …) now ride it; a frame the request already carries is not stated twice — the bundle, the boundary's direct-write style lane and the posture writer ask the same receipt ledger (`frameCarried`; a leg's head now registers a receipt for the posture as it does for the output style), one rule for every receipt-bearing source and every collector. The harness drain treats an engine ADVISORY (engine-authored, no event payload) by the same fact: at a drain the loop's own work owes, every queued advisory rides with whatever the drain takes — so a bundle collected behind a caller's steer now arrives WITH that steer instead of one request later (the one-steer-per-drain slot stays the person's); at a drain nothing owes, an advisory is held and, if nothing continues the run, ends with it. An engine frame no longer extends a run that ended on text or with a batch that ends it, so `result` is that turn's answer unless a queued caller input or a task notification continues the run (as before). An output-capped answer on such a boundary gets its continue nudge again (a reminder steer used to read as a user redirect and suppress it). Task notifications carry an event and still continue a run that ended on text; a caller's steer or follow-up is delivered as before.
|
|
596
|
+
- **Who is hurt.** A consumer that relied on the extra turn after the run's end — none is known: it was an unintended turn, and every face read its text as the run's answer. The boundary-carried frames — the tools_delta / mcp announcements, changed files, task and todo reminders, budget / token readouts, new language-server diagnostics — ride only a request the run's own work makes (a tool-result request). A fresh leg's first request carries only a `userPromptSubmit` hook's `additionalContext`, the date, the agent and skills listings, the output style, the git status and the posture; a resumed leg's first request carries in its head the continuation reminder, the orphaned-background-task announcement, the git status, the posture and the output style, plus the settled batch's boundary frames when that batch did not end the run (it carries no date or listings). So a frame that falls due on a run's last boundary is not shown on that run and waits for the next tool-result request; on a session whose runs all answer on text such frames never reach the model (nothing is stored meanwhile: windows are threshold-based, listings settle on receipt, a changed file is re-derived from the read state, diagnostics stay in the registry with their `diagnostics` event). Design #2168 puts them on every request a run makes, a leg's first included. The same holds wherever the turn's tool results do not go back as the next request and something else continues the run — a text-final turn a queued input continues, a batch that ended the run, a turn an interjection cut, a turn a notification continues, a recovery nudge: that request carries no steered frame (the output style keeps its direct-write lane there). A host whose resumed leg settles a batch that ends the run, with no prompt to deliver, no longer gets a model turn after it.
|
|
597
|
+
- **Compensation.** The answer the model gave at the run's end is the result again, and no engine frame opens a turn after the batch that ended the run, so a submitted structured output is no longer overwritten by a turn the engine itself opened (a queued caller input or a task notification still continues such a run, and the model may submit again there). The PostToolBatch observer still receives a batch that ended the run; its `additionalContext` is held — if queued input continues the run it reaches the next request the run's own work makes (late), otherwise it ends with the run.
|
|
598
|
+
- **Internal seats (not package surface).** `PrepareNextTurnContext.continues` and `TurnBoundaryEvent.continues` added; `getSteeringMessages(continuing)` takes the same fact; the runner's re-derived `rs.turn.lastTurnHadToolCalls` is retired. `docs/KNOWN-LIMITS.md` KL-1284-a closed (no request carries two output-style copies: the ride-along drain on live boundaries; on a resumed leg's first request, the receipt ledger read by both the bundle and the direct-write lane — also when an interjection cuts the settled batch). The planning posture's restatement on that same request is stated once too (a `posture` receipt, read by the posture writer; it was stated twice before this release as well).
|
|
599
|
+
- **Pins.** `test/backlog2140-post-stop-frames.test.ts` (text-final, StructuredOutput and ReportBlocked on the reminder boundary on the library and stream faces; a reminder on a tool turn unchanged; deferred, not lost, past a text-final boundary queued input continues; a bundle behind a caller's steer rides that steer's request, later steer and follow-up delivered; a notification still continues a run that ended on text; diagnostics, a PostToolBatch observer's context and the limit-approach frame behind the same door; the direct-write lane's halt rule; the harness drain on its own; a resumed leg's settled batch, terminating or not; a resumed leg's first request stating the style and the planning posture once — settled or cut by an interjection — and carrying the budget readout); `test/backlog1284-output-style-boundary.test.ts` ④ re-pinned to `[1,2,3]` (the old `[1,1,2,4]` was the one-request lateness plus a fourth request made after the run's end); `test/human-reject-batch-boundary.test.ts` (the halted-boundary deferral re-pinned on an owed boundary — its old fourth call was made by a bundle at a text-final boundary; a direction turn that ends on text ends the run with nothing stored; the next run states a change only if it still holds); `test/backlog1640-incomplete-outcome.test.ts` ④ re-pinned (the boundary reads the loop's fact; no handler derives one); `test/backlog560-announce-once-per-session.test.ts` and `test/backlog1971b-mounted-set-and-valve-readers.test.ts` fixtures re-pinned (their later runs / control ended on text and reached the model only through this defect's extra request — now every run has owed boundaries of its own); `test/internal-wording-gate.test.ts` runner bucket banked 78 → 76 (the retired field's notes).
|
|
600
|
+
|
|
601
|
+
### On the GLM / Z.AI anthropic route the tool-search tool travels as `ToolFinder`, and a call under that spelling runs `ToolSearch` (#2162; behavior change on one route, open to veto; differs from Claude Code's shape, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @dgx @client-core)
|
|
602
|
+
|
|
603
|
+
- **Before.** That route answers a request whose tool table holds a tool named exactly `ToolSearch` by giving the model that tool alone and folding every other tool into a name without a schema. Replaying a captured 37-tool request: the provider reported about 900 prompt tokens on that route against about 14,700 for the same body on the same host's OpenAI route, and the model called `ToolSearch` instead of the tool it was asked to use (0 direct calls in 3, on each of two models); with the tool renamed and its description and schema unchanged, the whole table was served and the tool called directly. The engine sent its tool-search tool under its own name on every route, so every run with deferred tools on that route met this.
|
|
604
|
+
- **Now.** The provider dialect table (`src/brain/provider-dialect.ts`, keyed by wire family and host; the GLM row covers `api.z.ai` and `open.bigmodel.cn`) gains a second column, `toolNames`. Its one row says that on the anthropic wire this endpoint gives `ToolSearch` a meaning of its own, so a tool by that name is sent as `ToolFinder`. The three brains send tool names through one projection (`endpointToolNames`), and both its halves read the one set the request declares. When the request declares `ToolSearch`, the tool table and every replayed call to it carry `ToolFinder`, and a call the model returns under `ToolFinder` is read back to `ToolSearch` before the engine sees it — `ToolCall.name`, `tool_start` / `tool_end`, the transcript, permission rules and approvals all keep `ToolSearch`. When the request does not declare it, nothing is renamed either way: a replayed call to `ToolSearch` keeps its name, and a call under `ToolFinder` reaches the engine as written (a deployment tool of that name, or a miss). A call under `ToolSearch` (the name the prompts and the deferred-tool placeholders use) still reaches the tool. The prompts, the tool's description and parameters, and its name inside the engine do not change. Every other route sends byte-identical requests: Anthropic's own, the same host's OpenAI route, every other dialect, and a host the table does not name.
|
|
605
|
+
- **Behavior narrowing (open to veto).** On that route, a request whose tool table holds both the engine's `ToolSearch` and a deployment tool already named `ToolFinder` is refused before anything is sent (`invalid_request`; the run ends `failed`; the message names both tools and asks for the deployment's `ToolFinder` to be renamed): the two would share one name on the wire and the call that comes back could not be told apart. Before, the request went out and the table was folded.
|
|
606
|
+
- **Differs from Claude Code (open to veto).** Claude Code 2.1.296 turns tool search off when its base URL is not a first-party Anthropic host, unless the user forces it on, so on this route it sends the whole table and no `ToolSearch` at all. This engine keeps deferred loading on every route — its search is client-side, materializing schemas into the next request — because a bring-your-own-model deployment almost never talks to a first-party host, and switching deferral off there would put every large MCP roster back into every request. It changes the one spelling the endpoint reacts to instead.
|
|
607
|
+
- **Who is hurt.** A deployment on the GLM / Z.AI anthropic route: the model sees the tool-search tool named `ToolFinder` (the prompts still say `ToolSearch`; both spellings reach it), and a host that reads the provider request body (request logging, a request hook) sees `ToolFinder` there. A deployment that mounts its own tool named `ToolSearch` on that route has it sent and read back the same way. A deployment that mounts its own `ToolFinder` beside deferred tools on that route is refused, as above. A deployment tool that declares `ToolFinder` as an *alias*: on that route the spelling is now a name in the table, and a table name wins over an alias (as it does on every route), so a call under it reaches `ToolSearch` where it used to reach the aliased tool. Nobody on any other route.
|
|
608
|
+
- **Compensation.** Nothing to migrate for transcripts, events, rules or approvals: they carry the declared name. The refusal names the tool to rename.
|
|
609
|
+
- **What triggers it (measured on `api.z.ai`, `glm-5.2`, two runs per shape).** The tool's name, matched exactly and case-sensitively: `ToolSearch` folds the table with its description and schema emptied, and with only one other tool beside it; `toolsearch`, `TOOLSEARCH`, `Toolsearch`, `tool-search`, `ToolSearchTool`, `ToolFinder` and `tool_search` do not. So the row matches the name exactly. The substitute is `ToolFinder` rather than `tool_search` because `tool_search` is a retired engine spelling (a rule naming it is refused as a legacy name), and one spelling must not be both retired and a live tool name.
|
|
610
|
+
- **Not covered.** A relay in front of this provider under a host of its own keeps the folded table (the table is keyed by where the request goes). The `open.bigmodel.cn` host shares the row as it shares the failure rows; it was not measured. `Model.extraBody` stays a verbatim passthrough: a tool a deployment names there (a `tool_choice` selector, say) is sent as written, so on this route it must be spelled as the endpoint is sent it, `ToolFinder` (reading that spelling from the one table instead is #2173).
|
|
611
|
+
- **Pins.** `test/backlog2162-tool-wire-names.test.ts` (T1–T4 the column, its per-wire scope, the way back, the refusal; L1–L8 a real Runner or the real anthropic brain: the substitute in the table and in the replayed call, `ToolSearch` running and activating the deferred tool, the prompts' spelling, the unmounted case, Anthropic's own route, the refusal before any request, a transcript that already holds a miss under the substitute, the `extraBody` passthrough, an aliased deployment tool; L9–L10 a transcript call to `ToolSearch` replayed as written when this request does not declare it, with and without a deployment tool named `ToolFinder`; O1 the same host's OpenAI route); `test/backlog2162-tool-wire-names-lanes.test.ts` (the same projection in the anthropic, OpenAI chat — text repair included — and Responses brains, against a probe endpoint with a row on each wire).
|
|
612
|
+
|
|
613
|
+
### The owner is a cell of the session row, and one atomic head read answers owner, occupant and leaf: `SessionStore.headOf?` replaces `ownerOf?` / `incarnationOf?`, `compareOccupants` compares two heads in three answers (#1468 S2; hard-breaking: H1–H4, open to veto; behavior change and behavior narrowing, open to veto; closed sets: none; exports: +2 / −2)
|
|
614
|
+
|
|
615
|
+
- **Before.** The owner of a session lived in `TtlSessionStore`'s process memory, beside the row, so every session read as "never registered" (claimable by the next caller) after a restart; a row `acquire` created read `undefined` until some caller registered it. A by-id reader asked two questions in two reads (`ownerOf`, `incarnationOf`), and a delete plus re-create of the id between them paired one generation's owner with the next one's occupant. The File repo's header read took `''` as an occupant token (two empty tokens compared as one occupant) and disclosed a `null` one; its create published the header with a rename, which silently replaced a row another writer had created in the gap between its existence check and its write.
|
|
616
|
+
- **Now.** (1) `SessionMetadata.owner?: string | null` is a cell of the row, recorded by the write that creates it: `register(id, owner)` on an id with no row creates an ordinary row with that owner (first write wins, atomic); `acquire` records `null`; a fork records the source row's owner unless one is named (`SessionCreateOptions.owner?`, the same write as the fork row); `importEntries` onto an absent id records the importer. No other write changes it: `register` on a row that exists is a no-op whatever it carries, and an `importEntries` over an existing row replaces its log but keeps the row's metadata whole (its owner and occupant, and its creation time, lineage and placement). Absent reads as `null` (anonymous). (2) `SessionStore.headOf?(id): Promise<SessionHead | undefined>` answers `{ owner, incarnation, leafId }` of one row in one read (`undefined` = no row); `TtlSessionStore` exposes it (and so the ownership pair) when its repo answers `SessionRepo.headOf?`, verbatim. (3) The File repo answers `headOf` from its log's first record and its tail on one descriptor, never by replaying the log, with the same per-line judgement a replay applies (`src/stores/file/fs-atomic.ts` `firstJsonlRecord` / `lastJsonlRecord` share `readJsonlRecords`' line reading, and the session log's judge holds the entry floor both read — a string `id`, a leaf's target a string or null), so the head and a restart's replay agree on every line they keep; the one shape they answer differently is a leaf entry naming an id the log does not hold (the head names it, the replay refuses the log). The log is the only carrier of the leaf. (4) The File header's occupant cells decode through `readIncarnationCell` (`''` ⇒ unknown, said; `null` ⇒ absence, silent), and its owner cell through one decoding (a cell that is neither an owner nor anonymous refuses the row as `corrupt_session`). (5) The File repo creates a row in one exclusive step (`writeThenLink`): a row that appears between the existence check and the creating write is refused with `SessionError("conflict")`, never replaced. (6) `compareOccupants(before, after)` answers `"same"` / `"different"` / `"unknown"` (either side without a row or a token ⇒ unknown, never same).
|
|
617
|
+
- **Hard-breaking H1 (open to veto).** **What changed:** `SessionStore.ownerOf?` is removed. **Who is hurt:** a store that implements it and a reader that calls it — *server*: the authorizer (`security.ts`, and `OwnerAwareSessionStore`'s own `ownerOf?` declaration), the seven read points that call it, the five session stores, the capability `capability.session_ownership_required`'s presence check; *sdk*: the wire pins that drive the server dist with an `ownerOf` stub. **How to migrate:** read `(await store.headOf?.(id))?.owner` — `null` is anonymous, a string is owned, an `undefined` head is no session; probe `headOf` and `register` together for the ownership pair.
|
|
618
|
+
- **Hard-breaking H2 (open to veto).** **What changed:** `SessionStore.incarnationOf?` is removed. **Who is hurt:** a by-id occupant reader of core's face — *server*: `incarnationReadOf` (boot) — and a third-party store implementing it (the server's own `sessionFaceOf` / `getHead` faces are not core's and do not move with this). **How to migrate:** read `(await store.headOf?.(id))?.incarnation` (`undefined` = unknown) in the same read as the owner, and compare two heads with `compareOccupants` (unknown ⇒ not the same occupant); a run's memory seat is unchanged (it reads the session it acquired).
|
|
619
|
+
- **Hard-breaking H3 (open to veto).** **What changed:** `sessionStoreIncarnationContract` and `SessionStoreIncarnationContractHooks` are removed; their six cases are `sessionStoreContract`'s `[incarnation]` group, read through `headOf`, and `SessionStoreContractHooks.onOptionalMember` reports the face as `"headOf"` (was `"incarnationOf"`; plus `[head]` cases and the new `[ownership]` cases). **Who is hurt:** a kit consumer that called the incarnation kit (*server*: `test/store-contract-kit*.test.ts`, `test/session-incarnation-single-source-1479.test.ts`) or matched the report member `"incarnationOf"`. **How to migrate:** call `sessionStoreContract(make, { deleteSession, reopen })` and assert the report says `headOf` and `ownership` are `verified`.
|
|
620
|
+
- **Hard-breaking H4 (open to veto).** **What changed:** a `SessionRepo` behind `TtlSessionStore` carries the owner: it records `SessionCreateOptions.owner` on create and fork (and on an `importEntries` onto an absent id), keeps it (with the rest of the row's metadata) through an `importEntries` over an existing row, reads it back on `open`, and answers `headOf`. **Who is hurt:** a third-party `SessionRepo`: one that drops metadata cells it does not know loses every owner (the store then answers owned sessions as anonymous); one without `headOf` leaves `TtlSessionStore` with no `headOf` and so no ownership pair (an authorizer takes its fail-closed arm). The in-tree reference adapters `src/examples/adapters/pg-adapter.ts` (`PgSessionRepo`) and `tidb-adapter.ts` (`TiDbSessionRepo`) are such repos: no owner cell, no `headOf`, and no occupant token either, so a `TtlSessionStore` over them has no ownership pair (their headers say so). **How to migrate:** persist the cell (string or null, absent ⇒ null), mint a fork row's cells with `forkRowCells(source, options.owner)` from the source row the fork copies, implement `headOf(id)` as one read of the row, and run `sessionRepoContract` (it now pins the owner round-trip, a create over an existing row returning that row's owner, fork / import, the store's `headOf` presence, and a row replaced behind a cached session).
|
|
621
|
+
- **Behavior change (open to veto).** `register` on a row that exists records nothing, so a row `acquire` created stays anonymous (`null`) — a deployment that let `acquire` create a row and then `register`ed a principal on it no longer gets an owned session: every principal is admitted to it. Register before acquiring (the server's order) to own a session from its first write; an authorizer that judged an existing row acquires it with `requireExisting: true` — a head read racing a release's delete can show the old, owned row, and a create-capable `acquire` after that delete mints an anonymous one. A row written before the cell existed reads `null` and is not claimed by a later register either.
|
|
622
|
+
- **Behavior narrowing (open to veto).** (a) On a store that declares the placement partition, `register(id)` then a placement-carrying `acquire(id)` is refused `placement_refused` (the registered row is ordinary; before, the register wrote no row and the placed acquire created one). (b) A File create whose existence check passed but whose exclusive write found the file: the repo's `create` is refused `conflict` (before: the row was replaced); `TtlSessionStore` reads that refusal as a racing creator, the reading it already gives a conflict from `open` — a `register` that lost is the silent no-op its contract promises (the re-read sees the winner), an `acquire` that lost resolves to the row that stands. (c) A File header whose first record is not the meta header is `corrupt_session` even when a later line is one (before: the last meta line anywhere was the header); a later meta line is never read as the header. (d) A File owner cell that is neither a string nor `null` refuses the row (`corrupt_session` on open and on the head read; skipped from listings, disclosed). (e) A create / fork / import whose `owner` option is neither a string, `null` nor absent is refused (`TypeError`) before anything is written; a create over an existing id still writes nothing and so judges nothing. (f) A File `importEntries` onto an existing row whose header cannot be read is refused (the read's `corrupt_session`; before: the log was replaced and the occupant left unknown) — the row's owner cannot be carried. (g) The File session log's line judge holds an entry floor: an entry record whose `id` is not a string, an entry that is an array, a leaf entry whose target is neither a string nor null is skipped and disclosed by the replay, as the head read skips it (before: the replay kept it — a numeric id became the leaf). (h) `FileSessionRepo.placementOf` of a row whose file exists but cannot be read rejects (before: `undefined`, as for a missing file or an unplaced row).
|
|
623
|
+
- **Also in this change.** (a) `forkRowCells(source, owner?)` mints the fork row's owner too (the named owner, else — absent or `null` — the source row's), from the same source row the fork copies its history from; both bundled repos' `fork` call it, so a source replaced between the store's look at it and the repo's copy can no longer pair one owner with another owner's history. (b) see the frozen-metadata narrowing below. (c) see the cached-view behavior change below.
|
|
624
|
+
- **Behavior narrowing: frozen row metadata (open to veto).** **What changed:** `InMemorySessionRepo` and `FileSessionRepo` hand out the row's metadata frozen (the object `Session.getMetadata()` returns, the in-memory repo's `list()` rows, and the placement tuple inside them), so a write to it throws instead of rewriting the owner or occupant a head read answers. **Who is hurt:** code that writes to a metadata object it was handed (a host, or a fixture that patches `getMetadata()`'s result in place): it gets a `TypeError` in strict mode where the write used to land silently. **How to migrate:** copy before changing (`{ ...await session.getMetadata(), … }`); the row's cells change only through the repo's writes (create / fork / import).
|
|
625
|
+
- **Behavior change: `acquire` serves, and `release` / `sweep` delete through, only the occupant the row holds.** **What changed:** on a cache hit `TtlSessionStore.acquire` reads the repo's head (when the repo answers `headOf`) and compares occupants with `compareOccupants`; anything but `"same"` drops the cached view and resolves the id as a miss (history is never touched). So a cached view of a row another writer deleted, or deleted and created again, is no longer served. On the File lane this holds for a writer in another process too, from this process's next open of the id: the repo joins a live authority only while it is a view of the row just read — its log is the file the path names now (same device and inode) and it names no other occupant — so at that open a row whose file was removed, replaced, or rewritten in place for another occupant is read from disk, and from then on a handle on the old row is refused `log_closed` (before: the join was by path alone, the stale view was served again, and its writes resolved onto the unlinked inode without reaching the file). Until that open, a write through a handle on the old row still resolves where no reader of the path sees it, and a replacement that keeps the occupant (an import) is not seen at all (#2183, #2166); a row with no occupant token rewritten in place is not seen (#2167). A `release`, or an idle `sweep` under `evict: "delete"`, that would delete through a cached view runs the same check first: a view the row no longer stands for deletes nothing, and the id is decided by its row as an uncached one is (before: the row standing under the id was deleted, whoever's it was). An uncached id's placement is its row's (the repo's placement probe, else the row's metadata): a placed release that did not complete (its delete failed, or a read before it faulted) leaves a tombstone that only makes the next release read the row, and a delete runs only on a read that answered — a head read or a placement read that faults deletes nothing, keeps the tombstone, and is the release's rejection — so a session another writer created under the id after a delete that reported failure but committed keeps its forget release, transient fault or not (#2184) (before: the tombstone alone decided, and a `register` then `release` with no `acquire` between deleted that session). That is a behavior narrowing (ruled C-R670, open to veto): a cold `release` of a row it cannot read rejects with the read's fault (a File row whose header is damaged, or a zero-byte file, on a store that declares placements — ordinary rows too — rejects `corrupt_session` on every attempt) where it used to succeed silently, and a placed row whose header is damaged is no longer deleted on the retry: it stays on disk until it reads again, and only manual removal deletes it (the File `listPlaced` skips it and names it through the store's `onCorruptRead` disclosure when the host wired one — path and session id, "session log unreadable … skipped from the placed listing" — and the registry's reap swallows the rejection after deleting the agent row). **Who is hurt:** a caller that treats a rejected `release` as fatal (the engine's own callers catch it), and a deployment holding damaged subagent transcripts, which now stay on disk. A release that may delete, and a sweep's delete, hold the id until they settle: `acquire` and `register` answer after them, so a delete that never returns holds that id's `acquire` / `register` as a placed deletion's always did; the store's `headOf` does not wait, and a read racing a delete answers either generation (before: only a placed deletion held the id, and a sweep issued its delete within the `sweep()` call). A sweep has no outcome to report: when its head read faults, or its delete fails, it deletes nothing and says nothing, and no later sweep retries the row, whose cached view it already dropped (#2199). Before, it was: once admission read the row's owner, the id's new owner was admitted on one store and served the previous owner's transcript from that store's cache (two stores over one repo, or a row replaced behind the store by repo writes). The old reading pinned by `test/backlog1479-session-incarnation.test.ts` ("A's resume runs on its cached session") is that shape and flips: A's resume runs on B's recreated row, and the separation is said (`memory.session_incarnation_mismatch`). **Who is hurt:** a deployment that resumed on a store's cached view after the row was deleted elsewhere (now: the row standing, or a fresh row on a create-capable acquire, or `not_found` on `requireExisting`). Cost: one head read per cached acquire and per release that may delete (memory O(1); File one descriptor, the first record and the tail), and a head read that faults (an I/O error) rejects an `acquire` the cache would have answered, a `register`, and a `release` that may delete (a placed view's release keeps its tombstone, so the retry reads the row and deletes it; an unplaced view's release under `evict: "delete"` keeps nothing — its view was already dropped — so after a head-read fault the retry resolves and deletes nothing, and the row stays). A row with no occupant token (written before 7.49.0) compares `"unknown"`: it is reopened on every acquire, so its list projection's `lastTaskId` / `lastTaskRunId` start over at each acquire (#2167), and under `evict: "delete"` its release and sweep no longer delete it (behavior narrowing, ruled C-R665, open to veto: its transcript stays on disk until #2167 gives such rows a token; before, it was deleted by id). An `importEntries` keeps the occupant, so the comparison does not see it: a view cached before the import is still served (#2166). **How to migrate:** nothing for a single store over its own repo; a deployment that relied on the stale view resumes from the row.
|
|
626
|
+
- **Behavior change.** (a) The owner survives a restart on the File lane (it is in the header). (b) A File header occupant cell `''` reads unknown and is disclosed (`session header incarnation: the incarnation cell holds an empty string — read as an unknown occupant`); `null` reads unknown silently (before: disclosed); a non-string one's disclosure now reads `session header <cell>: the incarnation cell holds a non-string value (<type>) — …`. (c) The File head's leaf is the log's: an entry that reached the log but not the process's live view (a crash between the write and the in-memory flip) is the head's leaf, as a restart's replay sees it. (d) An `importEntries` over an existing row keeps its `createdAt`, `forkedFrom` / `forkedFromIncarnation` and `placement` on both bundled repos (before: it minted a new `createdAt` and dropped the lineage and the placement, so a placed row imported over lost the placement its first write had recorded).
|
|
627
|
+
- **Exports.** +`compareOccupants` (function), +`SessionHead` (type); −`sessionStoreIncarnationContract`, −`SessionStoreIncarnationContractHooks` (this section's net is 0; the release's whole surface moves 2826 → 2885 at 57769b328, every section counted (recounted at the release head)). Members: `SessionStore` −`ownerOf?` −`incarnationOf?` +`headOf?`; `SessionRepo` +`headOf?`; `SessionMetadata` +`owner?`; `SessionCreateOptions` +`owner?`; `InMemorySessionRepo`'s create options widen to `SessionCreateOptions`; `AppendLog` (package root) gains a public method `isFileAtPath()` — whether the log's open descriptor is still the file its path names (same device and inode), the File repo's reading of "a view of the row just read".
|
|
628
|
+
- **Who else.** *client-core*: generated type surfaces naming `SessionStore` members. *cli*: no store face is imported. The session list, the wire and the Runner's own reads are unchanged.
|
|
629
|
+
- **Pins.** `test/backlog1468-s2-session-row-head.test.ts` (H1 the File head is the replay's answer: entries, a branch switch, torn / whole-unterminated / judged-out tails, the first record as the header, lines across read blocks; H2 the head does not replay the log; H3 the crash shape; H4 the owner survives a restart, FileStorageBackend included; H4b a cached view of a row another writer deleted and created again (two stores over one in-memory repo, two File repos over one directory, one store with the row replaced behind it) is not served by the next acquire, and a view the row still holds is; H4c an import over an existing row keeps its owner, and a File import over an unreadable header is refused; H4d a File row another process deleted and registered again (a real second process; raw writes; an in-place rewrite for another occupant; a row with no occupant token) is not joined — the next acquire serves the new row, its turn reaches the file, the old handle is refused; H4e release and sweep under `evict: "delete"` leave a row the cached view no longer stands for; H4f a delete that never returns holds `acquire` / `register` of its id, never `headOf`; H5 a row from before the cell; H6 occupant decoding; H7 the malformed owner cell; H8 import, a fork row's creation time and lineage, a placed row's placement; H9 `compareOccupants`; H10 a real Runner run; H11 a source replaced mid-fork; H12 frozen metadata; H13 a failed placed delete's tombstone against a session another writer created under the id, with and without an acquire between, over a repo with and without a placement probe, an unreadable placed row (the release rejects with the read's fault and keeps it; once it reads, the retry deletes it), and the retry after a head read that faulted on a placed view's release, a probe fault under a tombstone and on a placed view of a replaced row: nothing deleted, the release rejects); `test/backlog1468-s2-file-create-race.test.ts` (the exclusive create against a writer planted at the publish step); `sessionStoreContract` `[head]` / `[ownership]` / `[incarnation]` groups over the memory, bounded-wake, File and FileStorageBackend lanes and their mutants (`test/backlog1468-s1-session-store-kit.test.ts`); `sessionRepoContract`'s owner cases (including the store over the repo after a row is replaced behind a cached session, and a fork row imported over keeping its metadata); `test/design393-s6-run-leg-lanes.test.ts` (the rejection path's hop count stays 52); `test/session-ownership-face.test.ts` (its sweep cell awaits the delete the sweep issues after its head read), `test/backlog1479-session-incarnation.test.ts`, `test/backlog1468-k2-laws.test.ts` ⑭ (flipped) re-pinned to the row law.
|
|
630
|
+
|
|
631
|
+
### (#2181) A rule written to a session while one of its delegated agents runs binds that agent's next tool call, whether or not the session had a rule when the agent started (#2181; behavior narrowing, open to veto: a delegated leg enforces its parent session's rules row written after it was spawned, and an observer sidecar is bound by the session's rules like the agent it observes; behavior change: a delegated leg whose Runner has no `sessionPolicyStore` announces `config.session_rules_unreadable` once per ancestor session; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli @test)
|
|
632
|
+
|
|
633
|
+
- **Before.** A delegated leg reads its ancestors' session rules through the entries on the chain its spawner hands it, and re-reads each entry's row at every one of its gates. The parent put its own session's entry on that chain only once its own layer had read a rules ROW (at its prepare, or at one of its own gates). A session with no row when the agent was spawned — the ordinary case of writing a session's first rule while a background agent works — handed the agent no entry, so a deny written afterwards never reached it: every later call ran, background and foreground, on the in-memory and the file store; with any row present at spawn the next gate denied. A grandchild, and a parked agent resumed after the write, read the same way. A delegated leg on a Runner wired without a `sessionPolicyStore` enforced its spawn-time snapshot and said nothing.
|
|
634
|
+
- **Now.** A leg that enforces its own session-rule layer (a `sessionPolicyStore` is wired and the task carries a `sessionId`) hands its key on from prepare: the row it enforces, or the empty rev-0 seed when the session has none. Every delegated leg the engine builds from that chain (sync, background, forked, workflow, retained, resumed and observer-sidecar legs, at any depth) carries a layer for that key and re-reads it at each gate — the sidecar's chain copy now carries the session-rule entries beside the mode seats, shell floor and org freeze it already carried — so a rule written while an agent runs binds that agent's next call, its descendants' next calls, and a parked agent's first call after it resumes (a deps-only resume too: the entry rides the checkpoint's `inheritedGate.ancestorRules`). The key is the parent's own (its `sessionId` and its run's `principal`): a descendant reads exactly the row the parent's gate reads. A delegated leg whose Runner has no `sessionPolicyStore` cannot re-read its ancestors' rules: it keeps the spawn-time snapshot and announces `config.session_rules_unreadable` (`onError`, phase `config`) once per ancestor session it carries, the arm a store that throws already takes — the silent constant reading is gone. The checkpoint/live merge on a resume and the live reader now judge "may this row follow the floor" by one predicate: a floor at rev 0 has enforced no row, so a re-supplied row of any generation replaces it (before, the merge alone kept an empty rev-0 entry against a newer row of another generation).
|
|
635
|
+
- **Who is hurt.** (a) A deployment that delegates on a Runner with no `sessionPolicyStore` while the spawning Runner has one: one `config.session_rules_unreadable` advisory per ancestor session per delegated leg that makes a tool call, where there was none; give the delegation Runner the same store and rules written after spawn bind its legs. (b) Store reads per delegated tool call grow with depth. Every leg the delegation tool spawns runs on its own session, so it has its own layer, and its key (one nobody writes) is now handed to the legs below it like the root's: at each gate a delegated leg reads the root's key, its own, and one key per delegated leg between them — 1 → 2 reads per tool call at depth 1, 1 → 3 at depth 2 (d + 1 at depth d, one query each on a SQL store), where before it read its own key only (plus the root's when the root had a row at spawn). On a store whose reads hang, each read is one more bounded wait per gate (the existing per-read bound, KL-978b). Reading each key once per gate, and skipping a key nobody can have written, is #2198. (c) A host that reads a delegated child's checkpoint `inheritedGate.ancestorRules` finds the parent's entry at `rev: 0` when the parent had no row (the member's shape is unchanged). (d) An observer sidecar's tool calls are judged by the session's rules: a deny refuses the sidecar's call as it refuses the observed agent's, and its report tool is a tool like any other there (a session allowlist that does not admit it refuses the report).
|
|
636
|
+
- **Compensation.** A session rule binds every agent the session is running from that agent's next tool call, as a session-scoped deny does in the reference implementation, where an agent's permission check reads the session's live permission context.
|
|
637
|
+
- **Unchanged.** The org-admission freeze and the checkpoint writer's `inheritedGate` section reach the same answers: an engine-built chain always carries `admittedOrgScopes`, so the freeze never had to infer delegation from `ancestorRules` there and the section was already written for every delegated leg. A delegation Runner with a different store than the spawning one reads the parent's key in its own store, as before, and silently: no row there ⇒ nothing binds its legs and nothing is announced (`RunnerDeps.sessionPolicyStore` says so; a store handle on the chain, or a refusal, is #2197). A row of a new lineage (an erase, then a write) is not adopted by a leg already enforcing a row — the parent's own layer and a descendant's alike.
|
|
638
|
+
- **Pins.** `test/backlog2181-delegated-child-parent-session-rules.test.ts` (an older row at spawn then the deny ⇒ the next gate denies; no row at spawn then the deny ⇒ the next gate denies, background and foreground, in-memory and file store; the deny at spawn and no deny ever as controls; the store-less delegation Runner keeps the snapshot and announces once; the parent's principal is the key, for the agent and the parent alike; a grandchild; the org-admission verdict and the parked child's `inheritedGate` section unchanged; the deny written during a park binds the deps-only resumed child; a re-supplied newer row of another generation replaces the rev-0 entry on a store-less resume worker; the engine's own `Read` under a name rule and a path rule; two background agents bound by one write; a workflow child; a deny present at spawn refuses the observer sidecar's Write, and with no rule the sidecar writes; the store reads one delegated tool call costs at depth 1 and 2, and none for a top-level task that names no session).
|
|
639
|
+
|
|
640
|
+
### (#2192) `NodeExecutionEnv`'s spool directory lives exactly as long as a spool pair does: an env no longer leaves one empty `sema-bg-*` directory in the OS temp dir (#2192; behavior change, open to veto: the directory a shell's stdio spools into is removed when its last pair is given back; hard-breaking: none; closed sets: none; exports: none; members: none, one private field re-read)
|
|
641
|
+
|
|
642
|
+
- **Before.** The first shell command an env ran (foreground `exec` or `spawnBackground`) made a private directory `sema-bg-XXXXXX` under the OS temp dir and every later command spooled into it. Each command unlinked its own spool files when it ended, so the directory sat empty — one per env instance, never removed (the env's `cleanup()` ends shells but had no removal point, and nothing in the engine calls it for an env the host built). A machine that built many envs (every test file builds several) collected them without bound.
|
|
643
|
+
- **Now.** One seat opens a spool pair (the foreground exec and the background spawn are the same reader of the same directory); one seat gives a pair back (both files unlinked; a descendant still writing keeps its descriptor into the unlinked file, as before). The directory exists only while the env holds a pair: the last pair out removes it, together with anything a failed unlink stranded, and the next command makes a fresh one — so a temp cleaner that took the directory between commands can no longer leave the env spooling into a missing path (it fell back to pipes silently). A detach-adopted foreground exec hands its pair to the background shell, which gives it back at its terminal reclaim. A background spawn that throws before it has an entry gives its pair back (before, the files stayed on disk). `cleanup()` is unchanged: it ends the env's shells, and each reclaimed shell gives its pair back.
|
|
644
|
+
- **What stays.** A retain-declared env (`retainBackgroundProcesses`) never gives a retained shell's pair back — those files outlive the run by design — so the directory holding them stays until the deployment clears it, as it always did. A host process killed outright mid-command still leaves the directory with that command's files; an env that never runs a shell never made one.
|
|
645
|
+
- **Who is hurt.** Nobody on a public face: the directory was never named by the API. A test or host that reached into the private `bgSpoolDir` after the last command ended (it read the path of an empty directory) now reads `undefined` and the path is gone; read the directory while a command is live, or not at all. Directories already left in `$TMPDIR` by earlier versions are not touched by this change (they are empty; remove them with the OS tools).
|
|
646
|
+
- **Who else.** *server*: its host lane builds its own env (`RemoteHostExecutionEnv`, spooling under its own `sema-host-spool-*` root removed at its `destroy()`), not this one. *cli*, *client-core*: no env is built.
|
|
647
|
+
- **Pins.** `test/backlog2192-spool-dir-lifetime.test.ts` (a sandboxed `$TMPDIR` per case: a foreground exec's directory exists while it runs and is gone when it settles; foreground and background pairs share ONE directory, which outlives the exec while the shell lives and goes with the shell; `cleanup()` over a running shell leaves none; two concurrent execs share the directory and the last removes it; `cleanup()` over an in-flight exec leaves its directory and its output alone; a detach-adopted exec's pair is kept until the shell is reclaimed; a spawn that throws leaves neither files nor directory on both lanes; a retain env keeps the directory; a fresh directory is made after the previous one went); `test/reap-liveness-gate.test.ts` (the terminal-reclaim cell re-pinned from "the directory is empty" to "the directory is gone"); the per-fixture mutation table is in the car receipt.
|
|
648
|
+
|
|
649
|
+
### (#2151) A session rule leaves a running run when the row stops holding it, and a non-operator write may take back what non-operator writes added: the row records its operator part (`StoredSessionRules.operatorRules`), and the write judge never loosens below it (#2151; behavior change, a relaxation of the tighten-only rule, ruled C-R677, open to veto: a non-operator `putRules` may drop a deny, or undo an allowlist or allowDirs narrowing, that a non-operator write added — one notch wider than the ruling's text, which names dropped entries only; behavior change: the `loosen_forbidden` sentence now reads "a non-operator session-rule write may not loosen what an operator wrote"; behavior change: a value carrying `rev: 0` (the no-row record a read of an unwritten key answers) is read as written now, no stored row — in this engine's grammar, with no operator part; hard-breaking: none; closed sets: none; exports: +1 function `sessionPolicyStoreContract`; members: +1 optional `StoredSessionRules.operatorRules`, +1 optional `SessionRulesInGrammar.operatorRules`, `judgeSessionRulesWrite` answers `SessionRulesInGrammar`) (@server @cli @client-core)
|
|
650
|
+
|
|
651
|
+
- **Before.** The gate already re-read the session's rules row at every tool call, on the task's own layer and on every delegated leg carrying the key, so an operator replace that left an entry out bound the very next call. But the write judge read every non-operator write as tighten-only against the whole stored row. The session's owner could add a deny while a turn ran (the shell's mid-turn channel) and could never take it back: `/permissions` deleted the local rule while the engine record kept refusing for the rest of the session (cli L-1670 told the user so).
|
|
652
|
+
- **Now.** The delete port is the same `putRules`: a value without the entry takes it out of every gate that reads the row, from the next call (no verb, no frame; the observable is the next `tool_end` for that tool carrying no `denied` gate outcome). What a non-operator write may take out is judged against the row's **operator part** — the buckets and grammar the last operator write set. The judge mints it alone (a value handed in with one is no authority). **It is present only once a non-operator write has stamped the row; absent, the whole row is the operator's** — an operator write's whole value, and every row stored before this version, so existing rows read fail-closed exactly as before. Over no row nothing is judged for loosening, as before: there is no rev to compare and no tighten gate, so a host's pre-read with `prior = null` answers only the shape and entry refusals. The row starts with the part of the value written. A stored row handed in (an import replaying an exported row; it carries a store's `rev`, a whole number from 1) keeps its stamp, or, without one, is the operator's whole, so a replay never turns an operator's entry into one the owner may take back. Any other value starts with an empty part, whatever part it hands in: a fresh one, or one built on the no-row record `{ rev: 0 }` that a read of an unwritten key answers. Above the operator part the owner adds and takes back freely; at or below it every loosening is `loosen_forbidden`, an entry both writers asserted included (the drop is refused and the rule keeps binding). An erase (`deleteBySession`) mid-leg is still not a delete port: the leg keeps the row it enforced and announces a recreated row of a new lineage (#978 G7 / G14).
|
|
653
|
+
- **What changed (cross-repo contract, row shape +1).** A stored row may carry one more member, `operatorRules` (an object of rule buckets plus its own `grammar` stamp, never an `operatorRules` of its own). It is read by the one shape reading `readSessionRulesShape` (a part it cannot read is the row's fault, `corrupt` on read-back), handed back in `rules` beside the buckets, and carried by `listBySession`, the store's export, and the GET body of a host that serves the stored row.
|
|
654
|
+
- **Who is hurt.** A durable twin that re-assembles a row from the buckets and the grammar alone drops the member. Its owner writes are then judged against the whole row: no take-back, but nothing loosens either (fail-closed). Mount `sessionPolicyStoreContract` to find out. The member adds no rollout order of its own. An engine before 7.55.0 already cannot read any row this version writes: its shape reading refuses the row's grammar stamp (3, the #2150 section) before it reaches a member, and past the stamp it would refuse `operatorRules` as no rule bucket too. Upgrade readers first, as that section says (read from 7.54.0's shape reading; not run across versions). A row stored before 7.55.0 (no member) that holds a `mcp__*` deny: on 7.54.0 that entry named nothing, so the owner could remove it. Here it denies every MCP tool (#2150), and because the whole stamp-less row is the operator's, the owner can never remove it — R1's design consequence (pinned, H3). An operator write is a whole value, so it makes every entry it carries the operator's, the owner's included: after one, the owner can no longer take back what it wrote before. A host or test that matched the old `loosen_forbidden` text ("may only tighten") reads the new sentence.
|
|
655
|
+
- **How to migrate.** @server: the SQL twin already stores the judge's whole answer and reads it back through `readSessionRulesShape(body).rules`, so the member rides unchanged — run `sessionPolicyStoreContract(make)` beside the equivalence suite to prove it. `explicitOperatorOk` in `src/http/routes/sessions.ts` (`:1055`, the flag frozen into `putRules` at `:1100`) and the import replay's `operatorOk` stay the one authority switch: an operator-replayed import makes the imported row the operator's. A non-operator one keeps each record's part: the record carries its rev, which `readSessionRulesRecords` re-adds after the pre-read (`src/session-policy-wire.ts:87-89`). Onto a fresh or erased key nothing is judged for loosening. The PUT route's pre-read (`readWritableSessionPermissionRules`, `judgeSessionRulesWrite(null, …)`) answers only `invalid_shape` / `inert_entry`. A GET-modify-PUT that drops an operator's entry is answered by the store against the real row: 403 for the owner, success for an operator. @cli: L-1670's real take-back is the existing `PUT /v1/sessions/:id/policy` with the entry left out (expected rev = the row read). The "still holds … next session" sentence is true only for an entry the record's operator part holds, which a 403 `loosen_forbidden` says. @client-core: no new event; the removal shows as the next gate of that tool not refusing. A take-back verb beside `tightenSessionPolicy` (read the record, PUT it without the entry, CAS on its rev) is the package's to add, so the shell does not build the value itself.
|
|
656
|
+
- **Pins.** `test/backlog2151-session-rule-delete-port.test.ts` (real Runner: an operator drop binds the next call on the own layer and on a foreground and a background delegated leg; the owner's drop of the operator's deny is refused there too; the owner adds a deny mid-run and takes it back; the owner's deny on the parent's key taken back mid-child; an entry both writers asserted; a row stored before the member; the owner's own allowDirs taken back with a real Write landing after, and an operator's allowDirs that the owner cannot remove; the judge's stamp three ways, and over no row from a handed-in stored row; the no-row record `{ rev: 0 }` handed back with a deny, taken back mid-run; a host's GET-modify-PUT whose pre-read never refuses a loosening, with the store answering the owner 403 and the operator success, on an owner-stamped and an operator's row; the owner's own `mcp__*` deny taken back, an operator's or a stamp-less row's not). `test/backlog2151-session-policy-store-contract.test.ts` (the kit on the in-memory and file stores through the package root, including an exported row replayed onto a fresh and an erased key, a value built on the no-row record, and a backend that drops the member failing the stamp cases).
|
|
657
|
+
|
|
658
|
+
### Release documents: the 7.53.0 and 7.54.0 criteria errata (#2191, #2195; docs only; `docs/` is not in the package; no `src/` change; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
659
|
+
|
|
660
|
+
- **#2195.** Four texts of `docs/release-criteria/7.54.0.json` did not match the published artifact or a measured run, and are rewritten to what was measured, each marked `erratum #2195` in the field it corrects (the criteria schema carries no separate erratum member): G8.a counted `CHECKPOINT_CONFIG_REFUSAL_CODES` at 41, the state between the release's two entries — the package holds 42; G11.a (3) claimed the sub-package's registry `gitHead` is the tag's commit — neither 7.54.0 registry document carries a `gitHead` (both were published from a linked worktree), and the third leg was a fresh clone of the tag rebuilt to the registry's shasum; F20's `before` and G8.a's v7.53.0 tail said `aliases: "file"` threw a `TypeError` out of prepare — on the v7.53.0 tarball it is accepted and the tool mounts; G4.a's control said an `allow` whose edit changes the destination is executed — only a destination inside the root is, one outside it is refused on both versions. F28 and G12.a said the approver's ask carries `crossBoundaryMandated`; that member is the mark on a policy's or hook's own inbound ask (folded into `requiresRealApproval`), and the approver reads the fact as `askClass: "cross_boundary"` with `requiresRealApproval` / `mandated`.
|
|
661
|
+
- **#2191.** In `docs/release-criteria/7.53.0.json`: G2.a's v7.52.0 tail (the task started) — v7.52.0 fetched the peer's card and then refused the task `config.egress_requires_write_effect`; G3.a gave one sentence for five forms — the Proxy form has a sentence of its own; G17.a (v) said the caller tool mounts — under `excludeAllTools` the request carries `tools: []`; G20.a's count of 40 is 7.53.0's own (a later release states its own count); G22.b / G22.c and G12.a called their before-forms a defect of the unreleased 7.53.0 stage — v7.52.0 already had them. Each is marked `erratum #2191` in place. `docs/release-criteria/README.md` records both errata; the 7.53.0 section of this file carries a one-line note.
|
|
662
|
+
|
|
663
|
+
### The launch gate's notes, made whole: when the posture notice is minted, what the taint trace carries, what a leg on an environment that is not the target owes, what a call the table cannot read owes, and why the legs table and not the sandbox predicate (#469 O9b; documentation only; `docs/` is not in the package; no `src/` change; behavior change: none, the readings below are 7.54.0's own and were not in its notes; hard-breaking: none; closed sets: none added, `FileErrorCode` and `CrossBoundaryRecordPhase` gain their registry rows in `docs/CLOSED-SETS.md`; exports: none; members: none) (@server @client-core @test)
|
|
664
|
+
|
|
665
|
+
- **Why this section.** The 7.54.0 notes of the launch gate (under `## 7.54.0`, the sections "The device lane's cross-boundary vocabulary and data plane" and "The device lane's launch gate") state its rule, its record and its refusals. Five readings of the same code were left out, each read off the 7.54.0 tree (`src/core/runner/cross-boundary-lane.ts`, `src/core/cross-boundary-taint.ts`) and pinned in `test/design469-r3-launch-gate.test.ts`. None changes a decision; the one a deployment can observe without doing anything (the first) was missing from the "behavior change" line of the launch-gate section, which names a record, a trace and a report of a record that could not be written, and no notice on a tree that is not governed.
|
|
666
|
+
- **The undeclared-posture notice.** At the prepare of every leg that finds its tree armed — its environment declares `externalContentTarget`, the row it resumes was minted on one, the tree it joins was armed by another leg, or its session's record says the tree was — the engine mints one operator notice `config.cross_boundary_posture_undeclared` (`detail: { sessionId, runId, taint }`, `taint` the tree's taint bits) when `RunnerDeps.crossBoundaryPosture` is absent and the tree the leg holds is not governed (neither the tree it joins nor its session's record says so). A device-lane deployment that declared no posture before 7.54.0 therefore meets this notice once per armed leg from 7.54.0 on, until it declares `"single_user"` (the notice stops, the reading is the same) or `"multi_tenant"` (the gate engages); a tree that never armed (the host lane) mints none, and neither does a leg that declares a posture. *Who is hurt:* no decision moves; a notice projector that has no row for the code (add one: audience `operator`, minter `engine`) and an operator who reads one notice per leg as a fault. Pin: P17, "an armed tree with no posture announces the permissive reading once and traces its prepare" (once, for a one-leg run), and the same file's "no posture vs single_user on an undeclared env" for the arms that mint none.
|
|
667
|
+
- **The taint trace.** `permission.cross_boundary_taint` is a `TraceEvent` — it reaches a host through `RunnerDeps.tracer`, not as a `TaskEvent` — of the shape `{ kind, version: 1, taskId, runId, phase, taint, governed, toolName?, toolCallId?, ts }`. `phase` is `"prepare"` at an armed leg's prepare, `"resume"` at the prepare of a durable resume's leg, and `"observed"` once, with `toolName` and `toolCallId` of the call, at the allow exit of the call that first lets an off-target read into the TREE (a bit another leg of the tree already set emits none). The bit is set and the event emitted before the call runs and before its session record is written, so on a governed tree whose record then cannot be written the call is refused after the event, and the bit stays (the tree only grows). `taint` lists the tree's taint bits after the event (`CROSS_BOUNDARY_TAINT_BITS` members, none for a bit not set) and `governed` says whether the tree has run under `multi_tenant`. Metadata only, a tree that never armed emits none. The trace's `phase` is a literal member of the event's type; the other phase vocabulary of the gate, where a session record's write was owed (`prepare` / `exit` / `suspend` / `settle`), is `CrossBoundaryRecordPhase`, the word `config.cross_boundary_record_unwritten` carries as `detail.phase`.
|
|
668
|
+
- **A leg on an environment that is not the target is read as both outside legs (open to veto).** A call's `env` leg is judged by whether the environment it reaches declares itself the target at that moment. An environment that does not reads as an outside read AND an outside write. On an armed tree that is not only a rule about the device lane's own legs: a delegated leg running on an environment that declares no target — a host child of a device tree, a leg in an isolated sandbox — has every `env` call of its own (`Read`, `Bash`, `Write`, …) put to the table as an outside write, so under `multi_tenant` each of them is a person's from its first call, not only after an off-target read, and each of them sets the off-target-read bit once allowed; the bit reaches the parent through the tree's shared state, so the parent's next device call is a person's as well (pinned, P7). Under `single_user`, on a tree that never ran `multi_tenant`, the same calls ask nothing and set the bit (a leg's posture does not ungovern a tree: a session that once ran under `multi_tenant`, or a tree a `multi_tenant` leg joined, stays governed under a later `single_user` leg). *Who is hurt:* a `multi_tenant` device deployment that delegates to legs on another environment; its approvers meet cards they did not meet before 7.54.0, each with `cross_boundary` among its classes. *How to migrate:* answer the cards (`cross_boundary` is among their classes and they carry the real-approval bit); the rule has no knob, a child belongs to its spawner's tree.
|
|
669
|
+
- **A call the table cannot read reads as every leg, off the target.** A call whose name no roster row answers, and a call of a `"per_call"` row whose resolver (`boundaryLegsFor`) is missing, throws or answers outside its type, is read as all three legs on an environment that is not the target (so, on an armed tree, it sets the off-target-read bit once allowed and, on a governed one, is a person's). A missing, throwing or ill-typed resolver is reported on `onError` (phase `config`) where the station reads the call (the exits read the same answer quietly); a call with no roster row is not reported. Pinned: "a per_call row whose resolver is missing, throws or answers outside its type reads as every leg off the target, reported …" (`test/design469-r3-launch-gate.test.ts`).
|
|
670
|
+
- **The judging position is the call's legs, not `sandboxBoundaryCapable` (open to veto).** A proposal to read the gate's "is this call beyond the boundary" off the sandbox-admission predicate (`sandboxBoundaryCapable`: the egress mark, or not one of the env's hand tools) was not taken, because that predicate answers another question — whether a sandbox could contain the tool — and misses both ways here: a tool that is not an env hand tool (a task-list or delegation tool) reads as outside, and `Artifact` is an env hand tool (`prepare-artifact.ts`), so its hosted publish would read as inside. Each roster row states its legs instead (`ToolRosterEntry.boundaryLegs`; the registry `TaskOutput` / `TaskStop` and `Artifact` give theirs per call), which a host and a black-box check can read row by row on the wiring manifest.
|
|
671
|
+
- **Records.** `docs/CLOSED-SETS.md` registers `FileErrorCode` (`target_unavailable` came in 7.54.0 on a union that had no row) and `CrossBoundaryRecordPhase`, adds no word, and says that `cross_boundary_taint` is last in `PERSISTED_RULE_MANDATES` and first in precedence. `docs/KNOWN-LIMITS.md` gains KL-469-k (the legs a declaration states are the declarer's word: a request-side `boundaryFlow` on an MCP list that is not locked or on an A2A peer list, which has no lock key; and a host tool mounted with a core contract), and KL-469-c, -d and -e carry what happened to them since 7.54.0 (the settle sub-window is a defect on the ledger; the workflow handle's tree is a ledger item; the fleet-wide posture prerequisite is the host-facing text of `docs/INTEGRATION-CORE.md` §4.5c, the neighbour's own deploy document not yet holding the line). `docs/INTEGRATION-CORE.md` §4.5d states the non-target-environment reading. `docs/wire-consumption/7.54.0.md` records the #469 keys with the consumers' dispositions still to be filled by them.
|
|
672
|
+
- **Consumers.** *server:* the notice is minted per armed leg until a posture is declared (the first reading); the trace's three phases and fields are what the projection onto `GET /v1/runs/:id`, asked for in the 7.54.0 notes, carries. *client-core:* nothing new — the words were already in the 7.54.0 notes. *test:* the first and third readings are black-box checkable (the notice count per leg with no posture; the child-on-undeclared-environment cards under `multi_tenant`); the unreadable-call reading is checkable with a `"per_call"` tool whose resolver throws (an `onError` line, the call read as every leg); the criteria file of a later release is the place for them.
|
|
673
|
+
|
|
674
|
+
### Deny and ask lists read `mcp__*` and `mcp__*__*` as every tool of every MCP server; allow lists keep refusing them, now with one sentence naming the forms that do name tools (#2150; also #2155, no change — see its bullet; behavior change, open to veto: a rule policy built outside a Runner used to compile these two spellings and answer allow (a deny that protected nothing) and now decides, and inside a Runner the spellings go from refused at prepare to read; differs from the reference: no, this follows it; behavior narrowing: a rule policy built directly now refuses a partial wildcard in a peer segment, which it used to compile and match nothing with; hard-breaking, open to veto: the session-rule grammar stamp moves 2 → 3, so an engine of grammar 2 refuses every row this engine writes (see the session-rule grammar bullet); closed sets: none; exports: none added or removed — `SESSION_RULES_GRAMMAR` is 3 (was 2), `judgeRuleToolName` gains an optional trailing parameter `behavior`, `isNamespacedCoveringRuleName` an optional second parameter `behavior`, and the published conformance table `SAFETY_MERGE_CORPUS` changes content (one vector re-exemplified, two added); members: none) (@cli @client-core @server @dgx)
|
|
675
|
+
|
|
676
|
+
- **Before.** The engine did not read the two whole-protocol spellings in any list, and the surfaces disagreed about saying so. Through a Runner, a name list (`createAllowDenyPolicy`, `createApprovalPolicy`), a rule policy's deny or ask rule and a skill's `disallowedTools` carrying `mcp__*` or `mcp__*__*` failed the run at prepare (`config.legacy_tool_name`, the peer segment `*` "not a spelling the engine mints", with a rewrite that named one server), and a session's `toolDeny` was refused at the write (`inert_entry`) — but a rule policy built directly (`createPermissionRulePolicy`, no prepare-time name audit) compiled the rule with no issue and answered allow for every call: a deny that protected nothing, silently. An agent's denied tools read `mcp__*` as every MCP tool but refused `mcp__*__*` (and ignored it where the filter is called as a function).
|
|
677
|
+
- **Now.** A list that RESTRICTS — a rule policy's `deny` / `ask`, a policy's `deny` and `requireApproval`, a session's `toolDeny`, a skill's `disallowedTools`, an agent's `denyTools`, a run's `excludeTools` — reads `mcp__*` and `mcp__*__*` as every tool of every MCP server (any namespace in the protocol table reads its own prefix the same way: `a2a__*`), with one parse, `wholeProtocolOf`, shared by the rule layer, the name lists, the entry reader, the name judgment and the agent lists (the agent lists' own `wholeProtocols` side set is gone). A list that GRANTS — `allowedTools` (request, deployment, skill), a policy's `allow` / `autoAllow`, a session's `toolAllow`, an agent's or a run's `allowTools` — does not read them, as before; where it used to refuse them it still does, in the same seat and under the same code, and the sentence now names the forms that do name tools: `mcp__<peer>` (every tool of one peer), `mcp__<peer>__<tool>` (one tool), and that a deny or ask entry may write `mcp__*` / `mcp__*__*`. A wildcard in any other peer segment (`mcp__git*`, `mcp__*__get_*`) is read by no list and is refused with the same sentence — including by a rule policy built directly: `validatePermissionRules` and `createPermissionRulePolicy` answer `invalid.tool_name` with the judgment's sentence for such a deny or ask rule (`config.invalid_permission_rules` when built, a skipped-and-reported rule under `onInvalidRule: "skip"`), where they used to compile it and match nothing (`wildcardPeerRefusalOf` is that judgment for a station with no roster); its verdict (`unmintable_segment`) no longer carries a `currentName`, since there is no spelling to rewrite a wildcard to (it used to name one server). `judgeRuleToolName(name, roster, behavior?)` answers `current` for the whole-protocol name when `behavior` is `deny` or `ask`; with `allow` or no behavior it answers as before (`unmintable_segment`), so a caller that passes nothing reads what it read. An allowlist reads those names as nothing everywhere, the frozen one a policy projection carries included: the policy constructors refuse such an entry where it is written — `createAllowDenyPolicy({ allow })`, `createApprovalPolicy({ autoAllow })` and `createApprovalPolicy({ requireApproval })` under `denyByDefault` (the three lists that become a projection's `tool_allowlist`) throw `config.invalid_tool_name_set` with the judgment's sentence (a partial wildcard is refused there too; `onInvalidName: "skip"` drops it) — and the frozen replay of a stored `tool_allowlist` holding `mcp__*` / `mcp__*__*` (frozen before 7.55.0, or built by a host) denies every MCP tool not otherwise named, exactly as before; it is not read as "every MCP tool". A `requireApproval` without `denyByDefault` is an ask list and reads the whole-protocol name.
|
|
678
|
+
- **The session-rule grammar (hard-breaking, open to veto).** *What changed.* A session's `toolDeny` entry `mcp__*` / `mcp__*__*` used to read as an entry that names nothing and now denies every MCP tool — what an entry means moved, so the rule grammar the write judge stamps on every row moves with it: `SESSION_RULES_GRAMMAR` is 3 (was 2), minted by every store that writes through `judgeSessionRulesWrite`; a row stored in grammar 2 is read by this engine as written, so its `mcp__*` now denies (a tightening, never a grant). *Who is hurt.* Every engine of grammar 2 (7.54.0 and before), including a server worker running on one: it refuses a row stamped 3 whole (the shape reading faults `grammar`, the file store answers `corrupt`, a write answers `invalid_shape`, the run-time layer refuses every call, "session rules cannot be read as rules") rather than read its `mcp__*` as nothing and allow the calls the operator denied — per ROW, not per entry, so during a rolling upgrade a session whose rules a new worker wrote is unusable on an old worker until that worker is upgraded or drained. Measured on the 7.54.0 tree: a grammar-2 row holding `mcp__*` allowed every MCP call (fail-open), a grammar-3 row is refused whole. *How to migrate.* Upgrade readers before writers: roll the engine to 7.55.0 on every worker that reads session rules before any worker that writes them (a deployment with one process is unaffected); @server @dgx deploy: order the server release that carries 7.55.0 so no worker on the old engine can read a row written by a new one, or drain the old workers first. A host that stores `StoredSessionRules.grammar` itself keeps the stamp the write judge returns and nothing else.
|
|
679
|
+
- **The reference.** The settings validator of 2.1.296 refuses a wildcard in the server position of an allow rule ("Wildcard tool name … is not supported in allow rules … Deny and ask rules accept wildcards anywhere") and accepts it in deny and ask; the deny and ask matcher reaches the whole tool name through a glob, so `mcp__*` is every MCP tool there; an agent's denied-tools set reads `mcp__*` and `mcp__*__*` alike as every server. This change follows that for the two whole-protocol spellings. It does not follow the glob in general: the reference also lets a deny or ask rule glob any part of any name (`mcp__git*`, `Web*`), and the engine's name lists have no glob semantics (KL-1902-a).
|
|
680
|
+
- **Who is hurt.** (a) A deployment, skill or session that now writes `mcp__*` / `mcp__*__*` as a deny gets what it wrote: every MCP tool of the run refused, including a server's tools mounted later and a delegated child's. A session row stored before the write gate existed that holds `toolDeny: ["mcp__*"]` read as nothing and now denies every MCP tool; removing it is a loosening for a non-operator. (b) A host that took the engine's refusal of these spellings as its signal — client-core's package-side rewrite of a `*` peer segment, a shell or server that mirrors the name judgment with a hand-written exemption for `mcp__*` (server's `isProtocolWideName`) or reads `unmintable_segment`'s `currentName` for a wildcard peer. (c) `readRuleEntry("mcp__*", "deny")` is a `row` of kind `namespace` now, not `inert` (`namespace_wildcard`); a reader that matches such a row's `pattern` by comparing peers itself, instead of through `namespacedRuleNameCovers`, misses the whole-protocol name. (c') `isNamespacedCoveringRuleName(name)` with no behavior (and with `allow`) answers `false` for a name whose peer segment carries a wildcard — it answered `true` for such a name, which then matched nothing; a host that routed by it keeps matching nothing, and one that wants the restricting reading passes `"deny"` / `"ask"`. (d) The session-rule grammar stamp, above. (e) A host that builds `createAllowDenyPolicy({ allow })`, `createApprovalPolicy({ autoAllow })` or a default-closed `createApprovalPolicy({ requireApproval })` with a whole-protocol or partial-wildcard peer name: it used to build, with the entry admitting nothing (and the Runner's prepare audit refusing the run); it now throws `config.invalid_tool_name_set` at construction. A default-closed approval policy that meant "ask for every MCP tool" lists the servers (`mcp__<server>`) or drops `denyByDefault`. (f) A host that builds `createPermissionRulePolicy` with a deny or ask rule carrying a partial wildcard in an MCP or A2A peer segment (`mcp__git*`): it used to build, with the rule matching nothing; it now throws `config.invalid_permission_rules` (name the server, or write `mcp__*` for all of them). (g) A control plane that runs the published `SAFETY_MERGE_CORPUS` as a contract test of its own merger (server's control plane, web-admin): the vector `inert-deny-entry-removed-safe` now uses `mcp__git*` (a wildcard in another peer segment, which names nothing) where it used `mcp__*`, and two vectors are added (`whole-protocol-deny-removed-loosened`, `whole-protocol-deny-both-spellings-removed-loosened`: removing `mcp__*` or `mcp__*__*` from a deny list loosens, `loosens: true`); a merger that routes through `loosenReasons` is right already, one that re-implements the law fails the added vectors. (h) Text: the `guidance` of `unmintable_segment` for a wildcard peer, the `config.legacy_tool_name` message built from it, and the agent allowed-list refusal sentence changed.
|
|
681
|
+
- **How to migrate.** *cli* (L-1673): the permission-rules editor and `--disallowedTools` may pass `mcp__*` / `mcp__*__*` for a deny or ask rule, and must keep refusing them for an allow rule, quoting the engine's sentence. *client-core* (CC-886): stop rewriting a `*` peer segment and hand the rule text to the engine unchanged; the engine decides per list. *server*: a settings or session-rule write may accept the two spellings on deny and ask lists — read the verdict through `judgeRuleToolName(name, roster, behavior)` / `compileAllowList(…, "deny", roster)` instead of exempting them by hand, and match a `namespace` row through `namespacedRuleNameCovers`. Everyone else: nothing, unless (a) applies.
|
|
682
|
+
- **#2155 (no change).** The ticket asked what happens when two servers whose names normalize to one prefix (`a.b` and `a_b` both mint `mcp__a_b__`) meet a server-level rule and a tool-level rule. The engine already refuses the pair whole at the mount, before any dial and before any rule is read (`config.mcp_server_name_collision`, both raw spellings named; since #377), so no rule reaches two servers by one spelling; the reference merges such a pair silently (its tool pool keeps the first of a name), and the engine is stricter on purpose — a refresh of one of two colliding servers would unmount the other's tools, and a first-wins pool depends on list order. INTEGRATION-CORE §4.6g now says so; no code moved.
|
|
683
|
+
- **Pins.** `test/backlog2150-protocol-wide-names.test.ts` (real Runner, two stdio servers: a deny rule of either spelling refuses both servers' calls with the servers receiving nothing; an ask rule raises a card per call; the name lists, the approval factory, the tool selector, the entry reader, the session-rule store and the name judgment per behavior; an allow of either spelling refuses the run at prepare before a model turn, and the constructors refuse it where it is written; the frozen replay of a stored allowlist holding the whole-protocol name denies (base: deny too) and of a stored deny hides every MCP tool (base: no opinion); a rule policy built directly refuses a partial wildcard in a deny or ask rule at compile — `validatePermissionRules` and `createPermissionRulePolicy` — with the base readings: compiled clean and answered allow); `test/backlog2155-server-name-collision.test.ts` (the pair refused before a rule is read and before a dial; one server's rules reach exactly it; the raw spelling in a rule refused with the mint's spelling); re-pinned readings in `test/backlog1303-skill-grant-unit.test.ts`, `test/backlog1327-allow-entry-unit.test.ts`, `test/backlog1372-skill-deny-unreadable.test.ts`, `test/backlog1474-rule-entry-reader.test.ts` (their exemplar of "a deny entry that reads as nothing" is now `mcp__git*`) and `test/backlog1156-legacy-tool-name-session-rules.test.ts` (the judgment's parameter list); `test/backlog1355b-unmintable-segment-judge.test.ts` (a wildcard peer has no mint spelling: its own law cell, no `currentName`); `test/backlog1866-session-deny-fail-closed.test.ts` and `test/backlog1876-indented-key-under-value.test.ts` (the stamp is 3; a row stored in grammar 2 holding an entry no grammar reads now reads it as `earlier_grammar`; a stamp above the engine's is 4); `test/safety-merge-corpus.test.ts` runs the changed table; the per-fixture mutation table is in the car receipt.
|
|
684
|
+
|
|
685
|
+
### The wiring manifest says, per leg, whether the legs that leg delegates re-read its session's rules: `WiringManifest.sessionRules.childrenReadLive` (#2208; additive; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `WiringManifest.sessionRules` (`{ childrenReadLive: boolean }`), +1 optional `WiringFacts.sessionRulesChildrenReadLive`) (@cli @server @client-core)
|
|
686
|
+
|
|
687
|
+
- **Before.** Whether a rule added to a session while an agent runs reaches that agent depended on the engine's version: from 7.55.0 (#2181) every delegated leg re-reads its parent session's rules at each tool gate, before it kept the rules it started with. A shell that wants to tell the person "this binds the agents already running" had nothing to read it from but the version.
|
|
688
|
+
- **Now.** An effective wiring manifest carries `sessionRules?: { childrenReadLive: boolean }`, on every leg the engine prepares (root, child, resume). `childrenReadLive: true` means the leg enforces its own session-rule layer (a `sessionPolicyStore` is wired and the task carries a `sessionId`) and has handed that session's key on to the legs it delegates — sync, background, forked, workflow, retained, resumed and observer-sidecar legs, at any depth — each of which re-reads the row at every tool gate, so a rule written mid-run binds the agent's next call whether or not the session had a row when the agent was spawned. `false` means no layer, so no key is handed on: no store is wired, or the task names no session (a one-shot task). The bit is read off the wiring that hands the key on (the policy-chain phase's published key, the seat the delegation chain reads at spawn), never written as a constant: take the publication away and the bit goes with it, and the pins read the bit and the thing it promises in the same cell (a deny written to the session mid-run, refused at the next gate of a background, a foreground, a depth-2 and a workflow agent).
|
|
689
|
+
- **Presence.** Present on every effective manifest a 7.55.0 engine mints, `true` or `false`; **absent means an older engine (or an external `deriveWiringManifest` caller that passed no fact): assume nothing**. The static half (`describeStaticWiring`) never carries it — the bit follows the task's `sessionId`, a per-task seat. A leg-start snapshot, like the rest of the object, and outside `configFingerprint` (a root with a session and a one-shot root of one assembly fingerprint alike; every fingerprint is what it was).
|
|
690
|
+
- **What it does not say.** It speaks for the assembly the manifest can see. A delegation tool mounted over a Runner with no `sessionPolicyStore` of its own cannot re-read: its legs keep the rules they were spawned with and announce `config.session_rules_unreadable` once per ancestor session (#2181), while the root still reports `true` and each such leg's own manifest reports `false`. A Runner with a different store reads the key there, silently (#2197).
|
|
691
|
+
- **Who is hurt.** Nobody: one optional member beside the others; a consumer ignores fields it does not know, and `schemaVersion` does not move. Wire typeKeys (`npm run gate:wire -- v7.54.0 HEAD`): `WiringManifest` +`sessionRules` (optional), +`sessionRules.childrenReadLive`; `WiringFacts` +`sessionRulesChildrenReadLive` (optional).
|
|
692
|
+
- **Who else.** *cli* (L-1778): read `manifest.sessionRules?.childrenReadLive` and speak by the bit, not by the version — `true` ⇒ a rule added now binds the agents already running at their next call; `false` or absent ⇒ say nothing about running agents. *server* (S-1911): classify the new section key in the `wiring_manifest` projection (tenant and operator faces) and pass the member through to the capability read verbatim. *client-core*: type `sessionRules?: { childrenReadLive: boolean }` on the manifest.
|
|
693
|
+
- **Pins.** `test/backlog2208-session-rules-children-read-live.test.ts` (R1 a root leg's bit through a real prepare, four seatings — store and `sessionId` ⇒ `true`, any other ⇒ `false`, never absent; B1 / B2 / B3 a background, a foreground and a depth-2 agent: the legs say `true` and the deny written mid-run binds the agent's next call; W1 a workflow `agent()` leg, the same; S1 a delegation Runner with no store: the root says `true`, the agent's own leg says `false`, the rules it was spawned with are all it enforces and it announces that once; D1 the derivation: an absent fact states nothing, the static half carries none, a non-boolean fact is refused, the fingerprint does not move); `test/prepare-wiring-manifest.test.ts` (the phase relays the policy-chain fact both ways).
|
|
694
|
+
|
|
695
|
+
### A workflow run that is still running while one of its legs is parked at an approval gate says so: `WorkflowRunSummary.parked` and the TaskOutput poll body carry `parked: true`, derived by `runParkedOf` / `parkedAgentsOf` (#2215; additive; behavior change: none on any recorded state; hard-breaking: none; closed sets: none — `WorkflowRunStatus` keeps its three words; exports: +2 functions (`runParkedOf`, `parkedAgentsOf`); members: +1 optional `WorkflowRunSummary.parked?: true`) (@cli @server @client-core @test)
|
|
696
|
+
|
|
697
|
+
- **Before.** A workflow run's own faces say `running | completed | failed` and nothing more. A run in which one leg had parked at an approval gate (#642) while a sibling leg was still working read `running` on the list row, the TaskOutput poll body and the fleet row alike, so nothing at run level told a person they were being waited on; only the parked leg's own row said `parked`, in the per-agent drill-down.
|
|
698
|
+
- **Now.** Two states, one word each. (1) While the run is still `running` and at least one agent row is `parked`, the run carries `parked: true`: on `WorkflowRunSummary` (what `listWorkflowRuns` returns and `summarizeWorkflowRun` builds), and as the `parked` key of the TaskOutput poll body of a workflow run and of its structured details. The member is `true` or absent, never `false`. (2) Once the sibling legs have settled, the run suspends on the park: it is recorded `failed` with `errorCode: "workflow.agent_parked"` and its parked rows beside it (unchanged since #642). That code is the "awaiting approval" word of this state, so the bit is absent there rather than a second way of saying it. A consumer shows both arms as awaiting approval. The bit is derived on every read, `runParkedOf(run)` over `parkedAgentsOf(run.agents)` — the one definition of a parked row — and is not stored on the run, not journaled and not an event (`agent_end` with `status: "parked"` already names the leg). A settled record answers absent on purpose: a resume that decides the park leaves the old record as written, `failed` with its parked row in place, so reading that row would say "waiting" on a decided park for ever. `agents_parked` in the completion notice's `<usage>` block now counts through the same row predicate; that block is built only for a run that completed, and a completed run never carries a parked row, so the key does not appear today.
|
|
699
|
+
- **Closed sets.** None. `WorkflowRunStatus` stays `running | completed | failed`: a parked leg is a sub-state of a running run, and a fourth word would break every exhaustive table over the three.
|
|
700
|
+
- **Consumers (all additive, one optional key).** *server:* the fleet workflow row (`buildFleetWorkflowRow`) and the workflow detail (`summarizeWorkflowDetail`, served at `GET /v1/workflows/:id`) call `runParkedOf(run)` — the way they already call `deriveAgentDisplayStatus` — and carry `parked: true` when it holds; the contract appendix and the openapi document gain one optional key, and the sdk follows in step. *client-core:* `fleetProjection` and `coerceWorkflowStatus` read the one optional key; a status table that was exhaustive over the three words needs no change. *cli:* the `/workflows` header row's parked / awaiting-approval arm is the one already written for a parked agent row, so no shell change is expected (the cli request, B-079②). *test:* re-check B-079② — a run with a parked leg and a sibling still working reads `parked: true` beside `running`, and the same run after the siblings settle reads `failed` + `workflow.agent_parked` with the key absent.
|
|
701
|
+
- **Pins.** `test/backlog2215-run-parked-bit.test.ts` (a real two-leg run — one leg parks at an approval gate, the other holds a tool open: the stored row, the pure derivation, the summary, the listing and the real TaskOutput poll all read `parked: true` beside `running`; the same run drained reads `failed` + `workflow.agent_parked` with the key absent on every face; a resume that decides the park leaves the old record and the completed resume absent; the derivation on hand-built shapes; the single-source shape); the per-fixture mutation table is in the car receipt.
|
|
702
|
+
|
|
703
|
+
### A session reaches the reference agent's sessions — and is reached by them — over their own Unix socket: the opt-in `RunnerDeps.peerChannel` seat, the bundled `createCcUdsPeerChannel` and `createCcPeerDirectory` (#518 slice 3, #519; NEW I/O FACE — a socket listener a host opens; behavior change: none where neither the seat nor a row with a socket is wired; behavior narrowing, open to veto: a directory row with a socket is appended to only when its capability bit names the sender's own mailbox store; wire shape: the capability bit names the store (`sema_mailbox_v1_<16 hex>`, @cli @server); behavior change on a security face, a loosening, open to veto: `vetPeerRegistryDirectory` follows the reference's rules; hard-breaking: none; closed sets: a successful `PeerSendVerdict`'s `via` was always `store`, now `store` or `uds`, and SendMessage's `details.status` gains `peer_delivered`; new `PEER_CHANNEL_DROP_REASONS` (9, registered in `docs/CLOSED-SETS.md`); exports: +24 (12 runtime, 12 types); members: +1 optional `MailboxStore.identity` (`FileMailboxStore` implements it), +1 optional `RunnerDeps.peerChannel`, +1 optional `SendMessageToolOptions.peerChannel`, +1 optional `CcPeerFrameFields.fromPlugin`) (@cli @client-core @server @test)
|
|
704
|
+
|
|
705
|
+
- **Before (v7.54.0).** The cross-session lane had one transport, the recipient's durable box, and no reader of a directory row's `sockPath` / `peerFeatures`. A row that listed a reference-agent (Claude Code) session — a socket path, no box anywhere — resolved like any other: SendMessage appended the message to `session.<its id>`, a box nothing drains, and answered `Message queued … it is parked durably`. No bundled directory listed such sessions, so only a host writing such a row reached it. `decodeCcPeerFrame` refused a CC 2.1.296 frame carrying its sixth attribute, `from-plugin`. `vetPeerRegistryDirectory` refused every link on a path and every ancestor not owned by this uid (a `stopAt` aside), so the reference's own default directories failed on macOS (`/tmp` and `/var` are root-owned links) and on Linux (`/tmp` is root-owned).
|
|
706
|
+
- **Now — one lane reading per row.** `peerSessionLane(row, ownStore)` (`src/agents/peer-directory.ts`) is the one answer to "how does a message reach this peer": the box (`store`) only where a parked message is provably read — a row with no socket (every row a host writes today; the host's own directory vouches that its sessions drain its store), or a row whose capability bit names the SENDER's own mailbox store; every other row with a socket — a reference-agent (Claude Code) session, a session of this engine whose host drains another store, a row or a sender whose store cannot say what it is — is reached over its socket (`uds`). The bit is `sema_mailbox_v1_<16 hex>` (`semaMailboxFeature(identity)`, `SEMA_MAILBOX_FEATURE_PREFIX`: the first 16 hex of the sha-256 of the store's identity; 32 characters, inside the reference's bit grammar), and the store states its identity: the new optional `MailboxStore.identity` (two stores with the same identity serve the same boxes; absent = unknown = the socket); `FileMailboxStore.identity` is the sha-256 of its boxes directory's physical path. SendMessage's peer leg reads the lane once per resolved row, against its own `mailbox`: `store` keeps the box append exactly as before and, with the seat wired and the row live with a socket, then rings the socket once with a content-free `{"type":"control","action":"mailbox_nudge"}` frame (`details.doorbell: true|false`; its loss changes latency, never delivery — the recipient's host decides whether to start a turn); `uds` sends the message itself over the socket through the seat — the send-point admission gate first, as on the box leg — at most once, and the receipt says what it can confirm (`status: "peer_delivered"`, `verdict: { ok: true, disposition: "delivered", via: "uds" }`, "the message was written to its socket once and the connection closed cleanly, which is all this receipt confirms" — the reference's protocol answers no acknowledgement, and its own sender reads the same clean close as a completed send); a transport failure is the existing `peer_send.*` word (`not_reachable` / `stale_socket` / `socket_busy` / `timeout` / `too_large` / `send_uncertain` / `invalid_target` / `other`); `notify_when_idle` to such a session refuses `invalid_target`; with NO seat a `uds` row refuses `peer_send.not_reachable` and nothing is appended. With the seat wired, SendMessage's description gains one sentence after the peer-session one ("A peer whose box is not in this host's mailbox store — a Claude Code session, or a session of this engine on another store — is reached over its socket instead: nothing is parked for it, the message is sent once and only while it runs, and its receipt says so."); without it the description is unchanged. A receipt says "parked durably" only for a box this lane proves the recipient drains.
|
|
707
|
+
- **Now — the channel (`createCcUdsPeerChannel({ mailbox, socketPath, registryDir, lane?, registration? })`, contract `PeerChannel` in `src/core/peer-channel.ts`).** `listen({ sessionId, scope, onNudge?, onEvent? })` publishes in a fixed order: the sockets directory and the registry directory (a missing one created 0700, an existing one never re-moded, both vetted; one listener per process and registry, a second is `already_listening`) → the socket (0600) → the key file `<pid>.<sha256 of the socket path>.key` (0600, `{ peerToken: <32 hex>, procStart? }`) → the registry row `<pid>.json` (0600; the reference's field names, `peerProtocol: 1`, `peerFeatures: [semaMailboxFeature(mailbox.identity)]` — the capability broadcast, naming the store this listener appends into; none when the store cannot say — and `messagingSocketPath`). Any refusal throws `CcPeerChannelError` (`code`: one of the six vetting codes, `unsupported_platform`, `socket_path_too_long`, `address_in_use`, `already_listening`, `publish_failed`) with nothing left bound; `close()` withdraws all three. An inbound connection must authenticate on its FIRST line with this session's token; a 30 s first-line deadline and a 1 MiB line wall close a connection; something already at the socket path is replaced only when it is this uid's socket the kernel refuses a connection to (a regular file, a live listener or an inconclusive probe is `address_in_use`); after that the listener only checks a frame's shape, its addressee (`session_id`) and the box bound (`peerLane.maxQueuedPeerMessages`, also bounding the frames waiting for the store before any is queued) and appends the frame's typed fields into this session's own box — `peerMeta { fromSession, fromMode, senderKey (the s-axis token), kind: "peer_message" }`, the hop chain as carried, the envelope's body. It judges nothing: admission, mode parity and holds stay the drain's (one judgment point, as for every record). A dropped connection or frame is one `onEvent({ kind: "dropped", reason })` from `PEER_CHANNEL_DROP_REASONS`, never thrown. `readCcPeerLine` (`src/agents/cc-peer-wire.ts`) is the one reader of an untyped wire line (#519). `dial(address, message)` writes the reference's sender frame — the peer's published token on an auth line (none published: no auth line, the reference's own POSIX form), then `{ msgV: 1, msg_id, type: "user", message: { role: "user", content: <the envelope from encodeCcPeerFrame> }, priority: "next", from: "uds:<own socket>" }` (the reply address only when the sending session is the one this channel listens for — another session sharing the process names none), this engine's hop tokens in their wire spelling; a peer socket must sit in a vetted directory and not be a link. `ccPeerDefaultLocations({ env, homedir, pid, uid })` answers the reference's own placement.
|
|
708
|
+
- **Now — the reference registry as a directory (`createCcPeerDirectory({ registryDir, scope?, probe?, onSkip? })`).** Lists the rows of `<configDir>/sessions/<pid>.json` that are live and advertise a socket, as `PeerSessionRecord`s (an unnamed session is `(unnamed session)`, the reference's label). Live = the pid exists and, when the row carries `procStart`, the process start token (`ps -o lstart=` under `LC_ALL=C TZ=UTC`, `ccProcessStartToken`) equals it — a recycled pid is not the session. A file it cannot vouch for (not this uid's, group/world-writable, too large, not JSON, an invalid row, no socket, not live) is skipped and reported on `onSkip`; a registry directory that fails its vetting THROWS `CcPeerDirectoryError` naming the code, never an empty list. Dead rows are not returned — the reference deletes them, so this registry is not the name truth for offline delivery to a session of this engine; a host that needs both composes its own directory beside it.
|
|
709
|
+
- **Now — the loop identity across the wire.** The self-hop check reads a token in either spelling: this engine's own (`peerAxisToken`) and its wire form (`peerWireHopToken`: the first 24 hex of its sha-256; an entry already 24 hex is carried unchanged), so a chain that went out over a reference session and came back counts its self hops.
|
|
710
|
+
- **Now — `vetPeerRegistryDirectory` (CC 2.1.296 rules, the six codes kept).** The directory itself must be a real directory (never a link) owned by this uid with no group/world write bit, as before; an ANCESTOR may be owned by this uid or by root, and may be world-writable when it is sticky; an ancestor that is a link owned by this uid or root is followed and its target judged by the same rules; more than 16 links is `symlink`, a dangling one `missing`. The reference's nine refusal kinds land on the six codes (`directory_rule` → `foreign_owner` or `group_or_world_writable`, `leaf_shape` → `symlink` / `not_directory` / `foreign_owner`, `dangling_link` → `missing`, `symlink_loop` → `symlink`, `internal` → `not_absolute`). `decodeCcPeerFrame` / `encodeCcPeerFrame` take `fromPlugin`, after `from-mode`, canonicalized like `from-name`.
|
|
711
|
+
- **New I/O face (listed on its own).** A host that calls `listen` opens a Unix-domain socket server that accepts connections from any process of this uid able to read the published token; nothing in the engine opens one, and a run never listens. Identity is "same user" only — node exposes no peer credentials — so nothing here, and nothing a host renders from it, may claim a verified pid or a verified session.
|
|
712
|
+
- **Who is hurt (open to veto).** (1) A deployment whose directory lists a row with `sockPath` whose bit does not name the sender's store (or carries none): it is no longer appended to (refused `peer_send.not_reachable` without the seat, sent over the socket with it). No shipped host writes such a row: the server's rows never carry `sockPath`, the cli wires no directory. A backend that implements `MailboxStore` without `identity` (the server's SQL twins today) is never the box lane toward a socket row — every such send is a full frame over the socket and needs the recipient's listener up; implement `identity` (equal for every host on the same database) to get the box lane and the doorbell (@server). (2) A caller of `vetPeerRegistryDirectory`: two more shapes now pass — a root-owned or sticky ancestor and a link owned by this uid or root — a LOOSENING on a security face, the reference's own rule. (3) A caller of `decodeCcPeerFrame`: a frame with `from-plugin` decodes instead of being `not_an_envelope`. (4) A chain already carrying a 24-hex entry equal to a recipient's wire token counts it as a self hop — reachable only through the reference's wire.
|
|
713
|
+
- **Compensation.** None for a deployment that wires neither. A host that wants the lane: `createCcUdsPeerChannel` + `listen` at session start (`close()` at its end), wire the same channel as `RunnerDeps.peerChannel`, and give `RunnerDeps.peerDirectory` a directory that lists the reference's sessions (`createCcPeerDirectory`, or its own composed with it); start a turn on `onNudge` within its wake budget (`peerLane.maxWakesPerHour`). Hosts that should reach each other through the box (and the doorbell) give their sessions mailbox stores with one `identity` — the same file root, or a shared backend that states it; anything else is reached over the socket, at most once.
|
|
714
|
+
- **Differs from the reference implementation (CC 2.1.296), open to veto.** The auth line is REQUIRED on POSIX (the reference makes it optional there): its sender always presents the published token, so it is not refused; a bare injection without the auth line is. Windows is refused (`unsupported_platform`) instead of a named pipe. Over the socket this engine takes messages and its own doorbell only: idle subscriptions and their notices, `peer_message_status` receipts, file attachments (a frame with them is dropped whole, `unhandled_frame`), rename and artifact-yield controls are dropped and counted; `from-plugin` is not carried to the model face. A receipt the drain owes a reference-agent sender (a hold) is still routed to `session.<its id>`, a box nothing drains.
|
|
715
|
+
- **Pins.** `test/design385-s3-cc-wire.test.ts` (the line layer, the one wire reader, the key-file name, the default locations, the registration row, the lane reading, the wire hop token and the self-hop check, `from-plugin`, the vetting codes and the reference's acceptances, the system temp directory vetting clean), `test/design385-s3-cc-uds-channel.test.ts` (C1–C8 the listener on real sockets: publish order and withdrawal, the refusals before bind, only a proven-stale socket of this uid replaced (a regular file kept), the backlog bounded before anything is queued, a reference frame landing with its typed fields, the auth wall, the line wall, the first-line deadline, dropped frames, the inbound doorbell; D1–D3 the registry directory: mapping, skips, PID reuse, the scope wall, the vetting throw, a real child's start token; S1–S3 the dialer's frame and verdicts; E1 the silent-loss form refused, E1b the seat-absent invariance (receipt and description), E2 the socket arm through a Runner, E3 the box arm + doorbell, E3b the same store by identity (another object over the same root, through a link), E4 the drain injecting what the listener appended, E5 the listener judging nothing under `refuse`, E6 the wire-spelled self hop refused at the drain, E7 a session of this engine on ANOTHER store reached over its socket — the message lands in the store it drains, none in the sender's, refused without a channel; F1 one `FileMailboxStore.identity` per physical boxes directory), `test/public-knob-liveness.test.ts` (`peerChannel` row), `test/export-surface.snapshot.json` (+24).
|
|
716
|
+
|
|
717
|
+
### A task can name an ADVISOR model: the model calls a no-argument `advisor` tool, the engine asks the stronger model once over the conversation, and its guidance is the tool result (#1055; additive; behavior change: none for a leg whose spec names no advisor model — byte-identical, pinned — and one for every deployment: each side-call `brain.call` trace row now carries `lane`; hard-breaking: none; closed sets: new `AdvisorOutcome` (3), `AdvisorNotAttachedReason` (9), `AdvisorHoldDisposition` (3), registered in `docs/CLOSED-SETS.md`; `ENGINE_NOTICE_CODES` +2 (`advisor.not_attached` audience `user`, `route.seat_detached` audience `operator`); `CHECKPOINT_CONFIG_REFUSAL_CODES` +2, 42 → 44 (`config.advisor_tool_invalid`, `config.advisor_model_invalid`); `SideCallLane` +`advisor`; the `tool_end.structured` card set +`advisor`; the tool catalog +1 row (`advisor`); exports: +10 (4 runtime: `advisorAttachVerdict`, `advisorHoldOf`, `ADVISOR_NOT_ATTACHED_REASONS`, `ADVISOR_OUTCOMES`; 6 types: `AdvisorAttachVerdict`, `AdvisorNotAttachedReason`, `AdvisorOutcome`, `AdvisorHold`, `AdvisorCard`, `WiringAdvisorFact`); members: `TaskSpec.advisorModel?`, `RunnerDeps.advisorTool?: "disabled"`, `WiringManifest.advisor?`, `SideQueryResult.errorCode?` / `errorKind?` / `inputTooLongRuledOut?`, `brain.call` trace `lane?`, `MaybeCompactOptions.advisorRestate?`) (@server @cli @settings-schema @test @client-core)
|
|
718
|
+
|
|
719
|
+
- **Before.** The engine had no advisor: CC's `advisorModel` (an Anthropic server tool, `advisor_20260301`) has no counterpart on a third-party provider, and the setting reached nothing.
|
|
720
|
+
- **Now.** `TaskSpec.advisorModel` takes any ref `model` takes. Prepare judges it with `advisorAttachVerdict` — the deployment switch, then the advisor (`unknown_model`, `advisor_unranked`, `weakest_tier` = the `lite` tier), then the main model (`main_unranked`, `pairing` = the advisor is on a weaker tier); the tier reading is the classifier seat's own (`tierIndexOf`), so a deployment without `RunnerDeps.tiers` ranks nothing and attaches nothing. An `on` verdict passes the mount chain — the tool selector's valve (`excluded`), the conversation's hold (`held`; an unreadable transcript ⇒ `hold_unknown`), the shared derived-seat route pre-flight in a third posture, `detach` (a pairing the credentials cannot serve ⇒ `route_refused`, announced to the operator as `route.seat_detached`) — and the tool mounts, labelled with the advisor model's id. The model is told when to call it by CC's own `# Advisor Tool` text on the leg's first request (model lane only, no `steering_injected` echo), withdrawn or re-attached in CC's two short forms as the mount changes, judged against what this conversation was told since its latest compaction; a compaction inside a leg that mounts the tool restates the text right after it lands. A call asks the advisor model over the request the main lane last sent (the engine-level context, after the request reducers), the asking turn cut to this call, and a closing tool result; the reply is `reviewed` (usable text), `declined` (the provider's normalized refusal) or `unavailable` (with the failure's own code / kind / stop reason; `isError`), and the card carries no free text. An `auth` failure, or an `invalid_request` ruled out as over-window, switches the advisor off for the conversation until it is compacted — the tool result is the record, read by `advisorHoldOf` at the mount, at every call and by a host. The advisor's request is a side call on its own lane (`derivedLegBrain` — the compaction brain's factory, now with a `stream` arm that books too): re-priced from the deployment's table, in `costMicroUsd` and `costBreakdown.sideCallMicroUsd`, inside `llmRootMicroUsd` (the reconciliation is unchanged), behind the same status and reasoning shields. The setting rides the parent spec seat: a child judges it against its own model and tells nobody; a fork keeps the tool and is told "Never consult the advisor tool."; the observer sidecar excludes it by name. `RunnerDeps.advisorTool: "disabled"` turns it off for the deployment (the setting is ignored unread; manifest `disabled`; no notice); any other word refuses prepare (`config.advisor_tool_invalid`), and while it is on, an `advisorModel` that is neither a non-empty ref nor a Model refuses prepare (`config.advisor_model_invalid`). Both doors are value doors of the config-door phase, so a resume meets them before the claim: a bad switch or advisor model re-supplied on a resume is refused as `checkpoint.config_refused` (`detail.code` the same word, `detail.field` `deps` / `taskConfig`) and the row stays pending. The `plan-mode` conformance vector names `advisor` in its `absent` list (its `expected` set is unchanged).
|
|
721
|
+
- **This was the only, now one of two** (name a consumer that keys on the old closed reading): `ENGINE_NOTICE_CODES` gains two rows — a host's per-session whitelist that projects `user` notices sees `advisor.not_attached` (dedup unit: the conversation); `route.*`'s kept-seat twins now read `detail.action` in two words (`refused`, `detached`); the `tool_end.structured` `type` set gains `advisor`; a resume's `checkpoint.config_refused` `detail.code` gains two words (a retry policy that maps codes reads `detail.field` beside them); a `brain.call` row with no `turn` is no longer only the compaction summarizer — its `lane` names it (`compaction` / `classifier` / `mod` / `advisor`).
|
|
722
|
+
- **Who is hurt.** Nobody on an existing face: every new member is optional and absent unless a spec names an advisor. A trace consumer that inferred "no `turn` ⇒ compaction" now reads `lane`. A caller tool named `advisor` on a leg that mounts the advisor refuses prepare (`config.reserved_tool_name`).
|
|
723
|
+
- **Three sentences (closed-set and wire-key additions).** What changed: `ENGINE_NOTICE_CODES` +2, `CHECKPOINT_CONFIG_REFUSAL_CODES` +2, `SideCallLane` +1, the `tool_end.structured` card set +1, the tool catalog +1 row, three new closed sets, and optional keys on the wiring manifest, the announced-listing session record (`advisor`) and the `brain.call` trace row. Who breaks: a table keyed by one of those sets and checked for totality (a server wire-code register that must place every user-audience notice code, client-core's generated notice and error tables, cli's notice list, sdk's wire code book, a test that pins the tool catalog's names or counts); nobody reading the optional keys. How to migrate: add the rows (`advisor.not_attached` is audience `user`, `route.seat_detached` audience `operator`; the two refusal words are prepare's own, `detail.field` beside them).
|
|
724
|
+
- **Differs from Claude Code** (to be registered in the divergence ledger): the advisor runs engine-side on any catalog provider (no server tool, no beta header, no remote flag); the advisor's request may hit a provider cache and toggling it costs one prefix-cache miss where tool definitions are cached; a hold survives a resumed conversation (sema re-reads the transcript every run) and is cleared by a compaction or a new conversation; an API refusal at call time answers the model with an `unavailable` result instead of a silent resend; a saved setting the operator's allowlist later blocks is the server's fresh-submission refusal (the allowlist door is the host's); the advisor reads the engine-level request rather than provider bytes; the observer sidecar excludes the tool. The advisor-side system prompt and handoff text are sema's own.
|
|
725
|
+
- **Pins.** `test/backlog1055-advisor-verdict.test.ts` (the verdict in command order, raw deps read as the Runner's, only-lite deployments, one tier reading with the classifier seat, the hold read off the transcript, the hold-cause table, the closed sets, which reasons tell the person, the shape door); `test/backlog1055-advisor-mount.test.ts` (G1 attach / instructions / manifest / label and its control; no advisor ⇒ byte-identical request, also under `"disabled"`; G7 pairing told once per conversation, weakest tier, same tier attaches; G8 unranked / unknown / bad shape; G12 the switch and its bad word; G9 `excludeAllTools` / `excludeTools`; the reserved name; the instructions' three states and a fresh conversation); `test/backlog1055-advisor-call.test.ts` (G2 the projection; G3 reviewed and the full guidance in the next request, the bounded output; G4 rate-limit, a stream with no final message, a tools-only reply; G5 the auth hold — no request on the next call, the next run held and withdrawn — the ruled-out / plain / over-window invalid request, a thrown cross-realm value; G6 declined with a 70,000-character reason; G11 the account); `test/backlog1055-advisor-children.test.ts` (a child on pro and on max; the observer; the fork sentence and its control; the route detach; an unreadable transcript; the hold re-read after a compaction inside a leg; the compaction restate, absent when unmounted, withheld on a halted batch); re-pinned: `test/tool-catalog.test.ts` (53 rows, the card set, the budget-exempt set), `test/governance-codes.test.ts` (the user-audience set), `test/backlog1859-answer-conflict-verdict.test.ts` (the pre-flight's outcome object), `test/backlog1468-k3-engine-backend.test.ts` (a switch whose only word is `"disabled"` is not a store seat), `test/public-knob-liveness.test.ts`, `test/date-anchor-session.test.ts` (the date-anchor helpers' new module, `runner/conversation-segment.ts`), `test/export-surface.snapshot.json` (2 840 → 2 850), `test/backlog1950-resume-config-doors-preclaim.test.ts` (unchanged: its discovered-seat sweep now covers both advisor doors), `test/backlog1279-child-undrained-to-parent.test.ts` (100 notice codes), `test/core-context-coverage.test.ts` (the budget-exempt set), `test/design469-r3-boundary-legs.test.ts` (the leg table), `test/side-call-usage-account.test.ts` (the compaction and advisor lanes through the one factory), `test/runner-layer-order.test.ts` (three unlayered runner modules), `test/legacy-residue-gate.test.ts` / `test/module-size-ratchet.test.ts` (banked down), `scripts/verify-phase-api.mjs` (the phase field floor 1 111).
|
|
726
|
+
|
|
727
|
+
### (#2207) A deny or ask rule naming a parameter no face of its tool declares is refused at compile time without `ruleFaces` too: `WebFetch(domain:<host>)` no longer compiles into a rule that never fires (#2207; security; behavior narrowing (configuration face), open to veto; hard-breaking: none on the type surface; closed sets: none — the existing `unsupported.param_unknown` issue code; exports: none; members: none) (@server @cli @client-core)
|
|
728
|
+
|
|
729
|
+
- **Before.** `createPermissionRulePolicy` / `validatePermissionRules` without `ruleFaces` — the path a host compiles its settings on — judged a rule's parameter against the static catalog, and the catalog answered `WebFetch`, `WebSearch` and `Artifact` with their primary argument only (`url` / `query` / `action`) and no vocabulary — an absent vocabulary skipped the check — and the 36 other tools that are neither a path tool nor a shell (`Agent`, `SendMessage`, `TodoWrite`, the task-list, scheduler, memory, plan, worktree and background-task tools, …) with nothing at all, so their content rules compiled unjudged. So `WebFetch(domain:evil.com)`, the reference's host-rule spelling, compiled as a deny or an ask into a rule on an argument `domain` no call carries: no issue, and a fetch of `https://evil.com/x` answered `allow` — a deny that never fired. `WebFetch(foo:bar)`, `WebSearch(domain:…)`, `Artifact(foo:bar)`, `Agent(domain:…)` and `TodoWrite(foo:bar)` the same. With a leg's `ruleFaces` they were already refused.
|
|
730
|
+
- **Now.** One reading: a face's vocabulary is its `params`, and a face that states none declares its `primaryParams` only. The catalog is the one list of a tool's rule-readable arguments: every row that is not a path tool or a shell declares its fullest mount's scalar vocabulary for the static view, as the shell rows already did (WebFetch `url`, `prompt`; WebSearch `query`; Artifact its thirteen scalar arguments; `Agent` `subagent_type`, `description`, `prompt`, `name`, `cwd`, `model`, `isolation`, `run_in_background`; `TodoWrite`, `AskUserQuestion`, `EnterPlanMode`, `CronList`, `TaskList` none), and the static face of a name is every definition's under it, aliases included (`TaskOutput`: the shell pair's and the registry's; `ListPeers` reads `ListAgents`). A mount's instance carries the row's primary only, so a leg's roster still reads its vocabulary off the schema it mounts (a WebFetch without a summarizer: `url`; an argument a host added to a factory product's schema is on the row as before); an argument only some mounts carry is accepted without `ruleFaces` and can never fire on a mount that lacks it (KL-2207-b). The `Skill` name lane's retired-spelling check reads the same row instead of a list of its own. A rule naming any other parameter is `unsupported.param_unknown`. The two paths answer a rule with the same code; the sentence lists the parameters that path sees: `"domain" is not a scalar parameter of WebFetch (it carries prompt, url; arrays and objects are not rule-readable) — the rule would compile and never match. Name one of its parameters, or write the rule on the whole tool (`WebFetch`).` without `ruleFaces`, `(it carries url; …)` on a leg whose WebFetch has no summarizer. The sentence says "scalar" because the catalog lists scalar arguments only: an array argument (`WebSearch(allowed_domains:…)`, `TodoWrite(todos:…)`) is `unsupported.param_unknown` with that true sentence without `ruleFaces`, and `unsupported.param_non_scalar` with a leg's `ruleFaces` (the roster reads the argument's type). This head replaces "is not a parameter of" for every face, path tools included (its path-glob advice is a path tool's only); a face that declares no parameter says so (`it declares no rule-readable parameters`).
|
|
731
|
+
- **`StructuredOutput` (behavior narrowing, open to veto).** What: its catalog row declares no vocabulary (its schema is each task's output schema), so without `ruleFaces` a content rule on it is refused — `"<param>" cannot be judged on StructuredOutput here (its parameters are not declared statically: each mount's schema is its own) — compile with the leg's \`ruleFaces\`, or write the rule on the whole tool (\`StructuredOutput\`).` Who is hurt: a deployment that wrote a deny / ask on one of its task's output fields (`StructuredOutput(verdict:ship)`) and compiles without `ruleFaces` — the rule compiled and fired before. How to migrate: compile with the leg's `ruleFaces` (the roster reads the task's schema and the rule compiles), or deny / ask the whole `StructuredOutput` tool.
|
|
732
|
+
- **Who is hurt.** A deployment whose rule set holds such a rule: under the default `onInvalidRule: "throw"` the policy is not built (`config.invalid_permission_rules`, the message naming the rule and the sentence) — a host that compiles per request refuses the request; under `onInvalidRule: "skip"` the rule is dropped and reported once on `onIssue` (default: one aggregated warning). The rule never protected anything either way. A host's own `ruleFaces` resolver whose faces omit `params` now has only their `primaryParams` admitted. A host that mounts its own tool under one of these three names, or extends a factory product's schema, with an argument the built-in lacks, and compiles a rule on that argument without `ruleFaces`: the rule is now refused (it compiled and fired before) — the static path judges a catalog name by the catalog's vocabulary, as it already did for a path tool's or the shell's name; compile with the leg's `ruleFaces` (`ruleFacesFromRoster`), where the mounted schema is read, or give the tool a name the catalog does not carry (KL-2207-a).
|
|
733
|
+
- **How to migrate.** Remove the rule, or write the deny / ask on the whole tool (`WebFetch`); a deny or ask by host is the host grammar's (#1489). A host compiling settings passes no `ruleFaces`: the static path answers as a leg's roster does. A resolver of a host's own returns `params`.
|
|
734
|
+
- **Unchanged.** Every rule on a built-in's own argument that compiled and fired still does, `StructuredOutput` aside: `WebFetch(prompt:…)`, `Artifact(url:…)`, `Artifact(title:…)`, `Agent(subagent_type:…)`, `SendMessage(to:…)`, `TaskStop(task_id:…)`, `CronCreate(cron:…)`, `memory_search(query:…)`, `EnterWorktree(name:…)` without `ruleFaces`; `Skill(<name>)` (the name lane) and the refusal of its retired `Skill(skill:…)` spelling; a primary argument stays `invalid.primary_field` unless `primaryFieldGeneric: "allow-lexical"`; the bare `WebFetch` rule; the allow layer's `WebFetch(domain:<host>)` (`TaskSpec.allowedTools` / `RunnerDeps.allowedTools`, the host grammar since 7.49.0).
|
|
735
|
+
- **Who else.** *server*: its settings compile (`createRuleListPolicy`) and its pre-judge (`validatePermissionRules`) pass no faces, so both now refuse these rules with the code and sentence above; its docs listing `WebFetch(domain:<host>)` as a deny form, and pins of the silent compile, turn. *cli*: a settings `permissions.deny` / `ask` entry projected as written reaches the same refusal. *client-core*: a rule editor can show the sentence. `unsupported.param_unknown` is now also answered without `ruleFaces` for these three tools. `catalogRuleFaceOf` / `catalogRuleFaceView` answer a face for every name the catalog carries (aliases included; `undefined` only for a name it does not carry), with `params` where a definition declares them, and `describeToolCatalog()` rows carry the declared `ruleFace.params`; a mounted tool's face and roster row are unchanged (its vocabulary is still its schema's).
|
|
736
|
+
- **Pins.** `test/backlog2207-unbound-rule-content-refused.test.ts` (the throw arm's message and issue and the skip arm's `onIssue` and warning, word for word; the dry-run report with and without the catalog view; the bare rule fires; three other unbound spellings; the rules that fired before still fire; a path tool keeps its path-glob advice; an array argument's sentence without `ruleFaces` and its `param_non_scalar` through a roster; a resolver face without `params`, and one with no parameter at all; the name lane's vocabulary; no catalog face without a vocabulary; a real prepared leg's roster answers the same code, and with a summarizer the same sentence; a real Runner mounts a no-summarizer WebFetch through `TaskSpec.tools` and enforces a parameter rule on the call; the fullest mount's schema equals each row's declared vocabulary and every declared parameter compiles and matches; a prepared leg's roster row equals what the schema alone derives, order included; a mount's instance carries the row's primary only; a host's extension of a factory product's schema stays on the row and a rule on it fires through the roster); `test/backlog2207-catalog-vocabulary-lockstep.test.ts` (one cell per catalog row: the declared vocabulary equals the fullest mount's schema, the instance states none, every declared argument compiles and fires without `ruleFaces`; one cell per name: an undeclared argument is refused under one code without `ruleFaces` and through a roster built from the real instance; `Skill(<name>)` still fires; `StructuredOutput` refused statically and judged by a task's schema on a leg; an alias; a two-definition name); `test/tool-catalog.test.ts` (the two rows' faces re-pinned); `test/tool-conformance.test.ts` (L6 holds a hand definition to its own row).
|
|
737
|
+
|
|
738
|
+
### (#2221) Security fix: a session rule written while a call runs refuses the next call even when the provider repeats its tool-call ids across responses — every pass of every tool gate reads the session's rules, and no read is kept for a later one (#2221; behavior narrowing, open to veto: a deny written while a call is being decided — by a caller policy, or by the person answering its question when the answer edits the call — now refuses that call; an approval that edits nothing runs as before (#2225); behavior change: more session-store reads, see Who is hurt; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @test)
|
|
739
|
+
|
|
740
|
+
- **Before.** The session-rule layer re-read the session's rules row at every tool gate, but its live reader handed each read on under the call's `toolCallId`, and the next judgment carrying that id took the handed read instead of reading (unless the enforced row had moved meanwhile). A provider's id is unique within one assistant response only, and some OpenAI-compatible gateways number each response's calls from `call_0`, so the id repeats from one response to the next. On a run with no caller policy (the fold judges each call once) every second call then judged the previous call's snapshot: with six calls and a deny written while call k ran, 2, 2 and 4 calls ran for k = 1, 2, 3 where unique ids gave k. The same leftover read reached an approver's edit with unique ids too: the edit's re-check judged the snapshot the call's first judgment had read, so a deny written while the person was deciding did not refuse the edited call. With a caller policy in the fold, the narrowing pass after it took the read of the pass before it, so a deny written while the caller policy decided (one that asks a person, say) did not refuse that call, and a rewritten call was judged against the snapshot read before the rewrite.
|
|
741
|
+
- **Now.** Every ask of a session-rule layer is a store read, floored as before, and nothing is kept between asks: the call-id handoff, its 256-entry bound and the re-check of a handed read are gone. Each pass of the fold reads — the narrowing pass after a caller policy judges the rules as they stand once that policy has decided — and so does the next call under the same id, an approver's edit and a visibility probe. With repeated ids, 1, 2 and 3 calls run. The contract sentence `session_rules.every_gate_re_reads` is unchanged and now holds for these providers and for every pass of a gate that reaches the layer (an earlier refusal in the fold still short-circuits before it).
|
|
742
|
+
- **Who is hurt.** Store reads go up: a gate whose fold has a caller policy (or a parent's live policy on a delegated leg) reads at both of its passes — one read per live session-rule source per pass, so a fold with one source and a caller policy reads twice per call where it read once, and a delegated leg carrying its own and an ancestor's source reads four times where it read twice (one SQL query each on a SQL store); an approver's edit repeats every pass, so it costs as many reads again; the turn-boundary visibility probes read once per path they screen (consecutive probes under their shared synthetic id used to share reads). On a store whose reads hang, each extra read is one more bounded wait (KL-978b), so such a gate waits up to two bounds. A call whose decision overlapped a deny written for its tool is now refused where it used to run.
|
|
743
|
+
- **Who else.** *server*: no action; its runs judge through the engine's layer, and a deployment `toolPolicy` makes every tool call cost one extra read per live session-rule source. *test*: the repeated-id shape is a provider fact, reachable with any OpenAI-compatible gateway that numbers calls per response. No type changes: `SessionRuleSource.read` keeps `(toolCallId, signal)`; the engine's own reader no longer keys anything on the id.
|
|
744
|
+
- **Unchanged.** The floor law (the ratchet to the highest rev served, the lineage check, the vanished-row guard); one read per call on a fold without a caller policy; the person's persisted rules, which keep one read per call attempt.
|
|
745
|
+
- **Pins.** `test/backlog2221-session-rules-repeated-call-id.test.ts` (real Runner, six calls each carrying the id `call_0`: a deny written while call k runs refuses call k + 1, k = 1, 2, 3; unique ids as the control; the read count of a fold with a caller policy is one per call above one without; a deny written while a non-rewriting caller policy decided refuses that call; a rewriting caller policy and a deny written during its judgment refuse the rewrite; an approver's edit and a deny written while the question was open refuse the edited call; driven directly, the same request judged again reads and sees a write made in between, and so does a new request under the same id; through the fold, an overlapping judgment that advanced the floor and the waiting one's trailing pass both refused, and each pass reads); `test/backlog978-session-rules-every-gate.test.ts` G10 and G12 re-pinned (they pinned the call-id handoff); the per-fixture mutation table is in the car receipt.
|
|
746
|
+
|
|
747
|
+
### A refused model binding states its seat and its arm: every refusal of `config.unknown_role` — a role seat, a tier binding, the model a task names, a role word outside the vocabulary — carries `detail { field, reason }`, and the terminal of a fresh run states it as its `coded` account (#2152; additive; behavior change and behavior narrowing, open to veto — a falsy value written at a model seat is refused, not read as unset; hard-breaking: type only, open to veto — a host's exhaustive table over `CheckpointErrorReason` must name the new word; closed sets: `CheckpointErrorReason` 27 → 28 (+`unknown_role_word`); exports: none; members: none — `resolveModel` gains an optional third parameter `seat`) (@cli @client-core @server)
|
|
748
|
+
|
|
749
|
+
- **Before.** `config.unknown_role` was built in three places. A role seat or a tier binding the engine could not read carried `detail { field, reason }` (7.53.0). A `roles` key or a `modelRole` value outside the six-word vocabulary carried the code and a sentence and no `detail`. The model word a task names (`TaskSpec.model`, `TaskSpec.compactionModel`) and a role seat the fallback walk of a task that names no model reads (`roles.default`, the role the task asks for) carried no code at all: the failed terminal read `Unknown model ref "<name>". Provide it in RunnerDeps.models or pass a Model object.` with nothing beside it (and a `TaskSpec.model` that was a number ended on a `TypeError` from further down). A consumer that wanted the seat or the arm of a refusal read them out of the sentence.
|
|
750
|
+
- **Now.** One constructor builds the error: the code, and `detail { field, reason }` where `field` is the seat the sentence names (`roles.consolidate`, `roles.consolidate.model`, `tiers.pro`, `TaskSpec.model`, `TaskSpec.compactionModel`, `TaskSpec.limits.degrade.to`, `modelRole`, or — for a refused key — the map it was written in, `RunnerDeps.roles` / `TaskSpec.roles`) and `reason` is `unknown_model_ref` (a word the catalog does not hold), `not_a_model_ref` (a value that is no model reference: a number, a boolean, a list, `null`, a function) or `unknown_role_word` (a role word outside the vocabulary; a non-string `modelRole` is that too). Tier bindings, a role's `model` member and a task's model word are judged by one function (`resolveModel`, which takes the seat), so the unknown-word and not-a-reference arms are one rule at every seat. A fresh run's terminal states the pair as it states any thrown error's detail: `terminal.detail = { kind: "coded", detail: { field, reason } }` on `TaskResult.terminal`, on the stream's `done` event and on `result()` (one value, three reads); a resume's screen before the claim carries `reason` on `checkpoint.config_refused` as it did for the other two words. The word the caller wrote stays in the sentence (and `Unknown model ref` still opens it, bounded to 80 characters and quoted as JSON).
|
|
751
|
+
- **Closed sets.** `CheckpointErrorReason` +1, `unknown_role_word` (27 → 28; `docs/CLOSED-SETS.md`). The `reason` of `config.unknown_role` was one of two words when it was present and absent on the vocabulary arm; it is now always present and one of three. Downstream that listed the two words (a retry-policy table keyed by the reason) names the third.
|
|
752
|
+
- **Behavior change (open to veto).** (1) A task whose `model` or `compactionModel` — or, when it names no model, the role seat its fallback walk reads (`roles.default`, `roles.verifier` …) — is a word the catalog does not hold now ends `failed` with code `config.unknown_role` (it had no code), and its sentence reads `Unknown model ref "<name>" in TaskSpec.model. …` / `… in roles.default. …` (it read `Unknown model ref "<name>". …`; the name is JSON-quoted and cut at 80 characters, where it was spliced in raw). (2) A `TaskSpec.model` (or `compactionModel`, or a role's `model` member on the fallback walk) that is no model reference now ends `failed` with `not_a_model_ref` at its seat (a number ended on a `TypeError` from further down; the other truthy kinds were handed on as if they were a model). `resolveModel` called by a host with such a value (it handed it back as a model) now throws the same refusal. (2′, behavior narrowing) **At a task's model seats only an absent value (`undefined`) is "not set"; a value written there is judged, a falsy one too.** `0`, `false`, `null`, `""` and `NaN` written at `TaskSpec.model`, `TaskSpec.compactionModel`, a role in `RunnerDeps.roles` or `TaskSpec.roles` (as the role, or as the `model` of its record) are refused at their seat — the empty string as a word the catalog does not hold (`unknown_model_ref`), the others as no model reference (`not_a_model_ref`) — and `modelRole: null` as a role word outside the vocabulary (`unknown_role_word`). Before, each read as "not set" and the run went on silently: `TaskSpec.model` on the default role's model, `compactionModel` ignored, a role record's `model` unset (an uncoded `No model for role`, or the base binding kept), `modelRole` as `"default"`; a `null` role in either map was unset, and a `0` / `false` / `NaN` role in `TaskSpec.roles` over a role the deployment also bound ended on a `TypeError` raised by the merge. The values of both role maps, and the `model` each record names, are now shape-judged before the merge reads them — whichever role the task resolves through, and whether or not the task names its own model — so a bad value cannot vanish into an override or sit unread in a role nobody asked for; a WORD the catalog does not hold is still judged when its role is read, as before. The workflow lane followed: a script's `ctx.agent({ modelRole: null })` used to default to the subagent role and now reaches the same refusal (only an absent role defaults). (`0` / `false` / `NaN` as a role in a map on its own, and a `modelRole` that is any non-string other than `null`, were already refused.) **Not changed here — the same reading survives at other producers, a class ticket's list:** a `model` of an agent definition or of the delegation tool's options (a written `null` is passed on and the child falls to the role chain), `verifierModel` (`verify.ts`, a truthiness test) and `suggestNextPrompts.role` (a nullish default, inside a resolution that falls back to the task's model on any failure); the Agent tool's per-call `model` argument is not among them — it already refuses a non-string (`model.invalid`) and a blank (`model.empty`) at its own gate. (3) For `roles.<role> = { model: <not a reference> }` the `detail.field` is `roles.<role>.model` (7.53.0 stated `roles.<role>`, the role, while the sentence named the member). (4) An unresolvable `limits.degrade.to` is still optional (the run goes on), and the error handed to `onError` now carries the code and `detail` (it was a plain `Error`). (5) `sideQuery` with a model word the catalog does not hold rejects with the coded error; its `field` reads `TaskSpec.model`, the seat the shared resolver names.
|
|
753
|
+
- **Hard-breaking (type only, open to veto).** *What changed:* the closed set `CheckpointErrorReason` gains `unknown_role_word`. *Who is hurt:* a host or a backend that keeps an exhaustive table over the type (`Record<CheckpointErrorReason, …>`, or a `switch` that ends on an `assertNever` arm) stops compiling until it names the word; nothing changes at runtime. *How to migrate:* add the word to the table; a retry-policy table judges it as it judges `unknown_model_ref` (the refused seat is the caller's or the deployment's own configuration — corrected and sent again). **Also hurt, behavior narrowing (2′):** a caller or a config layer that fills an absent model option with `null`, `""`, `0` or `false` to mean "unset" (a settings layer that writes every key, a form that sends empty strings) is now refused at the seat it wrote; leave the key out, or pass `undefined` (a workflow script that passes `modelRole: null` to `ctx.agent` likewise). The engine's own neighbours were read for this: the server builds `model` from a resolved catalog name and writes `modelRole` nowhere on a task, and the shell and client-core never write it (read from the existing checkouts, not run).
|
|
754
|
+
- **Not in this change.** The word the caller wrote is not a member of `detail` (it stays in the sentence). `No model for role "<role>": …` (no role supplies a model) still carries no code.
|
|
755
|
+
- **Consumers.** *client-core:* the synthetic API error row and the result frame are minted in client-core; the terminal's `coded` account is already read there (`readRunTerminal`), and projecting it onto the row and the frame is what lets a shell read the seat by position (#2152). *cli:* once that projection lands, the seat and the arm come from `detail.field` / `detail.reason` and the sentence-shape anchors for the two `Unknown model ref` forms retire — there is one form now. *server:* `WORKER_SWAP_REDEEMABLE` (resume-legs) gains `unknown_role_word`; the side-query route may read `config.unknown_role` + `reason` for the model-word arm instead of the sentence prefix (the `No model for role` arm stays on the prefix).
|
|
756
|
+
- **Pins.** `test/backlog2152-unknown-role-terminal-detail.test.ts` (every arm on a real Runner, three surfaces compared; the vocabulary arm; the exact terminal key set and a JSON round trip; the task path's function called directly; the tier table at construction and the resume screen; the five falsy values at every model seat against the absent-seat controls; controls); `test/backlog1950-resume-config-doors-preclaim.test.ts`, `test/model-roles.test.ts`, `test/model-tiers.test.ts` (existing cells unchanged); the per-fixture mutation table is in the car receipt.
|
|
757
|
+
|
|
758
|
+
### Gate scripts make their throwaway directories through one helper, `scripts/scratch-dir.mjs`, and a run that fails or exits early leaves none behind; `gate:all` keeps no log directory; the delegated-car cards stop passing a `mktemp -d` data directory to vitest (#2222; release tooling and tests only; `scripts/` is not in the package; no `src/` change; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
759
|
+
|
|
760
|
+
- **Before.** Twelve scripts under `scripts/` made a directory with their own `mkdtempSync` and removed it with their own `rmSync`; six of them left it behind on an ordinary path. (1) A removal in a `finally` that `process.exit(1)` inside the `try` or `catch` never reaches — the path a FAILED run takes: `run-blackbox-gate`, `run-live-gate`, `verify-fresh-install`. (2) No removal at all: `repro/B-002`'s home on an early exit or a throw; `repro/shell-read-corpus` made up to two fixture trees per corpus row and removed none; `gate-all` made a `gate-all-XXXXXX` log directory per run, and every test that spawns it made another (one machine's temp directory held 438). `AGENT_DATA_DIR=$(mktemp -d)` — the form the delegated-car cards told a car to pass to vitest — made one more never-removed directory per command, although vitest mints and removes its own data root unless the launcher names one (`test/global-data-root.ts`).
|
|
761
|
+
- **Now.** `scripts/scratch-dir.mjs` is the one lifecycle: `makeScratchDir(prefix)` creates and registers the directory, `removeScratchDir(dir)` removes it (idempotent), `withScratchDir(prefix, fn)` is both around a sync or async callback. The registry is swept by one `exit` listener (it runs for `process.exit()` and for an uncaught throw); a removal that fails is said on stderr and the directory stays registered, so the sweep at exit tries once more. No signal listener is installed, on purpose: an interrupted script still leaves its directory, as every one of these did, because a listener cannot run while a script is blocked in a synchronous child (most of them are) and merely holding one turns an interrupt that killed the script into one it finishes its remaining steps through (measured: a three-step script signalled at 1 s ran on to 4.9 s and exited 0). `run-live-gate`, which handles SIGINT / SIGTERM itself, now removes its directory in those handlers. Every `mkdtemp` under `scripts/` is gone but the helper's (a pin scans for it, so a new script cannot skip the net by minting its own). `gate-all` no longer writes logs: a gate that does not pass has its own output echoed under the verdict table — each line behind ` | `, so a gate's own `FAIL …` line is never read as a verdict row, the first 120 lines with the command for the rest named — and the `logs in <dir>` line and the path column are gone. `gate:all` now ends through `process.exitCode` rather than `process.exit()`, because that text goes to stderr and, piped to a slow reader, an explicit exit drops what is past the pipe buffer (measured: 115 lines of 2 000 bytes arrived as 65 536 bytes). A failed run's evidence is its text in the output, not a kept tree; the one way to keep a scratch tree for a person to look at is the env name `SEMA_KEEP_SCRATCH=1` (the path of each kept directory is named on stderr; any other non-empty value but `0` is said aloud once and read as "remove"). The three `.claude/agents/car-*.md` cards now say vitest commands pass no `AGENT_DATA_DIR` and a tsx / node probe that needs a data directory makes its own and removes it when the probe ends.
|
|
762
|
+
- **What stays.** Test fixtures that make directories (`test/`, the `b<ticket>-*` family) are #2194's, 7.56.0. `bench/` and `eval/` keep their own cleanup. `scripts/publish-release.sh` already removes its work directory in an `EXIT` trap. The `sema-negctl-*` whole-repository copies seen in a temp directory are made by the shell repository's negative-controls runner, not by this repository: `gate:negative-controls` here (`test/gate-negative-controls.test.ts`) removes its own directories in `afterAll`, and what it cannot do (a worker killed outright) is #2194's fixture discipline, not this change.
|
|
763
|
+
- **Who is hurt.** Nobody at runtime: `src/` is untouched and nothing in the package changes. A person or script that read `gate:all`'s `logs in <dir>` line, or opened a per-gate log file under it, finds neither; the same text is in the run's output below the table, and `npm run -s <gate>` gives the rest of a cut one. A grep of `gate:all`'s output for lines starting `FAIL` still finds only verdict rows. A test fixture that copies a script by name into a throwaway repository must copy `scripts/scratch-dir.mjs` with it (`test/wire-changes-checker-fallback.test.ts` did not and failed until its list gained it).
|
|
764
|
+
- **Pins.** `test/backlog2222-scratch-dir-lifecycle.test.ts` (the helper on return, throw, async resolve / reject and the symlink-resolved form; `SEMA_KEEP_SCRATCH` unset / empty / `0` / `1` / a bad value; in a real child process: `process.exit()` inside a `try` and inside a `catch`, an uncaught throw, four directories at once, a removal that fails and is retried by the exit sweep; no signal listener is added, and SIGTERM to a script blocked in a synchronous step kills it at once by that signal; the single-source scan over `scripts/` with a negative control for the detector; `runRoster` with a fake spawn: nothing under the temp directory, a passing gate's text not echoed, a failing / refused / silent gate's text echoed indented and cut at the cap, and the entry ending through `process.exitCode`; and `ship-post-lint --tree-fingerprint <missing ref>` leaving the temp directory empty).
|
|
765
|
+
|
|
766
|
+
### A host rule — `WebFetch(domain:<host>)` — is read in deny, ask and allow, on the host the call reaches: one rule dispatch, one host reading, and a fetch tool that follows a redirect only to the host the rules read (#1489 car A; behavior narrowing, open to veto; upgrade behavior change, open to veto: a parked approval binds the URL slot; differs from Claude Code's shape, open to veto; hard-breaking: type only, open to veto — `RuleFaceView.pathTargeting` and `ruleFacesFromRoster`'s `pathTargeting` member are removed (ruled C-R686); closed sets: none — the roster row gains an optional member; exports: +1 type `ToolHostTarget`; members: +1 optional `hostTarget` on `ToolSpec`, `ToolFaceDeclaration`, `McpServerSpec.toolFaces[<tool>]` (refused at that door in this version), `ToolCallFace`, `ToolRosterEntry` and the parked approval row (`PendingAction`, `tool_approval`); −1 optional `RuleFaceView.pathTargeting`; `ruleFacesFromRoster`'s answer −1 `pathTargeting`) (@server @cli @client-core @connectors @test)
|
|
767
|
+
|
|
768
|
+
- **Before (v7.54.0).** A host rule had an allow form only: a person's stored deny / ask by host was refused (`unsupported.tool`), a deny list read it as nothing (`content_unsupported`; a skill whose `disallowed-tools` held one did not activate). The rule policy compiler did not consult the rule model's grammar at all: compiled with no leg resolver, `WebFetch(domain:evil.com)` became a rule on a parameter named `domain` — zero issues, every real call answered `allow`, and only a call that also carried `domain: "evil.com"` was refused; compiled with `ruleFaces` the same text was refused `unsupported.param_unknown`. Which tool a host rule reached came from a hand-kept table (`DOMAIN_RULE_FACES`, one row); the host was read by name from that table. The fetch tool followed a redirect whose host differed only by a leading `www.`, so it could fetch a host no rule had been read on.
|
|
769
|
+
- **Now.** One face member names the URL slot a tool's call reaches — `hostTarget: { param }`, beside `pathTarget` and exclusive with it (the catalog's `WebFetch` row declares `url`; a caller's `ToolSpec` under a host-rule name; the roster row, the call face and the parked approval row carry it). Which grammar reads a rule is one reading of the rule's tool NAME as the catalog declares it (`ruleToolGrammarOf`; the catalog's host-target rows replace `DOMAIN_RULE_FACES`), and the rule policy compiler dispatches every content by one function of (name, content) (`ruleLaneOf`) before any lane's own check — a leg's roster says which parameters a tool carries, never which grammar its name takes — so `WebFetch(domain:…)` is the host lane's whatever tool a leg mounts under that name, and both compiles take the same lane for every rule (a content rule on a tool name the catalog does not know is checked for its parameter only when `ruleFaces` are passed — KL-1489-b). Every judge reads the call's host through one function (`callHostTargetOf`): a deny or an ask on the face's own slot, else the catalog's slot for the name; an allow on the face's own slot only. The host is read by one URL reading (`hostOfUrl`) on both sides of a rule (lower case, IDN in its punycode form, a full-width dot folded, an IPv4 shorthand expanded, trailing dots read off) and must read back as itself. The fetch tool reads the host it connects to through the same reading and follows a redirect only to that same host.
|
|
770
|
+
- **Narrowing, a deny or an ask by host is in force.** What changed: `WebFetch(domain:<host>)` / `WebFetch(domain:*.<domain>)` as a deny or an ask is read on the host the call reaches in a rule policy (compiled with or without `ruleFaces`), a person's stored rows, a session's deny bucket, a skill's `disallowed-tools` and a sub-agent's deny list. Who is hurt: a deployment that wrote such a rule and has been running with it inert — its calls to that host are now refused or asked; a skill whose `disallowed-tools` holds one now activates (it no longer reads as an entry that cannot be enforced). How to migrate: nothing to change; review host deny / ask rules written while they had no effect.
|
|
771
|
+
- **Narrowing, a redirect that adds or drops `www.` is handed back (open to veto).** What changed: the fetch tool follows a redirect only when the target's host is the call's host as the rules read it (same port, no credentials, an http → https upgrade allowed); `example.com` → `www.example.com` is returned to the model as `REDIRECT DETECTED`, and the model's next call is judged again. Who is hurt: a fetch whose site redirects between its apex and its `www.` host takes one more model turn. How to migrate: nothing; a deployment that wants both hosts unasked allows both (`WebFetch(domain:example.com)`, `WebFetch(domain:www.example.com)`).
|
|
772
|
+
- **Narrowing, a call whose host cannot be read is refused by a host deny (open to veto).** What changed: a call whose URL does not parse, is not http(s), carries credentials or names a host that does not read back as itself (`https://2130706433../`) is refused at the gate by a host deny rule of its tool (whatever host the rule names; the refusal says the host cannot be read), asked in the fail-closed form under a host ask rule (`ruleUnreadable`, a real approval), and granted by no allow; the fetch tool itself refuses a URL whose host does not read back as one host. Who is hurt: such a call was refused by the fetch tool anyway; now the gate refuses it first under a host deny, and an ask rule asks for it. How to migrate: nothing.
|
|
773
|
+
- **Narrowing, an allow by host reaches only a tool that declares its URL slot (open to veto).** What changed: a `WebFetch(domain:…)` allow (a stored row, an entry list, a session's allow bucket) reaches a call only when the call's face declares `hostTarget`; the engine's `WebFetch` does. Who is hurt: a host that mounts a tool of its own under the name `WebFetch` and relies on a host allow for it — those calls are now asked. How to migrate: declare `hostTarget: { param: "<the URL argument>" }` on that tool.
|
|
774
|
+
- **Narrowing, a URL slot is declared only on a host-rule name (open to veto; ruled C-R686).** What changed: `hostTarget` is accepted only on a tool whose own name the catalog gives host rules — today `WebFetch` (an alias is not enough: it may resolve to another tool of that name, and then no host rule reads this tool's slot); on any other tool it is refused with `config.tool_face_invalid` at the caller door, and every MCP `toolFaces` overlay that declares one is refused at the reader and at the mount (no MCP tool is named so). A host rule's grammar belongs to the rule's tool name, so a slot on another tool would never be read. Who is hurt: a host that wanted host rules for a fetch tool of its own under another name, or for an MCP browser tool — it cannot have them in this version (design/1489 car B, #2212, reopens them). How to migrate: mount the tool under the name `WebFetch`, or keep using whole-tool rules on its own name.
|
|
775
|
+
- **Behavior change, the lane of a rule is the same with or without a leg resolver (ruled C-R686).** Two path cases answered differently by the two compiles before: a catalog path name (`Write`) mounted as a tool that declares no path target — the static compile took the path lane, a compile with `ruleFaces` refused `invalid.param_split`; now both take the path lane, judged on the catalog's slot for the name (the protective floor). A tool under a name the catalog gives no path rules, declaring a path target — the static compile refused `invalid.param_split`, a compile with `ruleFaces` `invalid.command_rule`; now both `invalid.param_split`.
|
|
776
|
+
- **Hard-breaking (type only, open to veto; ruled C-R686), the rule-face resolver no longer says whether a tool targets a path.** What changed: `RuleFaceView.pathTargeting` and the `pathTargeting` member of `ruleFacesFromRoster`'s answer are removed — no reader is left once the lane is the rule name's grammar. Who is hurt: code that reads either member, or returns an annotated `RuleFaceView` literal carrying it from its own `ruleFaces` resolver (it stops compiling); a grep of the server, cli and client-core sources finds zero readers. How to migrate: drop the member; a tool's path target is on its roster row (`ToolRosterEntry.pathTarget`) and the catalog's on `pathTargetOf(name)`.
|
|
777
|
+
- **Behavior change, the fetch tool's allowlist and private-host floor read the host as the rules read it.** `WebFetchConfig.allowHosts` and the private-host floor are judged on `hostOfUrl`'s host (trailing dots read off), on the first URL and every redirect target, and each allowlist entry is read the way a host rule's host is (an `example.com.` or `Example.COM` entry names `example.com`), so `https://example.com./x` is the host `example.com` for the gate and for the tool alike (before: the gate cleared it and an `allowHosts: ["example.com"]` tool refused it). A redirect to a URL carrying credentials, or to a host that does not read back as one host, is refused instead of being handed back.
|
|
778
|
+
- **Upgrade behavior change, a parked approval binds the URL slot (open to veto).** What changed: a parked tool approval's row carries the slot its host rules were read on (`hostTarget`, off the same roster row as `pathTarget`); the approval object compares it, so a resume whose tool now names another slot is vetoed (`resume.target_moved`, not run, the approval spent), and the pre-claim recheck of an edited input judges the host on it. Who is hurt: an approval of a `WebFetch` call parked by an earlier engine (its row carries no slot) and resumed by this one with its input unchanged is vetoed — the model re-issues the call and a new card is asked; an edited input on such a row is still judged, on the catalog's slot for the name. A checkpoint backend that copies a parked row member by member must keep the new member (`checkpointStoreContract` checks it). How to migrate: drain the parked `WebFetch` approvals before upgrading, or accept that each is vetoed once on 7.55.0 with `resume.target_moved` (the refusal says the approval is spent and the call has to be re-issued); a checkpoint backend keeps `pendingAction.hostTarget` verbatim.
|
|
779
|
+
- **Behavior change, the fetch card offers the exact host rule.** An ask a stored allow could clear, on a tool that declares its URL slot, carries `ruleOffers: [{ kind: "single", rule: "WebFetch(domain:<host>)", match: "host", command: "<host>" }]` (before: no offer); a tool under the name that declares no slot answers `ruleOffersAbsence: "lane_cannot_speak"`. `prepareCardApproval({ toolName: "WebFetch", command: <host>, … })` mints that one-rule record (before: `undefined`; no edit row), and the redeemed row is the one the next call meets.
|
|
780
|
+
- **Differs from Claude Code (open to veto).** The reference follows a redirect whose host differs by a leading `www.` (this engine hands it back, so a rule is never by-passed by that hop); the reference keys a URL it cannot parse as `input:…`, which no host rule reaches (this engine refuses it under a host deny, as the reference's own managed-browser site check does); the reference's rule side only lower-cases a host (this engine reads both sides through the URL parser, so a Unicode spelling and its punycode name one host). Unchanged and stricter than the reference: `domain:*`, a `*` inside a label and a `*.` over an address read as nothing.
|
|
781
|
+
- **Consumers.** *server*: a deny / ask `WebFetch(domain:…)` in a rule policy is now enforced, with or without `ruleFaces` (S-1910); a `hostTarget` on a tool under a name that is not a host-rule name is refused `config.tool_face_invalid` (the whole prepare), and `RuleFaceView` has no `pathTargeting`; the SQL checkpoint store keeps the parked row's new `hostTarget` member verbatim (the kit case says so). *cli*: the engine judges deny / ask host rules it is handed (L-1779); a same-process reading of exact-key host rules in the shell can retire on the shell's schedule; the rule editor's sentence for a host rule names the same rule — host rules are written for `WebFetch` only in this version. *client-core*: the rule editor's grammar for `WebFetch` gains deny and ask; the generated roster row type gains the optional `hostTarget`. *connectors*: an MCP `toolFaces` overlay cannot declare `hostTarget` in this version (refused at the door; car B reopens it); a host rule on an MCP tool name is still refused (`unsupported.mcp_paren`, as in Claude Code). *test*: G1–G4 / G8 in the pins below, and one upgrade cell: a `WebFetch` approval parked on 7.54.0 and resumed unchanged on 7.55.0 is vetoed once (`resume.target_moved`, nothing fetched).
|
|
782
|
+
- **Pins.** `test/host-rule-lane-static-vs-roster.test.ts` (G1: the same rule set compiled without and with the leg's real roster answers every cell the same — the exact host and its spellings, `*.` below and never the apex, a decoy `domain` argument, a look-alike, the four unreadable forms; a mounted tool that takes the `WebFetch` name with its own `domain` argument and no slot; a `domain:` content on a name with no host rules is a rule on that tool's own argument in both; the two path cases above; a real Runner refuses the engine's WebFetch before it fetches); `test/backlog1489-host-grammar.test.ts` (the face member and its doors, a slot on a name with no host rules refused at the caller door, the MCP reader and the mount alike; G2 the redirect hand-back, the same-host follow, a trailing-dot host one host for the gate and the tool's allowlist; G3 IDN both ways, a Cyrillic look-alike neither refused nor allowed, an address in three spellings, a same-name tool judged on its own slot and on the catalog's; G4 a stored deny / ask row, no rule, a deny list; G8 the offer, its absence, the consent round trip; a parked fetch edited to a denied host refused before the claim; a resume onto a moved URL slot vetoed; a row minted before the member); `checkpointStoreContract` (a backend keeps `pendingAction.hostTarget`); existing cells re-pinned to the three-behavior grammar (`backlog1352`, `backlog1327`, `backlog740`, `backlog1303`, `backlog1372`, `backlog1318`, `backlog1973`); the per-fixture mutation table is in the car receipt.
|
|
783
|
+
|
|
784
|
+
### The sentence that says why a matching allow rule did not clear a `probe_mandate` ask is the shell station's own account of its finding — no longer one sentence about "a read outside the directories" (#1516; behavior change, user-visible: the clause on the card, in the headless refusal and rendered from a parked row; hard-breaking: `SHADOW_REASON` loses its `probe` row, open to veto; closed sets: none — the word `probe_mandate` and the class `probe` are unchanged; exports: none added or removed at the root — `SHADOW_REASON` loses one key, `allowRuleShadowSentence` and `cardDisclosureOf` accept an optional `probeReason`; members: none) (@cli @client-core @server)
|
|
785
|
+
|
|
786
|
+
- **Before.** `probe_mandate` is one word for every finding of the shell read station that no allow rule may clear: a path outside the allowed directories (read or written) and a command whose reads it could not place (a text the shell rewrites, a command text it does not parse). Its disclosure was one table sentence — "the reversibility check declared this call structurally gated (the built-in shell check raises this for a read outside the directories allowed for this session) — it is cleared by confirming this call, never by a standing allow rule" — so a card for `cat $F` (nothing outside the task root is named), for `sudo -s` and for a `cp` that writes outside the directories all said a read outside them, while the same card's `probeReason` seat carried the right sentence. `probe_unanswered` ("the reversibility check did not answer") added "so whether the call reads outside the directories allowed for this session is unknown" for any tool whose probe threw, a wire-funds tool included. A policy's ask that the station also mandated carried the mandate and no account.
|
|
787
|
+
- **Now.** The clause after the dash is the ask's own `probeReason` — the sentence the station minted for this call — verbatim, and the table has no general sentence for the word: `… (this run's allowedTools (Bash) matches this call but does not clear the ask — reads outside the allowed directories for this session: "/out/data.txt" is not under /root — not auto-allowed)`, `… — where this command reads could not be established: "$F" is rewritten by the shell before the program sees it, so it cannot be confirmed to read only inside the allowed directories for this session (/root) — not auto-allowed)`, `… — writes outside the allowed directories for this session: "/out/w.txt" is not under /root — not auto-allowed)` (each cut at the account's own 200-character cap). Three surfaces say it: the card's `message`, the headless refusal (it embeds the card's message), and a parked row — `riskDescriptor.probeReason` is the account and `allowRuleShadowSentence(shadows, { askClass, mandate, probeReason })` renders from the row's members the sentence the card carried. The class `probe` with no mandate word (a real-approval ask the station also mandated) is worded the same way. A mandate merged onto an ask another layer raised now brings its account: the first layer to mandate an ask stamps `probeReason` (replacing one the ask arrived with, which only its author's prose could be — and removing it where the mandating layer gave no account, so that prose is never read as the reason); the ask's message, reason and every other member are untouched. A mandate stated with no account — a deployment probe's `mandated: true` without a `reason` — has no clause: the sentence ends at "do not clear the ask" (a probe that wants a reason on the card supplies `reason`). The account is taken whole or not at all: text the engine could not have minted (longer than its cap, blank) is another producer's and is dropped. `probe_unanswered` says "… did not answer for this call (it timed out or failed), so whether the call is reversible is unknown — …".
|
|
788
|
+
- **Behavior change (user-visible).** (1) The clause text above, on every card that discloses a matching allow rule beside a `probe_mandate` ask; the card's `message` grows by the account (at most 201 characters). (2) `AskRequest.probeReason` is now present on an ask a policy raised and the station mandated (it was absent), and replaces a `probeReason` that policy declared on its own ask. (3) A deployment probe's `mandated: true` with no `reason` loses the clause it had. (4) The station's account for a text it must treat as handed to a shell (`bash -c`, `sh`, `eval`, `source`, `sudo -s`, `su`, a here-document fed to a shell …) names the programs the plan resolved, each once in text order, and names the three ways the text can arrive (as an argument, on standard input, or in the file it names), the same list for every form — `the text given to "bash" — as an argument, on standard input, or in the file it names — runs as shell text this station does not parse — not auto-allowed` (`the text given to "bash" and "sh" — …` for `bash -c 'cat a' && sh -c 'cat /etc/x'`; `"sh"` for `sudo -s` and `su`) — where it quoted the first segment as it was spelled and called the text an argument (`"bash -c 'echo x'" runs its argument as shell text …`, `"bash <<'EOF'" runs its argument …`, naming only `bash` for a compound whose second shell was the one reaching outside): this is the `probeReason` text of every such card, with or without an allow entry, and a here-document form and its `-c` form are one sentence, a script on standard input is no longer called an argument, and no program of a compound is left out (the dual-run fixture of the shell readers carries the attribution `shell_text_subject` for the 56 inputs it changes, its reach ceiling 640 → 740). Nothing is decided differently: the mandate, the class, which lanes may answer, and whether a rule clears the ask are unchanged.
|
|
789
|
+
- **Hard-breaking (open to veto).** *What changed:* `SHADOW_REASON`, the frozen per-class reason table, has no `probe` key (it holds a row for every class that has a reason of its own; `probe` is the class of a finding about the very call, and the finding's account is its reason). *Who is hurt:* a host that indexes `SHADOW_REASON` by every `AskClass` reads `undefined` for an ask of class `probe` where it read the general sentence; no consumer was found in the shell, the server or client-core checkouts (client-core words the disclosure itself and does not read this table; read from the existing checkouts, not run). *How to migrate:* render with `allowRuleShadowSentence(shadows, ask)` passing the ask's `probeReason`, or read `probeReason` directly.
|
|
790
|
+
- **Who else.** *cli, client-core:* the user-visible sentence changes (the clause after the dash in the card's disclosure line, where the engine's `message` is rendered as it comes; client-core's own one-line "your rule is still there, but it cannot clear this call" is unchanged and takes none of this); copy the new sentences into any golden text. **`AskRequest.probeReason` is newly present on the cards of a policy-first ask and of a delegated child's ask under a host policy that asks (a `probe`-class card the shell station mandated), where it was absent: a card renderer that shows `probeReason` as its own line now shows the station's account there too, beside the same sentence in the disclosure clause.** *server:* none; its frames carry the engine's `message` and the row's `riskDescriptor.probeReason` as before.
|
|
791
|
+
- **Unchanged.** The word `probe_mandate` and the class `probe`; `PERSISTED_RULE_MANDATES`; the lanes (a rule never clears the ask); the `probeReason` seat of an ask the station tightens itself; the sentences of every other mandate word and class; `probeCause` (still not merged onto another layer's ask).
|
|
792
|
+
- **Pins.** `test/backlog1516-probe-mandate-disclosure.test.ts` (real Runner and the real shell station: the three findings' cards end with their own `probeReason`, the unplaced read inside the directories does not say "outside" and the write does not say "read"; the retired sentence has no source line, the table no row; a missing or foreign account makes no clause; the headless refusal and the parked row carry the card's account, and the exported sentence renders it from the row; a policy-first ask brings the account and a policy's own `probeReason` is replaced; two mandating layers keep the first's; the class path; a deployment probe with and without a reason, and a reasonless mandate over a policy's own `probeReason`; the renderer reads a caller's accessor once; the here-document and `-c` forms of one shell are one sentence, a compound names every re-entered program, a script on standard input is not an argument; `probe_unanswered`; controls); `test/backlog1474-rule-entry-reader.test.ts` C1a, `test/backlog502-out-of-root-read-mandate.test.ts` and `test/backlog1421-launcher-reread.test.ts` re-pinned (they pinned the general sentence, or the segment as spelled); `test/backlog1417-stdin-script.test.ts` ⑩ green again (its lanes compare the two forms' sentences); `test/backlog994-s1-dual-run.test.ts` attribution `shell_text_subject` and ceiling; the per-fixture mutation table is in the car receipt.
|
|
793
|
+
|
|
794
|
+
### A `Model.extraBody` that is not a plain object is refused instead of being spread into the request body or left out: one judge, `judgeExtraBody`, at the prepare step and at every request build (#1549; behavior narrowing, open to veto; hard-breaking: none; closed sets: none — the existing `config.invalid_argument`, `detail.field` `Model.extraBody`; exports: none; members: none — the internal `applyExtraBody` takes the model instead of its `extraBody`) (@server @dgx)
|
|
795
|
+
|
|
796
|
+
- **Before.** `applyExtraBody` ran `Object.entries` over any truthy `extraBody`. A string became numbered keys on the wire (`"garbage"` ⇒ `{"0":"g","1":"a",…}`), an array likewise (`["temperature", 0.7]` ⇒ `{"0":"temperature","1":0.7}`), a class instance its own fields; a number, `true`, `""`, `0` and `false` were left out without a word. At the task's own model spec, `null` failed the run with a bare `TypeError` ("Cannot convert undefined or null to object") from the prepare step's own key probe — no code, and nothing on `onError`, which is not a channel for a prepare refusal. At a model spec a host brain hands the adapter for one request (and the compaction model, and a side query's model), `null` was ignored.
|
|
797
|
+
- **Now.** `Model.extraBody` is absent or a plain object (prototype `Object.prototype` or none). Anything else is refused as `config.invalid_argument` with `detail.field` `Model.extraBody`, and the sentence names the model and the kind of value (`null`, a string, an array, a number, a boolean, an object that is not plain), never the value, and the way out (write a plain object, or remove it). The judge runs at two seats and says the same sentence at both: the prepare step (the task's own model, before the session is acquired — a refused request never touches the session; the terminal's code is `config.invalid_argument`) and the request build, `applyExtraBody`, which the three wire adapters reach and nothing else (a host brain's per-request rewrite, the compaction summary, a side query: a `BrainError` `invalid_request` whose message leads with `config.invalid_argument:`, the shape a pre-flight route refusal has; a refused compaction summary arrives on `onError` as `Turn prefix summarization failed: [invalid_request] config.invalid_argument: …`). Nothing reaches the wire. The adapters hold no guard of their own.
|
|
798
|
+
- **Who is hurt.** A host that hands the engine a model whose `extraBody` is not a plain object: a string or an array it meant as parameters (they went out as numbered keys, which a provider mostly ignored or answered with a 400), `null` or `false` or `""` or `0` it meant as "none" (they were ignored; `null` at the task's own model failed the run uncoded), a `Map` or a class instance (its own fields went out), an object built in another realm (a `vm` context, a worker — its prototype is not this realm's `Object.prototype`). The run now ends `failed` (or the compaction / side call fails) with the sentence above. A catalog the server builds is not hurt as far as its source shows (read from a checkout, not a published build): `MODEL_EXTRA_BODY` refuses a non-object at boot, and a catalog entry with a non-object `extraBody` is dropped by the settings schema before the engine sees it.
|
|
799
|
+
- **How to migrate.** Write a plain object, or leave `extraBody` undefined (delete the key rather than writing `null`); copy a `Map` or a class instance into a plain object (`Object.fromEntries(map)`, a spread).
|
|
800
|
+
- **Not in this change.** Prepare still judges the task's own model only; the compaction, advisor and role models are judged where their request is built (above), so a bad value there ends that call, not the door. Which of the engine's other pre-flight refusals could carry the engine's code word as the terminal's own `code` at the request seat (today the request seat's terminal code is the brain's `invalid_request`) is a separate question, not built here.
|
|
801
|
+
- **Pins.** `test/backlog1549-extra-body-shape.test.ts` (nine non-object values × both seats × the three wires, the sentence and the code at each seat, the two seats' sentence identical, the compaction model's summary call refused and said, the controls — a plain object, a null-prototype record, `{}` and absent ride through — the judge's totality over a bigint, a symbol, a function and a value whose `toString` throws, the value judged being the value merged — one read of the model's `extraBody` and one walk of its keys, so a getter or a Proxy trap that answers differently on a second read cannot pass one answer and send another — and the adapters' source holding no guard of their own); `test/backlog1298-resume-at-leaf-untouched-on-refusal.test.ts` ⑫b (a string, an array and `null` are refused before the acquire — the session log is byte for byte as it was); `test/request-params.test.ts` and `test/brain-protocol-coverage.test.ts` (`applyExtraBody` takes the model).
|
|
802
|
+
|
|
803
|
+
### `max_completion_tokens` in a model's `extraBody` has one law at both seats: refused as `config.extra_body_output_cap` for the task's own model spec at prepare, as before, and now also when a model spec a host brain hands the adapter for one request carries it (#1550; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @dgx)
|
|
804
|
+
|
|
805
|
+
- **Before.** The task's own model spec carrying `max_completion_tokens` in `extraBody` was refused at prepare (`config.extra_body_output_cap`, on every wire; on the OpenAI wire the reason is that stripping it would silently un-cap a deployment that used the spelling as its only bound, and passing it on lets a constant fight the overflow retry's retuning of the cap). A model spec swapped in for one request — a host brain's per-request rewrite, the compaction model, a side query's model — was not judged: the key was not in the wire's reserved set, so it reached the wire beside the engine's own cap (`max_completion_tokens: 5` beside `max_tokens: 4096` on the OpenAI wire; beside the lane's own cap key on the other two), where on the OpenAI wire a provider may reject the doubled cap or let the constant override the overflow retry's adjustment (not measured against a live provider). One key, two readings.
|
|
806
|
+
- **Now.** One judge (`judgeExtraBody`, the same one as above, in `src/brain/request-params.ts` beside the reserved tables) refuses the key at both seats, on every wire, with one wire-neutral sentence ("Model.extraBody of model \"<id>\" supplies \"max_completion_tokens\" — an output-cap spelling the engine owns: it writes the per-request cap itself, so a cap is never taken from extraBody. Set it via limits.maxOutputTokens (per request) or Model.maxTokens; on the OpenAI wire Model.compat.maxTokensField picks the spelling."; the reason is that the engine owns the output cap — on the OpenAI wire it is also the key the overflow retry lowers; the other two wires' endpoints do not read it, and the refusal at the task's own model never depended on the wire, so the request seat does not either): at prepare, before the session is acquired, with the terminal code `config.extra_body_output_cap` (unchanged); at the request build as a `BrainError` `invalid_request` whose message leads with `config.extra_body_output_cap:` and carries the same sentence, before any key is written to the wire. The prepare step's own inline arm is gone: it calls the judge. The sentence now names the model (it did not). `max_tokens` is unchanged: reserved, stripped and announced once as `route.extra_body_reserved_dropped`. The engine's own write of `max_completion_tokens` (a model that declares `compat.maxTokensField: "max_completion_tokens"`) is not `extraBody` and is untouched.
|
|
807
|
+
- **Who is hurt.** A host or catalog whose per-request model rewrite (or compaction / side-query model) carries `max_completion_tokens` in `extraBody`: that call now fails with the sentence above instead of sending both caps. A host that relied on the constant riding next to the engine's cap had a request that two spellings of one limit contradicted.
|
|
808
|
+
- **How to migrate.** Set the cap with `limits.maxOutputTokens` (per request) or `Model.maxTokens`; declare the spelling the model wants with `Model.compat.maxTokensField`.
|
|
809
|
+
- **Two tables, two questions.** `OPENAI_RESERVED` lists the keys the adapter strips (and announces); `max_completion_tokens` is not in it — it is refused, never silently removed. `OUTPUT_CAP_KEYS` lists the keys each wire READS as its output cap (the overflow retry's read, and which key the engine's cap is written under); `openai` keeps both spellings because the adapter writes whichever the model declares. They answer different questions and are not merged.
|
|
810
|
+
- **Pins.** `test/backlog1550-max-completion-tokens-one-law.test.ts` (both seats × the three wires, the two seats' sentence identical, nothing sent, the second request of a run judged on its own, `max_tokens` still stripped and announced at both seats, the engine's own `max_completion_tokens` write untouched, the two tables); `test/brain-protocol-coverage.test.ts` and `test/backlog1298-resume-at-leaf-untouched-on-refusal.test.ts` ⑫ (the prepare seat, unchanged); `test/brain-context-overflow-selfheal.test.ts` — four cells re-pinned: they used `extraBody.max_completion_tokens` to put a second cap key on the wire, which the law now refuses, so they pin the same properties through the engine's own cap key (a smaller wire cap stops the recovery; a cap written under the declared spelling bounds it under that one key) and pin the new fact (a model spec that gains the key between the send and the rebuild is declined at the rebuild: "could not be rebuilt with a smaller cap").
|
|
811
|
+
|
|
812
|
+
### A run can turn the deployment's project context off: `TaskSpec.projectMemory: "disabled"` — the leg's prepare does not call `RunnerDeps.loadProjectMemory`, and the wiring manifest says so (#1563; additive; behavior change: none for a run that omits the key — the same request bytes, pinned — and one for every leg: the manifest gains a cell; hard-breaking: none; closed sets: none — the refusal reuses `config.invalid_argument`, already in `CHECKPOINT_CONFIG_REFUSAL_CODES`; the clamp table gains a row; exports: none; members: +1 optional `TaskSpec.projectMemory?: "disabled"`, +1 optional `WiringManifest.projectMemory?: "on" | "disabled" | "absent"`, +1 optional `WiringFacts.projectMemory`, +1 optional `ParentSpecSeat.projectMemory`) (@server @cli @client-core)
|
|
813
|
+
|
|
814
|
+
- **Before.** A deployment's instruction files reach a run through one seat, `RunnerDeps.loadProjectMemory`, called at every prepare. Keeping them out of one run had no word: the only switch was not wiring the seat, which is the whole deployment's. A shell flag meant to run one session without them (the reference implementation's `--bare` and `--safe-mode`) had nothing to send.
|
|
815
|
+
- **Now.** `TaskSpec.projectMemory: "disabled"` — one word, `fileHistory`'s spelling — and the leg's prepare does not call the seat. Everything the seat feeds is absent with it: the fenced project layer, the declared instruction sources (so the instruction-change probe never runs), the seat's files seeded as already read, the compaction lanes' instruction-file text. Not that seat and untouched: the engine's own git-status lane, the memory engine (its index seed included; `memory.enabled: false` is its own word), and the skills, hooks, MCP servers and agents a host hands in. Omitted ⇒ the seat is called as before; the key present as `undefined` is the same as omitted.
|
|
816
|
+
- **The reference.** Claude Code 2.1.296 has two launch modes and one CLAUDE.md surface: `--bare` (`CLAUDE_CODE_SIMPLE`) and `--safe-mode` (`CLAUDE_CODE_SAFE_MODE`) each make that surface's predicate true, and the user context then loads no instruction file at all — managed, user and project alike. The one difference on this surface is that `--bare` turns it back on when directories are explicitly given for their CLAUDE.md files (`--add-dir`); `--safe-mode` has no such exception. Each mode switches off other surfaces too, a different set each (`--bare`: hooks, skills, plugins, agents, LSP servers, an agent's own MCP servers; `--safe-mode`: nearly every customization — skills, plugins, MCP servers, agents, output styles, workflows — while managed hooks still run); here those are seats a host already fills or leaves empty per run. So one surface is one word, and the two flags are two producers of it — a shell sends the word for `--safe-mode`, and for `--bare` unless directories were explicitly given.
|
|
817
|
+
- **Inheritance.** A clamp-table row (`restricting`, value `"disabled"`, carrier `specSeat`, `script: false`): the parent spec seat hands it to every engine-built child (delegated, background, forked, workflow, observer sidecar), a revived cycle takes a waking run's word, `tightenTaskSpec` keeps it across an override that leaves it unset and refuses one that states anything else, and a governed workflow script cannot set it for its child (its baseline can) — stripping a deployment's standing instructions is not an untrusted layer's call.
|
|
818
|
+
- **The door.** Any other value refuses prepare with `config.invalid_argument` and the sentence `TaskSpec.projectMemory must be "disabled" when present (got …)`, before the seat or the model is called; a resume whose re-supplied config carries one is refused before its claim (`checkpoint.config_refused`, `detail.code` `config.invalid_argument`, `detail.field` `taskConfig`), the row stays pending. The door reads the key once, and that reading is the leg's seat (a getter that answers differently later is not asked again). The door is the deployment seats' one-word reader, now spelled per seat (`RunnerDeps.x` / `TaskSpec.x`); the five deployment refusals it already served are byte-identical.
|
|
819
|
+
- **Resume.** A resumed leg runs on the word its re-supplied config carries, like the request members that are not re-seeded from the row (`permissionMode` / `prePlanMode` / `shellGate` are, via the row's recorded gate); the checkpoint row keeps no copy of it.
|
|
820
|
+
- **Presence.** `WiringManifest.projectMemory` is present on every effective manifest a 7.55.0 engine mints: one word per state — `"on"` when the leg called the seat (what the seat answered is the deployment's), `"disabled"` when a seat is wired and the request turned it off, `"absent"` when no seat is wired, whatever the request said (`"absent"` is the manifest's existing word for a seat nothing supplied: `ask`, `question`). The member missing means an older engine or an external `deriveWiringManifest` caller that passed no fact. Never on the static half; outside `configFingerprint` (a per-task seat, the `fileHistory` reasoning). One value screen reads both this cell and `fileHistory`, each against its own words.
|
|
821
|
+
- **Who is hurt.** Nobody: optional members; a consumer ignores fields it does not know, and `schemaVersion` does not move. Wire typeKeys (`npm run gate:wire -- v7.54.0 HEAD`): `TaskSpec` +`projectMemory` (optional), `WiringManifest` +`projectMemory` (optional), `WiringFacts` +`projectMemory` (optional), `ParentSpecSeat` +`projectMemory` (optional).
|
|
822
|
+
- **Who else.** *server* ([9484], the pass-through half): carry the top-level request key to `TaskSpec.projectMemory` verbatim (the `outputStyle` shape) and advertise it by its capability bit; classify the new `wiring_manifest` section key. A leg that runs with the word does not call your loader, so your own `wiring_manifest.instructions` segment is absent on it, as on a leg with no loader. *cli* (L-891): `--safe-mode` sends the word; `--bare` sends it unless directories were explicitly given for their instruction files; read `manifest.projectMemory` — `"disabled"`: the run loaded no project instructions because the request said so; `"absent"`: the deployment has no project-context seat — to say which; KL0168 retires on arrival. *client-core*: type `projectMemory?: "on" | "disabled" | "absent"` on the manifest.
|
|
823
|
+
- **Pins.** `test/backlog1563-project-memory-off.test.ts` (the five cells — zero seat calls, no project layer in any request, zero probe polls, manifest `"disabled"` — beside the control on the same deployment; absent ≡ `undefined`, same bytes; the door's seven bad values; the door's one reading through a flipping getter, both ways; the manifest's three words as a leg fact over five seatings (a `null` seat reads as no seat, as before) and through the derivation; the parent spec seat, a real delegated child both ways, the clamp row's merge / script / revival laws; a resume both ways and a refused resume before its claim); `test/backlog1052-clamp-set.test.ts`, `test/prepare-wiring-manifest.test.ts`, `test/public-knob-liveness.test.ts` (rows for the new seat).
|
|
824
|
+
|
|
825
|
+
### (#2229) Security fix: a persisted rule bucket carries the rule grammar its rows were written in, and a bucket — or a rule-sync state — a newer grammar wrote is refused whole; while the person's persisted rules cannot be read, every call is asked about, whatever its tool (#2229; security; behavior narrowing, ruled C-R689, open to veto: a damaged or newer-grammar store now holds back calls of every tool, not only of the tools a rule grammar names; hard-breaking, open to veto: the bucket file and the sync wire gain a `grammar` member that an engine of this version judges — see Hard-breaking; closed sets: none — one new error code, `permission_rules.later_grammar`, on the open `code` face of the store's refusals; exports: +1 function `readRuleGrammarStamp`, +1 constant `PERMISSION_RULES_LATER_GRAMMAR_CODE`; members: +1 required `RuleSyncRequestBody.grammar`, +1 optional `RuleSyncResponseBody.grammar`, +1 optional `ToolCallFace.engineMounted`) (@server @cli @client-core @test)
|
|
826
|
+
|
|
827
|
+
- **Before.** A session-rules row carried its grammar stamp (an engine given a later one refuses every call); a persisted rule bucket carried none. The file backend's read face served rows as stored, never judging them by grammar, and the gate's matcher read a row in a form it does not know as reaching no call. A sync round judged rows in this engine's grammar: a LOCAL row it could not read moved into quarantine (out of the live view, back only through a new consent) and an INBOUND one was dropped. So a deny a newer grammar writes — the two the rule-grammar design brings, `Agent(<type>)` and a mid-command `*` — would stand in the bucket and refuse nothing on an older engine. Measured on the tree before this change, with a planted bucket: a `Bash(echo * main)` deny, and `echo x main` ran; one sync round moved that deny into quarantine. Separately, while the person's store could not be read (a damaged bucket), only calls of a tool a persisted rule grammar names (the shell, the path tools, `Skill`, `WebFetch`) were asked about; any other call ran.
|
|
828
|
+
- **Now.** (1) One stamp per bucket: every write of the file backend stamps `grammar: SESSION_RULES_GRAMMAR` — the number a session-rules row carries; a stored rule text is the `Tool(content)` entry the same parser reads, so one counter covers both stores. (2) One judge, `readRuleGrammarStamp` — the session store's stamp judge, now exported and read by both stores and the sync wire: every read of the bucket (`list`, `quarantined`, a write's read of its base in `nextDot` and `apply`, and `readRaw`, the sync round's local read) refuses a later grammar whole, before any row is judged, with `code` `permission_rules.later_grammar`. Reads answer zero rules under the store's existing unreadable law (disclosed), writes refuse, the bytes stay. A stamp that is no whole number is damage, refused whole without the code. A bucket without the member reads in this engine's grammar, and its next write stamps it. (3) The sync wire: the request states this engine's grammar (`grammar`), and a response stamped later refuses the whole round with the same code and zero local effects, so a newer grammar's row is never screened as damage here. (4) One "this reader cannot read it" law at the gate (C-R689): while a partition of the person's store is unread — damaged, or stamped later — the rule lane judges every call, where it used to answer nothing for a call no persisted grammar names. An allow becomes a real-approval ask (origin `rule_store_unavailable`, class `rule_unreadable`, bypass-immune), a deny or ask row of the run's last successful read still stands, and a headless run refuses. The run's `permission.rule_store_unavailable` notice now says every call is asked about, whatever its tool. The content-routing tools the engine mints stay outside the lane, as its question tool always was: the lane's skip seat now holds the class — the engine's question, its answer (`StructuredOutput`) and its blocked report (`ReportBlocked`), ruled C-R689 — since such a call performs no action and no stored rule governs it, so a run over an unread store keeps its answer and can still say it is blocked. The seat used to read the wire name; it now reads who mounted the tool — `ToolCallFace.engineMounted`, minted from the roster row (one of the engine's own mount sites mounted it: not a caller's `TaskSpec.tools` entry, not a protocol peer's tool), the one identity a caller cannot declare — and then which of the three definitions (the contract its row carries). A caller's tool mounted under one of these names (legal where the engine has not reserved it — `StructuredOutput` without an `outputSchema`, `AskUserQuestion` where the question tool is not mounted), or carrying one of their contracts, is judged like any other call (behavior narrowing, open to veto: before, a caller tool named `AskUserQuestion` skipped the persisted lane, so a store that could not be read never held it back). And a caller's `TaskSpec.tools` spec may no longer declare an engine contract by hand: a `core.` contract id on a spec must ride the contract object the engine's catalog minted for one of its definitions (`toolFace`; recorded when the run snapshots its roster) — an engine definition a host mounts by hand, such as the delegation tool under whatever name it declares, passes, and so does a spread of one (a wrapper keeps the object) — else the run is refused at mount with `config.tool_face_invalid` (behavior narrowing, open to veto: a hand-written `core.` id used to mount the spec as a built-in — its row's source `builtin`, its card namespace `builtin:<name>` — without being one; a host that wore an engine contract on its own tool declares an id of its own, or none). What a host cannot forge is the object, not the string; the public `defineTool` and `attachToolContract` still put any contract on a product, which the content-routing seat does not read.
|
|
829
|
+
- **Behavior narrowing (open to veto, C-R689).** *What changed:* while a person's store is damaged or was written by a newer engine, calls of tools no persisted rule grammar names — the delegation tool, a caller's tool, an MCP tool, the task-list tools — are asked about too, and a headless run refuses them; before, they ran. *Who is hurt:* a deployment whose person store is damaged or newer: every call but the engine's question, answer and blocked report asks (headless: is refused) until the store reads; and every call of such a tool costs one store read per call where it cost none (a file read; one query on a SQL store) whether or not the store is readable — on a store whose reads hang, each such call now waits the read's bound (14 s) before it is asked about. *How to migrate:* repair or remove the damaged bucket, or run an engine that reads the newer grammar; nothing changes on a readable store beyond the read.
|
|
830
|
+
- **Hard-breaking (open to veto).** *What changed:* the bucket file gains a `grammar` member and an engine of this version refuses a bucket whose `grammar` is above its own, so the next rule grammar revision (each of the two rule-grammar cars moves `SESSION_RULES_GRAMMAR`) makes every bucket a newer engine writes unreadable here; `RuleSyncRequestBody` gains a required member. *Who is hurt:* a deployment that downgrades across a grammar revision — the person's persisted rules stop applying (every call is asked about) until a reading engine runs again; an engine before this version ignores the member and reads the rows, where a newer grammar's deny protects nothing (KL-2229-a), and its next write drops the stamp; a host that builds a `RuleSyncRequestBody` value itself (a test transport, a twin's fixture) must add `grammar`. *How to migrate:* before downgrading across a grammar revision, rewrite newer-grammar deny rows into forms the older engine enforces (a whole-tool deny on a tool it names, a covering prefix deny), or keep the newer engine; a twin follows Consumers.
|
|
831
|
+
- **Consumers.** *server:* the SQL twin (`src/plugins/permission-rule-store-sql.ts`) keeps one grammar per bucket (a column — its `decodeBucketRow` already refuses a bucket holding a row the validator refuses; it judges the stored stamp with `readRuleGrammarStamp` first and throws with `PERMISSION_RULES_LATER_GRAMMAR_CODE` for a later one) and stamps `SESSION_RULES_GRAMMAR` on each write; a sync twin, when one exists (the SQL backend refuses `sync-join` today), states its grammar on the response and refuses a request stamped above its own; the downgrade sentence for its deploy prerequisites is KL-2229-a. *cli / client-core:* a store refusal carrying `permission_rules.later_grammar` reads as "saved by a newer version" (the way out is an upgrade), not "repair the file"; the card and the run's notice still say only "could not be read" (KL-2229-c). *test:* the downgrade cells below, read on a real store directory.
|
|
832
|
+
- **Unchanged.** The session rules store's law (its unreadable rules refuse every call); the store's damaged-row posture inside a bucket of this engine's grammar (a sync round still quarantines a damaged local row); the persisted store keeps no earlier-grammar reading — a revision that changes what an existing persisted text means owes the store one (stated at `SESSION_RULES_GRAMMAR`); the store-level stamp refuses a bucket a newer engine wrote even when every row in it reads here (KL-2229-b).
|
|
833
|
+
- **Pins.** `test/backlog2229-rule-store-grammar-stamp.test.ts` (S1–S2: every write stamps, an unstamped bucket reads and its next write stamps it; R1–R2: every face of a later-grammar bucket refuses with one code and leaves the bytes, a stamp that is no whole number is damage; R3, W1–W2: a sync round over a later-grammar bucket refuses before the transport and quarantines nothing, a response stamped later refuses the round and lands nothing, a response in this grammar or unstamped lands; L1–L4 on a real Runner: a newer engine's `Agent(general-purpose)` deny asks with an approver and refuses headless, its `Bash(echo * main)` deny refuses `echo x main` headless, a damaged bucket asks about a caller's tool, a headless `outputSchema` run over a damaged bucket still returns its structured answer and a headless run still ends `blocked` through `ReportBlocked`, a caller's own tool named `StructuredOutput` (no `outputSchema`) over a damaged bucket is refused headless and asked with an approver, a readable bucket leaves a caller's tool alone); `test/name-readings-oracle.test.ts` and its frozen oracle lose the two rows of the retired name-keyed skip seat; `test/backlog7340rj-unread-path-rows.test.ts` and `test/rev7340rm-rule-store-partitions.test.ts` re-pinned (the notice sentence); the per-fixture mutation table is in the car receipt.
|
|
834
|
+
|
|
835
|
+
### A rule refusal says what a person can do and names no coordinate of the library: the option, allow-list seat or spec field only a host program holds is the issue's optional `PermissionRuleIssue.libraryOption`, and `invalid.primary_field` no longer ends "Opt in with primaryFieldGeneric" (#1484; user-visible sentences changed in nine refusal arms; no rule is judged differently; hard-breaking: none; closed sets: none; exports: none — the issue's mint `ruleIssue` is module-internal; members: +1 optional `PermissionRuleIssue.libraryOption`) (@server @cli @client-core)
|
|
836
|
+
|
|
837
|
+
- **Before.** `invalid.primary_field` ended `Opt in with primaryFieldGeneric:"allow-lexical" if you understand this.` — a way out that exists only for a program that builds the rule policy itself. A serving layer's request refusal (HTTP 422) and a refused boot carry the sentence to a person who cannot set an option of the library, and the layer relaying it had two readings of one refusal. Four more arms did the same: the wildcard rule `*` (`` … or state the answer for every call no rule reaches with `defaultAction` ``), a declared path base (`` … the `pathBases.<word>` this policy declares … ``), a tool whose face states no vocabulary (`` … compile with the leg's `ruleFaces`, or write the rule on the whole tool ``), and the size caps (`N rules > maxRules 256`, `pattern exceeds maxPatternLength 256 or maxStars 16`). Three more named the host API's own seats, which a person writing settings reaches no better: an allow rule (`` … put the entry in `TaskSpec.allowedTools` (a request's) or `RunnerDeps.allowedTools` (the deployment's) … ``), a list that is no list (`TaskSpec.allowedTools must be a list of permission-rule strings …`, `RunnerDeps.allowedTools[1] must be a permission-rule string …`, and the thrown text's `TaskSpec.allowedTools: 1 invalid entry — …`), and an MCP rule with parentheses (`… use the toolAxes system`). The issue had no member for any of it.
|
|
838
|
+
- **Now.** One rule: a rule refusal's `message` says what a person can do with the rule and names no library coordinate: no key of `PermissionRulePolicyOptions` (nor a `caps` / `pathBases` entry), no allow-list seat of the host API, no field of the MCP server spec. The coordinate the refusal relates to is the issue's optional `libraryOption: { name, value? }`, present where the engine offers it as the way out of the refusal or names it as the declaration the refusal rests on (a rule set over its cap, a declared base that is no path, a list that is no list), and absent as a key everywhere else; every issue is minted at one place (`ruleIssue` — the allow-list screen's `invalid.entry_type` issues too). `name` is one of `primaryFieldGeneric`, `defaultAction`, `ruleFaces`, `pathBases.root` / `pathBases.cwd` / `pathBases.home`, `caps.maxRules` / `caps.maxRuleLength` / `caps.maxPatternLength` / `caps.maxStars`, `TaskSpec.allowedTools`, `RunnerDeps.allowedTools`, `toolAxes`; `value` is present only where one value is the way out (`primaryFieldGeneric` ⇒ `"allow-lexical"`). The sentences, with `<…>` the rule's own words (a host that freezes them copies these):
|
|
839
|
+
- `invalid.primary_field` (`libraryOption { name: "primaryFieldGeneric", value: "allow-lexical" }`): `` "<param>" is <Tool>'s primary field, and a rule on it matches the WHOLE value lexically — no shell or path semantics — so `<Tool>(<param>:git push*)` would NOT match `cd /x && git push`. Write the rule as `<form>`, or on the whole tool (`<Tool>`). `` — `<form>` is the content form the rule model reads on that tool, and only that: `Bash(<command>)` for `Bash` and for every other shell-face tool (`Monitor`: a command rule is written on the command word and reaches `Monitor` as it reaches `Bash`), `<Tool>(<path>)` for a path tool (`Read`, `Write`, `Edit`, `Grep`, `Glob`, `NotebookEdit`, `LSP`, `RepoMap`), `WebFetch(domain:<host>)` for `WebFetch`; a tool the catalog gives no content grammar (`WebSearch`, a host's own tool whatever face its leg mounts) is offered the whole tool only (`` Write the rule on the whole tool (`<Tool>`). ``). One line of the compiler, so the static policy, a leg's roster and the dry-run report answer it alike. Every spelling it offers compiles as a deny and as an ask and reaches the call the refused spelling meant (pinned, per tool); a rule written on another tool's name would not (a `Read` ask leaves a `Write` call alone).
|
|
840
|
+
- `invalid.tool_name` for `*` (`{ name: "defaultAction" }`): `` `*` names no tool — a rule reaches the tool it names, so this one would compile and never match; name the tool ``.
|
|
841
|
+
- `unsupported.path_base` (`{ name: "pathBases.<root|cwd|home>" }`, the declared base the rule reads): `"<rule>" is relative to <the working directory | the project root | the home directory of the execution environment>, and the one this policy declares for it is not an absolute path of its tree's family — the rule could not be judged on any call`.
|
|
842
|
+
- `unsupported.param_unknown` on a face that states no vocabulary (`{ name: "ruleFaces" }`): `` "<param>" cannot be judged on <Tool> here (<its parameters are not declared statically: each mount's schema is its own>) — write the rule on the whole tool (`<Tool>`). ``
|
|
843
|
+
- `invalid.cap_exceeded`: the whole set — `<N> rules — a rule set holds at most <cap>` (`{ name: "caps.maxRules" }`); a rule — the sentence is unchanged (`rule longer than <cap> chars`) and gains `{ name: "caps.maxRuleLength" }`; a pattern — `a pattern holds at most <cap> characters and <cap> stars` (`caps.maxPatternLength` when the length crossed, else `caps.maxStars`).
|
|
844
|
+
- `unsupported.allow` (`{ name: "TaskSpec.allowedTools" }`; the deployment's home, `RunnerDeps.allowedTools`, is said in words only — the member names one coordinate): `an allow rule does not belong to a policy — allow entries live on the allow layer (the request's allow list, or the deployment's); deny / ask rules stay here`.
|
|
845
|
+
- `invalid.entry_type` (`{ name: <the seat the value was handed at> }`, `TaskSpec.allowedTools` or `RunnerDeps.allowedTools`): `<the request's allow list | the deployment's allow list> must be a list of permission-rule strings (got <type>)`; `entry <i> of <the request's allow list | the deployment's allow list> must be a permission-rule string (got <type>)`; and the thrown text's prefix reads `<the request's allow list | the deployment's allow list>: <n> invalid entr(y|ies) — refusing rather than running without them:` (it read the seat key).
|
|
846
|
+
- `unsupported.mcp_paren` (`{ name: "toolAxes" }`): `` MCP rules do not support patterns in parentheses (CC parity); a rule names a whole tool (`mcp__<server>__<tool>`) or a whole server (`mcp__<server>`), and what a tool does is declared per tool in the MCP server's spec ``.
|
|
847
|
+
- **Behavior.** No rule is judged differently: the same rules compile, the same are refused, with the same codes in the same order, and `primaryFieldGeneric: "allow-lexical"` still reads the rule as a whole-string lexical match (pinned). The thrown error's text (`createPermissionRulePolicy: N invalid/unsupported rule(s): …`) carries the new sentences and nothing else.
|
|
848
|
+
- **Who is hurt.** A host or a test that freezes or pins one of the sentences above takes the new text (a serving layer's frozen sentence table, its refusal tests, a shell's copy). A host that read `primaryFieldGeneric` out of the sentence to decide whether to offer the opt-in reads `libraryOption` instead. A deep-equal of a whole issue (`{ rule, code, message }`) sees one more key on the arms above and none on the others. Nothing else changes for a host that passes the issue through.
|
|
849
|
+
- **Not changed.** Issue codes, their count and order; what a request or a deployment may hand over (`screenAllowedToolsValue`'s seats are the same two); the retired-`scope` refusal of `createAllowDenyPolicy` (`config.deps_retired_key`, which a program passing that option meets, not a person writing rules) and the remedy line of an approval question for an ungranted tool both still name `RunnerDeps.allowedTools` / `TaskSpec.allowedTools` — other surfaces than a rule refusal.
|
|
850
|
+
- **Consumers.** *server*: the two readings of one refusal retire — the request refusal and the refused boot both relay the engine's sentence (it reads `issue.message` at `src/config.ts:2494-2501` and the thrown text at `src/permission-rule-list.ts:75-100`; inferred from a read-only look at its checkout, not re-verified at its origin/main), which is now complete for a person; it may render `libraryOption` where it exposes the option, and its frozen copies of the sentences above change. *cli*, *client-core*: copy the sentences if a table holds them; a surface that shows an issue may show `libraryOption` as a hint to an operator, never to an end user.
|
|
851
|
+
- **Pins.** `test/backlog1484-primary-field-sentence.test.ts` (the new sentence verbatim on the dry-run report for eight tools, each with the form its grammar reads, on the static policy and its thrown text, on a real prepared leg's roster and a host resolver, whose own tool is offered the whole tool only; the member present on each arm that has a way out — the allow rule, the MCP rule with parentheses, the wildcard, three path-base words, a face that states nothing, the four caps, the length-over-stars order — and absent as a key on six arms that have none; the allow-list screen's two arms at both seats say the list in words and carry the seat, and the two seat sentences are pinned verbatim; the names are real keys (a type-level lockstep); a battery asserting no sentence or thrown text names an option; every spelling the sentence offers, read out of the message, compiles as a deny and as an ask and reaches the call it was meant for — a `Read` ask leaves a `Write` call alone; the opt-in still reads lexically); `test/backlog644-home-base.test.ts`, `test/backlog2207-unbound-rule-content-refused.test.ts`, `test/backlog2207-catalog-vocabulary-lockstep.test.ts`, `test/permission-rules.test.ts`, `test/backlog1327b-policy-allow-retired.test.ts`, `test/backlog1327-allow-entry-unit.test.ts` re-pinned to the new sentences; the per-fixture mutation table is in the car receipt.
|
|
852
|
+
|
|
853
|
+
### (#1517 car A) A delegated agent type is one identity for the delegation tool and its rules: `Agent(<subagent_type>)` is a rule in every behavior; the dontAsk refusal says the way out; an allow the allow layer made names who made it (#1517; behavior narrowing (configuration face), open to veto; behavior change on model-visible text, open to veto; differs from the reference implementation, open to veto: the allow reading of `Agent(<type>)` is this engine's; hard-breaking, open to veto: the session-rule grammar stamp moves 3 → 4, the parameter spelling `Agent(subagent_type:<type>)` is refused, the delegation tool's preparation names the plain delegation `general-purpose`; closed sets: `PermissionRuleIssue.code` +`invalid.name_ambiguous` (a type-only union); exports: none; members: +1 optional `ToolRuleFace.nameValues`, +1 optional `ToolCallFace.nameValues`, +1 optional `ToolRosterEntry.ruleFace.nameValues`, +1 optional `RuleFaceView.nameValues`, +1 optional `GateDisposition` (allowed) `clearedBy`; `ruleFacesFromRoster`'s answer gains `nameValues`; `SESSION_RULES_GRAMMAR` is 4) (@cli @client-core @server @test)
|
|
854
|
+
|
|
855
|
+
- **Before.** The delegation tool read its `subagent_type` one way — exact first, then the reference's fold, several matches refused — and every rule another: `Agent(Explore)` as a deny or an ask was `invalid.param_split` at compile, as an allow it read as nothing (`content_unsupported`), and a deny on the parameter spelling `Agent(subagent_type:Explore)` compared the raw string, so it missed `explore` / `EXPLORE` (which run `Explore`) and the plain delegation's two spellings (the preparation deleted an explicit `general-purpose`). The dontAsk refusal was one literal for every class (`… run under a mode with an approver, or change the call`). An allowed disposition said nothing of which allow rule cleared the ask.
|
|
856
|
+
- **Now — one identity.** One resolver (`canonicalNameValue`): an exact member of the mount's agent types first, else the reference's fold — one hit ⇒ that type, several ⇒ refused, none ⇒ refused. The tool's argument preparation writes the canonical type, its input check refuses an unresolved one before any gate (the sentence its execution returns: `Sub-agent not started: subagent_type "Code Review" is ambiguous — it matches code-review, code_review. Use the exact name.`), and execution reads the same resolver for a direct caller. The mount declares its agent types as data (`ToolRuleFace.nameValues`); the roster row and every call's face carry them; an `Agent(<type>)` rule resolves its value against the same set, in the rule policy, a person's stored rows, the allow layer's lists, a session's buckets and the card's offer. So `Agent(Explore)` reaches `explore`, `EXPLORE` and `Explore `; on a mount offering `code-review` and `code_review`, `Agent(code-review)` reaches `code-review` only; `Agent(codereview)` (the fold lands on both) is refused at compile on a leg's `ruleFaces` (`invalid.name_ambiguous`) and, without them, a deny / ask reaches both and an allow neither. A call whose face carries no types cannot be read against such a rule: a deny list and a stored deny row refuse it, the rule policy asks a person in the fail-closed form, no allow grants it. A workflow's `agent(…, { agentType })` step is not a delegation-tool call and is not reached (KL-1517-e).
|
|
857
|
+
- **Hard-breaking, the plain delegation is named (open to veto).** What changed: an omitted `subagent_type` (and an empty one) is read as `general-purpose` while no definition takes that name, and an explicit `general-purpose` is kept (it used to be deleted) — so a PreToolUse hook, a policy, a card and a rewrite read `subagent_type: "general-purpose"` on the plain delegation. Who is hurt: a policy or hook that recognises the plain delegation by an absent `subagent_type`; a test pinning the old fold. How to migrate: recognise it by `subagent_type === "general-purpose"`, or write `Agent(general-purpose)`.
|
|
858
|
+
- **Hard-breaking, one spelling of a type rule (open to veto).** What changed: `Agent(subagent_type:<type>)` is refused at compile (`invalid.command_rule`, naming `Agent(<subagent_type>)`) — it compiled as a rule on the raw string until now (and #2207's "Unchanged" bullet in this release still listed it). Who is hurt: a deployment holding one; under `onInvalidRule: "throw"` the policy is not built. How to migrate: write `Agent(<type>)`. A rule on another argument (`Agent(model:<model>)`, `Agent(isolation:worktree)`) is the generic parameter lane's, as before; `Agent(<type>:*)` and any `*` are refused (no prefix form; the reference has none).
|
|
859
|
+
- **Narrowing, a `<argument>:<rest>` deny or ask also reads as the agent type (open to veto; the reference's shape).** What changed: one rule for such a content — a tightening reaches the call under every reading of its text, a grant reads exactly one way. `Agent(model:opus)` as a deny or an ask is the rule on the `model` argument AND the agent type `model:opus` (an agent type may carry a colon), either reaching applies it — and every reader answers alike: the rule policy (compiled with or without a leg's `ruleFaces`), a deny list (a skill's `disallowed-tools`, an agent definition's), a session's `toolDeny`, and the person's stored rows. A stored row is one text, read as the type and, by the same argument predicate the rule policy's parameter lane uses (`paramRuleReaches`, the one reader of a `key:pattern` over a call's arguments), as the argument rule — so a person's store now takes the `Agent(model:opus)` deny it used to refuse as `refuses nothing as written`, and enforces it. An argument rule with a `*` (`Agent(model:op*)`) is the rule policy's only: a list or a store refuses it loudly, as before (a stored row's value has no `*` form). As an allow it is the argument rule only, which an allow list has no form for: it reads as nothing (`grammar`) and never grants the type `model:opus`. The reference reads the content as an agent type for its deny filter (`cms`) and as `key:value` for its generic parameter check (`te`, `Eh`) alike, over one rule set (all three at `(cli296.js:242324)`). Who is hurt: a deployment offering an agent type spelled `<one of the tool's arguments>:<…>` with a deny on that argument (the type is refused too); a host relying on such an allow to grant a colon-spelled type (KL-1517-f). How to migrate: write the whole-tool allow, or rename the type. An argument a host's own `Agent` tool adds beyond the catalog's is no argument of a rule's grammar (#2248): a compile with the leg's `ruleFaces` sees the face and refuses a deny / ask keyed by it (`unsupported.param_unknown`: `"<arg>" is an argument declared only by the Agent tool this leg mounts, which is not rule-readable (#2248) — …`; a narrowing of the configuration face, open to veto); a compile without them cannot see it and reads the type (KL-1517-g).
|
|
860
|
+
- **Behavior change, an empty type names no type.** What changed: `subagent_type: ""` reads as no type to every reader (the rule policy, the lists, the stored rows, the pre-claim edit check of a resumed approval), as it already did to the tool that runs it; where a definition takes the name `general-purpose`, every `Agent(…)` deny or ask refuses it, as it refuses the omitted type. The call face's agent types are its own frozen copy, so no judge of a call can change them for the next.
|
|
861
|
+
- **Narrowing, `Agent(<type>)` deny / ask is in force; widening, an allow grants (open to veto).** What changed: as above, in every behavior. Who is hurt: a deployment that removed such a deny because it was refused (it now applies — a tightening); one that wrote `Agent(<type>)` allow expecting it to do nothing (it now grants that delegation where something asks: an ask rule, a default-closed rule set, `auto`'s classifier). How to migrate: drop an allow you do not mean; a deny needs nothing.
|
|
862
|
+
- **Differs from Claude Code (open to veto).** The reference compares an `Agent(<type>)` rule's content with the agent type exactly — a deny on the raw requested type and again on the type its fold resolves to, `cli296.js:254811` — and has no allow reading of it: its delegation tool allows a call itself in every mode but `auto` (`cli296.js:254814`). Here the rule's value is resolved like the call's (`Agent(explore)` reaches `Explore`, a tightening), and an allow reads (it clears only what an allow rule may clear; it matters where something asks).
|
|
863
|
+
- **Hard-breaking, the session-rule grammar stamp moves 3 → 4 (open to veto).** What changed: `SESSION_RULES_GRAMMAR` is 4 — a row holding `Agent(<type>)` is written in a grammar whose engines read it as a rule. Who is hurt: an engine of grammar 3 (7.54.0, and 7.55.0 before this car) given a row this engine writes refuses every call on that session until an operator rewrites the row through it — the intended fail-closed reading, so a downgraded engine never reads an `Agent(…)` deny as nothing. How to migrate: none needed on upgrade (a grammar-3 row reads as before); a downgrade rewrites the session's rules through the older engine.
|
|
864
|
+
- **Who is hurt — a mixed-version window on the person's stored rows (#2229).** The person's rule store carries a grammar stamp from this release (#2229, the section above): every write stamps the bucket, and an engine of this release refuses a bucket a later grammar wrote. The window that stays is an engine BEFORE this release: it ignores the stamp and reads a stored `Agent(<type>)` (or `Agent(<argument>:<value>)`) deny row as nothing (KL-2229-a) — a fail-open window while such an engine shares one store with this one, or after a downgrade below it. Do not run an engine older than this release against a store holding such rows; before such a downgrade, rewrite each such deny in a form the older engine enforces (the whole `Agent` tool, or a deny of the delegation that covers it).
|
|
865
|
+
- **Behavior change, the dontAsk refusal says the way out (model-visible text, open to veto).** What changed: `Permission to use <tool> has been denied because this run is in the dontAsk permission mode (this call needed approval: <askClass>). IMPORTANT: you may try to accomplish this with other tools that would naturally be used for it … If you believe this capability is essential to complete the user's request, STOP and explain to the user what you were trying to do and why you need this permission, and let the user decide how to proceed. To let a call like this run: <remedy>.` — the reference's head and guidance, then the one remedy function's `dontAsk` arm (`headlessApprovalRemedy`): where the ask's row lets an allow rule clear it, `an allow rule covering this call answers it under dontAsk — add one in this tool's rule shape (\`<shape>\`) to the allow rules (a settings file's \`permissions.allow\`, or the request's or the deployment's allow list), or run under a mode with an approver` — where allow rules live, in a person's words and no seat of the library (#1484's law; a host reads the same answer off the disposition below and offers its own seat) — the shape read off the call's grammar by the one rule-shape speller (`ruleShapeOf`, which also writes a compile refusal's way out: a tightening on the tool's own word, a grant on the face tool — `Edit(<path>)` / `Read(<path>)` by the call's face, `Bash(<command>)`, `WebFetch(domain:<host>)`, `Skill(<skill>)`, `Artifact(action:<action>)`, `Agent(<subagent_type>)`, else the tool's name — never a rule minted for the call); otherwise `no allow rule answers this ask (<askClass>) — only a person can: run under a mode with an approver, or change the call`. The literal `refused by the dontAsk permission mode: … the mode answers no ask; …` is gone. Who is hurt: a host or probe matching the old sentence. How to migrate: read `tool_end.gate.disposition` (`deniedBy: "mode"`, `mode`, `askClass`, `classes`), never the sentence. A hook's own ask under dontAsk is still put to the approver (unchanged).
|
|
866
|
+
- **Additive, who cleared the ask.** An allowed disposition carries `clearedBy: { member, entries, omitted?, positions? }` when an allow-layer member cleared a standing ask and nobody answered it (`hook` / `deployment` / `persisted` / `skill` / `request`; entries sanitized and capped like `allowRuleShadowed`; `positions` on a list member — the indices of those entries in the list the run received, a string listed twice giving both); absent on a call nothing asked about, a mode's release or acceptance, the read-only reader's, the classifier's and the sandbox's allow, and a person's yes. `screenGateOutcome` reads it (I9: allowed arm only, no settlement, no release, a member of the closed set, positions only on a list member).
|
|
867
|
+
- **Consumers.** *cli*: render the engine's dontAsk refusal (retire the shell's own `dontAskDenyReason`); the L-1168 D-dontAsk cell reads this sentence; read `clearedBy` for "which rule let this run" and map `positions` onto the declaration it sent; a stored `Agent(…)` rule and a settings `Agent(<type>)` entry are read by the engine. *client-core*: the rule editor's grammar for `Agent` gains the type form in every behavior and refuses the parameter spelling; the generated disposition type gains `clearedBy`; refusal text pins turn. *server*: `tool_end.gate.disposition.clearedBy` is a new member of `GateDisposition` — the key-set guard there (`src/trace/core-keyset-guard.ts` `GateDispositionProjected`) is closed and turns red at pickup until the key is dispositioned (project it: closed words, a capped list of rule texts, a list of indices); a settings `Agent(<type>)` deny / ask now compiles (it was refused `invalid.param_split`), `Agent(subagent_type:…)` is refused; the session rules SQL twin stamps grammar 4 through the judge. *test*: T-A1 / T-A1b / T-A2 / T-A3 in the receipt.
|
|
868
|
+
- **Pins.** `test/backlog1517-a-agent-type-rules.test.ts` (A1-id the roster-aware identity on real Runner lanes — exact over fold, the ambiguous call refused before the gate, the ambiguous operand refused at compile and tightening both / granting neither statically; A1-ctx a face with no types — rule policy, lists, stored rows; A1 every behavior, the spellings, the default, the fork, a shadowed `general-purpose`, a host judging a call it did not read, the compiler's three refusals, a session `toolDeny`, the catalog vocabulary against the fullest mount; A2 the refusal on real lanes — a covered write, an unstated tool, an ask rule, a delegation — the shapes, one source; A3 `clearedBy` for all five members, positions, a capped entry, the absent cases, the screen, a real lane; A4 a `~/` allow under dontAsk with and without the environment's home; A5 the grammar); re-pinned: `test/backlog1974-judge-what-runs.test.ts` ③, `test/backlog1974b-keyless-and-unreadable-resume.test.ts` ③ ⑧, `test/backlog1318-skill-rule-cc-form.test.ts` ⑤; the per-fixture mutation table is in the car receipt.
|
|
869
|
+
- **Known limit (KL-1517-g, #2248).** An argument only the mounted tool declares — one the catalog's `Agent` row does not — is no argument of the rule grammar (the lane is the catalog's, C-R686). The two compiles know different facts and say so: a compile with the leg's `ruleFaces` sees the face and refuses a deny / ask keyed by it at compile (`unsupported.param_unknown`, C-R708 — the loud path); a compile without them cannot see it and reads `Agent(region:prod)` as the type `region:prod` — the type arm fires for that type, and the call's argument is not judged (not an error: a face the static path cannot know is not judged). The roster carrying mounted parameters as the one source is #2248 (7.56.0).
|
|
870
|
+
|
|
871
|
+
### A Bash rule may be a pattern — `Bash(git * main)` — in deny, ask and allow: a star anywhere but the two trailing markers is the reference's wildcard, matched against one simple command (#1517 car B; behavior widening, open to veto: an allow pattern grants what it covers; behavior narrowing, open to veto: a deny / ask pattern is in force; differs from Claude Code's shape, open to veto, six points, see below: an allow's first-word star and an interpreter-led allow pattern refused, the auto strip, a deny / ask pattern read per segment, an expansion read fail-closed, a chain pattern refused; hard-breaking: type only, open to veto — an exhaustive table over `PersistedRuleMatch` must name the new word; closed sets: `PersistedRuleMatch` / `PERSISTED_RULE_MATCHES` 9 → 10 (+`pattern`; a Bash row's match was one of three words and is now one of four; registered in `docs/CLOSED-SETS.md`); exports: none added or removed — `PERSISTED_RULE_MATCHES` gains a word; members: none) (@server @cli @client-core)
|
|
872
|
+
|
|
873
|
+
- **Before (v7.54.0).** A Bash rule had three forms: the colon-star prefix (`Bash(git push:*)`), the trailing space-star (`Bash(git push *)`) and exact. Any other `*` — `Bash(git * main)`, `Bash(* --no-verify*)`, `Bash(cat src/**/key)` — was refused `unsupported.wildcard`: a rule policy (`createPermissionRulePolicy` / `validatePermissionRules`) refused it as `invalid.command_rule` (a host refused the whole request), an entry list (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`, a skill's lists, a session's buckets) read it as nothing (`grammar`), the settings import skipped it, the card's edit face refused it.
|
|
874
|
+
- **Now.** One string, one form: a trailing `:*` is the prefix form, exactly one trailing ` *` the wildcard form, any other unescaped `*` the pattern form (`match: "pattern"`, `command` = the folded pattern as written, spelled back `Bash(<pattern>)`), none the exact form — read off the folded content, so the stored spelling reads back as the same row (`Bash(echo<TAB>*)` and `Bash(echo * )` are the wildcard `Bash(echo *)`), and a content that folds to the lone `*` (`Bash( * )`, `Bash( *)`) is `Bash(*)`, the whole tool, in every lane (one reading): the store refuses it as it refuses `Bash(*)`, an entry list and a session's bucket read it as the whole shell tool, a rule policy compiles it as the bare-tool rule, a name set (`createAllowDenyPolicy`) holds it as the name `Bash` — never a pattern. A pattern names ONE simple command and is matched whole: `*` is any text, newlines included; `\*` a literal star; a `/` `**` `/` run matches `/` or `/…/` (once its star has read text the closing `/` is required: `Bash(rm safe/**/key)` reaches `rm safe/key` and `rm safe/a/key`, never `rm safe/monkey`) — the reference's reading of the spelling (cli296.js:237460:1297). An allow (a person's stored row, an entry list) is matched against the command's spelling segment by segment — a compound is covered when every segment is, so `git push x && rm main` is not covered by `Bash(git * main)` although the whole line matches it. A deny or an ask (a rule policy, a stored row, the org layer, a deny list, a session's `toolDeny`) is matched against the words each program run receives — every segment, quotes read off, wrappers peeled (`cd x && sudo git push "--force"` is reached by `Bash(git * --force*)`); a word carrying an expansion is text the reader cannot see, so the reach holds when the pattern matches whatever it holds, is `not_reached` when no text could match, and is otherwise `unreadable` — the fail-closed ask (`git push $F`). The matcher walks the subject once against the set of live pattern positions (no backtracking). Refused: a pattern naming more than one command (`Bash(git * && rm *)`, `unsupported.wildcard`), a star inside a prefix body, a backslash other than `\*`.
|
|
875
|
+
- **Widening, an allow pattern grants (open to veto).** What changed: `Bash(git * main)` in a person's stored rows, an entry list or a session's `toolAllow` now admits the commands it covers (before: refused, or read as nothing — the call asked, and under `dontAsk` was refused); `Bash( * )` in an entry list grants what `Bash(*)` grants, the whole shell tool (before: read as nothing). Who is hurt: a deployment or person that wrote such an allow and relied on it granting nothing. How to migrate: remove the allow, or narrow it to a prefix or an exact rule.
|
|
876
|
+
- **Narrowing, a deny / ask pattern is in force (open to veto).** What changed: `Bash(<pattern>)` as a deny or an ask compiles and reaches every run whose words it matches, in every lane above; `Bash( * )` as a deny or an ask is the bare-tool rule (before: refused in a rule policy, read as nothing in a deny list). Who is hurt: a deployment whose settings held such a deny or ask — its requests were refused at the door before and now run with the rule in force; a command whose words carry an expansion where the pattern reads asks (the fail-closed answer) instead of running. How to migrate: nothing to change; review the deny / ask patterns written while they were refused.
|
|
877
|
+
- **Two allow floors, stricter than Claude Code (open to veto).** A star in the first word (`Bash(* --version)`) has no allow form (`unsupported.wildcard` — an allow names the program it grants; a deny or an ask may leave it open; a list reads the entry as nothing, with no interpreter re-read). A pattern headed by an interpreter or launcher (`Bash(python * x.py)`, `Bash(env * x)`) is refused by the store (`invalid.bare_interpreter_prefix`, as `Bash(python:*)` is), and a written list grants it — the colon-star form's law, unchanged. The reference accepts both as allows.
|
|
878
|
+
- **Auto mode strips more than Claude Code (open to veto).** In `auto` a pattern allow that could run a program the strip names (`node * x.js`, `python * x.py`, `npm * build` — `npm run build` is a run of `npm run` —, `sudo * restart`) stands down to the classifier. The program is the one the read station reads — past the launchers and wrappers in front of it (`nice python * y`, `timeout 5 python * y`, `command python * y`, `sudo -u root node * x.js`), by basename (`/usr/bin/python * end`), on the words a run receives (`npm "run" * build`) — and a program word the pattern leaves open (`doas * x`, `env * x`) could be any; the reference strips a rule by its trailing star only and keeps `node * x.js`. `git * main` and `nice git * main` are not stripped; neither is `uv run * x` (`uv` is no launcher the read model knows and no program the strip names).
|
|
879
|
+
- **Three more points that differ from Claude Code (open to veto).** A deny / ask pattern is read segment by segment only (the reference also matches a deny wildcard against the whole line when its answer would be ask, so `Bash(git * main)` denies `git push x; rm main` there and not here — no single run of `git … main` happens); a word carrying an expansion is the fail-closed ask (the reference matches the raw text: `git push $F` does not match `git * --force*` and runs); a pattern naming a chain is refused (`Bash(git * && rm *)`; the reference reads it as an allow that matches nothing and a deny that matches the whole line).
|
|
880
|
+
- **Same as Claude Code, stated (KL-1517-h).** An allow pattern's `*` is any text: it crosses words and `/` — `Bash(rm -rf /tmp/*/cache)` covers `rm -rf /tmp/a / /cache`, `Bash(cat src/**/key)` covers `cat src/x /etc/shadow /key` (the reference compiles `*` to `.*` the same way). An allow pattern grants what its text matches, not the shape it looks like; write a prefix or exact rule, or a path rule (`Read(…)` / `Edit(…)`) for the files.
|
|
881
|
+
- **Known limit, as for every Bash form (KL-1517-i).** A deny / ask pattern matches the program word as spelled: `Bash(git * --force*)` does not reach `/usr/bin/git push --force` or `./git push --force`, as `Bash(git push:*)` does not; deny the path-qualified spelling too, or the whole `Bash` tool. One reading of the program across deny and strip is #2254.
|
|
882
|
+
- **Across versions — who is hurt.** A session's rule row holding a pattern is stamped with this version's session-rule grammar, 4 — `Agent(<type>)` rules and Bash patterns, the same version: an engine of grammar 3 refuses the row (every call), and even read in grammar 3 a pattern entry reads as no rule (`grammar`), which in `toolDeny` refuses every call — fail-closed. A person's persisted rule bucket carries the grammar its rows were written in from this version on (#2229): an engine of 7.55.0 or later refuses a bucket a newer grammar wrote — fail-closed. An engine before 7.55.0 reads no stamp: it keeps a pattern row its file store reads, its matcher reads `pattern` as no form, and a deny pattern there does not fire; its sync screen drops the row. So the one fail-open window left is an engine before 7.55.0 against a store that holds pattern deny rows — a mixed-version replica set, a shared rule directory, or a downgrade below 7.55.0. How to migrate: keep every replica on 7.55.0 or later; before a downgrade below it, rewrite each pattern deny as a form the earlier engine enforces (a prefix deny that covers it, or the whole `Bash` tool).
|
|
883
|
+
- **Unchanged.** The prefix, wildcard and exact forms and what they reach; the suggestion face (its open form stays the trailing-star wildcard — a card never offers a pattern); the colon-star compound prefix stays the one spelling of a chain; `Bash(*)` is still the whole tool.
|
|
884
|
+
- **Consumers.** *server*: `PersistedRuleMatch` has one more word, so the transcription in `src/permission-rule-vocab.ts:45` and its compile-time pin against the type turn red on the upgrade (read the exported `PERSISTED_RULE_MATCHES` instead), the exhaustive card-edit match switch in `src/rules-consent.ts:888` turns red until it names `pattern` (a Bash command form — an edit may persist one), and the SQL rule store's and the rules route's `z.enum(PERSISTED_RULE_MATCHES)` accept the word once they read the export; `settings.permissions.deny` / `ask` patterns now compile in `createRuleListPolicy` / `validatePermissionRules`. *cli*: a settings entry written as a pattern is now read by the engine in every direction; the shell's own per-segment reading of these rules has an engine answer to defer to. *client-core*: no action (a card's offers never carry `pattern`; a content-form entry is sent as written).
|
|
885
|
+
- **Pins.** `test/backlog1517-b-bash-pattern-rules.test.ts` (G the four forms, the refusals, the rule policy's compile, the store's re-read screen; B1 allow coverage cell by cell — one segment, never across `&&` / `;` / `|` / a line break / a substitution / a redirection, a quoted connector is text, a compound covered segment by segment; B2 deny / ask reach — a chain, `||`, `sudo` / `env` / `nohup`, a quoted flag, a quoted newline, an expansion that a star absorbs, one that may be empty, a program-name expansion; B3 the two floors and the list's re-read; B4 the trailing-star form unchanged; B5 `\*` and the `/` `**` `/` run; B6 the auto strip (the program read past `nice` / `timeout 5` / `command` / `sudo -u root`, an open program word, `uv run` not); B7 an imported pattern lands in the store and judges the same, the card's edit face, the suggestion face mints none; X1–X4 the closing `/` of a `/` `**` `/` run, the strip on the run's words and the program's basename, the form read off the folded content, a leading expansion that may yield no word; W1–W6 `Bash( * )` / `Bash( *)` / `Bash(\t*)` answer as `Bash(*)` in the store, an entry list, a session's bucket, a rule policy (`> out` refused by the deny) and a name set (`createAllowDenyPolicy`), `Bash(**)` stays a deny-only pattern; R1 a real Runner under `dontAsk` with `allowedTools: ["Bash(touch * marker)"]`; R2 a real Runner under `bypassPermissions` with a rule-policy deny pattern through a chain and a wrapper); re-pinned: `test/backlog490-cc-wildcard-import.test.ts` (a mid-pattern entry imports), `test/backlog1468-k1-vocabulary-twins.test.ts` (`pattern` is named as pending the server's adoption, apart from its transcription; a pending word the transcription already has is red); the per-fixture mutation table is in the car receipt.
|
|
886
|
+
|
|
887
|
+
### (#2159) On a target device, an off-target read the exit refuses no longer sets the off-target-read bit, and a session record that cannot be written is reported once per state; (#2160) as filed does not reproduce — its true shape is KNOWN-LIMITS KL-469-c, left to a design ticket (security, device lane; behavior change: fewer asks and fewer notices, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none; signatures: none) (@server @cli @client-core @test)
|
|
888
|
+
- **Widening (open to veto; r18).** A device call whose exit is judged while a sibling's off-target read is still writing its own record is allowed; it used to be asked about as unconfirmed. Nothing read beyond the device has reached a model context at that point (the read has not been let run), so this is not a fail-open; a slow child store widens the window (lane-level pin; no slow-store cell yet).
|
|
889
|
+
|
|
890
|
+
- **Before (v7.54.0).** An off-target read (a call whose legs reach beyond the device to read) set the tree's off-target-read bit at its exit *before* its session record was written; when that write failed on a governed (`multi_tenant`) tree the read was refused and never ran, but the bit stayed, so the next device call (`Bash`, `Write`, …) was put to a person for a read that never happened. `config.cross_boundary_record_unwritten` was minted per failed write: under `single_user` with a failing store, the state `{armed, observed}` was reported at the read's exit and again at the leg's settle; on a governed tree the prepare's state and the settle's same state were each reported.
|
|
891
|
+
- **Now.** The bit a call owes joins the tree when its exit lets it run — after the record that carries it is written, before the call runs; a call the exit refuses sets nothing. A record that cannot be written and refused nothing is reported once per state: a write of the same state that fails again (an exit's, then the leg's end) mints no second notice; a state that grew is reported again. On a tree that is not governed the read still runs and still sets the bit when its write fails (no judgment reads that record).
|
|
892
|
+
- **#2160 as filed does not reproduce; what is true is this.** No writer lowers the off-target-read bit: the record reader joins every record on the branch, and a leg's tree and its recorded state only grow. In the reported shape (a background child reads beyond the device while the parent's settle record is in the store; the settle's first record lands, its catch-up record fails), the bit never reached any carrier the next leg reads — the next leg is a new tree, starting from the parent session's newest record, which holds the state before the child's read and has no content after it. Measured on the test's g2c shape, the same before and after this change (and as the test read v7.54.0): at the next leg's prepare the parent's branch holds the prepare's record, the leg's content, then the settle's first record `{armed, governed}` with no entry after it; the next leg's device `Bash` runs without a person (inject), where the catch-up that landed `{armed, observed, governed}` puts it to one (control). Covering only up to the last record written changes nothing here — that record already sits after all the content. This is the same root as the accepted half of KNOWN-LIMITS KL-469-c (a child's bit set after its parent's leg ended): both are a governed leg ending while another lane of its tree is still live. The row now states both forms; they close together with the design ticket that makes such a leg end leave its content uncovered, so the next leg reads it as off-target through the reader that exists (live lanes tracked per tree; a narrowing — after a background child, the next leg's device calls go to a person; open to veto). The not-governed sentence of `config.cross_boundary_record_unwritten` adds that a failure of the same state is not reported again.
|
|
893
|
+
- **The notice tells the truth about the gap (behavior unchanged — v7.54.0 already let this through; open to veto).** What changed: on a governed tree, a failed write whose earlier record of the same write landed (the catch-up arm — the shape above) mints its own sentence: the catch-up record that carries the tree's newer bits has not been written; until #2160 lands the next leg may read the content before that earlier record as covered and may not route its device calls to a person (KNOWN-LIMITS KL-469-c); the terminal shape is fail-closed. Every other governed failure keeps the sentence it had (the next leg reads the content after the newest record as off-target, so its device calls go to a person — true there). Who is hurt: an operator who read the old sentence on this arm as a protection that was not there. How to migrate: nothing; treat that sentence as an open gap until #2160's ticket ships.
|
|
894
|
+
- **Widening (open to veto).** What changed: on a governed target tree whose store fails the record of an off-target read, the device calls after that refused read run on the policy's answer as they would have before it — no `cross_boundary` ask. Who is hurt: nobody by the read (it never ran); a deployment that counted on that extra ask as a signal of the failing store reads `config.cross_boundary_record_unwritten` instead (minted at the prepare or settle that failed; a governed exit's refusal is its own report). How to migrate: nothing to do.
|
|
895
|
+
- **Fewer notices.** What changed: one `config.cross_boundary_record_unwritten` per state a leg could not write; `detail.phase` names where that state first failed. Who is hurt: a projector or test that counted one notice per failed write (the test's g2 S1 shape reads two, `prepare` and `exit`, where it read three). How to migrate: count states, not writes; the not-governed sentence gained a clause — match on `code` and `detail`, not on the sentence.
|
|
896
|
+
- **Unchanged.** The obligation table, the station, both exits' refusal rule, the record's shape and reader, the suspend's refusal of its row, the settle's catch-up writes, and the host lane (a tree that never armed writes, traces and reports nothing).
|
|
897
|
+
- **Consumers.** *server* / *cli* / *client-core*: no code reads the notice's text or counts it (checked against their present checkouts: no `config.cross_boundary_record_unwritten` reference in their sources); a notice projector that rows it by `code` is unaffected. *test*: two cells — g2 S2-governed-fail2nd (after the refused off-target read, the device `Bash` runs with no `cross_boundary` ask; v7.54.0 asked once) and g2 S1-single-failall (two record notices, `prepare` and `exit`; v7.54.0 three); g2c#inject reads as before (KL-469-c; its design ticket carries the cell that turns it).
|
|
898
|
+
- **Pins.** `test/backlog2159-cross-boundary-bit-on-run.test.ts` (the lane: a refused read sets nothing and the next device exit is not a person's, with the healthy-store control asking; not governed, a failed write still runs the read and sets the bit; reports once per state, a grown state reported again; on a Runner: g2 S2-governed-fail2nd, its healthy control, g2 S1-single-failall; g2c inject / control with a background child and a forced order — inject pins what the store holds, every parent record `{armed, governed}`, one settle notice). `test/design469-r3-launch-gate.test.ts` P18 single_user re-pinned (the settle's failure of the exit's state is not reported again), and its concurrent-exit pin split in two: a sibling's read let run while a device exit waits on a record it owes refuses the device call (the wait is now a real owed write), and a device call leaving while the sibling's own record is still being written leaves allowed — that read has not been let run; `test/design469-r3-boundary-legs.test.ts` reads the not-governed sentence's new clause.
|
|
899
|
+
|
|
900
|
+
### (#2117) A pattern segment of a read-deny row or a sensitive-path pattern is matched by the engine's `*` glob instead of an expression built from it, so stars a literal apart (`*a*a*a*b`) no longer take minutes to judge (src only; security hardening; matching result unchanged: every pattern accepts and refuses the same segments; hard-breaking: none; closed sets: none; exports: none; members: none) (@server)
|
|
901
|
+
|
|
902
|
+
- **What changed.** `compileSegmentPattern` (`src/tools/fs/read-deny.ts`), the one compiler behind the read-deny rows, a task's or deployment's read-deny additions and the sensitive-path write list, built one regular expression per pattern segment. v7.54.0 folded adjacent stars into one `[^/]*`; stars a literal apart still backtracked, with the number of stars as the degree. Measured on this tree before the change, against a segment of `a`s that never reaches the final `b`: fifteen stars (`(*a)×14 *b`) against 28 characters took 1.1 s, seventeen against 32 took 23 s, a read-deny table of eight such rows 101 s, and seventeen stars through the sensitive-path policy's file check 35 s on a loaded host. The match runs synchronously on every judged path, on the shared event loop, so a pattern any layer can write was a denial-of-service lever. A compiled segment is now a small matcher over `wildcardMatch`, the `*` glob the rule grammar already uses (`src/core/namespaced-rule-name.ts`, a package-root export whose signature and answers do not change): `*` stands for any run of characters but a separator, every other character for itself, and no expression is built from the pattern — the escape set is gone with it. The same shapes take well under a millisecond (the table of eight rows, 0.1 ms). `wildcardMatch` itself no longer walks character by character: it checks the literal before the first star and the one after the last at the two ends and puts each literal between two stars at its earliest place with the engine's native substring search — measured on this tree, one entry of a star and a literal tail of about 250 characters judged against a 65 536-character segment took 534 ms walked and 1.9 ms placed (an expression: 0.3–1 ms), and sixteen such entries over twelve 4 096-character segments 4.9 s walked, 4.7 ms placed (an expression: 4.8 ms); the pins compare it with the walk over 3.6 million pairs. Its cost is at most the product of the pattern's and the segment's lengths and in no case a function of how many stars there are or how they are arranged; a 100 000-character segment against sixteen separated stars is judged in one pass of its length. `SegmentMatcher` (`test`, `anything`) replaces `RegExp` as the type of `CompiledSegmentPattern.segments`; the one reader that looked at the compiled source (`isAnySegment`, whether a segment is "any one segment") reads `anything`. `CompiledSegmentPattern.fold` (`"none" | "ascii" | "unicode"`) replaces `foldAscii`: the pattern is folded when compiled and a path's segments once per path, by the table `SEGMENT_FOLDS`. None of `SegmentMatcher`, `SegmentFold`, `SEGMENT_FOLDS` or `ignoreCaseFold` is a package-root export (`export-surface` green).
|
|
903
|
+
- **Matching result unchanged.** A segment containing a separator never matches, as before. The "unicode" fold (the sensitive-path write list's, kept answer for answer) used to be the `i` flag; it is now the fold that flag applies — one UTF-16 unit at a time, the upper-case mapping when it is a single unit that does not step from a non-ASCII unit into ASCII — and was compared with the engine over all 65 536 × 65 536 pairs of units: 0 disagreements. It is applied to the pattern when it is compiled and to a path's segments once per path, with the path's other readings (the ASCII fold's way), and the glob then compares exactly. The pins compare the compiler with the compiler as it first stood (one `[^/]*` per star) pair by pair: 22 680 enumerated pairs and a seeded random corpus under all three folds, 20 hand-written readings of a glob whose literals may not overlap, and the fold unit for unit.
|
|
904
|
+
- **Who is hurt.** Nobody: a match is the same answer, and the shapes measured are as fast or faster, the case-insensitive fold of the sensitive-path write list (macOS and Windows hosts by default) staying at v7.54.0's cost: a 24-segment path of non-ASCII segments through 40 rows takes 0.26–0.34 ms with the fold and 0.20–0.25 ms without, where v7.54.0's `i` flag took 0.18–0.43 ms and 0.17–0.44 ms (one host, interleaved runs); a fold repeated inside every test would have taken 1.5–11 ms on the same shapes, which is why the segments are folded once per path. *server* (`@server`): the request-side star caps in `src/tighten-only-seats.ts` (`MAX_READ_DENY_STARS_PER_SEGMENT` = 2, `MAX_READ_DENY_STARS_TOTAL` = 16, and the paragraph in `docs/ASSISTANT-WIRE-CONTRACT.md` that says the engine makes each `*` a backtracking point) rest on a cost this version removes from the engine; once a deployment runs this version they can go, and a request with more stars is then accepted (the entry-count and length bounds are unrelated). Read in the local checkout of server's `origin/main` (e9d9ccf0), not re-fetched. *cli* / *client-core*: nothing to do.
|
|
905
|
+
- **Pins.** `test/backlog2117-read-deny-star-fold.test.ts`: F1a–c the pair-for-pair comparison (subjects now include the units whose case mapping is not ASCII), F1d no segment is an expression and `anything` is true of exactly the segments of stars, F2a–d the runs of stars of v7.54.0, F4a–g separated stars through both consumers (fifteen and twenty-five stars, a table, the sensitive-path policy, a 100 000-character segment, the overlap and order readings, and the glob's worst shape), F5a–b the fold unit for unit, F6a–c the glob against the character walk it replaced (every pattern up to six characters over {a, b, *} against every value up to seven, a seeded random corpus, the readings a placement-based glob can get wrong), F7a–b the case-insensitive fold costs a deep path one fold of its segments — a count through `SEGMENT_FOLDS.unicode`, no clock: forty rows over a 24-segment path fold as many segments as one row does, and a second judgment of the path folds none — and the folded rows still fold; `test/workflow-governance-tightening-keys.test.ts` adds sixteen entries at the 256-character length bound over a deep path; the clocks are upper bounds (`< 50 ms` ×5, `< 200 ms` ×3, `< 1000 ms` ×2) declared in `test/flaky-guard.test.ts`, each new one the fastest of three runs, and each cost pin reads the kind of the compiled segment before its clock, so a regression to an expression fails at once instead of hanging the file. Mutations (each reverted): the separator guard dropped, a lower-case fold, the step-down-into-ASCII guard dropped, the single-unit guard dropped, the unicode fold never applied, `anything` always false and too wide, the v7.54.0 compiler restored, and in `wildcardMatch` the character walk back, a middle literal allowed into the tail's room, head and tail allowed to share a character, the latest placement instead of the earliest, and the fold repeated per row (the count 936, not 24) or not applied to the segments at all — each red in the pins named for it.
|
|
906
|
+
|
|
907
|
+
### (#2117) A workflow script's deny pattern may hold several `*` in one path segment: the governed child spec's per-segment wildcard cap is retired (src only; behavior loosening, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
908
|
+
|
|
909
|
+
- **What changed.** `buildGovernedChildSpec` read a script's `readDenyPatterns` (`src/core/child-clamp-seats.ts`) and refused an entry with more than one `*` in a path segment (`*a*b`, `a/*b*c/d`) — `has a path segment with N "*" wildcards — a workflow script's deny pattern allows at most 1 per path segment`. The cap existed because the segment matcher backtracked per star; with the previous section's glob it guards nothing, and a cap whose reason is gone is a rule to explain, not a bound. The entry-count (16) and length (256 characters) bounds are unchanged. A script's deny pattern the engine refused before is accepted now; the list is deny-only, so a pattern can only deny more.
|
|
910
|
+
- **Who is hurt.** Nobody: a refusal turned into an acceptance in an add-only list. A host or a script author that matched the old refusal text finds it gone. *How to migrate:* nothing; entries written as several one-star entries to get around the cap keep working.
|
|
911
|
+
- **Pins.** `test/workflow-governance-tightening-keys.test.ts`: several stars in one segment are admitted and the admitted entry is the entry the child's matcher judges (not a trimmed one); sixteen entries of stars a literal apart over a deep path and over a 65 536-character segment are judged in under a second, the matches kept; the hostile-element pins (an own `map`, a Proxy, a shifting accessor) read the length bound now, the refusal that remains.
|
|
912
|
+
|
|
913
|
+
### The leg's effect table follows the member that answers to each name after every mount and unmount, so a selector that keeps a caller tool by its alias and unmounts the engine's same-named tool no longer leaves the engine's effect behind (#2107; safety narrowing and one widening, both open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli)
|
|
914
|
+
|
|
915
|
+
- **Before (v7.54.0).** A caller tool named like an engine tool and carrying an alias the leg's selector names (`Read{effect: "write", aliases: ["X"]}` with `allowTools: ["X"]` on a full-hands leg): the engine's `Read` mounted later and its `read` was written under `Read`; the selector then unmounted the engine instance (it does not answer to `X`) and kept the caller's, and the table kept `read`. Measured with a real Runner under a session `allowDirs` confinement: the caller's write tool ran (1), called as `Read` and as `X`; the same tool under a name of its own was refused (0). A caller tool that declared nothing there went out on its roster row as `read` (the row folds the table for an undeclared tool). The reverse shape — a caller `TaskStop{effect: "read"}` kept the same way — was refused on the engine's `write`. The alias `X` itself had no entry.
|
|
916
|
+
- **Now.** One rule in the roster builder, run after every mount, unmount and domain refresh over the names the change can have moved: each holds the declaration of the member the dispatcher resolves it to on the live set — the same resolution the roster's rows are minted by, and the declaration read off that member as its row reads it. A member that declares no effect leaves no entry (every reader takes write), except an MCP / A2A tool, whose effect is the leg's protocol fold; a name nothing answers to has none. A name never reads a member that does not answer to it. The safety scan no longer writes the table (its caller declarations feed the wake reconcile's pre-mount reading only), and the end-of-prepare sync that filled missing entries is gone (it could no longer fill anything). The three safety axes (irreversibility tier, probe, egress) are unchanged: they stay keyed by name — they do not yet follow the member that answers to a name (#2266, 7.56.0; the two behavior changes of this section are open to veto under C-R714).
|
|
917
|
+
- **Safety narrowing (open to veto).** (1) **What changed:** in the shape above the caller's tool reads its own declaration — declared `write` or nothing ⇒ `allowDirs` refuses it, `verify.wroteThisRun` counts it, an interrupted call re-read by its raw spelling is UNKNOWN, and an undeclared tool's roster row says `write` (provenance `default`). (2) **Who is hurt:** a deployment whose own write tool shares an engine tool's name and is kept by an alias, and that relied on it passing `allowDirs` as a read. (3) **How to migrate:** declare the tool's real `effect`, or give it a name of its own.
|
|
918
|
+
- **Widening (open to veto).** (1) **What changed:** the same shape with a caller tool that declares `read` under an engine write name now reads `read`: `TaskStop{effect: "read", aliases: ["X"]}` with `allowTools: ["X"]` is admitted under `allowDirs` (0 → 1), as its roster row always said. A tool under a path-confinable catalog name (`Write` / `Edit` / `NotebookEdit`) is still refused there when it declares no path target: that refusal is the confinement's own. (2) **Who is hurt:** a deployment that relied on the unmounted engine tool's `write` to refuse its own read-declared tool. (3) **How to migrate:** declare `effect: "write"` on it.
|
|
919
|
+
- **Safety narrowing, a deferred name's direct call (open to veto).** (1) **What changed:** a deferred name's placeholder resolved the tool its direct call runs (and its scheduling and static face) as the FIRST instance mounted under the name, while the harness — once the name is activated — and the effect table read the later one. With `deferTools: ["Read"]` and a caller `Read{effect: "write"}` on a full-hands leg, a direct call ran the caller's write tool while the table said `read`: `allowDirs` admitted it (1). Now the placeholder resolves the instance the harness dispatches the name to (0: the engine's `Read` answers, and the call's arguments are judged against its schema). (2) **Who is hurt:** a deployment whose deferred caller tool shares an engine tool's name and relied on direct calls reaching its own. (3) **How to migrate:** give the tool a name of its own.
|
|
920
|
+
- **Cost.** The rule settles, after a mount, only the names that mount can move (its name, its aliases, and the aliases of the member it displaces) and, after a removal, every name; one index per change resolves them (`toolNameIndex`, the indexed form of `findToolByName`'s rule — `findToolByName` is now defined through it). Measured, 2 048 distinct tools mounted one by one: v7.54.0 0.48 s, this change 1.2 s (an earlier draft that settled every name per mount took 86 s with aliases). With one alias on every tool the same mount sequence takes 1.1 s on the previous release and 4.3 s here (measured); `findToolByName` now builds its index per lookup on the dispatch path (not measured on the loop; KL-2107-a).
|
|
921
|
+
- **Unchanged.** Plan mode already judged a call by the tool that answers (no reading change). The wake reconcile's pre-mount reading. Which tool answers to a name when a caller tool and an engine tool share it (the later mount; #1434 is that question).
|
|
922
|
+
- **Consumers.** *server* / *cli*: a roster row for an undeclared caller tool in this shape now says `write` / `default` where it said `read`; a display or a server-side check reading the row sees the tool's own word. No wire key or type changes.
|
|
923
|
+
- **Pins.** `test/backlog2107-alias-effects-identity.test.ts` (F1 the ticket's shape, by name and by alias, table = row; F2 an undeclared tool: no entry, row write / default; F3 the reverse shape; F4 controls; F5 plan mode, a control green on v7.54.0 too; F6 the alias answers to the caller again; F7 the table equals the roster for every row and alias under alias-keeping selectors; F9 a deferred name's direct call runs the instance the harness dispatches to, and a deferred caller tool under its own name still runs; F8 a protocol member's fold survives an unrelated unmount and a domain refresh, a roster of caller tools alone carries their declarations, a displacing mount moves the displaced member's aliases at once, and a caller member that declares nothing never keeps another member's entry). Re-pointed: `test/name-readings-behavior.test.ts` and `test/backlog126-definetool-axis-carry.test.ts` read the scan's alias mirror in its wake reading (the scan returns the table empty); `test/design388-write-fences.test.ts` pins the builder's rule where it pinned "a caller mount never writes" (the post-scan mutation that guarded against is closed by the frozen roster face, pinned on the real lane by `test/prepare-task-phase-pins.test.ts` T1).
|
|
924
|
+
|
|
925
|
+
### Two more store vocabularies on the package root — the rule offer's `single` / `batch` and the denial bound's `consecutive` / `total` — and an owner read from data has one reading, `readRuleOwner` (#2102, #2103; #1468 stage one; behavior narrowing, open to veto; hard-breaking: the root type `AdoptionSource` is removed, open to veto; closed sets: two newly registered sets, `RuleOffer["kind"]` and `DenialLimitBound` — no word added to or removed from any set; exports: +5 / −1 — +4 runtime `RULE_OFFER_KINDS`, `DENIAL_LIMIT_BOUNDS`, `isDenialLimitBound`, `readRuleOwner`, +1 type `DenialLimitBound`, −1 type `AdoptionSource`; `RuleOwner`, `RULE_OWNER_KINDS` and `sameRuleOwner` move to `src/core/rule-owner.ts` with their names and values unchanged) (@server)
|
|
926
|
+
|
|
927
|
+
- **Before (v7.54.0).** The engine published the offer discriminant and the denial bound only as types, and its own readers spelled the words again: the consent record check (`offer.kind !== "single" && offer.kind !== "batch"`), the offer read-back judge, the stored-fallback reader and the decision receipt's value fence. An owner read out of data had five readings: the rule-directory adoption marker accepted a `principal` source with an empty name, the root adoption marker refused it, the root adoption entry checked the name and the kind in two statements (reading `from.kind` before checking `from` is an object), the rule-directory adoption entry checked nothing, and the consent lane read any `kind` other than `principal` — a stored record's `owner`, a caller's `owner` option — as the local owner. The owner equality was written three times, and `AdoptionSource` was a second definition of `RuleOwner`.
|
|
928
|
+
- **Now — the words.** `RULE_OFFER_KINDS` (`single`, `batch`) is the one spelling of both offer unions' discriminant — the card offer `RuleOffer` and the consent record's option form, which stores the same choice by candidate index; four compile fences bind both unions to it in both directions. `DENIAL_LIMIT_BOUNDS` (`consecutive`, `total`) is the bound that tripped; `DenialLimitBound` is derived from it, `DenialLimitCounts.limit` is typed by it (the same two words), and `isDenialLimitBound` is its membership. Every engine reader above reads the tuple; each judges every plain value as the spelled-out comparison did (pinned value for value; a member held by an accessor, see (6) below).
|
|
929
|
+
- **Now — one owner reading.** `readRuleOwner(raw)` (package root, beside `RuleOwner`, `RULE_OWNER_KINDS` and `sameRuleOwner` in `src/core/rule-owner.ts`) answers an owner only for a plain record (prototype `Object.prototype` or none — members inherited from a prototype other than `Object.prototype` are not read) whose `kind` is a member and, on the `principal` arm, whose `principal` is a string that is not empty or whitespace-only (kept as written, never trimmed) — a fresh copy holding only those members — and `undefined` for anything else. Both adoption markers' readers, both adoption entries' request checks, a stored approval record's owner and the consent entries' `owner` option read through it; `sameRuleOwner` is the one equality. An adoption entry therefore writes only a source its marker's reader reads back. Not yet (open, listed for the next car): the store's `addPersistedRule` / `removePersistedRule` target, `resolveOwnerStore` / `effectivePermissionRules` and `syncPermissionRules` dispatch on a typed `owner` without it — an `owner` whose `kind` is not `local-owner` takes their principal path.
|
|
930
|
+
- **Behavior narrowing (open to veto).** What changed: (1) a stored approval record whose `owner` is not an owner owns nothing — confirm answers `record_not_found` and a redemption refuses it as another principal's (before: `{ kind: "bogus" }` was the local owner's record and the local owner could confirm it; `null` threw a `TypeError`); (2) a caller's `owner` option that is not an owner is refused before any record is minted, `<entry> requires a verified principal or the local owner — …` (before: an unknown kind minted into the local-owner bucket); (3) a rule-directory adoption marker whose source names a principal name that is empty or whitespace-only is a marker without a readable shape — owner resolution over that directory refuses until it is repaired (before: read as a principal source); (4) `adoptFilePermissionRuleStore` refuses a source that is not an owner before writing anything (before: it completed an adoption from an empty or whitespace-only principal name and wrote the marker of (3); an unknown kind threw a `TypeError` from the file-name hash); (5) `adoptLocalDataRoot` refuses `from: null` with `adoption_bad_request` (before: a `TypeError`), and its three source refusals are one sentence; an owner is an object, so a function carrying a `kind` is no owner on any of these faces (the two marker readers already refused one; the consent `owner` option read its `kind`); (6) `readDenialLimitFallback` (the inbox summary's read of a parked fallback) reads each member once and answers the values it judged (before: `limit` was read once per word compared and again for the answer, so a member held by an accessor could be answered with a word it was not judged on), and reads a plain record only — members inherited from a prototype other than `Object.prototype` are not read (before: a member on the value's prototype, or a class instance's, read as the fallback's); a stored record's owner is still read before its `principal`, each once; (7) a principal name that is empty or whitespace-only (`" "`, `"\t"`) is no owner in any spelling — the `owner` arm, the consent entries' v1 `principal` parameter and a stored record's v1 `principal` field (before: only `""` was refused; `" "` named a bucket — both adoption entries adopted from it and wrote their marker, and a consent caller `" "` minted and confirmed its records); (8) members inherited from a prototype other than `Object.prototype` are not read: an owner is a plain record, so a value whose `kind` or `principal` comes from its prototype, or a class instance, is no owner on any of these faces (before: read through the prototype chain — a live store's record whose owner was `Object.create({ kind: "local-owner" })` was the local owner's, and such a caller `owner` minted). Who is hurt: a host whose store holds such a record or marker — written by hand, by a foreign writer, or (for the marker) by an `adoptFilePermissionRuleStore` call with an empty or whitespace-only principal name, an entry with no caller in sema-server, sema-cli, sema-client-core or sema-test origin/main; a JavaScript caller passing a malformed `owner`. Compensation: the refusals are loud and name the shape; a marker is repaired by hand (the refusal asks for that) — its `from` names the bucket's real source — and a record by re-triggering its card.
|
|
931
|
+
- **Hard-breaking, `AdoptionSource` is removed (open to veto).** What changed: the package root no longer exports the type `AdoptionSource`; the root adoption marker, its report, `AdoptLocalDataRootOptions.from` and `AdoptionStatus` type their source as `RuleOwner` (the same shape). Who is hurt: a TypeScript consumer importing `AdoptionSource` from the package — none in sema-server (`e9d9ccf0`), sema-cli (`8ff3383b`), sema-client-core (`55af349`) or sema-test (`7d4878db`) origin/main (the server's `AdoptionSourceSchema` in `src/adoption/wire.ts` is its own form, unaffected). How to migrate: import `RuleOwner`.
|
|
932
|
+
- **Unchanged.** The words and their order; every judgment of an offer kind or a bound; an adoption from a valid source; the owner of a record written with a valid owner; `sameRuleOwner`'s answers.
|
|
933
|
+
- **Consumers.** *server* (stage two; nothing in this release turns a server build red): read `RULE_OFFER_KINDS` where the two rule stores spell the record option's arms (`src/plugins/permission-rule-store-file.ts:92` / `:95`, `src/plugins/permission-rule-store-sql.ts:270` / `:273` — e.g. a per-word arm table keyed by the tuple, so a new word is a missing key at compile time); the card offer shapes (`src/approval-card.ts:143` / `:146` / `:195` / `:198`) and the batch member arms (`:114` / `:125` / `:202` / `:203`, the already-exported `RULE_OFFER_BATCH_MEMBER_KINDS`) are the structure `readRuleOffers` already judges — read through it and keep the display caps and redaction; `z.enum(DENIAL_LIMIT_BOUNDS)` at `src/approval-card.ts:919`; read a stored owner with `readRuleOwner` — the file store's two owner unions (`src/plugins/permission-rule-store-file.ts:109` / `:190`, which accept an empty or whitespace-only principal name the engine reads as no owner) and the SQL store's approval reader, which reads any `owner_kind` other than `principal` as the local owner (`src/plugins/permission-rule-store-sql.ts:1121`).
|
|
934
|
+
- **Pins.** `test/backlog2102-offer-kind-denial-bound-vocab.test.ts` (a frozen tuples and tsc type equalities with the four offer fences; b root identity; c the engine's pre-change spelling and the server's transcriptions; d every reader against the spelled-out verdict over 21 values — `readRuleOffers`, the consent record check including a store that hands back accessors, `readDenialLimitFallback` and `isDenialLimitBound` with one read per member, the decision receipt's value fence); `test/backlog2103-rule-owner-one-reading.test.ts` (a the judge over 25 shapes, blank and padded names and prototype-supplied members included, and one read per member; b root identity and `AdoptionSource` gone at tsc; c no other owner judge or equality spelled in `src/`; d the lanes above on their real functions, and a record's identity read owner first; e each adoption entry completes from a source exactly when its marker reader accepts a marker naming it); `test/backlog2097-rule-vocab-twins.test.ts` and `test/file-adoption-root.test.ts` read the moved names from their new module.
|
|
935
|
+
|
|
936
|
+
### Five small corrections: an approval feedback that cannot be delivered names its cell, `detail.field: "feedback"` (#2240); the refusal of a session log whose parent chain loops says what a person can do (#2245); the durable-approval park refuses in the approval judge's own sentence and the row holds as deep as the judge binds (#2116); `settledForkOptions` is a package-root export and refuses a parent loop instead of climbing it for ever (#2098); three test files call git through the seam (#2237) (#2240, #2245, #2116, #2098, #2237; behavior change on host-visible text, one widening (#2116, arguments nested 129–256 levels now park) and one correction (#2098, a fork keeps a fully answered last tool turn), open to veto; hard-breaking, type only, open to veto: `CheckpointErrorField` gains a word (#2240); closed sets: `CheckpointErrorField` 9 → 10 (+`feedback`; the `detail.field` union, now named and registered in `docs/CLOSED-SETS.md`); exports: +1 function `settledForkOptions`; members: none) (@server @cli @client-core @sdk)
|
|
937
|
+
|
|
938
|
+
- **Hard-breaking, type only (#2240; open to veto).** (1) **What changed:** the words of `CheckpointError`'s optional `detail.field` are a named closed set, `CheckpointErrorField` (an inline union before; not a root export), and gain `feedback`: an allow's `feedback` refused pre-CAS — written on a `deny`, written on a decide a machine made, or carrying the engine's `<system-reminder>` tag, opening or closing — is `checkpoint.invalid_outcome` with `detail: { field: "feedback" }` (it carried no `detail` before); the sentences and the pending row are unchanged. (2) **Who is hurt:** a consumer that keeps an exhaustive table over `NonNullable<CheckpointError["detail"]>["field"]` — the server's `CHECKPOINT_ERROR_FIELD_ROLE` (`src/http/resume-legs.ts:392`, a `Record` over the whole union, read in the server checkout) — stops type-checking on this version until it places the word; at runtime a server that has not yet done so reads the word as outside its table, so it has no decision cause and answers these refusals exactly as before (`approval_binding_mismatch`, "re-fetch the pending list" — the answer that sends a shell back to the same refusal; this is what the change exists to end). (3) **How to migrate:** add `feedback` to the table in the role whose recovery is "change the words and decide again" (the server's `outcome_cell`, wire code `resume_outcome_invalid`); a shell then shows the person that their note is what to change or drop, not that the card moved. The non-string refusal of `feedback` (`not a plain string`) carries the word too; the deny `reason` and plan `editedPlan` refusals (the sentinel tag and the non-string alike) still carry no `field`: a word for each is a separate closed-set widening, not made here.
|
|
939
|
+
- **Before / Now (#2240).** The three refusals of `refuseUnfitFeedback` (`src/core/runner/approval-feedback.ts`) were built three times with no `detail`, so a host that sorts an `invalid_outcome` by its `field` had no word for them. They now leave through one construction, `feedbackRefusal`, with `{ field: "feedback" }`. `docs/INTEGRATION-CORE.md` (the `feedback` row) and `docs/CLOSED-SETS.md` say so.
|
|
940
|
+
- **Now (#2245).** The `invalid_session` sentence of a parent chain that loops (7.54.1's guard, `ParentChainGuard`) ends in the way out: `Entry <id> is its own ancestor (parent chain cycles at <id>) — start a new session, or fork from an entry above the loop`. The second half is true and pinned: the branch verb (`SessionRepo.fork` at an explicit entry) forks at an entry the loop does not touch, and is refused, in the same sentence, at an entry on the loop and at a leaf that walks into it. The fork-point judge of the whole-session fork takes the same guard (#2098, below). A host that renders the sentence as written needs no change.
|
|
941
|
+
- **Now (#2116).** The park mint (`prepare-park-ask.ts`) asked "can an approval be bound to these arguments?" twice, in two sentences of its own (`cannot be bound into the approval row's digest (<kind> at pendingAction.args…)` from a row-digest walk, and `hold a value no exact digest binds`), neither the one the synchronous seat speaks for the same value. It now asks the one judge, `bindableArgsOf`, and refuses in its sentence (`no approval can be bound to the arguments of "<tool>": they hold <what> at args… — …; denied fail-closed`); the row-digest walk the mint used as a pre-check (`unencodableIn`, module-internal) is gone. The row digest's own depth cap, a second constant (128) beside the approval judge's 256, is the judge's: the row holds exactly the 256 containers an approval can bind, so arguments nested 129 to 256 levels — bindable, but refused by the park before — now park, carry a digest and resume (model-produced JSON of that depth is the case; widening, open to veto); 257 are refused by the judge, before the remote VM is paused or anything is swept. The one extra level the row had — the record that holds the digested members counted as a container — is removed by giving the serializer a start depth: `canonicalize(value, standsAt = 0)`, and the row digest passes its holder at −1, so a member counts its containers from 0 as a value digested alone does. The depth only moves where the serializer's sentinel stands, so the digest of every row below it is unchanged (read on the base and the head trees on four rows: plain, branded values, 127 levels, a JSON store's; pinned as literals), and one level past the old bound the sentinel would have made the digest blind to the innermost member — measured: lifting the cap without the start depth gave two 256-level rows that differ only in their innermost member the same digest. On this path the deny reason carries the sentence twice — the seat's own refusal and the park-failure note that quotes the cause. `ownArgs` / `unownedArgsMessage` (the gate's own copy; a different question) are not changed.
|
|
942
|
+
- **Now (#2098).** `settledForkOptions(source)` — the options that cut a whole-session fork before an in-flight assistant turn (one with a tool call that no tool result behind it answers — the agent loop's own reading of an open batch, `findUnansweredToolBatch`), `{}` otherwise — is exported from the package root; `TtlSessionStore.fork` (the Fork tool's fork and `warmResume`) calls this very function. One answer is corrected on the way out (behavior change, open to veto): a session whose LAST tool turn is fully answered — every call has its result — is settled and now forks whole (`{}`); the private function it was exported from cut such a turn off (answering the turn's parent), so a person's fork or a warm resume of a finished session lost its last tool turn. An in-flight turn is cut as before, whether its open call is the leaf or tool results already sit behind it. The shared store kit's `[fork]` case follows: its "sequential" fixture (one call, answered by its one result) was a settled turn read as open, and now makes two calls with one answered; a new assertion in the same case pins that a tool turn every call of which is answered is kept, and a mutant of the bundled store that cuts it (M7b) reddens the case. Server's `LocalSessionStore.fork` (`src/plugins/local-session-store.ts:1002–1005`, read in the server checkout) forks the whole log (`repo.fork(meta, {})`), so a child opens with the in-flight call and wake-time reconciliation gives it an `[INTERRUPTED]` result. The walk takes the shared `ParentChainGuard`: a leaf on a loop of tool-result messages alone — which the walk climbed for ever, in the Fork tool's own fork (`TtlSessionStore.fork`) as in the exported function — is refused `invalid_session`, in the sentence `getPathToRoot` refuses a loop with (behavior change: a hang becomes a refusal). The import validator keeps its own loop (#2253, 7.56.0).
|
|
943
|
+
- **Who is hurt.** (#2240) the type-only break above, nothing else at runtime. (#2245, #2116) a host or model that matches the text of these refusals; the sentences are longer or different by the stated parts. (#2116) a host that relied on arguments nested 129–256 levels being refused at the park gets a park. (#2098) a log with a tool-result-only parent loop that a fork used to hang on now answers `invalid_session`; a host that relied on a fork of a finished session dropping its last tool turn (`warmResume` of a run that ended after its tools) now gets that turn in the child. (#2098, #2237) nobody: an added export; tests only.
|
|
944
|
+
- **How to migrate.** *server*: add `feedback` to `CHECKPOINT_ERROR_FIELD_ROLE` (above); replace the whole-log fork in `LocalSessionStore.fork` (`src/plugins/local-session-store.ts:1002–1005`) by `const source = await repo.open(meta); const options = await settledForkOptions(source); await repo.fork(await source.getMetadata(), options)` (a session whose last tool turn is fully answered forks whole, as a text-only turn does; only a turn with a call no result answers is cut before) and run the session-store kit: its `[fork] a fork never inherits an in-flight assistant turn` case (`src/core/store-contracts/session-store-contract.ts:582`) is the cell the ledger names as the one real divergence of the server's store (not run against it here). *client-core* / *sdk*: both list the field words of `resume_outcome_invalid` in comments and docs (`sema-client-core/src/engineErrorCodes.ts:956`, `sema-sdk/packages/sdk/dist/errors.d.ts:1052`, read in the checkouts; no closed table seen) — `feedback` joins that list. *cli*: render the `invalid_session` and park-refusal sentences as written; once the server maps the `feedback` word, the pre-CAS refusal means "change or drop the note" (inferred: not run against a shell).
|
|
945
|
+
- **Pins.** (#2240) `test/backlog2240-feedback-refusal-field.test.ts` — the three arms on a real Runner over a durable park (each: code, `detail` exactly `{ field: "feedback" }`, the sentence unchanged, the row pending and then redeemed with clean words), the control that a moved binding keeps `boundCallId`, and a total table over `CheckpointErrorField`; per-arm mutations each turn their own cell red. (#2245) `test/backlog2239-session-path-cycle.test.ts` L8 (the branch verb above, on, and into the loop; child process, 2 s budget), L1–L7 re-pinned on the new sentence. (#2116) `test/backlog2116-park-one-judge.test.ts` (S1 five unbindable rewrites: the error face and the model's deny carry `bindableArgsOf`'s own sentence, nothing put or paused; D1 129 / 200 / 255 / 256 levels park, digest, resume and run once on exactly those arguments; D4 the digest sees a change of the innermost member at 256; D2 257 levels refused in the shared sentence; R on a remote environment 256 levels pause the VM once and park, 257 are refused with zero suspend calls and nothing put; G1 the row digest of four rows is the base tree's byte for byte, G2 the serializer's start depth moves nothing below the sentinel, G3 the row digest's bound is 256 containers exactly), `park-mint-backend-fidelity`, `backlog1320-reader-requirements` H.encode ④ and `backlog1391-approval-object` P-exact re-pinned; seven mutations each turn a cell red. (#2098) `test/backlog2098-settled-fork-options.test.ts` (root identity; the answer for a settled turn, an in-flight turn, tool results behind it, a fully answered last tool turn — one call, three calls answered out of order, a stray result that answers nothing the turn asked, one call answered twice —, a user / non-message leaf, an empty log, a parentless turn; the bundled store's fork and a backend's own give the same child, for an in-flight source and for one whose last tool turn is answered, and the whole-log fork copies the in-flight call) and `test/backlog2239-session-path-cycle.test.ts` L9 (a tool-result-only parent loop: the exported judge and the bundled store's fork both refuse `invalid_session` in the shared sentence — red on the base tree as "no answer in 2 s" —, and a sound in-flight session is still judged). (#2237) `test/gate-git-unavailable.test.ts` "no file spawns the git binary directly" — `backlog2154-publish-checkout` (two clones), `backlog2154-publish-release-run` (the tag clone) and `retirement-lines-gate` (init and base commit) call `gitRun`; a raw call put back at any of the five sites turns that cell red.
|
|
946
|
+
|
|
947
|
+
no-notes: #1645(anchor-claim ledger 1672 → 192: comment-level edits over 398 files that drop or pin CC version provenance; no behavior, no export; C-R648), #1608(TaskOutput retirement re-windowed to 7.57.0 — design/1056 R1–R7 alternative read seats unlanded; C-R675 → C-R716 ③: car-1608-B becomes the design + split car in 7.56.0 W2, the deletion lands 7.57.0), #1055(comments and docs made true after the #1056 teacher retirement; no behavior), #1318(KL-1372-a 行改真:`Skill(<name>)` deny 自 #1318 起可执行;文书,无行为面), #2223(`roles.ts` 两处注释改真,指向待实施票;无行为面), #11c(非票号 —— commit 消息里引用的 r15 回执行号 11(c)), #2246(KL-1517-a 引用其票号 —— park 行携 nameValues 的实施在 7.56.0,本版无行为), #1876(钉改读 SESSION_RULES_GRAMMAR 常量,文法 3 自 #1517 起为 earlier;只测试), #2253(the fork walk is guarded in this release by #2098's own ParentChainGuard; the ticket now covers the import validator only), #17(非票号 —— commit 消息引用的 r16 回执行号 17), #3(非票号 —— car-2107 commit 消息引用的 codex sol 回执条目 3), #5(非票号 —— car-2107 commit 消息引用的 codex sol 回执条目 5), #6(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #8(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #9(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #10(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #11(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #12(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #3(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #4(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit)
|
|
948
|
+
|
|
1
949
|
## 7.54.1 — 2026-10-20
|
|
2
950
|
|
|
3
951
|
**Hard-breaking changes:** none. **Closed sets:** none (`invalid_session` is an existing code). **Exports:** +0 / −0 names; no type or signature change. **Behavior:** a session log whose parent chain loops back on itself is refused `invalid_session` by `getPathToRoot` and by the resume-at ancestry scan, where each used to hold the thread until the process aborted; the shared store kit gains one `[tree]` case that says so. Present in every earlier version.
|
|
@@ -290,6 +1238,8 @@ no-notes: #794(③ 按新律重钉:allow 层之外的表态不再免 unstated
|
|
|
290
1238
|
|
|
291
1239
|
## 7.53.0 — 2026-10-17
|
|
292
1240
|
|
|
1241
|
+
> Erratum (recorded in 7.55.0, #2191): this release's materials called the before-forms of the delegation refusal quoting a worktree's output and an undeclared extraTools name (#2045, #2051) and of the host store lane reading a row's getter twice per list read (#2012) a defect of the unreleased 7.53.0 stage; v7.52.0 already had all three, and 7.53.0 fixed them. The criteria document is corrected in place (`docs/release-criteria/README.md`).
|
|
1242
|
+
|
|
293
1243
|
### Docs and pins: the task-stream thinking arm, the classifier attribution sentence, the TaskEvent roster, one re-anchored pin (#1042, #1961, #2029, #1432)
|
|
294
1244
|
|
|
295
1245
|
- **#1042 (docs).** The task stream's thinking arm is `reasoning_delta` with no start / end frame — a sidecar that listens for the provider-layer name `thinking_delta` counts nothing (`docs/sdk/05-streaming-and-http-server.md`, one pointer sentence in `README.md`). INTEGRATION-CORE states the two host-side facts beside it: a host that renders a local date passes `TaskSpec.clientContext.timeZone` (erratum #2072: the first edition said `EnvironmentFacts.timeZone`, which is the facts carrier the engine fills from this field — a host driving a Runner passes the zone on `TaskSpec.clientContext`); a cache-stable deployment keeps one tool block across modes and refuses at call time rather than varying the block (`KL-1042-a` records the `</think>` leading newline as the template's own).
|