@sema-agent/core 7.55.0 → 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 +513 -0
- package/dist/agents/cascade.js +1 -1
- package/dist/agents/cc-peer-directory.js +1 -0
- package/dist/agents/child-model-seat.d.ts +0 -11
- package/dist/agents/child-model-seat.js +0 -3
- package/dist/agents/list-agents-tool.d.ts +3 -1
- package/dist/agents/list-agents-tool.js +7 -7
- package/dist/agents/peer-directory.d.ts +22 -4
- package/dist/agents/peer-directory.js +24 -2
- package/dist/agents/peer-session-drain.js +2 -2
- package/dist/agents/repair-loop.js +2 -2
- package/dist/agents/send-message-tool.js +4 -4
- package/dist/agents/subagent.d.ts +6 -4
- package/dist/agents/subagent.js +64 -60
- package/dist/agents/tool-filter.d.ts +7 -5
- package/dist/agents/tool-filter.js +12 -9
- package/dist/agents/verify.d.ts +3 -2
- package/dist/agents/verify.js +3 -3
- package/dist/brain/anthropic.js +30 -73
- 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 +2 -8
- package/dist/brain/model-presets.js +1 -6
- package/dist/brain/open-responses.js +13 -26
- package/dist/brain/openai.js +14 -87
- package/dist/brain/reasoning-request.d.ts +96 -0
- package/dist/brain/reasoning-request.js +174 -0
- package/dist/brain/reasoning.d.ts +11 -11
- package/dist/brain/reasoning.js +4 -3
- package/dist/brain/request-params.d.ts +7 -4
- package/dist/brain/request-params.js +7 -4
- package/dist/brain/status-sink.d.ts +20 -0
- package/dist/brain/status-sink.js +7 -0
- package/dist/core/a2a.d.ts +2 -1
- package/dist/core/a2a.js +19 -13
- package/dist/core/agent-definition.d.ts +10 -0
- package/dist/core/allow-entry.d.ts +20 -13
- package/dist/core/allow-entry.js +26 -22
- package/dist/core/allow-rule-shadow.d.ts +3 -3
- package/dist/core/ask-origin.d.ts +64 -18
- package/dist/core/ask-origin.js +63 -23
- package/dist/core/ask-question.d.ts +51 -8
- package/dist/core/ask-question.js +3 -4
- package/dist/core/ask-unresolvable-notice.d.ts +8 -3
- package/dist/core/ask-unresolvable-notice.js +16 -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 +13 -1
- package/dist/core/checkpoint-requirements.js +4 -0
- package/dist/core/checkpoint-store.d.ts +88 -24
- package/dist/core/checkpoint-store.js +24 -9
- package/dist/core/cross-boundary-taint.d.ts +6 -5
- package/dist/core/cross-boundary-taint.js +1 -3
- package/dist/core/decision-receipt.d.ts +37 -3
- package/dist/core/decision-receipt.js +17 -17
- package/dist/core/delegation-not-started.d.ts +6 -0
- package/dist/core/delegation-not-started.js +17 -0
- 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 +30 -11
- package/dist/core/file-history-store.d.ts +90 -14
- package/dist/core/file-history-store.js +23 -15
- package/dist/core/gate-fold.d.ts +1 -1
- package/dist/core/gate-fold.js +22 -10
- package/dist/core/gate-lanes.js +73 -64
- package/dist/core/gate-outcome.d.ts +26 -5
- package/dist/core/gate-outcome.js +26 -0
- package/dist/core/governance-codes.d.ts +5 -1
- package/dist/core/governance-codes.js +6 -0
- package/dist/core/hooks.d.ts +17 -16
- 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 +38 -3
- package/dist/core/host-target.js +38 -5
- package/dist/core/mcp.d.ts +5 -1
- package/dist/core/mcp.js +67 -41
- package/dist/core/memory-engine/delegation-settlement.js +15 -24
- 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/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 +32 -38
- package/dist/core/namespaced-rule-name.js +29 -36
- package/dist/core/parent-spec-seat.d.ts +6 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/park-selfcheck.js +1 -0
- package/dist/core/permission-rule-consent.d.ts +19 -0
- package/dist/core/permission-rule-consent.js +116 -26
- package/dist/core/permission-rule-model.d.ts +14 -3
- package/dist/core/permission-rule-model.js +39 -24
- 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 +5 -2
- package/dist/core/permission-rule-provider.js +41 -11
- package/dist/core/permission-rule-store.d.ts +3 -2
- package/dist/core/permission-rule-store.js +11 -2
- package/dist/core/permission-rule-sync.js +3 -2
- 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 +4 -4
- package/dist/core/permission-rules.js +11 -20
- package/dist/core/persisted-rule-arms.d.ts +2 -2
- package/dist/core/persisted-rule-arms.js +10 -12
- 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/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 +28 -1
- package/dist/core/roles.js +31 -19
- package/dist/core/rule-lane-reads.d.ts +20 -15
- package/dist/core/rule-lane-reads.js +45 -17
- package/dist/core/rule-owner.d.ts +3 -3
- package/dist/core/rule-owner.js +3 -2
- package/dist/core/runner/announce-once-ledger.d.ts +14 -0
- package/dist/core/runner/announce-once-ledger.js +5 -0
- package/dist/core/runner/assemble-result.d.ts +1 -0
- 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 +16 -4
- package/dist/core/runner/caller-roster.js +33 -6
- 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 +7 -9
- package/dist/core/runner/classifier-ladder.js +3 -3
- package/dist/core/runner/construction-doors.js +3 -0
- package/dist/core/runner/contracts.d.ts +28 -25
- package/dist/core/runner/cross-boundary-lane.d.ts +17 -13
- package/dist/core/runner/cross-boundary-lane.js +40 -27
- package/dist/core/runner/denial-limit-arms.d.ts +2 -2
- package/dist/core/runner/denial-limit-arms.js +1 -1
- 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 +3 -3
- package/dist/core/runner/gate-exit.js +4 -3
- package/dist/core/runner/image.d.ts +7 -17
- package/dist/core/runner/image.js +9 -60
- package/dist/core/runner/permission-rule-lanes.d.ts +4 -4
- package/dist/core/runner/permission-rule-lanes.js +26 -10
- package/dist/core/runner/person-path-rows.d.ts +12 -9
- package/dist/core/runner/person-path-rows.js +18 -7
- 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 +4 -3
- package/dist/core/runner/prepare-config-doors.d.ts +29 -38
- package/dist/core/runner/prepare-config-doors.js +42 -25
- 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-file-history.d.ts +48 -8
- package/dist/core/runner/prepare-file-history.js +7 -39
- package/dist/core/runner/prepare-gate-stations.d.ts +1 -1
- package/dist/core/runner/prepare-gate-stations.js +10 -1
- 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 +6 -3
- package/dist/core/runner/prepare-inherited-gate.js +5 -3
- package/dist/core/runner/prepare-memory-engine-session.js +1 -1
- package/dist/core/runner/prepare-park-ask.js +5 -5
- package/dist/core/runner/prepare-policy-chain.d.ts +6 -3
- package/dist/core/runner/prepare-policy-chain.js +65 -72
- 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 +3 -5
- package/dist/core/runner/prepare-protocol-tools.d.ts +2 -0
- package/dist/core/runner/prepare-protocol-tools.js +3 -3
- package/dist/core/runner/prepare-safety-scan.d.ts +1 -1
- package/dist/core/runner/prepare-safety-scan.js +0 -17
- 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 +29 -20
- package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +7 -4
- package/dist/core/runner/prepare-tool-disclosure-mount.js +20 -16
- 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 +6 -2
- 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/retired-runner-deps.js +1 -0
- 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 -1
- 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-harness-handlers.js +12 -4
- package/dist/core/runner/run-leg.d.ts +1 -1
- package/dist/core/runner/run-leg.js +30 -18
- package/dist/core/runner/run-settle-and-teardown.js +2 -1
- package/dist/core/runner/run-turn-boundary.js +1 -10
- 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.js +4 -3
- 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/skills-seat.js +6 -2
- package/dist/core/runner/stream-settle-backstop.js +1 -1
- package/dist/core/runner/sub-agent-deny-lists.js +4 -7
- package/dist/core/runner/synthetic-tools.d.ts +6 -0
- package/dist/core/runner/synthetic-tools.js +4 -1
- package/dist/core/runner/tool-disclosure.d.ts +10 -12
- package/dist/core/runner/tool-disclosure.js +1 -3
- 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-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/turn-attachments.d.ts +33 -15
- package/dist/core/runner/turn-attachments.js +62 -37
- package/dist/core/runner-deps.d.ts +5 -5
- 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 +11 -3
- 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/session-policy-store.d.ts +27 -15
- package/dist/core/session-policy-store.js +156 -36
- package/dist/core/session-store.d.ts +29 -10
- package/dist/core/session-store.js +51 -19
- package/dist/core/session.d.ts +82 -16
- package/dist/core/session.js +53 -0
- 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.js +10 -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 +140 -2
- package/dist/core/store-contracts/file-history-store-contract.js +31 -1
- 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-repo-contract.js +51 -0
- package/dist/core/store-contracts/session-store-contract.d.ts +2 -1
- package/dist/core/store-contracts/session-store-contract.js +150 -14
- package/dist/core/stub-env.d.ts +2 -0
- package/dist/core/stub-env.js +1 -0
- package/dist/core/synthetic-read-verdict.js +3 -3
- package/dist/core/task-event.d.ts +32 -4
- package/dist/core/task-notification.d.ts +16 -12
- package/dist/core/task-registry.d.ts +9 -0
- package/dist/core/task-registry.js +11 -1
- package/dist/core/task-result.d.ts +18 -0
- package/dist/core/task-spec.d.ts +18 -13
- package/dist/core/tighten-task-spec.js +2 -1
- package/dist/core/tool-catalog-entries.d.ts +17 -0
- package/dist/core/tool-catalog-entries.js +13 -2
- package/dist/core/tool-check-reading.d.ts +11 -0
- package/dist/core/tool-check-reading.js +11 -6
- package/dist/core/tool-model-gate.d.ts +2 -2
- package/dist/core/tool-name-aliases.d.ts +16 -16
- package/dist/core/tool-name-aliases.js +13 -19
- package/dist/core/tool-name-readings.d.ts +5 -35
- package/dist/core/tool-name-readings.js +1 -9
- package/dist/core/tool-policy.d.ts +68 -198
- package/dist/core/tool-policy.js +149 -198
- package/dist/core/tool-result-store.js +3 -0
- package/dist/core/tool-roster.d.ts +14 -19
- package/dist/core/tool-roster.js +11 -44
- package/dist/core/tool-spec-members.js +7 -2
- package/dist/core/tool-spec.d.ts +19 -11
- package/dist/core/tools.js +49 -9
- package/dist/core/trace.d.ts +2 -2
- package/dist/core/types.d.ts +1 -1
- package/dist/core/user-attachment.d.ts +8 -1
- package/dist/core/user-attachment.js +64 -4
- package/dist/core/wiring-manifest.d.ts +9 -0
- package/dist/core/wiring-manifest.js +1 -0
- package/dist/core/workflow-journal-store.js +1 -1
- package/dist/engine/compaction/compaction.js +3 -2
- package/dist/engine/execution-env/node-execution-env.d.ts +7 -3
- package/dist/engine/execution-env/node-execution-env.js +11 -2
- package/dist/engine/harness/agent-harness.d.ts +25 -1
- package/dist/engine/harness/agent-harness.js +54 -7
- package/dist/engine/harness/types.d.ts +29 -2
- package/dist/engine/llm/types.d.ts +28 -11
- package/dist/engine/loop/agent-loop.d.ts +22 -0
- package/dist/engine/loop/agent-loop.js +16 -26
- package/dist/engine/loop/types.d.ts +16 -6
- package/dist/engine/session/memory-repo.d.ts +9 -2
- package/dist/engine/session/memory-repo.js +11 -1
- package/dist/engine/session/repo-utils.d.ts +6 -5
- package/dist/engine/session/repo-utils.js +14 -6
- package/dist/index.d.ts +22 -10
- package/dist/index.js +15 -7
- package/dist/internal/harness-types.d.ts +1 -1
- package/dist/internal/llm.d.ts +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +6 -4
- package/dist/orchestration/run-workflow-tool.js +5 -3
- package/dist/orchestration/workflow.d.ts +3 -0
- package/dist/orchestration/workflow.js +36 -18
- 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 +1 -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 +37 -3
- package/dist/prompt-assembly/tool-catalog.js +114 -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/scenarios/full-body.d.ts +11 -11
- package/dist/scenarios/full-body.js +4 -3
- package/dist/shell/lex.js +10 -1
- package/dist/stores/file/adoption/adopt.js +4 -3
- package/dist/stores/file/adoption/marker.js +3 -2
- package/dist/stores/file/checkpoint-store.d.ts +21 -0
- package/dist/stores/file/checkpoint-store.js +38 -6
- package/dist/stores/file/file-history-store.js +6 -7
- package/dist/stores/file/index.js +2 -1
- package/dist/stores/file/permission-rule-adopt.js +4 -3
- package/dist/stores/file/permission-rule-store.js +11 -5
- package/dist/stores/file/session-store.d.ts +29 -6
- package/dist/stores/file/session-store.js +42 -50
- package/dist/tools/artifact/artifact-tool.js +3 -2
- 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/fs-bash.js +5 -5
- 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/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/removal-targets.d.ts +17 -7
- package/dist/tools/fs/removal-targets.js +47 -20
- package/dist/tools/fs/search.d.ts +10 -0
- 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 +6 -19
- 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-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/todo.js +63 -63
- package/dist/tools/web.d.ts +19 -14
- package/dist/tools/web.js +38 -55
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +143 -5
- package/wire-changes/core@7.56.0.json +3891 -0
- package/wire-changes/mods-kernel@7.56.0.json +303 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,519 @@
|
|
|
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
|
+
|
|
1
513
|
## 7.55.0 — 2026-10-21
|
|
2
514
|
|
|
3
515
|
|
|
516
|
+
|
|
4
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.
|
|
5
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)
|
|
6
519
|
|