@sema-agent/core 7.54.0 → 7.55.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 +451 -0
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/builtin-agents.d.ts +12 -14
- package/dist/agents/cc-peer-directory.d.ts +36 -0
- package/dist/agents/cc-peer-directory.js +138 -0
- package/dist/agents/cc-peer-wire.d.ts +105 -0
- package/dist/agents/cc-peer-wire.js +98 -0
- package/dist/agents/cc-uds-peer-channel.d.ts +40 -0
- package/dist/agents/cc-uds-peer-channel.js +457 -0
- package/dist/agents/cross-session-envelope.d.ts +4 -1
- package/dist/agents/cross-session-envelope.js +8 -1
- package/dist/agents/cross-session-judge.d.ts +2 -2
- package/dist/agents/list-agents-tool.js +2 -1
- package/dist/agents/observer.d.ts +4 -5
- package/dist/agents/observer.js +2 -1
- package/dist/agents/peer-admission.d.ts +6 -0
- package/dist/agents/peer-admission.js +4 -1
- package/dist/agents/peer-directory.d.ts +43 -9
- package/dist/agents/peer-directory.js +66 -23
- package/dist/agents/peer-notices.d.ts +1 -1
- package/dist/agents/peer-session-drain.d.ts +3 -1
- package/dist/agents/peer-session-drain.js +1 -0
- package/dist/agents/read-inbox-tool.js +2 -1
- package/dist/agents/retain-ledger.d.ts +1 -1
- package/dist/agents/roster-store.d.ts +1 -1
- package/dist/agents/send-message-tool.d.ts +10 -0
- package/dist/agents/send-message-tool.js +34 -5
- package/dist/agents/subagent.d.ts +34 -79
- package/dist/agents/subagent.js +45 -46
- package/dist/agents/tool-filter.d.ts +29 -46
- package/dist/agents/tool-filter.js +8 -31
- package/dist/agents/verify.d.ts +3 -3
- package/dist/brain/anthropic.js +14 -9
- package/dist/brain/errors.d.ts +15 -0
- package/dist/brain/errors.js +10 -0
- package/dist/brain/model-presets.d.ts +4 -4
- package/dist/brain/open-responses.js +15 -10
- package/dist/brain/openai.js +17 -12
- package/dist/brain/provider-dialect.d.ts +39 -3
- package/dist/brain/provider-dialect.js +28 -0
- package/dist/brain/reasoning.d.ts +1 -1
- package/dist/brain/request-params.d.ts +55 -5
- package/dist/brain/request-params.js +35 -3
- package/dist/brain/retry.d.ts +1 -1
- package/dist/brain/route-adjudicator.d.ts +15 -0
- package/dist/brain/route-adjudicator.js +15 -0
- package/dist/brain/stream-engine.js +6 -5
- package/dist/brain/stream-shared.d.ts +1 -1
- package/dist/core/advisor-verdict.d.ts +120 -0
- package/dist/core/advisor-verdict.js +92 -0
- package/dist/core/agent-definition.d.ts +7 -8
- package/dist/core/allow-entry.d.ts +49 -23
- package/dist/core/allow-entry.js +57 -32
- package/dist/core/allow-rule-shadow.d.ts +7 -0
- package/dist/core/approval-object.d.ts +2 -1
- package/dist/core/approval-object.js +6 -6
- package/dist/core/ask-origin.d.ts +63 -11
- package/dist/core/ask-origin.js +27 -5
- package/dist/core/ask-question.js +10 -4
- package/dist/core/auto-compaction.d.ts +11 -8
- package/dist/core/auto-compaction.js +4 -2
- package/dist/core/auto-mode-arming.d.ts +2 -2
- package/dist/core/auto-mode-defaults.d.ts +8 -8
- package/dist/core/auto-mode-prompt-assets.d.ts +3 -3
- package/dist/core/auto-mode-prompt.d.ts +1 -1
- package/dist/core/auto-mode.d.ts +17 -9
- package/dist/core/auto-mode.js +9 -6
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/canonical-json.d.ts +1 -1
- package/dist/core/canonical-json.js +2 -2
- package/dist/core/checkpoint-requirements.d.ts +6 -3
- package/dist/core/checkpoint-requirements.js +4 -14
- package/dist/core/checkpoint-store.d.ts +80 -51
- package/dist/core/checkpoint-store.js +56 -74
- package/dist/core/child-clamp-seats.d.ts +6 -0
- package/dist/core/child-clamp-seats.js +3 -16
- package/dist/core/compliance.d.ts +2 -2
- package/dist/core/context-edit.d.ts +6 -7
- package/dist/core/cross-boundary-taint.d.ts +9 -3
- package/dist/core/cross-boundary-taint.js +5 -3
- package/dist/core/decision-receipt.js +2 -2
- package/dist/core/effective-path-target.d.ts +1 -1
- package/dist/core/engine-notice.d.ts +19 -5
- package/dist/core/exec-output-tail.d.ts +1 -1
- package/dist/core/file-history-retention.d.ts +1 -1
- package/dist/core/file-history-store.d.ts +59 -9
- package/dist/core/file-history-store.js +35 -17
- package/dist/core/gate-fold.js +3 -2
- package/dist/core/gate-lanes.js +12 -8
- package/dist/core/gate-outcome.d.ts +17 -4
- package/dist/core/gate-outcome.js +30 -0
- package/dist/core/governance-codes.d.ts +13 -3
- package/dist/core/governance-codes.js +10 -0
- package/dist/core/hands-band.d.ts +1 -1
- package/dist/core/hooks.d.ts +4 -4
- package/dist/core/host-target.d.ts +49 -0
- package/dist/core/host-target.js +59 -0
- package/dist/core/image-downsample.d.ts +1 -1
- package/dist/core/lsp-diagnostics.d.ts +4 -4
- package/dist/core/lsp.d.ts +3 -3
- package/dist/core/mailbox-store.d.ts +4 -0
- package/dist/core/mcp-connection-pool.d.ts +18 -2
- package/dist/core/mcp-connection-pool.js +53 -6
- package/dist/core/mcp-failure.d.ts +23 -4
- package/dist/core/mcp-failure.js +26 -11
- package/dist/core/mcp-listing-drift.d.ts +10 -0
- package/dist/core/mcp-listing-drift.js +6 -0
- package/dist/core/mcp-server-link.d.ts +173 -0
- package/dist/core/mcp-server-link.js +127 -0
- package/dist/core/mcp-server-spec.d.ts +4 -2
- package/dist/core/mcp-session-end.d.ts +26 -0
- package/dist/core/mcp-session-end.js +29 -0
- package/dist/core/mcp-startup-request.d.ts +17 -0
- package/dist/core/mcp-startup-request.js +17 -0
- package/dist/core/mcp.d.ts +13 -13
- package/dist/core/mcp.js +187 -162
- package/dist/core/media-byte-cap.d.ts +1 -1
- package/dist/core/memory-engine/engine.d.ts +9 -9
- package/dist/core/memory-engine/provenance-wording.d.ts +1 -1
- package/dist/core/memory-engine/tools.js +4 -1
- package/dist/core/mode-policy.d.ts +1 -1
- package/dist/core/namespaced-rule-name.d.ts +33 -7
- package/dist/core/namespaced-rule-name.js +39 -25
- package/dist/core/output-style.d.ts +1 -1
- package/dist/core/param-rule-reach.d.ts +15 -0
- package/dist/core/param-rule-reach.js +30 -0
- package/dist/core/parent-spec-seat.d.ts +8 -1
- package/dist/core/parent-spec-seat.js +4 -0
- package/dist/core/pause-registry.d.ts +16 -5
- package/dist/core/pause-registry.js +6 -2
- package/dist/core/peer-channel.d.ts +70 -0
- package/dist/core/peer-channel.js +1 -0
- package/dist/core/permission-rule-consent.d.ts +11 -5
- package/dist/core/permission-rule-consent.js +37 -30
- package/dist/core/permission-rule-model.d.ts +233 -64
- package/dist/core/permission-rule-model.js +384 -85
- package/dist/core/permission-rule-provider.d.ts +2 -1
- package/dist/core/permission-rule-store.d.ts +20 -25
- package/dist/core/permission-rule-store.js +1 -4
- package/dist/core/permission-rule-sync.d.ts +11 -2
- package/dist/core/permission-rule-sync.js +8 -1
- package/dist/core/permission-rules.d.ts +49 -11
- package/dist/core/permission-rules.js +123 -89
- package/dist/core/persisted-rule-arms.d.ts +4 -2
- package/dist/core/present-plan-tool.js +3 -1
- package/dist/core/protocol-naming.d.ts +2 -2
- package/dist/core/read-only-shell-table.d.ts +1 -1
- package/dist/core/read-only-shell.d.ts +1 -1
- package/dist/core/roles.d.ts +6 -2
- package/dist/core/roles.js +45 -44
- package/dist/core/rule-offer-read.d.ts +53 -0
- package/dist/core/rule-offer-read.js +146 -0
- package/dist/core/rule-owner.d.ts +46 -0
- package/dist/core/rule-owner.js +28 -0
- package/dist/core/runner/advisor-instructions.d.ts +70 -0
- package/dist/core/runner/advisor-instructions.js +61 -0
- package/dist/core/runner/advisor-tool.d.ts +33 -0
- package/dist/core/runner/advisor-tool.js +112 -0
- package/dist/core/runner/announce-once-ledger.d.ts +4 -26
- package/dist/core/runner/announce-once-ledger.js +14 -46
- package/dist/core/runner/approval-feedback.js +6 -3
- package/dist/core/runner/assemble-result.d.ts +1 -1
- package/dist/core/runner/caller-roster.d.ts +7 -0
- package/dist/core/runner/caller-roster.js +11 -0
- package/dist/core/runner/classifier-ladder.d.ts +27 -5
- package/dist/core/runner/classifier-ladder.js +12 -11
- package/dist/core/runner/compaction-restate.d.ts +10 -6
- package/dist/core/runner/compaction-restate.js +9 -3
- package/dist/core/runner/contracts.d.ts +13 -9
- package/dist/core/runner/conversation-segment.d.ts +28 -0
- package/dist/core/runner/conversation-segment.js +42 -0
- package/dist/core/runner/cross-boundary-lane.d.ts +6 -5
- package/dist/core/runner/cross-boundary-lane.js +26 -15
- package/dist/core/runner/decide-continuation.d.ts +7 -4
- package/dist/core/runner/decide-continuation.js +2 -2
- package/dist/core/runner/denial-limit-arms.d.ts +2 -2
- package/dist/core/runner/derived-route-fallback.d.ts +24 -24
- package/dist/core/runner/derived-route-fallback.js +9 -7
- package/dist/core/runner/gate-exit.d.ts +6 -0
- package/dist/core/runner/gate-exit.js +7 -2
- package/dist/core/runner/initial-run-state.js +1 -1
- package/dist/core/runner/permission-rule-lanes.d.ts +2 -2
- package/dist/core/runner/permission-rule-lanes.js +14 -7
- package/dist/core/runner/person-path-rows.js +1 -1
- package/dist/core/runner/prepare-acquire-reconcile.d.ts +4 -5
- package/dist/core/runner/prepare-acquire-reconcile.js +5 -3
- package/dist/core/runner/prepare-advisor.d.ts +63 -0
- package/dist/core/runner/prepare-advisor.js +77 -0
- package/dist/core/runner/prepare-caps-and-workflow.js +1 -1
- package/dist/core/runner/prepare-config-doors.d.ts +6 -4
- package/dist/core/runner/prepare-config-doors.js +18 -12
- package/dist/core/runner/prepare-delegation-surface.d.ts +2 -2
- package/dist/core/runner/prepare-delegation-surface.js +1 -1
- package/dist/core/runner/prepare-file-history.js +39 -39
- package/dist/core/runner/prepare-gate-stations.d.ts +1 -4
- package/dist/core/runner/prepare-gate-stations.js +2 -7
- package/dist/core/runner/prepare-inherited-gate.d.ts +5 -4
- package/dist/core/runner/prepare-inherited-gate.js +3 -3
- package/dist/core/runner/prepare-park-ask.js +7 -7
- package/dist/core/runner/prepare-policy-chain.d.ts +1 -1
- package/dist/core/runner/prepare-policy-chain.js +4 -10
- package/dist/core/runner/prepare-project-context.d.ts +5 -2
- package/dist/core/runner/prepare-project-context.js +6 -3
- package/dist/core/runner/prepare-prompt-inputs.js +1 -1
- package/dist/core/runner/prepare-protocol-tools.d.ts +2 -2
- package/dist/core/runner/prepare-protocol-tools.js +3 -2
- package/dist/core/runner/prepare-safety-scan.d.ts +9 -12
- package/dist/core/runner/prepare-safety-scan.js +8 -6
- package/dist/core/runner/prepare-task.js +15 -18
- package/dist/core/runner/prepare-tool-disclosure-mount.js +6 -4
- package/dist/core/runner/prepare-wiring-manifest.d.ts +6 -3
- package/dist/core/runner/prepare-wiring-manifest.js +2 -2
- package/dist/core/runner/resume-policy-outcome.js +1 -1
- package/dist/core/runner/retired-runner-deps.js +1 -1
- package/dist/core/runner/run-attachment-seats.js +0 -1
- package/dist/core/runner/run-compaction-machinery.d.ts +1 -1
- package/dist/core/runner/run-compaction-machinery.js +2 -12
- package/dist/core/runner/run-git-lane.d.ts +1 -0
- package/dist/core/runner/run-harness-handlers.js +2 -3
- package/dist/core/runner/run-leg.js +13 -0
- package/dist/core/runner/run-settle-and-teardown.js +1 -1
- package/dist/core/runner/run-telemetry-and-budget-seats.js +1 -1
- package/dist/core/runner/run-turn-boundary.d.ts +1 -0
- package/dist/core/runner/run-turn-boundary.js +14 -12
- package/dist/core/runner/session-rule-policy.d.ts +22 -21
- package/dist/core/runner/session-rule-policy.js +13 -28
- package/dist/core/runner/side-call-account.d.ts +32 -8
- package/dist/core/runner/side-call-account.js +23 -1
- package/dist/core/runner/strict-output-schema.d.ts +1 -1
- package/dist/core/runner/sub-agent-deny-lists.js +4 -3
- package/dist/core/runner/synthetic-tools.d.ts +10 -13
- package/dist/core/runner/synthetic-tools.js +4 -2
- package/dist/core/runner/tool-disclosure.js +2 -1
- package/dist/core/runner/tool-face-overlay.d.ts +2 -1
- package/dist/core/runner/tool-face-overlay.js +17 -1
- package/dist/core/runner/tool-progress-lane.d.ts +2 -2
- package/dist/core/runner/tool-turn-repair-seat.d.ts +3 -2
- package/dist/core/runner/turn-attachments.d.ts +60 -67
- package/dist/core/runner-deps.d.ts +37 -37
- package/dist/core/runtime-caps.d.ts +4 -4
- package/dist/core/safety-merge-corpus.js +17 -1
- package/dist/core/same-plain-value.d.ts +40 -0
- package/dist/core/same-plain-value.js +49 -0
- package/dist/core/scheduler.d.ts +1 -1
- package/dist/core/sensitive-path-policy.d.ts +1 -1
- package/dist/core/sensitive-path-policy.js +1 -1
- package/dist/core/session-policy-store.d.ts +77 -22
- package/dist/core/session-policy-store.js +56 -14
- package/dist/core/session-reconcile.d.ts +2 -2
- package/dist/core/session-store.d.ts +80 -30
- package/dist/core/session-store.js +118 -76
- package/dist/core/session.d.ts +24 -46
- package/dist/core/shared-memory/tools.js +3 -1
- package/dist/core/shared-memory/types.d.ts +1 -1
- package/dist/core/side-query.d.ts +10 -0
- package/dist/core/side-query.js +5 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +29 -2
- package/dist/core/store-contracts/file-history-store-contract.js +43 -3
- package/dist/core/store-contracts/session-policy-store-contract.d.ts +7 -0
- package/dist/core/store-contracts/session-policy-store-contract.js +130 -0
- package/dist/core/store-contracts/session-repo-contract.js +125 -0
- package/dist/core/store-contracts/session-store-contract.d.ts +4 -7
- package/dist/core/store-contracts/session-store-contract.js +259 -41
- package/dist/core/store-contracts/workflow-journal-store-contract.js +26 -1
- package/dist/core/surrogate-safe-slice.d.ts +3 -3
- package/dist/core/task-event.d.ts +10 -4
- package/dist/core/task-notification.d.ts +3 -3
- package/dist/core/task-registry-agent.d.ts +4 -4
- package/dist/core/task-registry-shared.d.ts +4 -4
- package/dist/core/task-registry-shared.js +1 -0
- package/dist/core/task-registry.d.ts +4 -4
- package/dist/core/task-registry.js +3 -1
- package/dist/core/task-result.d.ts +3 -3
- package/dist/core/task-spec.d.ts +52 -52
- package/dist/core/task-stream.d.ts +6 -6
- package/dist/core/task-tool-shape.d.ts +4 -4
- package/dist/core/tighten-task-spec.d.ts +1 -1
- package/dist/core/tool-catalog-entries.d.ts +19 -2
- package/dist/core/tool-catalog-entries.js +57 -42
- package/dist/core/tool-conformance.js +1 -0
- package/dist/core/tool-errors.d.ts +2 -2
- package/dist/core/tool-face-reader.js +5 -3
- package/dist/core/tool-face.d.ts +21 -2
- package/dist/core/tool-face.js +1 -1
- package/dist/core/tool-model-gate.d.ts +3 -3
- package/dist/core/tool-name-aliases.d.ts +29 -8
- package/dist/core/tool-name-aliases.js +27 -3
- package/dist/core/tool-name-readings.d.ts +5 -8
- package/dist/core/tool-name-readings.js +1 -2
- package/dist/core/tool-policy.d.ts +24 -5
- package/dist/core/tool-policy.js +34 -10
- package/dist/core/tool-registry.d.ts +14 -7
- package/dist/core/tool-registry.js +17 -7
- package/dist/core/tool-result-store.js +2 -1
- package/dist/core/tool-roster.d.ts +47 -18
- package/dist/core/tool-roster.js +53 -32
- package/dist/core/tool-spec-members.js +1 -1
- package/dist/core/tool-spec.d.ts +35 -21
- package/dist/core/tools.js +3 -2
- package/dist/core/trace.d.ts +4 -0
- package/dist/core/untrusted-egress.d.ts +1 -1
- package/dist/core/untrusted-text.d.ts +1 -1
- package/dist/core/warm-resume.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +49 -0
- package/dist/core/wiring-manifest.js +13 -5
- package/dist/core/workflow-journal-store.d.ts +62 -0
- package/dist/core/workflow-journal-store.js +77 -1
- package/dist/core/workflow-run-store.d.ts +6 -0
- package/dist/core/workflow-run-store.js +2 -0
- package/dist/engine/compaction/compaction.d.ts +9 -9
- package/dist/engine/execution-env/node-execution-env.d.ts +19 -5
- package/dist/engine/execution-env/node-execution-env.js +43 -22
- package/dist/engine/harness/agent-harness.d.ts +14 -8
- package/dist/engine/harness/agent-harness.js +33 -26
- package/dist/engine/harness/types.d.ts +29 -7
- package/dist/engine/harness/types.js +12 -1
- package/dist/engine/llm/types.d.ts +9 -9
- package/dist/engine/llm/validation.d.ts +4 -1
- package/dist/engine/llm/validation.js +13 -1
- package/dist/engine/loop/agent-loop.d.ts +1 -1
- package/dist/engine/loop/agent-loop.js +10 -8
- package/dist/engine/loop/types.d.ts +15 -13
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -2
- package/dist/engine/session/memory-repo.d.ts +10 -11
- package/dist/engine/session/memory-repo.js +13 -8
- package/dist/engine/session/parent-chain-guard.d.ts +11 -0
- package/dist/engine/session/parent-chain-guard.js +10 -0
- package/dist/engine/session/repo-utils.d.ts +31 -3
- package/dist/engine/session/repo-utils.js +42 -5
- package/dist/engine/session/storage-base.js +3 -0
- package/dist/index.d.ts +27 -15
- package/dist/index.js +21 -11
- package/dist/internal/harness-types.d.ts +1 -1
- package/dist/internal/harness.d.ts +4 -3
- package/dist/internal/harness.js +3 -2
- package/dist/internal/llm.d.ts +1 -1
- package/dist/internal/llm.js +1 -1
- package/dist/orchestration/goal.js +2 -2
- package/dist/orchestration/run-workflow-tool.d.ts +2 -2
- package/dist/orchestration/run-workflow-tool.js +5 -3
- package/dist/orchestration/workflow-size-guideline.d.ts +4 -4
- package/dist/orchestration/workflow-types.d.ts +18 -0
- package/dist/orchestration/workflow-types.js +6 -0
- package/dist/orchestration/workflow.d.ts +2 -13
- package/dist/orchestration/workflow.js +21 -31
- package/dist/prompt-assembly/event-registry.js +1 -0
- package/dist/prompt-assembly/tool-catalog.d.ts +3 -0
- package/dist/prompt-assembly/tool-catalog.js +3 -0
- package/dist/prompts/default.d.ts +14 -14
- package/dist/scenarios/full-body.d.ts +4 -4
- package/dist/stores/file/adoption/adopt.d.ts +4 -3
- package/dist/stores/file/adoption/adopt.js +24 -31
- package/dist/stores/file/adoption/marker.d.ts +6 -13
- package/dist/stores/file/adoption/marker.js +6 -15
- package/dist/stores/file/checkpoint-store.d.ts +3 -0
- package/dist/stores/file/checkpoint-store.js +9 -3
- package/dist/stores/file/file-history-store.d.ts +20 -19
- package/dist/stores/file/file-history-store.js +152 -135
- package/dist/stores/file/fs-atomic.d.ts +15 -0
- package/dist/stores/file/fs-atomic.js +89 -14
- package/dist/stores/file/index.d.ts +1 -1
- package/dist/stores/file/mailbox-store.d.ts +3 -0
- package/dist/stores/file/mailbox-store.js +3 -1
- package/dist/stores/file/permission-rule-adopt.d.ts +1 -1
- package/dist/stores/file/permission-rule-adopt.js +15 -16
- package/dist/stores/file/permission-rule-store.d.ts +18 -2
- package/dist/stores/file/permission-rule-store.js +21 -14
- package/dist/stores/file/session-policy-store.d.ts +3 -3
- package/dist/stores/file/session-store.d.ts +26 -11
- package/dist/stores/file/session-store.js +77 -40
- package/dist/stores/file/workflow-journal-store.d.ts +10 -4
- package/dist/stores/file/workflow-journal-store.js +28 -13
- package/dist/tools/artifact/artifact-text.d.ts +1 -1
- package/dist/tools/artifact/artifact-tool.js +2 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +3 -3
- package/dist/tools/fs/fs-bash.d.ts +2 -2
- package/dist/tools/fs/fs-bash.js +3 -1
- package/dist/tools/fs/fs-shared.d.ts +8 -8
- package/dist/tools/fs/gh-rate-limit.d.ts +1 -1
- package/dist/tools/fs/notebook.d.ts +2 -2
- package/dist/tools/fs/read-deny.d.ts +29 -7
- package/dist/tools/fs/read-deny.js +63 -22
- package/dist/tools/fs/removal-targets.d.ts +1 -1
- package/dist/tools/fs/safety.d.ts +9 -9
- package/dist/tools/fs/search.d.ts +6 -6
- package/dist/tools/fs/shell-read-check.js +6 -1
- package/dist/tools/fs/shell-read-judge.d.ts +4 -0
- package/dist/tools/fs/shell-read-judge.js +5 -2
- package/dist/tools/loop-tick.d.ts +2 -2
- package/dist/tools/scheduler-tools.js +5 -1
- package/dist/tools/task-list.d.ts +7 -7
- package/dist/tools/task-list.js +5 -1
- package/dist/tools/todo.js +2 -1
- package/dist/tools/web.d.ts +1 -1
- package/dist/tools/web.js +40 -37
- package/dist/tools/worktree.d.ts +3 -3
- package/dist/tools/worktree.js +3 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +5756 -5638
- package/wire-changes/core@7.54.1.json +37 -0
- package/wire-changes/core@7.55.0.json +3318 -0
- package/dist/core/store-contracts/session-store-incarnation-contract.d.ts +0 -39
- package/dist/core/store-contracts/session-store-incarnation-contract.js +0 -95
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,452 @@
|
|
|
1
|
+
## 7.55.0 — 2026-10-21
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
> 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
|
+
### 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
|
+
|
|
7
|
+
- **Before.** Which arm a journal row is, and whether that arm is legal, had four readers at three strictness levels. `FileWorkflowJournalStore.load` took any `parked` member as the park arm without looking at its terminal (and handed a row with neither arm back result-less); its line floor skipped, with a per-line disclosure, every line that was not a record — one that did not parse, one that was not an object, one without a string `callKey` / `scope` or a numeric `ordinal`, one under another scope — so whatever such a line held looked never journaled; the engine's resume admission re-derived the arm from the entries a store returned and checked the paused kind and the token; the engine's read of an ORIGIN run's journal for re-park material (a chained resume) picked rows by the `parked` member alone; a SQL backend outside this package checked the kind only. So a hand-edited or damaged parked row whose terminal is not `paused` was refused at resume by the engine (`workflow.journal_incompatible`), but `load` itself answered it as an entry; a row carrying BOTH arms was read as the park arm by the file store and as a settled call by the admission's park bookkeeping; an origin journal's corrupt parked row whose token matched was re-parked on and copied into the successor's journal; and a skipped file line left its ordinal looking never journaled, so a resume ran that step again.
|
|
8
|
+
- **Now.** `readWorkflowJournalEntry(row)` answers `{ entry } | { corrupt, why }` (`corrupt` one of `CORRUPT_BODY_FORMS`; `why` a clause naming what is wrong, quoting no stored value) over a DECODED logical row — `callKey` plus exactly one of `result` / `parked` (an encoding's own members, such as the file ledger's `scope` / `ordinal` or a SQL envelope, are decoded first and are not read). It judges which arm the row is and whether the arm carries what a resume reads from it; the payload's other members (`sessionId`, `gate`, `stats`) are not judged. Corrupt: not an object; no `callKey` or a non-string one; neither arm; both arms; a `result` that is not an object; a `parked` that is not an object, whose result has no `terminal`, whose terminal is not `paused`, or whose paused terminal carries no non-empty `token`. Other members are not read and not carried: the entry is minted from `callKey` and its arm. `InMemoryWorkflowJournalStore.load` and `FileWorkflowJournalStore.load` read every ordinal's winning row through it, and a corrupt row refuses the whole load with `WorkflowJournalIncompatibleError` (code `workflow.journal_incompatible`, the entry's ordinal named, no disk location in the message; the file store discloses `{kind:"file", path, reason}` on `onCorruptRead` with the refusal). The file store's line floor judges only the ledger's own members (`scope`, `ordinal`), so a line that lost its call key reaches the reader and refuses the load too; and in a run's own ledger only the unterminated tail a crash leaves mid-write is uncommitted — any other line that is not one of the run's records (it does not parse, is not an object, lacks a string `scope` or a numeric `ordinal`, or carries another scope) refuses the load with `WorkflowJournalIncompatibleError.ledgerLine(runId, line, form)` (same code, `ordinal` `-1`, the 1-based line and its `CORRUPT_BODY_FORMS` form named, the line disclosed on `onCorruptRead` before the refusal). A cross-scope caller still reads an empty journal; a ledger in which no line is a record refuses every caller, since nothing establishes whose it is. The run's scope is read from the ledger's first record, so "another scope" means another than that record's: when the FIRST record's `scope` is itself damaged into another string, that string is taken for the run's, the owner's `load` answers an empty journal, and a resume runs every step again. That form is unchanged in this release; #2174 closes it by keying the ledger by scope in its file path (the shape the resume-claim file already has), so no line is read to learn whose the ledger is. Dropping a row instead would leave its ordinal looking never journaled, and a resume would run it again — a completed step's side effects twice, or a parked step beside the child its pending checkpoint pins. A row a later write to the same ordinal superseded is not part of the answer; `append` does not judge rows (it may re-read the ledger to learn its run's scope), so a run whose ledger carries a corrupt row stays writable. The engine reads the same judge at both of its journal reads and adds only what a resume needs on top — a completed arm's terminal cause: the admission refuses a corrupt row with the same error a judging store throws, and the origin-journal read for re-park material treats a corrupt row as a load that failed. `WorkflowJournalIncompatibleError` now lives with the journal contract (`src/core/workflow-journal-store.ts`, beside `JOURNAL_OVERSIZE_ERROR_CODE`), keeps its root export, its name and its code, takes an optional third argument (`entry`, the row as read: when the reader finds it corrupt the message carries the reader's fixed clause instead of "carries no terminal cause", so no caller text reaches the message), clamps the run id itself, and is reworded: `workflow run "<id>": journal entry #<n> <clause> — this journal cannot be resumed.`, followed by what repair costs (deleting the entry does not repair it; restoring it needs a copy of the ledger kept outside the engine; a fresh run executes every step again, side effects included). The kit (`workflowJournalStoreContract`) gains one case: a parked row whose payload is not a paused result with its token is refused by `append` (any code) or by the whole `load` with code `workflow.journal_incompatible`, never read back as an entry and never dropped. `gate:single-mint` gains a routing row: every class implementing `WorkflowJournalStore` reads its rows through `readWorkflowJournalEntry` in `load`.
|
|
9
|
+
- **Behavior narrowing (open to veto).** (1) **What changed:** `load` of the two bundled journals refuses a run whose winning row for an ordinal is corrupt, where it used to answer. A resume from that run was refused before too (by the engine, same code, zero live dispatches) for every corrupt form except the ones below; only the message now says what is wrong. (2) A row carrying both arms: before, read as the park arm by the file store, and — from a store returning it whole — admitted with the ordinal counted as settled by the park bookkeeping (measured: under a changed call the resume ran that ordinal live, where a parked row is refused); now refused. (3) An entry with a non-string `callKey` from a store outside this package: before, a `TypeError` at admission; now the coded refusal with ordinal `-1`. That `-1` returns to this code meaning only that the refusal names no entry ordinal — a row with no readable call key (core's own stores never produce one: they name the ledger's ordinal), or a ledger line that is not a record (the message then names the line) — and is not a store fault, the meaning its retired `-1` arm used to carry. The two refusals have the same members, so telling them apart today means reading the message; #2185 replaces the `-1` overload with a structured subject member. (4) A file ledger line that is not one of the run's records — call key absent or not a string, `ordinal` absent or not a number, `scope` not a string or another scope than the first record's, a newline-terminated line that does not parse, a line that is not an object: before, skipped and disclosed per line, and a resume ran that step again (a run whose ledger lost ONE line resumed everything else and re-ran that step); now the load and the resume are refused. Not covered: a FIRST record whose `scope` was damaged into another string still makes the owner read an empty journal (Now; #2174). A crash's unterminated last line is still dropped silently. (5) A parked row whose paused terminal carries no token: refused by the engine before (same code), now by the stores' `load` too. (6) A chained resume reads its park material from the origin run's journal: when that journal holds ANY corrupt row, at any ordinal and in any store (core's own included), every park whose material lives there re-parks from its checkpoint row alone — the successor's journal carries the checkpoint-only material (an empty result, zero stats, `usageMissing`) instead of the child's paused result, and the run log names the refused read; before, a corrupt row elsewhere in that journal did not touch the material, and a corrupt parked row whose token matched was re-parked on. (7) The in-memory store's `load` no longer carries members an entry was appended with beyond `callKey` and its arm. **Who is hurt:** only rows no core writer produces (hand edits, damage, a store that returns ill-typed entries) — a host that calls `load` directly on such a run (a journal viewer, a deployment's journal read face) now gets a refusal for the whole journal instead of an entry list. **How to migrate:** nothing for rows core wrote. Do NOT repair a refused journal by deleting the named line: a missing entry is an ordinal a resume runs again, live. Core keeps no copy of a ledger, so restoring the line needs a copy the host kept; otherwise settle the run's pending approvals through the deployment's approval channel and start a fresh run — which executes every step again, side effects included.
|
|
10
|
+
- **How to migrate (consumers).** A journal backend outside this package reads each decoded row through `readWorkflowJournalEntry` in `load` and refuses a corrupt verdict with `throw new WorkflowJournalIncompatibleError(runId, ordinal, row)`; the kit's new case asserts that code on a refused `load`. *server*: `readJournalRowBody` keeps the envelope decode (a JSON object; an own `parked` key selects the arm) and hands `{ callKey, parked }` / `{ callKey, result }` to the reader; its own paused check retires, and the reader also refuses a paused parked row with no token, which the server's reading admits today. Until then the SQL twins' kit binding fails the new case (their `load` refuses with the store-corrupt error, not this code, and admits the token-less row). The local lane's journal read face reads core's file store, so one corrupt row now refuses the whole request there, while the SQL lane stubs the row; answering the local refusal with the same per-row stub (or a coded response) keeps the endpoint one shape across backends. *client-core*: the park-truth projection gate reads `workflow.journal_incompatible` as a string out of `dist/orchestration/workflow.{js,d.ts}`; the class now lives in `dist/core/workflow-journal-store.{js,d.ts}`, so compare against the root-exported `JOURNAL_INCOMPATIBLE_ERROR_CODE` (beside `JOURNAL_OVERSIZE_ERROR_CODE`; the class's `code` is an instance field, not a static one) instead of grepping a build path. Imports of `WorkflowJournalIncompatibleError` from the package root are unchanged.
|
|
11
|
+
- **Pins.** `test/backlog1468-t3-journal-readback.test.ts` (J1 the judge's grid; J2 legal rows across a restart; J3 / J3b a bad row refuses the file load, the entry named, the path only on the seat, the run still writable, a superseding write answered; J4 / J4b a real `runWorkflow` resume refused with zero live dispatches; J5 the in-memory store, the run id clamped, a caller cannot put text in the message; J6 the engine judging a store that does not; J7 / J7b / J7c the kit case discriminates — a store answering the bad row, dropping it, or refusing under a code of its own fails it — and the bundled backends pass; J8 a chained resume over a corrupt origin row re-parks from the checkpoint row alone; J9 / J9b a file line whose call key is a number or absent refuses the load and the resume, zero live dispatches; J10–J10d a line with a string or missing ordinal, a number scope, or a terminated line that does not parse refuses the load and the resume, naming the line, zero live dispatches; J10e a crash's unterminated tail is still uncommitted; J10f the ledger-line refusal carries no caller text); `test/backlog1041-whole-file-corrupt-session.test.ts` and `test/backlog1013-jsonl-read-back-shape.test.ts` re-pinned: their journal cells now expect the refusal after the same disclosures; `test/backlog672-journal-oversize-refusal.test.ts` binds the kit (with its new case) over both backends.
|
|
12
|
+
|
|
13
|
+
### A checkpoint row's index facts, the card table the deadline facet reads, and the read-back judge of a stored rule-offer list, exported from the package root (#1468; additive exports; behavior change: none on a row the engine mints, see Out-of-contract rows; hard-breaking: none; closed sets: new `RULE_OFFER_SHAPE_PROBLEMS` (3 words; registered in `docs/CLOSED-SETS.md`, listed in INTEGRATION-CORE §15); exports: +9 (4 runtime: `checkpointIndexFacts`, `CARD_FACET_OVERRIDES`, `readRuleOffers`, `RULE_OFFER_SHAPE_PROBLEMS`; 5 types: `CheckpointIndexFacts`, `RuleOffersReading`, `RuleOfferRead`, `RuleOfferDropped`, `RuleOfferShapeProblem`); members: none; signatures: `summarizeCheckpoint` gains an optional second parameter `disclose`, `InMemoryCheckpointStore` an optional constructor argument `{ disclose }`, `riskSeverity` an `unknown` overload) (@server)
|
|
14
|
+
|
|
15
|
+
- **Before.** A backend that keeps columns beside a stored checkpoint row derived each one itself (server `checkpoint-store-sql.ts` `put`: the gate kind, the tool name / call id / bound hash read off the pending action without branching on its kind, the descriptor, the directional bit), and its reaper's facet predicate was a hand-written list of approval gate kinds; a parked question (an approval gate whose card is `question`, facet `B`) is invisible to such a list. `facetOf` answered the question card by a literal comparison inside `isQuestionGate`, a second reading of the gate's card beside `parkedApprovalCardOf`. The stored rule-offer list (`pendingAction.ruleOffers`, the same `RuleOffer[]` as `AskRequest.ruleOffers`) had no reader in core: the server judged it with its own schema (`RuleOfferRawSchema`) behind two pre-screens of its own (`backfillLegacyBatchMemberKind`, `screenUncoveredDetailSeat`).
|
|
16
|
+
- **Now.** (1) `checkpointIndexFacts(cp)` is the one derivation of a row's immutable, indexable facts: thirteen members, every one present, `null` = the row carries none — `token`, `scope`, `sessionId`, `version` (`checkpointVersionOf`), `gateKind`, `approvalCard`, `toolName`, `toolCallId`, `boundInputHash` (the three off a `tool_approval` action only), `deadline`, `createdAt`, `riskDescriptor` (an approval gate's, the row's own object), `hasBidiControls` (`true` or `null`). The card is read through `parkedApprovalCardOf` and kept AS STORED: a word of `TOOL_APPROVAL_CARDS`, or a string outside the set (kept, so a later engine that knows the word reads the column right); `null` for a non-string, no card, or a non-approval gate. Not in it: the deadline facet (a registry reading a stored copy would freeze), state the CAS faces move, a value the deployment mints (an abandonment backstop), a display projection the deployment bounds or redacts. Pure. `summarizeCheckpoint` reads the members it shares with it (token, scope, session, gate kind, deadline, createdAt, tool id and name, the descriptor behind `severity`, the row's own directional bit), so its severity line no longer spells the two approval kinds: the descriptor is an approval gate's by the registry, through `parkedApprovalCardOf`. (2) `CARD_FACET_OVERRIDES` (`{ question: "B" }`, frozen) is the data `facetOf` reads: a key of the table on an approval gate takes the key's facet, anything else the gate kind's registry facet. `isQuestionGate` reads `parkedApprovalCardOf`. A backend's facet predicate over a stored card column is generated from the table and the registry (`approval_card IN (<keys>)` takes the key's facet, any other word or NULL the kind's): row by row it equals `facetOf` (pinned over six gate kinds × fifteen card values). (3) `readRuleOffers(raw)` reads a stored offer list: `{ offers: [{ index, offer, uncoveredDetailDropped? }], dropped: [{ index, problem }] }`, or `{ problem: "not_an_array" }`; `undefined` reads as no offers. Per element, original index kept: additive keys stripped at every level; a word outside a closed set (`kind`, a batch member's `kind`, `match`) drops the whole element (`unknown_word`), so does a missing or mistyped member, an empty batch or a count that is not a non-negative safe integer (`malformed`); a batch with one unreadable member is dropped whole; siblings stand. A batch member with no `kind` and the four text members of a command member reads as `command` — a stored-row form from before members carried a `kind`, on retirement line `RL-2164-kindless-batch-member` (`docs/RETIREMENT-LINES.json`, last version allowed to ship it 7.57.0, #2164: once the backends holding parked rows confirm none carries it, the reading goes and such a member is `malformed`); an offer read through that form is marked `kindlessForm: true` on its reading, so a backend counts the rows that still carry it with the engine's reader rather than a copy of the predicate. A batch `uncoveredDetail` seat that is not one well-formed row per counted segment is left out alone and the reading says so. Each member is read once; the answer is a fresh object. Against the server's own reading of the same element (its schema behind its two pre-screens, same zod), 73 of 76 forms agree; the three that differ are the server's display cap of 32 detail rows (the cap stays the server's), an array carrying the four text members as properties and a class instance (neither is a plain record to core — the judge and the descriptor axes share one record reading, `isPlainRecord` — and no JSON value has either form). (4) The store-contract kit gains one case: a row's `checkpointIndexFacts` survive `put` → `get` (an approval gate with a card, a word outside the set, no card, a descriptor, a deadline, the directional bit; a tool-less park).
|
|
17
|
+
- **Out-of-contract rows (no row the engine mints; behavior change, ruled C-R674, open to veto).** `summarizeCheckpoint` now reads three members through the facts' `null`, so on a hand-written or damaged row: a stored `deadline: null` omits the key (before: `deadline: null`); a `tool_approval` row whose `toolName` / `toolCallId` is missing or `null` omits that key (before: the key present with `undefined` / `null`); an approval gate whose `riskDescriptor` is present but whose axes are damaged — not a plain record, or a mark (`egress` / `irreversible` / `shell`) that is neither absent nor a boolean — is disclosed once per listing, through `summarizeCheckpoint`'s new optional second argument `disclose(sentence)`. Axes that are not a plain record omit `severity`. For marks: a damaged mark contributes nothing; the valid marks give their floor; "nothing tripped" can't be claimed past damage — so `{ irreversible: true, egress: "true" }` reads 4 and is disclosed, `{ egress: "true" }` has no severity and is disclosed, and `{}` / `{ egress: false }` read 2. The sentence names the row by checkpoint id and session, each JSON-rendered (an absent id reads `null`), never by token, and the member by its shape, never its value. The file store hands it its `onCorruptRead` seat (`{ kind: "file", path: <its ledger>, reason: <the sentence> }`); `InMemoryCheckpointStore` takes the same seat under the same name (`new InMemoryCheckpointStore({ disclose })`; it has no path, so it forwards the sentence); the pg reference adapter has none and its header says so. So a damaged row does not sink below the graded rows of a severity-sorted inbox unannounced where a seat is wired. Cost, by design: the sentence repeats on every listing, so a polled inbox logs it once per poll until the row is repaired or resolved. Before: a non-object descriptor, `{}` and `{ axes: null }` threw a `TypeError` out of the projection, so out of `listByScope` for the whole scope; axes that were a string, a number, a boolean, an array or a class instance read a tier (2, or the instance's marks), and a mark that was not a boolean (`{ egress: "true" }`, `{ egress: 1 }`) read as unset, lowering the tier silently. A stored `riskDescriptor: null` reads as no descriptor: `severity` omitted, nothing disclosed (before: a `TypeError`). One rule, not an arm per form: `riskSeverity` is total over one reading of the axes (a plain record by `isPlainRecord`, the one the descriptor's own mint reads a call's arguments with; its marks read by the rule above) — what proves no tier has none (`undefined`), never a throw — through an added overload (`riskSeverity(axes: unknown)`), the typed signature unchanged. Every other row projects byte-for-byte as before (2 880 rows: six gate kinds × ten card values × four descriptors × four deadlines, the two approval kinds also × seven pending-action forms; `facetOf` / `isQuestionGate` identical on all of them).
|
|
18
|
+
- **Who is hurt.** Nobody at runtime on rows the engine mints. A store-contract binding whose backend changes a row's gate card, deadline, descriptor, directional bit or tool fields on the way back now fails the new kit case.
|
|
19
|
+
- **How to migrate.** *server* (stage two of the store boundary): write the checkpoint columns from `checkpointIndexFacts` instead of reading the row; add the `approval_card` column holding `facts.approvalCard`, filled for existing rows from the stored row through the same function, and turn the generated facet predicate on only once the deployment's own record of that one-time backfill says it finished; generate the reaper's facet predicate from `CARD_FACET_OVERRIDES` and `PAUSE_REGISTRY` and retire the hand-written approval-kind list; read the offer list with `readRuleOffers` on both legs (the durable read and the synchronous mint) and retire `RuleOfferRawSchema`, `backfillLegacyBatchMemberKind` and `screenUncoveredDetailSeat` — keep the display caps (`MAX_RULE_OFFERS`, the member pre-bound, the 32-row seat cap), the redaction and the clipping, and count a `dropped` element and an `uncoveredDetailDropped` reading where the pre-screens counted (and a `kindlessForm` reading toward the retirement of `RL-2164`); a listing that projects through `summarizeCheckpoint` passes its corrupt-read sink as the second argument. *cli* / *client-core*: nothing.
|
|
20
|
+
- **Internal (no behavior).** `samePlainValue` moved from `checkpoint-store.ts` into its own module (`src/core/same-plain-value.ts`); it is not exported.
|
|
21
|
+
- **Pins.** `test/backlog1468-p1-checkpoint-facts.test.ts` (R1 the facts: keys, the card as stored and as the one card reading reads it, the generated predicate against `facetOf`, tool fields and descriptor by kind, scalars, purity, two real Runner parks; R2 the summary reads the facts; R3 the table, `facetOf` and `isQuestionGate` over every kind × card value; R4 the kit reds a store that drops the card; R5 the offer judge on the server pins' fixtures, problems and indices, whole-batch drops, the detail seat, one read per member, every list `suggestRulesForCommand` mints reads back unchanged); `test/cross-backend-inmem-file.test.ts` / `test/pg-cross-backend.test.ts` (the kit case on the bundled stores); `test/export-surface.snapshot.json` (+9, all `advanced`).
|
|
22
|
+
|
|
23
|
+
### A file-history failure says whether retrying can work: `FileHistoryError.permanence`, the backend's judgment of a fault in the engine's one retry vocabulary (`GovernanceRetryClass`); every `onError({ phase: "rewind" })` delivery is a `FileHistoryStoreError` carrying the failure's `code` and `permanence`; the first-touch edit refusal's whole sentence reaches the model (#1468 stage one, T2 — the 7.55.0 theme; behavior change, open to veto: the first-touch edit refusal is reworded and reordered and a permanent failure tells the model not to retry; an import onto a target whose tracked set or boundary directory cannot be read answers `adopt_failed` instead of `conflict`; a rewind onto a boundary that is there but cannot be used fails with `rewind.restore_failed` instead of `rewind_snapshot.unresolvable` (ruled C-R664, open to veto); behavior change, ruled C-R672, open to veto: that rewind's terminal carries the store's judgment in `terminal.detail`; a retraction that cannot be judged answers `track_failed` instead of `conflict`; an adoption or import onto a target holding a boundary nobody can use is refused instead of publishing over it; an import whose source lacks a blob answers `adopt_failed` instead of `not_found`; a failure code outside the closed set reads as a non-result; and **an export of a scope holding one boundary nobody can use (torn, or unreadable) — or an unreadable tracked set — is a typed failure instead of the readable part, so a session sync that reads it carries none of that session's rewind history unless the host acts on the failure**; the contract kit is tightened to match; hard-breaking: `FileHistoryStore.exportHistory` returns `FileHistoryExportResult` instead of `FileHistoryExport | null` — a backend that implements it and a host that reads it must change (ruled C-R672, open to veto); closed sets: none new — `permanence` reuses `GovernanceRetryClass` (`transient` / `terminal`), ruled C-R663, open to veto; exports: +1 class, +1 type (`FileHistoryExportResult`); members: +1 optional `FileHistoryError.permanence`) (@server @client-core)
|
|
24
|
+
|
|
25
|
+
- **What changed.** (1) `FileHistoryError` gains `permanence?: GovernanceRetryClass` — the backend's judgment of a FAULT (it could not do what it was asked), in words defined once, on `GovernanceRetryClass` — whose definition (the file header's second one retired) now covers both readers: can repeating the same request succeed? `transient` = with nothing changed first (the cause clears by itself, with no stated horizon); `terminal` = not until something named changes first — a verdict (a policy, or a usage window until the `retryAfterMs` it states), the deployment, the store or the input. One membership reading (`isGovernanceRetryClass`, keyed by the type, module-level) serves the engine and the contract kit. Stated whenever the backend knows: from a caught error's code, or from a state it found itself (a damaged record — unparseable, the wrong shape, failing its own digest — is `terminal`). Absent when it does not know (a code it does not judge), and on an outcome that is not a fault (a boundary never made, the empty-target law's `conflict`, an abort, a spent capture lease). The vocabulary is the one the governance codes already answer in (ruled C-R663, open to veto); no second word list. Both bundled backends read a caught value one way, by its `code` member, never its message: `EACCES`, `EPERM`, `EROFS`, `ENAMETOOLONG`, `EFBIG`, `ENOTDIR`, `EISDIR` ⇒ `terminal`; `EAGAIN`, `EWOULDBLOCK`, `EBUSY`, `EINTR`, `ETIMEDOUT`, `EMFILE`, `ENFILE` ⇒ `transient`; any other code (or none) ⇒ absent — a full disk or quota (`ENOSPC`, `EDQUOT`) included, since whether anything frees it is not the store's to know. `InMemoryFileHistoryStore` states it at its nine catches; `FileFileHistoryStore` at its nine catches and at every answer built from a helper that met a fault — the tracked set (unreadable or damaged), the consumed-id ledger, a tombstone append, a boundary file that is there but cannot be used (restore answers `restore_failed` with the fault — `not_found` is only a boundary never made, at every verb, and never carries `permanence`; a snapshot that meets such a file at publish answers `snapshot_failed` with it), boundaries that cannot be judged — a directory that cannot be listed or a boundary file that cannot be used, read by ONE judge (unreadable ≠ empty: an adoption or import onto such a target and an adoption from such a source answer `adopt_failed` with the fault, an import before it fetches a single blob; a retraction that would need the set answers `track_failed` with it, record left in place; an export of such a scope is a typed failure, (4) below), a source blob a copied chain names that is missing or damaged (`adopt_failed`, `terminal`), and a target or source whose tracked set cannot be read (`adopt_failed`; a retraction over it, `track_failed`). An import whose source cannot supply a referenced blob answers `adopt_failed` with no `permanence` (the source's loss is not this store's to judge). (2) Every `onError({ phase: "rewind" })` delivery is a `FileHistoryStoreError` (package root; it carries `FileHistoryError`'s `code` and `permanence` — its `message` is the engine's sentence, not the store's text). A timed-out boundary capture is `aborted`; a backend that threw, rejected or answered something that is not a result — on any seat: capture, fork carry, first-touch record, retraction, read by one judge — is that verb's `*_failed` with no `permanence`, a thrown value as the standard `Error.cause`. That judge reads every answer once into a plain copy — a success (`ok`, and `trackEdit`'s `minted`) as much as a failure — so no member of the store's object runs again at a seat that must not throw: a failure whose code is outside the closed set is a non-result that names the word ("the history store answered a code outside the closed set ("…"): <store text>"), a `permanence` outside the two words reads as unjudged, a message that is not a string as "(the history store gave no message)" (ruled C-R674, open to veto). A failed rewind task (`rewind.restore_failed` from the restore's own failure) carries the store's `{ code, permanence }` in `terminal.detail` (`{ kind: "coded", detail }`, the existing coded-failure channel); a boundary never made carries none (ruled C-R672, open to veto). The result arms' messages are unchanged; the throw arms' messages name the verb and keep the thrown text. The error's `name` is `FileHistoryStoreError`, so `String(err)` begins with it instead of `Error`. (3) `fileHistoryStoreContract` checks the cell with one rule: a stated `permanence` is a `GovernanceRetryClass` word, on a code that names a fault (`*_failed`, `unsupported_format`) — never on `not_found`, `conflict` or `aborted`. Leaving a fault unjudged still conforms — the engine then keeps the retry wording. Two cases are tightened (ruled C-R672, open to veto): the absent case also runs beside a tracked, edited file and checks that a restore of a boundary never made touches nothing (a backend that writes first and then answers `not_found` is refused), and an import whose source lacks a blob must answer `adopt_failed`; the export case reads the typed result, and an empty scope must export `{ ok: true, history: null }`. (4) `exportHistory` answers a `FileHistoryExportResult` (package root): `{ ok: true, history }` with the whole graph, `{ ok: true, history: null }` for nothing to export (the one meaning of `null`), or `{ ok: false, error }` — `adopt_failed`, the adoption family's code, with the fault and a message naming what cannot be read ("exportHistory scope "…" has boundaries that cannot be used ("b2") — its history cannot be exported whole, so nothing was exported") — for an unreadable tracked set or boundaries that cannot be judged. Never a partial graph.
|
|
26
|
+
- **Behavior change (open to veto): the first-touch edit refusal.** Before: `Error (<Tool>): the edit was refused because its first-touch file-history backup could not be persisted (<code>: <store message>). Proceeding would leave "<path>" with no durable pristine record — after the history store recovers, a retried edit would mint already-modified bytes as the pristine baseline. Retry when the file-history store is healthy.` The Write / Edit / NotebookEdit lane bounds a refusal at 300 characters, and the head, the store's message and the path usually filled them: the model received the sentence cut inside the store's message, and the retry advice never reached it. Now: `Error (<Tool>): the edit was refused because its first-touch file-history backup could not be persisted (<code>). <lead> Proceeding would leave the file with no durable pristine record. <store message>`. `<lead>` is "The history store reports this failure as permanent: do not retry this edit until the store is fixed." when the store said `terminal`, and "Retry when the file-history store is healthy." otherwise. The path is no longer in the sentence (the call names it). The engine's whole sentence — code, lead and consequence — is at most 300 characters for every tool, code and lead (the longest, NotebookEdit × `unsupported_format` × the permanent lead, is 298), so only the store's text after it can be cut. A store whose `trackEdit` throws or answers something that is not a result is the same sentence (`track_failed`, the retry lead, "the history store threw: …" / "… answered something that is not a result"), where before it was a separate "the file-history first-touch backup failed (…) — nothing was written" sentence with no lead. The model now receives the code and the retry advice it did not receive before, and a permanent failure tells it not to retry.
|
|
27
|
+
- **Behavior change (open to veto): an import onto an unreadable target.** `importHistory` (and the publish step of `adoptScope`) onto a target whose tracked set cannot be read answered `conflict` — "the target gained history of its own while this import was being staged", which was not true. It now answers `adopt_failed`, naming what cannot be read (the tracked set, the boundary directory, or the boundary files that cannot be used), with the fault's `permanence`, and an import says so before it fetches a single blob. Before, a boundary directory that could not be listed read as "no boundaries", and a target holding only a boundary nobody could use read as EMPTY: the publish removed that record (tombstoning its id). Now such a target is refused and the record stays for an operator (ruled C-R672, open to veto).
|
|
28
|
+
- **Behavior change (ruled C-R672, open to veto): a retraction that cannot be judged, and an import whose source lacks a blob.** `annulTrack` over an unreadable tracked set, or over boundaries that cannot be judged (before or after its proof read), answered `conflict` — the refusal law's code, as if a boundary had frozen the record. It now answers `track_failed` with the fault, so `permanence` appears only on a code that names a fault. An `importHistory` whose source cannot supply a referenced blob answered `not_found` — the code that means "no such boundary". It now answers `adopt_failed` (bundled backends; a third-party backend's kit run asks the same).
|
|
29
|
+
- **Behavior change (ruled C-R664, open to veto): a rewind onto a boundary that is there but cannot be used.** Before: the File backend answered `not_found`, so `restoreFiles` / `rewindFilesTo` onto an unreadable or damaged boundary failed the task with `rewind_snapshot.unresolvable` and "no file-history boundary exists for entry …" — untrue, and with nothing for a host to tell it from a boundary never made. Now the backend answers `restore_failed` with the fault, and the task fails with `rewind.restore_failed` ("rewind-files restore failed (restore_failed): the history boundary for entry … is there but cannot be used …") and `terminal.detail` = `{ kind: "coded", detail: { code: "restore_failed", permanence } }` (that half ruled C-R672, open to veto), so a host can tell a transient read from a damaged record; a boundary never made keeps `rewind_snapshot.unresolvable`, with no detail. A "before" rewind no longer asks `canRestore` first: the restore's own answer decides it (one `restore` call on the target's own key, `not_found` = the refusal). `canRestore` is now documented as what the bundled backends answer — a USABLE boundary, `false` both for one never made and for one that cannot be read; `restore` says which, and its documentation now states that a boundary never made touches nothing.
|
|
30
|
+
- **Hard-breaking (ruled C-R672, open to veto): `exportHistory` answers a typed result.** `FileHistoryStore.exportHistory?(scope)` returns `Promise<FileHistoryExportResult>` (`{ ok: true; history: FileHistoryExport | null } | { ok: false; error: FileHistoryError }`) instead of `Promise<FileHistoryExport | null>`, and a scope whose history cannot be read whole — one boundary nobody can use (torn, or unreadable such as `EACCES`), a boundary directory that cannot be listed, or an unreadable tracked set — answers `{ ok: false, error: { code: "adopt_failed", … } }` where 7.54.0 exported the readable part (the graph minus that boundary) and the unreadable-tracked-set case answered `null`. A backend that implements `exportHistory` (the server's SQL store) no longer type-checks until it returns the result, a host that reads it (the server's session-sync manifest and blob route) must read `.ok` / `.history`, and a host that treats a failed export as "no history" now syncs a session with one damaged boundary with none of its rewind history — before, it lost that one turn. Read `const r = await store.exportHistory(scope)`: `r.ok` ⇒ `r.history` (an envelope, or `null` for nothing to export); `!r.ok` ⇒ `r.error` (`adopt_failed`, its `permanence`, a message naming what cannot be read), and what a sync does with it — report it, retry it, or proceed without history — is the host's to decide.
|
|
31
|
+
- **Who is hurt.** A test or host that pins the old refusal sentence, its order, or the path in it. A host that recovers the failure code from a rewind disclosure's text: the result arms keep the code word in the message, but the reading to use is `err.code`. A host that read the raw thrown value off the fork-carry or boundary-capture throw arms: it is now `err.cause`. A host that branched on `conflict` from an import onto an unreadable target. A host that maps the task code `rewind_snapshot.unresolvable` (the server answers 422 "the anchor is wrong"; client-core reads it as user-fixable): a damaged boundary now arrives as `rewind.restore_failed`, whose classification is the host's own — a host that answers it as an ordinary failed run (and caches it under an idempotency key) can now read `terminal.detail.detail.permanence` to tell a transient read from a damaged record. A host that read `conflict` off a retraction disclosure as "frozen by a boundary": a retraction that could not be judged is now `track_failed`. A host or backend that reads an import's `not_found` as "the source lacks a blob": it is `adopt_failed`; a third-party backend that still answers `not_found` there fails the tightened kit case. A fork whose own scope holds a boundary nobody can use: its carry is now refused (`adopt_failed`, disclosed once per session) instead of publishing over that record. A store whose retraction throws is now an `onError({ phase: "rewind" })` delivery (`track_failed`, the thrown value as cause) — before, the lane swallowed it with no delivery, so a host that counts disclosures by code sees one more kind. A backend that implements, or a host that reads, `exportHistory` — and a session sync that reads a scope holding one damaged boundary: see the hard-breaking entry above. Log lines built with `String(err)` show `FileHistoryStoreError:`. The server's file-history disclosure notes say the model hears "Retry when the file-history store is healthy"; before this release it did not.
|
|
32
|
+
- **How to migrate.** Read `err.code` / `err.permanence` on a rewind disclosure, and `terminal.detail` on a failed rewind task, instead of matching text; read `exportHistory`'s `.ok` / `.history` / `.error`, and return that shape from a backend's own `exportHistory`; the words mean what `GovernanceRetryClass` defines for every reader, and an absent cell is "not judged". A backend states `permanence` where it meets a fault (a size-cap error ⇒ `terminal`) instead of folding a code word into its message; a host's metrics read the result's `permanence`.
|
|
33
|
+
- **Known limits.** `KL-1468-t2-b` (a session-metadata read failure in the fork-carry seat is reported as the carry's `adopt_failed`), `KL-1468-t2-c` (an environment read failure while capturing a user's file carries no `permanence`).
|
|
34
|
+
- **Pins.** `test/backlog1468-t2-file-history-permanence.test.ts`: the one reading of a caught value (judged codes, unjudged codes leave no member, a code-less message that spells codes, a hostile `code` member); the File backend's track / snapshot / restore / adopt / import × {terminal (injected `EROFS`, a real read-only directory where the process is not root, a damaged record), transient (injected `EAGAIN`), unjudged (injected `EIO`), not a fault}; the helper arms (tracked set, consumed-id ledger, tombstone carry, unusable boundary file ⇒ `restore_failed`, unlistable boundary directory on adopt / retraction / export, missing or damaged source blob, unjudgeable source boundaries, unreadable import target); a full disk or quota is unjudged; a rewind onto a damaged boundary fails `rewind.restore_failed` at and before alike while a boundary never made stays `rewind_snapshot.unresolvable`; a `trackEdit` / retraction / capture that answers a non-result, and a failure answer read once (a Symbol or throwing message, a foreign code and word); the retry words defined once; a path whose name spells codes moves nothing; through a real Runner: the refusal text the model receives, exactly, for an injected store (terminal / transient / unstated) and for a throwing `trackEdit`, the File backend's sentence whole within the bound, the 300 bound over every tool × code × lead; every rewind delivery arm typed (answered failure, sync / async throw, non-result, tolerated first-touch failure, failed and thrown retraction, fork carry answered and thrown); the kit refuses a third word and a stamped non-fault (`not_found` alone included), and accepts a backend that states none; an answer whose `ok` reads true once and then false or throws is read once at the boundary, edit, retraction and carry seats (the tools all run); the four cannot-judge retraction arms answer `track_failed`; an unusable boundary file (torn, or `EACCES`) refuses an adoption onto its scope and a retraction it may reference; an import onto a target whose boundary directory cannot be listed names that directory and fetches nothing; a source's missing blob is `adopt_failed` in both backends; a failed rewind's `terminal.detail` carries the store's judgment (transient / terminal apart, a third word dropped); the kit refuses a backend that rewrites a tracked file before answering `not_found`, and one that still answers `not_found` for a missing blob; an export over one torn or `EACCES` boundary out of two is a typed failure naming it (never the readable subset, never `null`), an empty scope exports `{ ok: true, history: null }`, and a readable-again scope exports the whole graph. `test/export-surface.snapshot.json`: +2 (`FileHistoryStoreError`, class, and `FileHistoryExportResult`, type, both `advanced`). `test/backlog1050-file-history-capture-off.test.ts`: a refused "before" rewind makes one `restore` call, on the target's own key (it made none while `canRestore` was asked first). `test/file-history-file-store.test.ts`: a torn boundary restores as `restore_failed` / `terminal`; a target holding only a torn boundary is refused (adoption and import), and publishes once the record is removed; a retraction over a corrupt tracked set is `track_failed`. `test/file-history-retention.test.ts`: a scope holding only a boundary nobody can use is not empty (the record stays, its id is not handed back).
|
|
35
|
+
|
|
36
|
+
### The approval-card closed set gains `skill` and `workflow`, and the engine's own `Skill` and `Workflow` tools declare them (#1475; hard-breaking, open to veto: a type-level exhaustion of the set; closed sets: `TOOL_APPROVAL_CARDS` / `ToolApprovalCard` 8 → 10, +`skill` +`workflow`; exports: none; members: none; behavior change: the card word an ask, a parked gate and a pending row carry for these two tools) (@cli @client-core @server)
|
|
37
|
+
|
|
38
|
+
- **What changed.** `TOOL_APPROVAL_CARDS` (`file-edit`, `file-write`, `notebook-edit`, `shell`, `fetch`, `plan`, `question`, `generic`) gains `skill` and `workflow`. The `Skill` and `Workflow` catalog rows declare them (`renderHints.approvalCard`), so the approval a shell receives for the model's `Skill` call carries `approvalCard: "skill"` and for its `Workflow` call `approvalCard: "workflow"` — on the live ask (`AskRequest.approvalCard`), on the parked gate (`CheckpointGate.approvalCard`, both approval arms) and on the pending row (`CheckpointSummary.approvalCard`); before, both read `generic`. The reference implementation renders these two approvals under their own dialogs (the skill's name and description; the workflow's phases and script), which is why they are cards and not hints on `generic`; a read-only file approval is not one (its dialog is the generic fallback with a different title), so no read card is added. Both words are ordinary cards: a declared (MCP or caller) tool may name them, `skill` only on a schema that has a `skill` property (`APPROVAL_CARD_REQUIRED_KEYS`), `workflow` on any schema (a workflow call names its script one of three ways, so no single key is common to them). Neither is reserved to the engine's tool, and neither is read from a tool's name: a caller tool called `Skill` that declares no card still asks under `generic`.
|
|
39
|
+
- **Who is hurt.** (a) Type only: code that exhausts `ToolApprovalCard` — a `Record<ToolApprovalCard, …>`, or a `switch` ending in a `never` check — stops compiling until the two words have a row; the engine's own table, `APPROVAL_CARD_REQUIRED_KEYS`, is one (a downstream copy of it is another). (b) A shell that maps a card word to a dialog and shows a word it does not know as `generic` shows these two approvals exactly as before, until it adds the dialogs. (c) A shell that treats a word outside its table as a fault, and a rollback: a row parked with the new word and read by an engine of an earlier version reads as `unknown_word` (`parkedApprovalCardOf`); a row parked before this version keeps `generic` and reads the same. (d) Nobody at runtime inside the engine: no gate, rule or classifier reads the word.
|
|
40
|
+
- **How to migrate.** *cli*: delete the two face-based fallback arms that stood in for these cards and render a `skill` and a `workflow` approval from the card word (L-1724). *client-core*: regenerate the card-word table of the generated type surface. *server*: add the two words to any table or switch over the approval-card set (a card-word wire enum, a mirror of `TOOL_APPROVAL_CARDS`). The wire-change list records the closed-set growth at the next cut.
|
|
41
|
+
- **Pins.** `test/backlog1475-skill-workflow-approval-cards.test.ts` (the set and the required-keys table keyed by exactly it; the overlay door for each word; a real Runner for the model's `Skill` call and `Workflow` call asking under the new words; the control that a caller tool asks under `generic`); `test/tool-name-literal-gate.test.ts` (the two new `skill` string literals registered as engine identifiers: `src/core` 91 → 92, `src/core/runner` 32 → 33); the per-fixture mutation table is in the car receipt.
|
|
42
|
+
|
|
43
|
+
### The "The user answered:" echo names each chosen option by its place among the question's options (`[n] label`), so two options that differ only by a space character are told apart (#2153; behavior change, model-visible text, differs from the reference implementation's echo, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@cli)
|
|
44
|
+
|
|
45
|
+
- **Before.** The AskUserQuestion result echoed each chosen option as its label alone, folded to one line (`- Pick: Plan A`). The fold maps every whitespace class — the ideographic space U+3000, the no-break space U+00A0, the rest — to one ordinary space, so two options whose labels differ only by such a character (`Plan` + U+3000 + `A` and `Plan` + U+00A0 + `A`) echoed as the same text, and the model could not tell which of them the person took.
|
|
46
|
+
- **Now.** Each chosen label that is one of the question's options is echoed with its 1-based place in `options`: `- Pick: [2] Plan A`. A multi-select names each chosen label's own place, in the order chosen (`- Pick: [3] C, [1] A`). The fold is unchanged (it is what stops a label from forging a second answer line, a closing tag or a fence marker), and one function now produces the echo of a chosen label for every line it lands on: the trusted answer line, the duplicate-answer line and the discarded-extra-selection line (the two fenced lines carry the label's characters as they came, with the place in front). An off-list entry is not an option, has no place, and is echoed as it came inside the fence. Nothing else in the result changes: the lead sentence, the "no listed option selected" and "did not select any option" sentences, the fenced note and the details card.
|
|
47
|
+
- **Differs from the reference implementation (open to veto).** The reference implementation echoes the raw label characters on a `"<question>"="<answer>"` line, with no place and no fold. The engine already differed (folded labels, one bullet per question); the place is a second difference on the same line, taken because the fold is a safety guard that stays and the place is what restores the distinction the fold costs.
|
|
48
|
+
- **Who is hurt.** (a) A host, harness or eval that reads the answer line of the tool result by its old shape (`- <header>: <label>`) now meets `- <header>: [n] <label>`: match the label after an optional `[n] `. (b) Prompts or tests that quote the old line. Nobody else: the result text is the model's, the shell renders the question from its own card, and no wire member moves.
|
|
49
|
+
- **How to migrate.** *cli*: where the shell echoes or re-parses the model-visible answer text (L-1680), accept the leading `[n] `; the place is the option's 1-based index in the asked question, so a shell that holds the question can map it back to the option. Everyone else: nothing, unless (a) applies.
|
|
50
|
+
- **Pins.** `test/backlog2153-ask-answer-ordinal.test.ts` (look-alike labels told apart and the fold unmoved; multi-select places; the two fenced lines and the off-list entry; the fold still defusing a line break and a closing tag; a real Runner reading the result); `test/core-toolgate-coverage.test.ts` and `test/ask-question-continuation-card.test.ts` (the existing echo cells re-pinned to the new line); the per-fixture mutation table is in the car receipt.
|
|
51
|
+
|
|
52
|
+
### A release is published from an ordinary clone of its tag, so the registry document carries `gitHead`: `scripts/publish-release.sh <version>` is that publish, and `release-preflight --publish-checkout <dir>` judges the clone (#2154; release tooling only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
53
|
+
|
|
54
|
+
- **Before.** The 7.54.0 root package and `@sema-agent/mods-kernel` were published from a linked worktree of the tag, and their registry documents carry no `gitHead` (`npm view @sema-agent/core@7.54.0 gitHead` is empty; 7.53.0, published from an ordinary checkout, has it). npm fills `gitHead` at publish time by reading `<package dir>/.git/HEAD` (npm 11.3.0, `@npmcli/package-json` `normalize.js`: it finds the git root from the package directory and reads the `.git` inside it as a directory); a worktree's `.git` is a file, and a workspace package's directory has no `.git` at all. The third leg of the registry's three-way sameness (shasum, `gitHead`, tag) was gone for both packages.
|
|
55
|
+
- **Now.** (1) `docs/RELEASE-TIERS.md` (release steps 3 and 4) publishes from `git clone --branch v<ver> --depth 1 <repo> <dir>`, never a worktree, and writes the clone's HEAD into each workspace package's manifest for its publish only (`npm pkg set gitHead=…`, restored with `git checkout -- packages/<dir>/package.json`, never committed). (2) `scripts/publish-release.sh <version>` does the whole publish in one order that keeps the token last: `PUBLISH_GATE_DIST` (the `dist/` the RC gate ran on) must be named, the tag must exist, the tag is cloned and its root package and every non-private workspace package must be at `<version>`, the preflight leg runs over the clone, `dist/` is built there and must be byte-identical to the gated one — all of which refuse before anything is read or published — then the token goes into a private temporary npmrc (read from the shell profile's `NPM_TOKEN` export; mode 600; never printed, never on a command line, removed on exit), `npm whoami` must be `vivi-ai`, the root and then every non-private workspace package are published, and `npm view <name>@<version> gitHead` must be the tag's commit for each of them (exit 1, naming the package, otherwise). A package the registry already holds at the tag's commit is skipped, so a release that stopped half way (the root out, a workspace package failed) is finished by running the same command again; one the registry holds with another gitHead, or none, is refused. (3) `node scripts/release-preflight.mjs --rc <ref> --publish-checkout <dir>` adds a leg to the pre-tag check: `<dir>/.git` must be a directory and `git -C <dir> rev-parse HEAD` must be the RC commit; the verdict now splits `manifestProblems` from `checkoutProblems` (`problems` is both, in that order).
|
|
56
|
+
- **Not done here.** The script has not been run against the real registry or the real token: its first use is the 7.55.0 publish. It has been run end to end against a fake `npm` and a fake profile (git, node and the preflight real; the v7.54.0 tag as the release): the guards above, the order of the calls, the token only in the private npmrc, the workspace manifest dirty only for its own publish, the half-finished release finished by a second run. What that cannot show is the real `npm`'s own behavior (that it reads a gitHead from an ordinary clone's `.git` is read from npm 11.3.0's source, `@npmcli/package-json` `normalize.js`, not measured by a publish) and a byte-identical `dist/` from a real build (the comparison is `diff -rq` over a build the fake `npm` stands in for).
|
|
57
|
+
- **Who is hurt.** Nobody at runtime: `src/` is untouched and nothing in the package changes. The publisher uses `scripts/publish-release.sh` (or follows steps 3 and 4) instead of `npm publish` from the RC tree. `releasePreflight()`'s return gains `manifestProblems` and `checkoutProblems` (the existing `ok`, `problems`, `notes` keep their meaning).
|
|
58
|
+
- **Pins.** `test/backlog2154-publish-checkout.test.ts` (the leg on a throwaway repository: an ordinary clone clean; a worktree, a missing directory, a hollow `.git` and a clone at another commit each named; the leg through `releasePreflight` and the CLI; the script parses, refuses before the profile, orders its steps token-last; the release steps name the clone, the script and the leg); `test/backlog2154-publish-release-run.test.ts` (the script run against a fake `npm`: H1 no gate dist, H2 / H3 version disagreements, H4 a dist mismatch, H5 the whole release and its token hygiene, H6 / H7 a package already on the registry, H8 a half-finished release finished by a second run, H9 the wrong account); `test/backlog2068-release-preflight.test.ts` unchanged and green.
|
|
59
|
+
|
|
60
|
+
### An MCP server connection ends its session when the engine lets it go, follows a session the server dropped, and stops dialing when the run is aborted (#2147, #2148, #2149; behavior change, open to veto; hard-breaking: none; closed sets: none (two new tool-result codes, `mcp.tool_declaration_changed` and `mcp.tool_listing_unreached`, on the open `details.code` / `tool_end.errorCode` face); exports: `materializeMcpTools` gains a trailing optional `signal`, `classifyMcpFailure`'s request site gains an optional `session: true`; members: `McpLease` gains a required `listing` (the connection's listing record; the lease is not exported by name, it is what `McpConnectionPool.acquire` answers, so only a host that substitutes a lease object of its own must add it), and `McpConnectionPool.acquire`'s `dial` is handed a `stop: AbortSignal` and its options gain an optional `signal`) (@dgx @server @cli @client-core)
|
|
61
|
+
|
|
62
|
+
- **Before (v7.54.0), #2147.** Letting a Streamable HTTP connection go — a run's end, a re-dial replacing it, a dial that failed after its handshake — closed the transport, which aborts the local requests and nothing else: the server's session for it stayed standing until the server reaped it, one per run.
|
|
63
|
+
- **Now, #2147.** The transport the engine dials HTTP servers with ends its session in its own `close()` — one `DELETE` carrying the session id, then the close (`SessionEndingTransport`, `src/core/mcp-session-end.ts`) — so every way a connection is let go does it: a run's end, a re-dial (the session it replaces ends before the next opens — for a pooled connection, once no other leg of the run holds it), the pool's last release, a dial that failed or was stopped, and a handshake the MCP SDK rejects and closes itself. One `DELETE` however many closes ask. The `DELETE` is courtesy: a refusal (405), an unknown-session answer (404), any other status, a network failure, or no answer within 2 s (`MCP_SESSION_END_WAIT_MS`) changes nothing about the close — the transport closes regardless and aborts a request still in the air. A server that issued no session id is sent nothing; a stdio server has no session and its close is unchanged.
|
|
64
|
+
- **Who is hurt, #2147 (open to veto).** A server that answers a `DELETE` slowly or never: every let-go of one of its connections waits up to 2 s for it — the run's teardown (once per such server, in parallel with the others), a host `reconnectMcpServer` and the engine's own recovery (the old session's `DELETE` goes before the new handshake), a dial the run's abort stopped, and a host that disposes a materialization in its own request path (a status panel that awaits `dispose()` answers up to 2 s later). A proxy that drops or rejects `DELETE` on the MCP endpoint costs the same bounded wait at most. Nobody else: a server that does not track sessions sees one more request per connection and answers it as it likes.
|
|
65
|
+
- **Compensation, #2147.** None needed on the host side (no knob, no field). A server that cannot end sessions answers 405 promptly — the spec's own word — and costs nothing; one that tracks sessions should honor the `DELETE`.
|
|
66
|
+
- **Before (v7.54.0), #2148.** A tool call to a Streamable HTTP server whose session the server had dropped (a restart, a reaped session) was answered 404 by the endpoint and reached the model as `http_status` 404; only the host's `reconnectMcpServer` verb re-dialed, and every later call on the mounted tools failed the same way until it did.
|
|
67
|
+
- **Now, #2148 — a lost session reads one way (ruled C-R667, open to veto).** A request that carried the session id the server issued at the handshake and was answered 404 by the endpoint — the protocol's word for a session it no longer knows — is a lost session (`sessionGone(err, site)`, `src/core/mcp-failure.ts`; the site says whether the request carried a session, `requestSite`, `src/core/mcp-session-end.ts`, read before the request is sent). It is the server's report, not a proof that nothing ran: its record stays the table's for a 404 (`http_status`, `delivered: "unknown"`, as before — `"no"` keeps meaning "provably never sent"), and every face that renders the failure names it with one phrase (`LOST_SESSION`: "the server reported the session the request was sent on gone …") — the tool-call lane, the resource read and directory lanes, the resource listing and a refresh's failed entry. On the tool-call, resource-read and directory lanes the words also say whether the request was sent again — only a tool call ever is — and that a server that executed it before answering ran it (once, or twice after a resend that met a lost session too); the resource listing and a refresh's failed entry carry the phrase alone. Nothing else reads that way: a 5xx, a timeout and a lost pipe leave the outcome unknown, a JSON-RPC error and a tool's own `isError` mean the server processed the call, a 400 is the protocol's word for a missing session header, and a 404 on a connection that holds no session id is an endpoint answering.
|
|
68
|
+
- **Now, #2148 — a lost session is recovered once.** A tool call whose session is gone is sent again once, through the one rule below: the engine runs the re-dial `reconnectMcpServer` runs — this leg lets the old connection go (its session is ended once no other leg of the run holds it) and opens a new one — and repeats the request where the rule allows. The resend is capped at one: a second lost session fails the call with that failure, saying it was sent twice and may have run twice; a resend that ends any other way — it succeeds, the server's tool reports an error, a 5xx — answers with that outcome as any call's would and says nothing of the first attempt (KL-2148-e; a rule that every resent call says so, whatever its outcome, is #2202). A call that is not sent again — a re-dial that cannot be had (the server is then down as a failed host re-dial leaves it; the next call says "disconnected", not attempted), a declined one, a replacement whose listing mints the tool another gate or has not reached it (the next call is then refused before it is sent), or the call's own abort or the run's (which end the wait at once) — fails with its first attempt's failure, saying it was not sent again; the shared re-dial goes on for the calls still waiting, and a re-dial the run's abort stopped records nothing against the server (no `failed` row, no `mcp.server_unavailable` warning — the stop is the engine's own). Calls that find the session gone together share ONE re-dial; a call whose 404 lands after the connection it was sent on has been replaced is sent on the replacement without another re-dial, so a late 404 never closes the connection other calls have moved to; a host `reconnectMcpServer` that asks while a re-dial runs is refused (`outcome: "refused"`, "already in flight"), and a call that finds the host's re-dial running joins it. The recovery sends no `mcp.server_redialed` notice, and — like a host re-dial — re-queues the server's instructions for the model (one redundant re-announcement).
|
|
69
|
+
- **Now, #2148 — where a mounted tool may be sent.** One rule, asked on every call (`reach`, `src/core/mcp-server-link.ts`): a mounted tool is sent only on its server's live connection, and only while the latest listing taken on that connection — by this leg (its dial, re-dial or refresh) or, on a pooled connection, by another leg holding it; of two listings in the air the one opened later wins, and a refresh answering after it is discarded with `status: "failed"` — put through this leg's own intake, mints the tool the gate it was mounted with — the raw name it is sent under, its safety axis (the server's `destructiveHint` / `openWorldHint` folded with the caller's `toolAxes`), its plan-mode read-only reading (`readOnlyHint`), the server's "a person must answer this call" mark (`_meta["anthropic/requiresUserInteraction"]`), and its task-support refusal (`mcpToolGate`, `src/core/mcp-listing-drift.ts`). The connection's listing is one record per connection (`ConnectionListing`, `src/core/mcp-server-link.ts`) with the one reading of an incomplete walk the engine has: a walk that stopped early cannot prove a tool gone, so it answers only for the names it reached and every other name keeps its entry from the listing before it — on a re-dial's new connection, from the connection it replaced (the refresh's own merge retired into it). An entry vouches for a tool's gate only if a listing of that same connection reached it: one carried from the connection a re-dial replaced keeps its tool mounted but proves nothing about what the new session declares — a reconnect is when a declaration may have changed. No live connection ⇒ "disconnected", not attempted (`connection_closed`, `delivered: "no"`) — never the connection the tool was minted around, which another leg of a pooled run may still hold. Otherwise the call is refused before it is sent (`isError`, `delivered: "no"`), every call, until a listing of the connection reaches the tool (a host `reconnectMcpServer` or a refresh): another gate, or none (a listing that reached the tool's place and no longer lists it) ⇒ `details.code` `mcp.tool_declaration_changed`; no listing of this connection has reached the tool (a re-dial's walk that stopped before it) ⇒ `mcp.tool_listing_unreached`, an unchanged tool included, since whether it changed is unknown. Once a listing reaches the tool, it runs if its gate is the one it was mounted with. Who opened the re-dial that replaced the connection does not enter the answer, and neither does the direction of the change (tightened or relaxed). The connection itself is kept — the server's other tools, whose gates did not change, keep working, and the status row reads `connected`. A tool the restarted server lists that was never mounted changes nothing.
|
|
70
|
+
- **Reference implementation (CC 2.1.296), #2148.** The corpus carries the code (`cli296.js:281899` / `:282073`, two copies; `:282072` the judge; `:282077` the throw; `:238349` the error): the reference resends a tool call once after recovering a lost session, judges by the status (a 404 to a request that held a session id read at the call's start; the JSON-RPC body is not read), treats the stale 404 as not having reached the server's handling, and after the one retry reports only `MCP server "<name>" session expired`. This engine's resend-once and its judge are the reference's shape (parity). Three differences stay, each recorded: the reference also reads a 400 whose text says the session is unknown ("No valid session ID" and two other phrases, `:282072`) as expired — this engine reads structure, not words, ruled C-R668, #2178 (KL-2148-d); the reference also resends after an HTTP disconnect when no POST was in flight — #2179; and the reference sends the same mounted tool again without comparing the re-listed declaration, where this engine refuses a tool whose gate changed or whose re-listing did not reach it (above).
|
|
71
|
+
- **Who is hurt, #2148 (open to veto).** (a) A gateway that answers 404 to a request carrying a session id for a reason other than a lost session: each call re-dials once and fails as before, and the re-dial replaces a session that was healthy. (b) A call in flight on the old connection when another call recovers it dies with that connection as a lost connection (outcome unknown, never sent again). (c) A pooled run tree: each leg that finds its session gone re-dials its own connection, so the legs stop sharing one. (d) The resend rests on reading a 404 to a session-bearing request as the server's report of a lost session — the reference's own reading; the MCP spec mandates a new handshake there and does not itself say the request may be repeated. A server that runs a request and then answers 404 for its session runs the repeat too: when the repeat also meets a lost session, the record (`unknown`) and the words ("it ran twice") say so (ruled C-R667); when the repeat ends any other way (it succeeds, the server's tool reports an error, a 5xx), the result says nothing of the first attempt (KL-2148-e, #2202). (e) A server restarted with a tool declared differently keeps its connection; the 404'd call is not sent again and that tool is refused on every later call — `mcp.tool_declaration_changed` when the recovery's listing reached it, `mcp.tool_listing_unreached` when its walk stopped before it — until a listing of the connection reaches the tool (a host re-dial or a refresh takes one; one that stops before the tool leaves it refused); before, every call to the server failed with 404. (f) A consumer that lists the tool-result codes it knows: `mcp.tool_declaration_changed` and `mcp.tool_listing_unreached` are new codes. (g) A tool call or a refresh racing a re-fold: a tool object minted from the old listing that is still called after the connection's listing changed is refused rather than sent with its old gate, and a refresh that answers after a later-opened one has applied now reports `failed` instead of putting its older roster in place. (h) A pooled run tree: after another leg's listing — its dial, its refresh — takes in a changed declaration, this leg's tool of that declaration is refused on its next call; the listing is compared, not adopted: this leg's roster is not swapped, and its own refresh or re-dial takes the declaration in (KL-2148-c). An incomplete listing on a connection answers only for the names it reached, so it refuses nothing it did not reach that a listing of that connection had reached before. (i) A re-dial — the host's or the engine's — whose listing stops early now keeps the tools its walk did not reach (from the connection it replaced) instead of reporting them removed, as a refresh always did; those tools stay mounted but are refused before they are sent (`mcp.tool_listing_unreached`) until a listing of the new connection reaches them — an unchanged tool on a page the walk did not reach is refused too (before, v7.54.0, the re-dial reported it removed). A leg joining a pooled connection whose own first listing stops early likewise mounts the tools it did not reach from the connection's listing — those another leg's listing of that same connection reached, and which therefore run; the connection's record may also hold carried entries that no listing of this connection has reached, and the joining leg mounts those too — they are refused (`mcp.tool_listing_unreached`) like any carried entry. (j) A server whose restart answers the old session 400 "No valid session ID" (the MCP SDK's example servers) is not recovered; each call fails with that 400 until the host re-dials (ruled C-R668, #2178; KL-2148-d).
|
|
72
|
+
- **Compensation, #2148.** None needed on the host side for the recovery: the call that failed now succeeds. A host that sees `mcp.tool_declaration_changed` or `mcp.tool_listing_unreached` calls `reconnectMcpServer` (or refreshes the server's tools) to take the declaration in. An observable account of automatic recoveries — and of a recovery whose listing changed a mounted tool — is a follow-up: today the status row, the warnings and the notices say nothing about either.
|
|
73
|
+
- **Before (v7.54.0), #2149.** The handshake and the first `tools/list` of a dial carried the startup bound (the server's `startupTimeoutMs`, else `MCP_TIMEOUT`, else the SDK's 60 s) and no abort: a run the host aborted while a server held either of them ended only when the bound did, and the session's next message queued behind it.
|
|
74
|
+
- **Now, #2149.** The dial stops when the run is aborted. `materializeMcpTools(…, signal?)` takes a trailing signal; the Runner passes the host's `spec.signal` joined with the leg's own controller (the leg's controller follows `spec.signal` only once its harness exists, which is after the dial). When it fires, a dial in the air ends — the handshake owns its transport until it lands, so the abort closes it wherever it waits, a held `initialized` notification included; the first `tools/list` runs on a connection the pool may share, so the abort ends that one request — and the server is skipped like any server that did not connect (`mcp.server_unavailable` through `onError`, a `failed` row in the wiring manifest, the abort as the row's error and not as a request timeout, the half-open session ended as #2147 says), and the run ends. A signal that fired before the dial starts nothing — no request, no stdio child — and a re-dial under a fired signal neither dials nor lets the connection it would replace go. The listing follows the signal only while it is in flight: an abort after the dial sends the server nothing and leaves the connection alone. A pooled dial belongs to the pool (`McpConnectionPool.acquire` gains an optional `signal`): each leg waiting on it walks away on its own abort, leaving the dial to the legs still waiting; the dial stops only when the last has, and leaves the pool's table in the same step, so a leg that asks while the stopped dial is still letting its connection go dials afresh instead of failing with another leg's abort, and a dial nobody waits on leaves no connection behind (one that landed just as the last leg walked away is closed). A pool that is closed closes a connection nobody leased. An unfired or absent signal changes nothing.
|
|
75
|
+
- **Who is hurt, #2149 (open to veto).** Nobody who does not abort. A host that aborts a run while a server holds the handshake or the first listing now sees the run end as soon as the stopped dial has let its connection go — at once when the server answers the session's `DELETE`, up to 2 s later (`MCP_SESSION_END_WAIT_MS`) when it holds it — with that server `failed` and one `mcp.server_unavailable` error on `onError`, where it used to see neither until the bound elapsed. A server may see a `notifications/cancelled` for the listing request that was in the air when the abort fired, and the session's `DELETE`; it is never sent the cancellation of a request it answered.
|
|
76
|
+
- **Compensation, #2149.** None needed. A library caller of `materializeMcpTools` passes a signal to get the same; one that passes nothing is unchanged. `probeMcpServers` passes none (its own `signal` still stops its wait).
|
|
77
|
+
- **Pins.** `test/backlog2147-mcp-session-end.test.ts` (A–G: the one `DELETE` at dispose and none before; 405 / 404 / 500 / no answer / gone server; a re-dial's order; a dial that fails after its handshake; a pooled connection; the transport under a real client, a server with no session id, closes once; a real run; a handshake the SDK rejects), `test/backlog2148-mcp-session-gone.test.ts` (J the one judge and its record; V1–V16: a lost session recovered once and the mounted tool following it; a 5xx and a JSON-RPC error not sent again; the second 404 read as a lost session; three calls sharing one re-dial; a host re-dial refused while the engine's own runs; a call in the air when its connection is replaced; a pooled connection; a refused handshake; a real run; a stateless server's 404; a changed, relaxed, equal and extended listing), `test/backlog2148-mcp-recovery-gate.test.ts` (G1–G16: two pooled legs after a changed declaration, the unchanged tool still running; a leg with no connection sending nothing on the one its sibling holds; the interaction mark; a call joining the host's re-dial; a duplicated listing; the call's abort and the run's abort during a recovery; a late 404 after the re-dial; a stopped pooled dial leaving the table; two real runs, a changed declaration and a re-dial that cannot be had; the resource lane's lost session; a refresh re-folding the gate; a declined re-dial not resending on the lost connection; a raw name swapped under one minted name; two refreshes answering out of order; a pooled sibling's refresh checked, not adopted; G17–G20: an incomplete listing — a pooled sibling's dial, a recovery's re-listing, a real run, a refresh as control — refusing nothing it did not reach; a real run against a server that runs the call and then answers 404, read as `unknown` and "ran twice"; the resource listing and a refresh naming the lost session; a 400 unknown-session answer not read as a lost session), `test/backlog2149-mcp-startup-abort.test.ts` (U1–U9 and P1–P7: a held listing, a held handshake and a held `initialized` ended by the abort; a fired signal dials nothing, over http and stdio; an abort after the dial sends nothing and leaves the connection alone; a re-dial under a fired signal; a real run aborted by the host and the session's next message; the unfired signal as the negative control; legs waiting on one pooled dial, on the pool and over a real server; a dial abandoned just as it lands leaving no connection behind), over `test/mcp-http-session-stub.ts` (a Streamable HTTP stub with real sessions, holds and a request log).
|
|
78
|
+
|
|
79
|
+
### (#2140) A turn that ends the run is no longer reopened by an engine reminder: an advisory frame rides a request the run already owes and never makes one (#2140; behavior narrowing, open to veto: the model sees one turn fewer where a reminder fell due on the run's last boundary; behavior change, open to veto: on a resumed leg whose settled batch did not end the run, that batch's boundary frames (the budget / token readouts, reminders, …) now ride the leg's first request; hard-breaking: none; closed sets: none; exports: none; members: none on the package surface)
|
|
80
|
+
|
|
81
|
+
- **Before.** The turn boundary steered its engine frames (the reminder bundle — todo / task reminder, listings, changed files, budget and token readouts, output style, date, instructions and size-guideline changes —, a PostToolBatch observer's `additionalContext`, the limit-approach frame, the new-diagnostics block) wherever they fell due, and a steer makes the next provider request. On the boundary of a turn that ENDED the run the model was asked again, and its second answer became the result: a text-final stop on the boundary where the task reminder fell due gave one more model turn whose "No further action needed." replaced the real final answer in `TaskResult.result`, in the stream's `done` event and on every `-p` / SDK face that reads them (field: 7 of 89 benchmark trials). A batch that ends the run — `StructuredOutput`, `ReportBlocked` — was reopened the same way: the model could submit its structured output a second time, which overwrote the first, or end a blocked run with extra text. Two seats carried it: the boundary judged "a request is owed" from the message's content (any tool call), which is wrong for a batch that ends the run and was read by eight readers (six bundle lanes, the limit-approach frame, the direct-write argument) while the other lanes read nothing; and a bundle queued at an owed boundary behind a caller's steer (one steer per drain) drained at the next, text-final boundary and made the request there (the doubled output-style copy of KL-1284-a was the same root).
|
|
82
|
+
- **Now.** One rule, two readers of one fact. The loop hands the boundary its own continuation decision, `continues` (defined once, at `PrepareNextTurnContext.continues`: the turn's tool results go back to the model as the next request — its batch neither ended the run nor was cut by an interjection, and a recovery nudge is not such a request. In practice: a cut turn's results reach the model too, but it is the queued input it was cut for that continues the run; a nudge — the output cap, a cut stream, a malformed or thinking-only turn — is a request the loop makes on a turn with no tool results, so the boundary's frames wait; a resumed leg's settled batch states it by the same rule); the boundary collects the frames it steers only when that holds — one door for every lane whose collection consumes something (the bundle, the limit-approach frame, the new-diagnostics drain) instead of eight scattered readers and none on the rest; a PostToolBatch observer's context consumes nothing and is governed by the drain rule below alone. On a resumed leg whose settled batch did not end the run, that boundary owes the leg's first request, so its frames (budget / token readouts, reminders, …) now ride it; a frame the request already carries is not stated twice — the bundle, the boundary's direct-write style lane and the posture writer ask the same receipt ledger (`frameCarried`; a leg's head now registers a receipt for the posture as it does for the output style), one rule for every receipt-bearing source and every collector. The harness drain treats an engine ADVISORY (engine-authored, no event payload) by the same fact: at a drain the loop's own work owes, every queued advisory rides with whatever the drain takes — so a bundle collected behind a caller's steer now arrives WITH that steer instead of one request later (the one-steer-per-drain slot stays the person's); at a drain nothing owes, an advisory is held and, if nothing continues the run, ends with it. An engine frame no longer extends a run that ended on text or with a batch that ends it, so `result` is that turn's answer unless a queued caller input or a task notification continues the run (as before). An output-capped answer on such a boundary gets its continue nudge again (a reminder steer used to read as a user redirect and suppress it). Task notifications carry an event and still continue a run that ended on text; a caller's steer or follow-up is delivered as before.
|
|
83
|
+
- **Who is hurt.** A consumer that relied on the extra turn after the run's end — none is known: it was an unintended turn, and every face read its text as the run's answer. The boundary-carried frames — the tools_delta / mcp announcements, changed files, task and todo reminders, budget / token readouts, new language-server diagnostics — ride only a request the run's own work makes (a tool-result request). A fresh leg's first request carries only a `userPromptSubmit` hook's `additionalContext`, the date, the agent and skills listings, the output style, the git status and the posture; a resumed leg's first request carries in its head the continuation reminder, the orphaned-background-task announcement, the git status, the posture and the output style, plus the settled batch's boundary frames when that batch did not end the run (it carries no date or listings). So a frame that falls due on a run's last boundary is not shown on that run and waits for the next tool-result request; on a session whose runs all answer on text such frames never reach the model (nothing is stored meanwhile: windows are threshold-based, listings settle on receipt, a changed file is re-derived from the read state, diagnostics stay in the registry with their `diagnostics` event). Design #2168 puts them on every request a run makes, a leg's first included. The same holds wherever the turn's tool results do not go back as the next request and something else continues the run — a text-final turn a queued input continues, a batch that ended the run, a turn an interjection cut, a turn a notification continues, a recovery nudge: that request carries no steered frame (the output style keeps its direct-write lane there). A host whose resumed leg settles a batch that ends the run, with no prompt to deliver, no longer gets a model turn after it.
|
|
84
|
+
- **Compensation.** The answer the model gave at the run's end is the result again, and no engine frame opens a turn after the batch that ended the run, so a submitted structured output is no longer overwritten by a turn the engine itself opened (a queued caller input or a task notification still continues such a run, and the model may submit again there). The PostToolBatch observer still receives a batch that ended the run; its `additionalContext` is held — if queued input continues the run it reaches the next request the run's own work makes (late), otherwise it ends with the run.
|
|
85
|
+
- **Internal seats (not package surface).** `PrepareNextTurnContext.continues` and `TurnBoundaryEvent.continues` added; `getSteeringMessages(continuing)` takes the same fact; the runner's re-derived `rs.turn.lastTurnHadToolCalls` is retired. `docs/KNOWN-LIMITS.md` KL-1284-a closed (no request carries two output-style copies: the ride-along drain on live boundaries; on a resumed leg's first request, the receipt ledger read by both the bundle and the direct-write lane — also when an interjection cuts the settled batch). The planning posture's restatement on that same request is stated once too (a `posture` receipt, read by the posture writer; it was stated twice before this release as well).
|
|
86
|
+
- **Pins.** `test/backlog2140-post-stop-frames.test.ts` (text-final, StructuredOutput and ReportBlocked on the reminder boundary on the library and stream faces; a reminder on a tool turn unchanged; deferred, not lost, past a text-final boundary queued input continues; a bundle behind a caller's steer rides that steer's request, later steer and follow-up delivered; a notification still continues a run that ended on text; diagnostics, a PostToolBatch observer's context and the limit-approach frame behind the same door; the direct-write lane's halt rule; the harness drain on its own; a resumed leg's settled batch, terminating or not; a resumed leg's first request stating the style and the planning posture once — settled or cut by an interjection — and carrying the budget readout); `test/backlog1284-output-style-boundary.test.ts` ④ re-pinned to `[1,2,3]` (the old `[1,1,2,4]` was the one-request lateness plus a fourth request made after the run's end); `test/human-reject-batch-boundary.test.ts` (the halted-boundary deferral re-pinned on an owed boundary — its old fourth call was made by a bundle at a text-final boundary; a direction turn that ends on text ends the run with nothing stored; the next run states a change only if it still holds); `test/backlog1640-incomplete-outcome.test.ts` ④ re-pinned (the boundary reads the loop's fact; no handler derives one); `test/backlog560-announce-once-per-session.test.ts` and `test/backlog1971b-mounted-set-and-valve-readers.test.ts` fixtures re-pinned (their later runs / control ended on text and reached the model only through this defect's extra request — now every run has owed boundaries of its own); `test/internal-wording-gate.test.ts` runner bucket banked 78 → 76 (the retired field's notes).
|
|
87
|
+
|
|
88
|
+
### On the GLM / Z.AI anthropic route the tool-search tool travels as `ToolFinder`, and a call under that spelling runs `ToolSearch` (#2162; behavior change on one route, open to veto; differs from Claude Code's shape, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @dgx @client-core)
|
|
89
|
+
|
|
90
|
+
- **Before.** That route answers a request whose tool table holds a tool named exactly `ToolSearch` by giving the model that tool alone and folding every other tool into a name without a schema. Replaying a captured 37-tool request: the provider reported about 900 prompt tokens on that route against about 14,700 for the same body on the same host's OpenAI route, and the model called `ToolSearch` instead of the tool it was asked to use (0 direct calls in 3, on each of two models); with the tool renamed and its description and schema unchanged, the whole table was served and the tool called directly. The engine sent its tool-search tool under its own name on every route, so every run with deferred tools on that route met this.
|
|
91
|
+
- **Now.** The provider dialect table (`src/brain/provider-dialect.ts`, keyed by wire family and host; the GLM row covers `api.z.ai` and `open.bigmodel.cn`) gains a second column, `toolNames`. Its one row says that on the anthropic wire this endpoint gives `ToolSearch` a meaning of its own, so a tool by that name is sent as `ToolFinder`. The three brains send tool names through one projection (`endpointToolNames`), and both its halves read the one set the request declares. When the request declares `ToolSearch`, the tool table and every replayed call to it carry `ToolFinder`, and a call the model returns under `ToolFinder` is read back to `ToolSearch` before the engine sees it — `ToolCall.name`, `tool_start` / `tool_end`, the transcript, permission rules and approvals all keep `ToolSearch`. When the request does not declare it, nothing is renamed either way: a replayed call to `ToolSearch` keeps its name, and a call under `ToolFinder` reaches the engine as written (a deployment tool of that name, or a miss). A call under `ToolSearch` (the name the prompts and the deferred-tool placeholders use) still reaches the tool. The prompts, the tool's description and parameters, and its name inside the engine do not change. Every other route sends byte-identical requests: Anthropic's own, the same host's OpenAI route, every other dialect, and a host the table does not name.
|
|
92
|
+
- **Behavior narrowing (open to veto).** On that route, a request whose tool table holds both the engine's `ToolSearch` and a deployment tool already named `ToolFinder` is refused before anything is sent (`invalid_request`; the run ends `failed`; the message names both tools and asks for the deployment's `ToolFinder` to be renamed): the two would share one name on the wire and the call that comes back could not be told apart. Before, the request went out and the table was folded.
|
|
93
|
+
- **Differs from Claude Code (open to veto).** Claude Code 2.1.296 turns tool search off when its base URL is not a first-party Anthropic host, unless the user forces it on, so on this route it sends the whole table and no `ToolSearch` at all. This engine keeps deferred loading on every route — its search is client-side, materializing schemas into the next request — because a bring-your-own-model deployment almost never talks to a first-party host, and switching deferral off there would put every large MCP roster back into every request. It changes the one spelling the endpoint reacts to instead.
|
|
94
|
+
- **Who is hurt.** A deployment on the GLM / Z.AI anthropic route: the model sees the tool-search tool named `ToolFinder` (the prompts still say `ToolSearch`; both spellings reach it), and a host that reads the provider request body (request logging, a request hook) sees `ToolFinder` there. A deployment that mounts its own tool named `ToolSearch` on that route has it sent and read back the same way. A deployment that mounts its own `ToolFinder` beside deferred tools on that route is refused, as above. A deployment tool that declares `ToolFinder` as an *alias*: on that route the spelling is now a name in the table, and a table name wins over an alias (as it does on every route), so a call under it reaches `ToolSearch` where it used to reach the aliased tool. Nobody on any other route.
|
|
95
|
+
- **Compensation.** Nothing to migrate for transcripts, events, rules or approvals: they carry the declared name. The refusal names the tool to rename.
|
|
96
|
+
- **What triggers it (measured on `api.z.ai`, `glm-5.2`, two runs per shape).** The tool's name, matched exactly and case-sensitively: `ToolSearch` folds the table with its description and schema emptied, and with only one other tool beside it; `toolsearch`, `TOOLSEARCH`, `Toolsearch`, `tool-search`, `ToolSearchTool`, `ToolFinder` and `tool_search` do not. So the row matches the name exactly. The substitute is `ToolFinder` rather than `tool_search` because `tool_search` is a retired engine spelling (a rule naming it is refused as a legacy name), and one spelling must not be both retired and a live tool name.
|
|
97
|
+
- **Not covered.** A relay in front of this provider under a host of its own keeps the folded table (the table is keyed by where the request goes). The `open.bigmodel.cn` host shares the row as it shares the failure rows; it was not measured. `Model.extraBody` stays a verbatim passthrough: a tool a deployment names there (a `tool_choice` selector, say) is sent as written, so on this route it must be spelled as the endpoint is sent it, `ToolFinder` (reading that spelling from the one table instead is #2173).
|
|
98
|
+
- **Pins.** `test/backlog2162-tool-wire-names.test.ts` (T1–T4 the column, its per-wire scope, the way back, the refusal; L1–L8 a real Runner or the real anthropic brain: the substitute in the table and in the replayed call, `ToolSearch` running and activating the deferred tool, the prompts' spelling, the unmounted case, Anthropic's own route, the refusal before any request, a transcript that already holds a miss under the substitute, the `extraBody` passthrough, an aliased deployment tool; L9–L10 a transcript call to `ToolSearch` replayed as written when this request does not declare it, with and without a deployment tool named `ToolFinder`; O1 the same host's OpenAI route); `test/backlog2162-tool-wire-names-lanes.test.ts` (the same projection in the anthropic, OpenAI chat — text repair included — and Responses brains, against a probe endpoint with a row on each wire).
|
|
99
|
+
|
|
100
|
+
### The owner is a cell of the session row, and one atomic head read answers owner, occupant and leaf: `SessionStore.headOf?` replaces `ownerOf?` / `incarnationOf?`, `compareOccupants` compares two heads in three answers (#1468 S2; hard-breaking: H1–H4, open to veto; behavior change and behavior narrowing, open to veto; closed sets: none; exports: +2 / −2)
|
|
101
|
+
|
|
102
|
+
- **Before.** The owner of a session lived in `TtlSessionStore`'s process memory, beside the row, so every session read as "never registered" (claimable by the next caller) after a restart; a row `acquire` created read `undefined` until some caller registered it. A by-id reader asked two questions in two reads (`ownerOf`, `incarnationOf`), and a delete plus re-create of the id between them paired one generation's owner with the next one's occupant. The File repo's header read took `''` as an occupant token (two empty tokens compared as one occupant) and disclosed a `null` one; its create published the header with a rename, which silently replaced a row another writer had created in the gap between its existence check and its write.
|
|
103
|
+
- **Now.** (1) `SessionMetadata.owner?: string | null` is a cell of the row, recorded by the write that creates it: `register(id, owner)` on an id with no row creates an ordinary row with that owner (first write wins, atomic); `acquire` records `null`; a fork records the source row's owner unless one is named (`SessionCreateOptions.owner?`, the same write as the fork row); `importEntries` onto an absent id records the importer. No other write changes it: `register` on a row that exists is a no-op whatever it carries, and an `importEntries` over an existing row replaces its log but keeps the row's metadata whole (its owner and occupant, and its creation time, lineage and placement). Absent reads as `null` (anonymous). (2) `SessionStore.headOf?(id): Promise<SessionHead | undefined>` answers `{ owner, incarnation, leafId }` of one row in one read (`undefined` = no row); `TtlSessionStore` exposes it (and so the ownership pair) when its repo answers `SessionRepo.headOf?`, verbatim. (3) The File repo answers `headOf` from its log's first record and its tail on one descriptor, never by replaying the log, with the same per-line judgement a replay applies (`src/stores/file/fs-atomic.ts` `firstJsonlRecord` / `lastJsonlRecord` share `readJsonlRecords`' line reading, and the session log's judge holds the entry floor both read — a string `id`, a leaf's target a string or null), so the head and a restart's replay agree on every line they keep; the one shape they answer differently is a leaf entry naming an id the log does not hold (the head names it, the replay refuses the log). The log is the only carrier of the leaf. (4) The File header's occupant cells decode through `readIncarnationCell` (`''` ⇒ unknown, said; `null` ⇒ absence, silent), and its owner cell through one decoding (a cell that is neither an owner nor anonymous refuses the row as `corrupt_session`). (5) The File repo creates a row in one exclusive step (`writeThenLink`): a row that appears between the existence check and the creating write is refused with `SessionError("conflict")`, never replaced. (6) `compareOccupants(before, after)` answers `"same"` / `"different"` / `"unknown"` (either side without a row or a token ⇒ unknown, never same).
|
|
104
|
+
- **Hard-breaking H1 (open to veto).** **What changed:** `SessionStore.ownerOf?` is removed. **Who is hurt:** a store that implements it and a reader that calls it — *server*: the authorizer (`security.ts`, and `OwnerAwareSessionStore`'s own `ownerOf?` declaration), the seven read points that call it, the five session stores, the capability `capability.session_ownership_required`'s presence check; *sdk*: the wire pins that drive the server dist with an `ownerOf` stub. **How to migrate:** read `(await store.headOf?.(id))?.owner` — `null` is anonymous, a string is owned, an `undefined` head is no session; probe `headOf` and `register` together for the ownership pair.
|
|
105
|
+
- **Hard-breaking H2 (open to veto).** **What changed:** `SessionStore.incarnationOf?` is removed. **Who is hurt:** a by-id occupant reader of core's face — *server*: `incarnationReadOf` (boot) — and a third-party store implementing it (the server's own `sessionFaceOf` / `getHead` faces are not core's and do not move with this). **How to migrate:** read `(await store.headOf?.(id))?.incarnation` (`undefined` = unknown) in the same read as the owner, and compare two heads with `compareOccupants` (unknown ⇒ not the same occupant); a run's memory seat is unchanged (it reads the session it acquired).
|
|
106
|
+
- **Hard-breaking H3 (open to veto).** **What changed:** `sessionStoreIncarnationContract` and `SessionStoreIncarnationContractHooks` are removed; their six cases are `sessionStoreContract`'s `[incarnation]` group, read through `headOf`, and `SessionStoreContractHooks.onOptionalMember` reports the face as `"headOf"` (was `"incarnationOf"`; plus `[head]` cases and the new `[ownership]` cases). **Who is hurt:** a kit consumer that called the incarnation kit (*server*: `test/store-contract-kit*.test.ts`, `test/session-incarnation-single-source-1479.test.ts`) or matched the report member `"incarnationOf"`. **How to migrate:** call `sessionStoreContract(make, { deleteSession, reopen })` and assert the report says `headOf` and `ownership` are `verified`.
|
|
107
|
+
- **Hard-breaking H4 (open to veto).** **What changed:** a `SessionRepo` behind `TtlSessionStore` carries the owner: it records `SessionCreateOptions.owner` on create and fork (and on an `importEntries` onto an absent id), keeps it (with the rest of the row's metadata) through an `importEntries` over an existing row, reads it back on `open`, and answers `headOf`. **Who is hurt:** a third-party `SessionRepo`: one that drops metadata cells it does not know loses every owner (the store then answers owned sessions as anonymous); one without `headOf` leaves `TtlSessionStore` with no `headOf` and so no ownership pair (an authorizer takes its fail-closed arm). The in-tree reference adapters `src/examples/adapters/pg-adapter.ts` (`PgSessionRepo`) and `tidb-adapter.ts` (`TiDbSessionRepo`) are such repos: no owner cell, no `headOf`, and no occupant token either, so a `TtlSessionStore` over them has no ownership pair (their headers say so). **How to migrate:** persist the cell (string or null, absent ⇒ null), mint a fork row's cells with `forkRowCells(source, options.owner)` from the source row the fork copies, implement `headOf(id)` as one read of the row, and run `sessionRepoContract` (it now pins the owner round-trip, a create over an existing row returning that row's owner, fork / import, the store's `headOf` presence, and a row replaced behind a cached session).
|
|
108
|
+
- **Behavior change (open to veto).** `register` on a row that exists records nothing, so a row `acquire` created stays anonymous (`null`) — a deployment that let `acquire` create a row and then `register`ed a principal on it no longer gets an owned session: every principal is admitted to it. Register before acquiring (the server's order) to own a session from its first write; an authorizer that judged an existing row acquires it with `requireExisting: true` — a head read racing a release's delete can show the old, owned row, and a create-capable `acquire` after that delete mints an anonymous one. A row written before the cell existed reads `null` and is not claimed by a later register either.
|
|
109
|
+
- **Behavior narrowing (open to veto).** (a) On a store that declares the placement partition, `register(id)` then a placement-carrying `acquire(id)` is refused `placement_refused` (the registered row is ordinary; before, the register wrote no row and the placed acquire created one). (b) A File create whose existence check passed but whose exclusive write found the file: the repo's `create` is refused `conflict` (before: the row was replaced); `TtlSessionStore` reads that refusal as a racing creator, the reading it already gives a conflict from `open` — a `register` that lost is the silent no-op its contract promises (the re-read sees the winner), an `acquire` that lost resolves to the row that stands. (c) A File header whose first record is not the meta header is `corrupt_session` even when a later line is one (before: the last meta line anywhere was the header); a later meta line is never read as the header. (d) A File owner cell that is neither a string nor `null` refuses the row (`corrupt_session` on open and on the head read; skipped from listings, disclosed). (e) A create / fork / import whose `owner` option is neither a string, `null` nor absent is refused (`TypeError`) before anything is written; a create over an existing id still writes nothing and so judges nothing. (f) A File `importEntries` onto an existing row whose header cannot be read is refused (the read's `corrupt_session`; before: the log was replaced and the occupant left unknown) — the row's owner cannot be carried. (g) The File session log's line judge holds an entry floor: an entry record whose `id` is not a string, an entry that is an array, a leaf entry whose target is neither a string nor null is skipped and disclosed by the replay, as the head read skips it (before: the replay kept it — a numeric id became the leaf). (h) `FileSessionRepo.placementOf` of a row whose file exists but cannot be read rejects (before: `undefined`, as for a missing file or an unplaced row).
|
|
110
|
+
- **Also in this change.** (a) `forkRowCells(source, owner?)` mints the fork row's owner too (the named owner, else — absent or `null` — the source row's), from the same source row the fork copies its history from; both bundled repos' `fork` call it, so a source replaced between the store's look at it and the repo's copy can no longer pair one owner with another owner's history. (b) see the frozen-metadata narrowing below. (c) see the cached-view behavior change below.
|
|
111
|
+
- **Behavior narrowing: frozen row metadata (open to veto).** **What changed:** `InMemorySessionRepo` and `FileSessionRepo` hand out the row's metadata frozen (the object `Session.getMetadata()` returns, the in-memory repo's `list()` rows, and the placement tuple inside them), so a write to it throws instead of rewriting the owner or occupant a head read answers. **Who is hurt:** code that writes to a metadata object it was handed (a host, or a fixture that patches `getMetadata()`'s result in place): it gets a `TypeError` in strict mode where the write used to land silently. **How to migrate:** copy before changing (`{ ...await session.getMetadata(), … }`); the row's cells change only through the repo's writes (create / fork / import).
|
|
112
|
+
- **Behavior change: `acquire` serves, and `release` / `sweep` delete through, only the occupant the row holds.** **What changed:** on a cache hit `TtlSessionStore.acquire` reads the repo's head (when the repo answers `headOf`) and compares occupants with `compareOccupants`; anything but `"same"` drops the cached view and resolves the id as a miss (history is never touched). So a cached view of a row another writer deleted, or deleted and created again, is no longer served. On the File lane this holds for a writer in another process too, from this process's next open of the id: the repo joins a live authority only while it is a view of the row just read — its log is the file the path names now (same device and inode) and it names no other occupant — so at that open a row whose file was removed, replaced, or rewritten in place for another occupant is read from disk, and from then on a handle on the old row is refused `log_closed` (before: the join was by path alone, the stale view was served again, and its writes resolved onto the unlinked inode without reaching the file). Until that open, a write through a handle on the old row still resolves where no reader of the path sees it, and a replacement that keeps the occupant (an import) is not seen at all (#2183, #2166); a row with no occupant token rewritten in place is not seen (#2167). A `release`, or an idle `sweep` under `evict: "delete"`, that would delete through a cached view runs the same check first: a view the row no longer stands for deletes nothing, and the id is decided by its row as an uncached one is (before: the row standing under the id was deleted, whoever's it was). An uncached id's placement is its row's (the repo's placement probe, else the row's metadata): a placed release that did not complete (its delete failed, or a read before it faulted) leaves a tombstone that only makes the next release read the row, and a delete runs only on a read that answered — a head read or a placement read that faults deletes nothing, keeps the tombstone, and is the release's rejection — so a session another writer created under the id after a delete that reported failure but committed keeps its forget release, transient fault or not (#2184) (before: the tombstone alone decided, and a `register` then `release` with no `acquire` between deleted that session). That is a behavior narrowing (ruled C-R670, open to veto): a cold `release` of a row it cannot read rejects with the read's fault (a File row whose header is damaged, or a zero-byte file, on a store that declares placements — ordinary rows too — rejects `corrupt_session` on every attempt) where it used to succeed silently, and a placed row whose header is damaged is no longer deleted on the retry: it stays on disk until it reads again, and only manual removal deletes it (the File `listPlaced` skips it and names it through the store's `onCorruptRead` disclosure when the host wired one — path and session id, "session log unreadable … skipped from the placed listing" — and the registry's reap swallows the rejection after deleting the agent row). **Who is hurt:** a caller that treats a rejected `release` as fatal (the engine's own callers catch it), and a deployment holding damaged subagent transcripts, which now stay on disk. A release that may delete, and a sweep's delete, hold the id until they settle: `acquire` and `register` answer after them, so a delete that never returns holds that id's `acquire` / `register` as a placed deletion's always did; the store's `headOf` does not wait, and a read racing a delete answers either generation (before: only a placed deletion held the id, and a sweep issued its delete within the `sweep()` call). A sweep has no outcome to report: when its head read faults, or its delete fails, it deletes nothing and says nothing, and no later sweep retries the row, whose cached view it already dropped (#2199). Before, it was: once admission read the row's owner, the id's new owner was admitted on one store and served the previous owner's transcript from that store's cache (two stores over one repo, or a row replaced behind the store by repo writes). The old reading pinned by `test/backlog1479-session-incarnation.test.ts` ("A's resume runs on its cached session") is that shape and flips: A's resume runs on B's recreated row, and the separation is said (`memory.session_incarnation_mismatch`). **Who is hurt:** a deployment that resumed on a store's cached view after the row was deleted elsewhere (now: the row standing, or a fresh row on a create-capable acquire, or `not_found` on `requireExisting`). Cost: one head read per cached acquire and per release that may delete (memory O(1); File one descriptor, the first record and the tail), and a head read that faults (an I/O error) rejects an `acquire` the cache would have answered, a `register`, and a `release` that may delete (a placed view's release keeps its tombstone, so the retry reads the row and deletes it; an unplaced view's release under `evict: "delete"` keeps nothing — its view was already dropped — so after a head-read fault the retry resolves and deletes nothing, and the row stays). A row with no occupant token (written before 7.49.0) compares `"unknown"`: it is reopened on every acquire, so its list projection's `lastTaskId` / `lastTaskRunId` start over at each acquire (#2167), and under `evict: "delete"` its release and sweep no longer delete it (behavior narrowing, ruled C-R665, open to veto: its transcript stays on disk until #2167 gives such rows a token; before, it was deleted by id). An `importEntries` keeps the occupant, so the comparison does not see it: a view cached before the import is still served (#2166). **How to migrate:** nothing for a single store over its own repo; a deployment that relied on the stale view resumes from the row.
|
|
113
|
+
- **Behavior change.** (a) The owner survives a restart on the File lane (it is in the header). (b) A File header occupant cell `''` reads unknown and is disclosed (`session header incarnation: the incarnation cell holds an empty string — read as an unknown occupant`); `null` reads unknown silently (before: disclosed); a non-string one's disclosure now reads `session header <cell>: the incarnation cell holds a non-string value (<type>) — …`. (c) The File head's leaf is the log's: an entry that reached the log but not the process's live view (a crash between the write and the in-memory flip) is the head's leaf, as a restart's replay sees it. (d) An `importEntries` over an existing row keeps its `createdAt`, `forkedFrom` / `forkedFromIncarnation` and `placement` on both bundled repos (before: it minted a new `createdAt` and dropped the lineage and the placement, so a placed row imported over lost the placement its first write had recorded).
|
|
114
|
+
- **Exports.** +`compareOccupants` (function), +`SessionHead` (type); −`sessionStoreIncarnationContract`, −`SessionStoreIncarnationContractHooks` (this section's net is 0; the release's whole surface moves 2826 → 2885 at 57769b328, every section counted (recounted at the release head)). Members: `SessionStore` −`ownerOf?` −`incarnationOf?` +`headOf?`; `SessionRepo` +`headOf?`; `SessionMetadata` +`owner?`; `SessionCreateOptions` +`owner?`; `InMemorySessionRepo`'s create options widen to `SessionCreateOptions`; `AppendLog` (package root) gains a public method `isFileAtPath()` — whether the log's open descriptor is still the file its path names (same device and inode), the File repo's reading of "a view of the row just read".
|
|
115
|
+
- **Who else.** *client-core*: generated type surfaces naming `SessionStore` members. *cli*: no store face is imported. The session list, the wire and the Runner's own reads are unchanged.
|
|
116
|
+
- **Pins.** `test/backlog1468-s2-session-row-head.test.ts` (H1 the File head is the replay's answer: entries, a branch switch, torn / whole-unterminated / judged-out tails, the first record as the header, lines across read blocks; H2 the head does not replay the log; H3 the crash shape; H4 the owner survives a restart, FileStorageBackend included; H4b a cached view of a row another writer deleted and created again (two stores over one in-memory repo, two File repos over one directory, one store with the row replaced behind it) is not served by the next acquire, and a view the row still holds is; H4c an import over an existing row keeps its owner, and a File import over an unreadable header is refused; H4d a File row another process deleted and registered again (a real second process; raw writes; an in-place rewrite for another occupant; a row with no occupant token) is not joined — the next acquire serves the new row, its turn reaches the file, the old handle is refused; H4e release and sweep under `evict: "delete"` leave a row the cached view no longer stands for; H4f a delete that never returns holds `acquire` / `register` of its id, never `headOf`; H5 a row from before the cell; H6 occupant decoding; H7 the malformed owner cell; H8 import, a fork row's creation time and lineage, a placed row's placement; H9 `compareOccupants`; H10 a real Runner run; H11 a source replaced mid-fork; H12 frozen metadata; H13 a failed placed delete's tombstone against a session another writer created under the id, with and without an acquire between, over a repo with and without a placement probe, an unreadable placed row (the release rejects with the read's fault and keeps it; once it reads, the retry deletes it), and the retry after a head read that faulted on a placed view's release, a probe fault under a tombstone and on a placed view of a replaced row: nothing deleted, the release rejects); `test/backlog1468-s2-file-create-race.test.ts` (the exclusive create against a writer planted at the publish step); `sessionStoreContract` `[head]` / `[ownership]` / `[incarnation]` groups over the memory, bounded-wake, File and FileStorageBackend lanes and their mutants (`test/backlog1468-s1-session-store-kit.test.ts`); `sessionRepoContract`'s owner cases (including the store over the repo after a row is replaced behind a cached session, and a fork row imported over keeping its metadata); `test/design393-s6-run-leg-lanes.test.ts` (the rejection path's hop count stays 52); `test/session-ownership-face.test.ts` (its sweep cell awaits the delete the sweep issues after its head read), `test/backlog1479-session-incarnation.test.ts`, `test/backlog1468-k2-laws.test.ts` ⑭ (flipped) re-pinned to the row law.
|
|
117
|
+
|
|
118
|
+
### (#2181) A rule written to a session while one of its delegated agents runs binds that agent's next tool call, whether or not the session had a rule when the agent started (#2181; behavior narrowing, open to veto: a delegated leg enforces its parent session's rules row written after it was spawned, and an observer sidecar is bound by the session's rules like the agent it observes; behavior change: a delegated leg whose Runner has no `sessionPolicyStore` announces `config.session_rules_unreadable` once per ancestor session; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli @test)
|
|
119
|
+
|
|
120
|
+
- **Before.** A delegated leg reads its ancestors' session rules through the entries on the chain its spawner hands it, and re-reads each entry's row at every one of its gates. The parent put its own session's entry on that chain only once its own layer had read a rules ROW (at its prepare, or at one of its own gates). A session with no row when the agent was spawned — the ordinary case of writing a session's first rule while a background agent works — handed the agent no entry, so a deny written afterwards never reached it: every later call ran, background and foreground, on the in-memory and the file store; with any row present at spawn the next gate denied. A grandchild, and a parked agent resumed after the write, read the same way. A delegated leg on a Runner wired without a `sessionPolicyStore` enforced its spawn-time snapshot and said nothing.
|
|
121
|
+
- **Now.** A leg that enforces its own session-rule layer (a `sessionPolicyStore` is wired and the task carries a `sessionId`) hands its key on from prepare: the row it enforces, or the empty rev-0 seed when the session has none. Every delegated leg the engine builds from that chain (sync, background, forked, workflow, retained, resumed and observer-sidecar legs, at any depth) carries a layer for that key and re-reads it at each gate — the sidecar's chain copy now carries the session-rule entries beside the mode seats, shell floor and org freeze it already carried — so a rule written while an agent runs binds that agent's next call, its descendants' next calls, and a parked agent's first call after it resumes (a deps-only resume too: the entry rides the checkpoint's `inheritedGate.ancestorRules`). The key is the parent's own (its `sessionId` and its run's `principal`): a descendant reads exactly the row the parent's gate reads. A delegated leg whose Runner has no `sessionPolicyStore` cannot re-read its ancestors' rules: it keeps the spawn-time snapshot and announces `config.session_rules_unreadable` (`onError`, phase `config`) once per ancestor session it carries, the arm a store that throws already takes — the silent constant reading is gone. The checkpoint/live merge on a resume and the live reader now judge "may this row follow the floor" by one predicate: a floor at rev 0 has enforced no row, so a re-supplied row of any generation replaces it (before, the merge alone kept an empty rev-0 entry against a newer row of another generation).
|
|
122
|
+
- **Who is hurt.** (a) A deployment that delegates on a Runner with no `sessionPolicyStore` while the spawning Runner has one: one `config.session_rules_unreadable` advisory per ancestor session per delegated leg that makes a tool call, where there was none; give the delegation Runner the same store and rules written after spawn bind its legs. (b) Store reads per delegated tool call grow with depth. Every leg the delegation tool spawns runs on its own session, so it has its own layer, and its key (one nobody writes) is now handed to the legs below it like the root's: at each gate a delegated leg reads the root's key, its own, and one key per delegated leg between them — 1 → 2 reads per tool call at depth 1, 1 → 3 at depth 2 (d + 1 at depth d, one query each on a SQL store), where before it read its own key only (plus the root's when the root had a row at spawn). On a store whose reads hang, each read is one more bounded wait per gate (the existing per-read bound, KL-978b). Reading each key once per gate, and skipping a key nobody can have written, is #2198. (c) A host that reads a delegated child's checkpoint `inheritedGate.ancestorRules` finds the parent's entry at `rev: 0` when the parent had no row (the member's shape is unchanged). (d) An observer sidecar's tool calls are judged by the session's rules: a deny refuses the sidecar's call as it refuses the observed agent's, and its report tool is a tool like any other there (a session allowlist that does not admit it refuses the report).
|
|
123
|
+
- **Compensation.** A session rule binds every agent the session is running from that agent's next tool call, as a session-scoped deny does in the reference implementation, where an agent's permission check reads the session's live permission context.
|
|
124
|
+
- **Unchanged.** The org-admission freeze and the checkpoint writer's `inheritedGate` section reach the same answers: an engine-built chain always carries `admittedOrgScopes`, so the freeze never had to infer delegation from `ancestorRules` there and the section was already written for every delegated leg. A delegation Runner with a different store than the spawning one reads the parent's key in its own store, as before, and silently: no row there ⇒ nothing binds its legs and nothing is announced (`RunnerDeps.sessionPolicyStore` says so; a store handle on the chain, or a refusal, is #2197). A row of a new lineage (an erase, then a write) is not adopted by a leg already enforcing a row — the parent's own layer and a descendant's alike.
|
|
125
|
+
- **Pins.** `test/backlog2181-delegated-child-parent-session-rules.test.ts` (an older row at spawn then the deny ⇒ the next gate denies; no row at spawn then the deny ⇒ the next gate denies, background and foreground, in-memory and file store; the deny at spawn and no deny ever as controls; the store-less delegation Runner keeps the snapshot and announces once; the parent's principal is the key, for the agent and the parent alike; a grandchild; the org-admission verdict and the parked child's `inheritedGate` section unchanged; the deny written during a park binds the deps-only resumed child; a re-supplied newer row of another generation replaces the rev-0 entry on a store-less resume worker; the engine's own `Read` under a name rule and a path rule; two background agents bound by one write; a workflow child; a deny present at spawn refuses the observer sidecar's Write, and with no rule the sidecar writes; the store reads one delegated tool call costs at depth 1 and 2, and none for a top-level task that names no session).
|
|
126
|
+
|
|
127
|
+
### (#2192) `NodeExecutionEnv`'s spool directory lives exactly as long as a spool pair does: an env no longer leaves one empty `sema-bg-*` directory in the OS temp dir (#2192; behavior change, open to veto: the directory a shell's stdio spools into is removed when its last pair is given back; hard-breaking: none; closed sets: none; exports: none; members: none, one private field re-read)
|
|
128
|
+
|
|
129
|
+
- **Before.** The first shell command an env ran (foreground `exec` or `spawnBackground`) made a private directory `sema-bg-XXXXXX` under the OS temp dir and every later command spooled into it. Each command unlinked its own spool files when it ended, so the directory sat empty — one per env instance, never removed (the env's `cleanup()` ends shells but had no removal point, and nothing in the engine calls it for an env the host built). A machine that built many envs (every test file builds several) collected them without bound.
|
|
130
|
+
- **Now.** One seat opens a spool pair (the foreground exec and the background spawn are the same reader of the same directory); one seat gives a pair back (both files unlinked; a descendant still writing keeps its descriptor into the unlinked file, as before). The directory exists only while the env holds a pair: the last pair out removes it, together with anything a failed unlink stranded, and the next command makes a fresh one — so a temp cleaner that took the directory between commands can no longer leave the env spooling into a missing path (it fell back to pipes silently). A detach-adopted foreground exec hands its pair to the background shell, which gives it back at its terminal reclaim. A background spawn that throws before it has an entry gives its pair back (before, the files stayed on disk). `cleanup()` is unchanged: it ends the env's shells, and each reclaimed shell gives its pair back.
|
|
131
|
+
- **What stays.** A retain-declared env (`retainBackgroundProcesses`) never gives a retained shell's pair back — those files outlive the run by design — so the directory holding them stays until the deployment clears it, as it always did. A host process killed outright mid-command still leaves the directory with that command's files; an env that never runs a shell never made one.
|
|
132
|
+
- **Who is hurt.** Nobody on a public face: the directory was never named by the API. A test or host that reached into the private `bgSpoolDir` after the last command ended (it read the path of an empty directory) now reads `undefined` and the path is gone; read the directory while a command is live, or not at all. Directories already left in `$TMPDIR` by earlier versions are not touched by this change (they are empty; remove them with the OS tools).
|
|
133
|
+
- **Who else.** *server*: its host lane builds its own env (`RemoteHostExecutionEnv`, spooling under its own `sema-host-spool-*` root removed at its `destroy()`), not this one. *cli*, *client-core*: no env is built.
|
|
134
|
+
- **Pins.** `test/backlog2192-spool-dir-lifetime.test.ts` (a sandboxed `$TMPDIR` per case: a foreground exec's directory exists while it runs and is gone when it settles; foreground and background pairs share ONE directory, which outlives the exec while the shell lives and goes with the shell; `cleanup()` over a running shell leaves none; two concurrent execs share the directory and the last removes it; `cleanup()` over an in-flight exec leaves its directory and its output alone; a detach-adopted exec's pair is kept until the shell is reclaimed; a spawn that throws leaves neither files nor directory on both lanes; a retain env keeps the directory; a fresh directory is made after the previous one went); `test/reap-liveness-gate.test.ts` (the terminal-reclaim cell re-pinned from "the directory is empty" to "the directory is gone"); the per-fixture mutation table is in the car receipt.
|
|
135
|
+
|
|
136
|
+
### (#2151) A session rule leaves a running run when the row stops holding it, and a non-operator write may take back what non-operator writes added: the row records its operator part (`StoredSessionRules.operatorRules`), and the write judge never loosens below it (#2151; behavior change, a relaxation of the tighten-only rule, ruled C-R677, open to veto: a non-operator `putRules` may drop a deny, or undo an allowlist or allowDirs narrowing, that a non-operator write added — one notch wider than the ruling's text, which names dropped entries only; behavior change: the `loosen_forbidden` sentence now reads "a non-operator session-rule write may not loosen what an operator wrote"; behavior change: a value carrying `rev: 0` (the no-row record a read of an unwritten key answers) is read as written now, no stored row — in this engine's grammar, with no operator part; hard-breaking: none; closed sets: none; exports: +1 function `sessionPolicyStoreContract`; members: +1 optional `StoredSessionRules.operatorRules`, +1 optional `SessionRulesInGrammar.operatorRules`, `judgeSessionRulesWrite` answers `SessionRulesInGrammar`) (@server @cli @client-core)
|
|
137
|
+
|
|
138
|
+
- **Before.** The gate already re-read the session's rules row at every tool call, on the task's own layer and on every delegated leg carrying the key, so an operator replace that left an entry out bound the very next call. But the write judge read every non-operator write as tighten-only against the whole stored row. The session's owner could add a deny while a turn ran (the shell's mid-turn channel) and could never take it back: `/permissions` deleted the local rule while the engine record kept refusing for the rest of the session (cli L-1670 told the user so).
|
|
139
|
+
- **Now.** The delete port is the same `putRules`: a value without the entry takes it out of every gate that reads the row, from the next call (no verb, no frame; the observable is the next `tool_end` for that tool carrying no `denied` gate outcome). What a non-operator write may take out is judged against the row's **operator part** — the buckets and grammar the last operator write set. The judge mints it alone (a value handed in with one is no authority). **It is present only once a non-operator write has stamped the row; absent, the whole row is the operator's** — an operator write's whole value, and every row stored before this version, so existing rows read fail-closed exactly as before. Over no row nothing is judged for loosening, as before: there is no rev to compare and no tighten gate, so a host's pre-read with `prior = null` answers only the shape and entry refusals. The row starts with the part of the value written. A stored row handed in (an import replaying an exported row; it carries a store's `rev`, a whole number from 1) keeps its stamp, or, without one, is the operator's whole, so a replay never turns an operator's entry into one the owner may take back. Any other value starts with an empty part, whatever part it hands in: a fresh one, or one built on the no-row record `{ rev: 0 }` that a read of an unwritten key answers. Above the operator part the owner adds and takes back freely; at or below it every loosening is `loosen_forbidden`, an entry both writers asserted included (the drop is refused and the rule keeps binding). An erase (`deleteBySession`) mid-leg is still not a delete port: the leg keeps the row it enforced and announces a recreated row of a new lineage (#978 G7 / G14).
|
|
140
|
+
- **What changed (cross-repo contract, row shape +1).** A stored row may carry one more member, `operatorRules` (an object of rule buckets plus its own `grammar` stamp, never an `operatorRules` of its own). It is read by the one shape reading `readSessionRulesShape` (a part it cannot read is the row's fault, `corrupt` on read-back), handed back in `rules` beside the buckets, and carried by `listBySession`, the store's export, and the GET body of a host that serves the stored row.
|
|
141
|
+
- **Who is hurt.** A durable twin that re-assembles a row from the buckets and the grammar alone drops the member. Its owner writes are then judged against the whole row: no take-back, but nothing loosens either (fail-closed). Mount `sessionPolicyStoreContract` to find out. The member adds no rollout order of its own. An engine before 7.55.0 already cannot read any row this version writes: its shape reading refuses the row's grammar stamp (3, the #2150 section) before it reaches a member, and past the stamp it would refuse `operatorRules` as no rule bucket too. Upgrade readers first, as that section says (read from 7.54.0's shape reading; not run across versions). A row stored before 7.55.0 (no member) that holds a `mcp__*` deny: on 7.54.0 that entry named nothing, so the owner could remove it. Here it denies every MCP tool (#2150), and because the whole stamp-less row is the operator's, the owner can never remove it — R1's design consequence (pinned, H3). An operator write is a whole value, so it makes every entry it carries the operator's, the owner's included: after one, the owner can no longer take back what it wrote before. A host or test that matched the old `loosen_forbidden` text ("may only tighten") reads the new sentence.
|
|
142
|
+
- **How to migrate.** @server: the SQL twin already stores the judge's whole answer and reads it back through `readSessionRulesShape(body).rules`, so the member rides unchanged — run `sessionPolicyStoreContract(make)` beside the equivalence suite to prove it. `explicitOperatorOk` in `src/http/routes/sessions.ts` (`:1055`, the flag frozen into `putRules` at `:1100`) and the import replay's `operatorOk` stay the one authority switch: an operator-replayed import makes the imported row the operator's. A non-operator one keeps each record's part: the record carries its rev, which `readSessionRulesRecords` re-adds after the pre-read (`src/session-policy-wire.ts:87-89`). Onto a fresh or erased key nothing is judged for loosening. The PUT route's pre-read (`readWritableSessionPermissionRules`, `judgeSessionRulesWrite(null, …)`) answers only `invalid_shape` / `inert_entry`. A GET-modify-PUT that drops an operator's entry is answered by the store against the real row: 403 for the owner, success for an operator. @cli: L-1670's real take-back is the existing `PUT /v1/sessions/:id/policy` with the entry left out (expected rev = the row read). The "still holds … next session" sentence is true only for an entry the record's operator part holds, which a 403 `loosen_forbidden` says. @client-core: no new event; the removal shows as the next gate of that tool not refusing. A take-back verb beside `tightenSessionPolicy` (read the record, PUT it without the entry, CAS on its rev) is the package's to add, so the shell does not build the value itself.
|
|
143
|
+
- **Pins.** `test/backlog2151-session-rule-delete-port.test.ts` (real Runner: an operator drop binds the next call on the own layer and on a foreground and a background delegated leg; the owner's drop of the operator's deny is refused there too; the owner adds a deny mid-run and takes it back; the owner's deny on the parent's key taken back mid-child; an entry both writers asserted; a row stored before the member; the owner's own allowDirs taken back with a real Write landing after, and an operator's allowDirs that the owner cannot remove; the judge's stamp three ways, and over no row from a handed-in stored row; the no-row record `{ rev: 0 }` handed back with a deny, taken back mid-run; a host's GET-modify-PUT whose pre-read never refuses a loosening, with the store answering the owner 403 and the operator success, on an owner-stamped and an operator's row; the owner's own `mcp__*` deny taken back, an operator's or a stamp-less row's not). `test/backlog2151-session-policy-store-contract.test.ts` (the kit on the in-memory and file stores through the package root, including an exported row replayed onto a fresh and an erased key, a value built on the no-row record, and a backend that drops the member failing the stamp cases).
|
|
144
|
+
|
|
145
|
+
### Release documents: the 7.53.0 and 7.54.0 criteria errata (#2191, #2195; docs only; `docs/` is not in the package; no `src/` change; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
146
|
+
|
|
147
|
+
- **#2195.** Four texts of `docs/release-criteria/7.54.0.json` did not match the published artifact or a measured run, and are rewritten to what was measured, each marked `erratum #2195` in the field it corrects (the criteria schema carries no separate erratum member): G8.a counted `CHECKPOINT_CONFIG_REFUSAL_CODES` at 41, the state between the release's two entries — the package holds 42; G11.a (3) claimed the sub-package's registry `gitHead` is the tag's commit — neither 7.54.0 registry document carries a `gitHead` (both were published from a linked worktree), and the third leg was a fresh clone of the tag rebuilt to the registry's shasum; F20's `before` and G8.a's v7.53.0 tail said `aliases: "file"` threw a `TypeError` out of prepare — on the v7.53.0 tarball it is accepted and the tool mounts; G4.a's control said an `allow` whose edit changes the destination is executed — only a destination inside the root is, one outside it is refused on both versions. F28 and G12.a said the approver's ask carries `crossBoundaryMandated`; that member is the mark on a policy's or hook's own inbound ask (folded into `requiresRealApproval`), and the approver reads the fact as `askClass: "cross_boundary"` with `requiresRealApproval` / `mandated`.
|
|
148
|
+
- **#2191.** In `docs/release-criteria/7.53.0.json`: G2.a's v7.52.0 tail (the task started) — v7.52.0 fetched the peer's card and then refused the task `config.egress_requires_write_effect`; G3.a gave one sentence for five forms — the Proxy form has a sentence of its own; G17.a (v) said the caller tool mounts — under `excludeAllTools` the request carries `tools: []`; G20.a's count of 40 is 7.53.0's own (a later release states its own count); G22.b / G22.c and G12.a called their before-forms a defect of the unreleased 7.53.0 stage — v7.52.0 already had them. Each is marked `erratum #2191` in place. `docs/release-criteria/README.md` records both errata; the 7.53.0 section of this file carries a one-line note.
|
|
149
|
+
|
|
150
|
+
### The launch gate's notes, made whole: when the posture notice is minted, what the taint trace carries, what a leg on an environment that is not the target owes, what a call the table cannot read owes, and why the legs table and not the sandbox predicate (#469 O9b; documentation only; `docs/` is not in the package; no `src/` change; behavior change: none, the readings below are 7.54.0's own and were not in its notes; hard-breaking: none; closed sets: none added, `FileErrorCode` and `CrossBoundaryRecordPhase` gain their registry rows in `docs/CLOSED-SETS.md`; exports: none; members: none) (@server @client-core @test)
|
|
151
|
+
|
|
152
|
+
- **Why this section.** The 7.54.0 notes of the launch gate (under `## 7.54.0`, the sections "The device lane's cross-boundary vocabulary and data plane" and "The device lane's launch gate") state its rule, its record and its refusals. Five readings of the same code were left out, each read off the 7.54.0 tree (`src/core/runner/cross-boundary-lane.ts`, `src/core/cross-boundary-taint.ts`) and pinned in `test/design469-r3-launch-gate.test.ts`. None changes a decision; the one a deployment can observe without doing anything (the first) was missing from the "behavior change" line of the launch-gate section, which names a record, a trace and a report of a record that could not be written, and no notice on a tree that is not governed.
|
|
153
|
+
- **The undeclared-posture notice.** At the prepare of every leg that finds its tree armed — its environment declares `externalContentTarget`, the row it resumes was minted on one, the tree it joins was armed by another leg, or its session's record says the tree was — the engine mints one operator notice `config.cross_boundary_posture_undeclared` (`detail: { sessionId, runId, taint }`, `taint` the tree's taint bits) when `RunnerDeps.crossBoundaryPosture` is absent and the tree the leg holds is not governed (neither the tree it joins nor its session's record says so). A device-lane deployment that declared no posture before 7.54.0 therefore meets this notice once per armed leg from 7.54.0 on, until it declares `"single_user"` (the notice stops, the reading is the same) or `"multi_tenant"` (the gate engages); a tree that never armed (the host lane) mints none, and neither does a leg that declares a posture. *Who is hurt:* no decision moves; a notice projector that has no row for the code (add one: audience `operator`, minter `engine`) and an operator who reads one notice per leg as a fault. Pin: P17, "an armed tree with no posture announces the permissive reading once and traces its prepare" (once, for a one-leg run), and the same file's "no posture vs single_user on an undeclared env" for the arms that mint none.
|
|
154
|
+
- **The taint trace.** `permission.cross_boundary_taint` is a `TraceEvent` — it reaches a host through `RunnerDeps.tracer`, not as a `TaskEvent` — of the shape `{ kind, version: 1, taskId, runId, phase, taint, governed, toolName?, toolCallId?, ts }`. `phase` is `"prepare"` at an armed leg's prepare, `"resume"` at the prepare of a durable resume's leg, and `"observed"` once, with `toolName` and `toolCallId` of the call, at the allow exit of the call that first lets an off-target read into the TREE (a bit another leg of the tree already set emits none). The bit is set and the event emitted before the call runs and before its session record is written, so on a governed tree whose record then cannot be written the call is refused after the event, and the bit stays (the tree only grows). `taint` lists the tree's taint bits after the event (`CROSS_BOUNDARY_TAINT_BITS` members, none for a bit not set) and `governed` says whether the tree has run under `multi_tenant`. Metadata only, a tree that never armed emits none. The trace's `phase` is a literal member of the event's type; the other phase vocabulary of the gate, where a session record's write was owed (`prepare` / `exit` / `suspend` / `settle`), is `CrossBoundaryRecordPhase`, the word `config.cross_boundary_record_unwritten` carries as `detail.phase`.
|
|
155
|
+
- **A leg on an environment that is not the target is read as both outside legs (open to veto).** A call's `env` leg is judged by whether the environment it reaches declares itself the target at that moment. An environment that does not reads as an outside read AND an outside write. On an armed tree that is not only a rule about the device lane's own legs: a delegated leg running on an environment that declares no target — a host child of a device tree, a leg in an isolated sandbox — has every `env` call of its own (`Read`, `Bash`, `Write`, …) put to the table as an outside write, so under `multi_tenant` each of them is a person's from its first call, not only after an off-target read, and each of them sets the off-target-read bit once allowed; the bit reaches the parent through the tree's shared state, so the parent's next device call is a person's as well (pinned, P7). Under `single_user`, on a tree that never ran `multi_tenant`, the same calls ask nothing and set the bit (a leg's posture does not ungovern a tree: a session that once ran under `multi_tenant`, or a tree a `multi_tenant` leg joined, stays governed under a later `single_user` leg). *Who is hurt:* a `multi_tenant` device deployment that delegates to legs on another environment; its approvers meet cards they did not meet before 7.54.0, each with `cross_boundary` among its classes. *How to migrate:* answer the cards (`cross_boundary` is among their classes and they carry the real-approval bit); the rule has no knob, a child belongs to its spawner's tree.
|
|
156
|
+
- **A call the table cannot read reads as every leg, off the target.** A call whose name no roster row answers, and a call of a `"per_call"` row whose resolver (`boundaryLegsFor`) is missing, throws or answers outside its type, is read as all three legs on an environment that is not the target (so, on an armed tree, it sets the off-target-read bit once allowed and, on a governed one, is a person's). A missing, throwing or ill-typed resolver is reported on `onError` (phase `config`) where the station reads the call (the exits read the same answer quietly); a call with no roster row is not reported. Pinned: "a per_call row whose resolver is missing, throws or answers outside its type reads as every leg off the target, reported …" (`test/design469-r3-launch-gate.test.ts`).
|
|
157
|
+
- **The judging position is the call's legs, not `sandboxBoundaryCapable` (open to veto).** A proposal to read the gate's "is this call beyond the boundary" off the sandbox-admission predicate (`sandboxBoundaryCapable`: the egress mark, or not one of the env's hand tools) was not taken, because that predicate answers another question — whether a sandbox could contain the tool — and misses both ways here: a tool that is not an env hand tool (a task-list or delegation tool) reads as outside, and `Artifact` is an env hand tool (`prepare-artifact.ts`), so its hosted publish would read as inside. Each roster row states its legs instead (`ToolRosterEntry.boundaryLegs`; the registry `TaskOutput` / `TaskStop` and `Artifact` give theirs per call), which a host and a black-box check can read row by row on the wiring manifest.
|
|
158
|
+
- **Records.** `docs/CLOSED-SETS.md` registers `FileErrorCode` (`target_unavailable` came in 7.54.0 on a union that had no row) and `CrossBoundaryRecordPhase`, adds no word, and says that `cross_boundary_taint` is last in `PERSISTED_RULE_MANDATES` and first in precedence. `docs/KNOWN-LIMITS.md` gains KL-469-k (the legs a declaration states are the declarer's word: a request-side `boundaryFlow` on an MCP list that is not locked or on an A2A peer list, which has no lock key; and a host tool mounted with a core contract), and KL-469-c, -d and -e carry what happened to them since 7.54.0 (the settle sub-window is a defect on the ledger; the workflow handle's tree is a ledger item; the fleet-wide posture prerequisite is the host-facing text of `docs/INTEGRATION-CORE.md` §4.5c, the neighbour's own deploy document not yet holding the line). `docs/INTEGRATION-CORE.md` §4.5d states the non-target-environment reading. `docs/wire-consumption/7.54.0.md` records the #469 keys with the consumers' dispositions still to be filled by them.
|
|
159
|
+
- **Consumers.** *server:* the notice is minted per armed leg until a posture is declared (the first reading); the trace's three phases and fields are what the projection onto `GET /v1/runs/:id`, asked for in the 7.54.0 notes, carries. *client-core:* nothing new — the words were already in the 7.54.0 notes. *test:* the first and third readings are black-box checkable (the notice count per leg with no posture; the child-on-undeclared-environment cards under `multi_tenant`); the unreadable-call reading is checkable with a `"per_call"` tool whose resolver throws (an `onError` line, the call read as every leg); the criteria file of a later release is the place for them.
|
|
160
|
+
|
|
161
|
+
### Deny and ask lists read `mcp__*` and `mcp__*__*` as every tool of every MCP server; allow lists keep refusing them, now with one sentence naming the forms that do name tools (#2150; also #2155, no change — see its bullet; behavior change, open to veto: a rule policy built outside a Runner used to compile these two spellings and answer allow (a deny that protected nothing) and now decides, and inside a Runner the spellings go from refused at prepare to read; differs from the reference: no, this follows it; behavior narrowing: a rule policy built directly now refuses a partial wildcard in a peer segment, which it used to compile and match nothing with; hard-breaking, open to veto: the session-rule grammar stamp moves 2 → 3, so an engine of grammar 2 refuses every row this engine writes (see the session-rule grammar bullet); closed sets: none; exports: none added or removed — `SESSION_RULES_GRAMMAR` is 3 (was 2), `judgeRuleToolName` gains an optional trailing parameter `behavior`, `isNamespacedCoveringRuleName` an optional second parameter `behavior`, and the published conformance table `SAFETY_MERGE_CORPUS` changes content (one vector re-exemplified, two added); members: none) (@cli @client-core @server @dgx)
|
|
162
|
+
|
|
163
|
+
- **Before.** The engine did not read the two whole-protocol spellings in any list, and the surfaces disagreed about saying so. Through a Runner, a name list (`createAllowDenyPolicy`, `createApprovalPolicy`), a rule policy's deny or ask rule and a skill's `disallowedTools` carrying `mcp__*` or `mcp__*__*` failed the run at prepare (`config.legacy_tool_name`, the peer segment `*` "not a spelling the engine mints", with a rewrite that named one server), and a session's `toolDeny` was refused at the write (`inert_entry`) — but a rule policy built directly (`createPermissionRulePolicy`, no prepare-time name audit) compiled the rule with no issue and answered allow for every call: a deny that protected nothing, silently. An agent's denied tools read `mcp__*` as every MCP tool but refused `mcp__*__*` (and ignored it where the filter is called as a function).
|
|
164
|
+
- **Now.** A list that RESTRICTS — a rule policy's `deny` / `ask`, a policy's `deny` and `requireApproval`, a session's `toolDeny`, a skill's `disallowedTools`, an agent's `denyTools`, a run's `excludeTools` — reads `mcp__*` and `mcp__*__*` as every tool of every MCP server (any namespace in the protocol table reads its own prefix the same way: `a2a__*`), with one parse, `wholeProtocolOf`, shared by the rule layer, the name lists, the entry reader, the name judgment and the agent lists (the agent lists' own `wholeProtocols` side set is gone). A list that GRANTS — `allowedTools` (request, deployment, skill), a policy's `allow` / `autoAllow`, a session's `toolAllow`, an agent's or a run's `allowTools` — does not read them, as before; where it used to refuse them it still does, in the same seat and under the same code, and the sentence now names the forms that do name tools: `mcp__<peer>` (every tool of one peer), `mcp__<peer>__<tool>` (one tool), and that a deny or ask entry may write `mcp__*` / `mcp__*__*`. A wildcard in any other peer segment (`mcp__git*`, `mcp__*__get_*`) is read by no list and is refused with the same sentence — including by a rule policy built directly: `validatePermissionRules` and `createPermissionRulePolicy` answer `invalid.tool_name` with the judgment's sentence for such a deny or ask rule (`config.invalid_permission_rules` when built, a skipped-and-reported rule under `onInvalidRule: "skip"`), where they used to compile it and match nothing (`wildcardPeerRefusalOf` is that judgment for a station with no roster); its verdict (`unmintable_segment`) no longer carries a `currentName`, since there is no spelling to rewrite a wildcard to (it used to name one server). `judgeRuleToolName(name, roster, behavior?)` answers `current` for the whole-protocol name when `behavior` is `deny` or `ask`; with `allow` or no behavior it answers as before (`unmintable_segment`), so a caller that passes nothing reads what it read. An allowlist reads those names as nothing everywhere, the frozen one a policy projection carries included: the policy constructors refuse such an entry where it is written — `createAllowDenyPolicy({ allow })`, `createApprovalPolicy({ autoAllow })` and `createApprovalPolicy({ requireApproval })` under `denyByDefault` (the three lists that become a projection's `tool_allowlist`) throw `config.invalid_tool_name_set` with the judgment's sentence (a partial wildcard is refused there too; `onInvalidName: "skip"` drops it) — and the frozen replay of a stored `tool_allowlist` holding `mcp__*` / `mcp__*__*` (frozen before 7.55.0, or built by a host) denies every MCP tool not otherwise named, exactly as before; it is not read as "every MCP tool". A `requireApproval` without `denyByDefault` is an ask list and reads the whole-protocol name.
|
|
165
|
+
- **The session-rule grammar (hard-breaking, open to veto).** *What changed.* A session's `toolDeny` entry `mcp__*` / `mcp__*__*` used to read as an entry that names nothing and now denies every MCP tool — what an entry means moved, so the rule grammar the write judge stamps on every row moves with it: `SESSION_RULES_GRAMMAR` is 3 (was 2), minted by every store that writes through `judgeSessionRulesWrite`; a row stored in grammar 2 is read by this engine as written, so its `mcp__*` now denies (a tightening, never a grant). *Who is hurt.* Every engine of grammar 2 (7.54.0 and before), including a server worker running on one: it refuses a row stamped 3 whole (the shape reading faults `grammar`, the file store answers `corrupt`, a write answers `invalid_shape`, the run-time layer refuses every call, "session rules cannot be read as rules") rather than read its `mcp__*` as nothing and allow the calls the operator denied — per ROW, not per entry, so during a rolling upgrade a session whose rules a new worker wrote is unusable on an old worker until that worker is upgraded or drained. Measured on the 7.54.0 tree: a grammar-2 row holding `mcp__*` allowed every MCP call (fail-open), a grammar-3 row is refused whole. *How to migrate.* Upgrade readers before writers: roll the engine to 7.55.0 on every worker that reads session rules before any worker that writes them (a deployment with one process is unaffected); @server @dgx deploy: order the server release that carries 7.55.0 so no worker on the old engine can read a row written by a new one, or drain the old workers first. A host that stores `StoredSessionRules.grammar` itself keeps the stamp the write judge returns and nothing else.
|
|
166
|
+
- **The reference.** The settings validator of 2.1.296 refuses a wildcard in the server position of an allow rule ("Wildcard tool name … is not supported in allow rules … Deny and ask rules accept wildcards anywhere") and accepts it in deny and ask; the deny and ask matcher reaches the whole tool name through a glob, so `mcp__*` is every MCP tool there; an agent's denied-tools set reads `mcp__*` and `mcp__*__*` alike as every server. This change follows that for the two whole-protocol spellings. It does not follow the glob in general: the reference also lets a deny or ask rule glob any part of any name (`mcp__git*`, `Web*`), and the engine's name lists have no glob semantics (KL-1902-a).
|
|
167
|
+
- **Who is hurt.** (a) A deployment, skill or session that now writes `mcp__*` / `mcp__*__*` as a deny gets what it wrote: every MCP tool of the run refused, including a server's tools mounted later and a delegated child's. A session row stored before the write gate existed that holds `toolDeny: ["mcp__*"]` read as nothing and now denies every MCP tool; removing it is a loosening for a non-operator. (b) A host that took the engine's refusal of these spellings as its signal — client-core's package-side rewrite of a `*` peer segment, a shell or server that mirrors the name judgment with a hand-written exemption for `mcp__*` (server's `isProtocolWideName`) or reads `unmintable_segment`'s `currentName` for a wildcard peer. (c) `readRuleEntry("mcp__*", "deny")` is a `row` of kind `namespace` now, not `inert` (`namespace_wildcard`); a reader that matches such a row's `pattern` by comparing peers itself, instead of through `namespacedRuleNameCovers`, misses the whole-protocol name. (c') `isNamespacedCoveringRuleName(name)` with no behavior (and with `allow`) answers `false` for a name whose peer segment carries a wildcard — it answered `true` for such a name, which then matched nothing; a host that routed by it keeps matching nothing, and one that wants the restricting reading passes `"deny"` / `"ask"`. (d) The session-rule grammar stamp, above. (e) A host that builds `createAllowDenyPolicy({ allow })`, `createApprovalPolicy({ autoAllow })` or a default-closed `createApprovalPolicy({ requireApproval })` with a whole-protocol or partial-wildcard peer name: it used to build, with the entry admitting nothing (and the Runner's prepare audit refusing the run); it now throws `config.invalid_tool_name_set` at construction. A default-closed approval policy that meant "ask for every MCP tool" lists the servers (`mcp__<server>`) or drops `denyByDefault`. (f) A host that builds `createPermissionRulePolicy` with a deny or ask rule carrying a partial wildcard in an MCP or A2A peer segment (`mcp__git*`): it used to build, with the rule matching nothing; it now throws `config.invalid_permission_rules` (name the server, or write `mcp__*` for all of them). (g) A control plane that runs the published `SAFETY_MERGE_CORPUS` as a contract test of its own merger (server's control plane, web-admin): the vector `inert-deny-entry-removed-safe` now uses `mcp__git*` (a wildcard in another peer segment, which names nothing) where it used `mcp__*`, and two vectors are added (`whole-protocol-deny-removed-loosened`, `whole-protocol-deny-both-spellings-removed-loosened`: removing `mcp__*` or `mcp__*__*` from a deny list loosens, `loosens: true`); a merger that routes through `loosenReasons` is right already, one that re-implements the law fails the added vectors. (h) Text: the `guidance` of `unmintable_segment` for a wildcard peer, the `config.legacy_tool_name` message built from it, and the agent allowed-list refusal sentence changed.
|
|
168
|
+
- **How to migrate.** *cli* (L-1673): the permission-rules editor and `--disallowedTools` may pass `mcp__*` / `mcp__*__*` for a deny or ask rule, and must keep refusing them for an allow rule, quoting the engine's sentence. *client-core* (CC-886): stop rewriting a `*` peer segment and hand the rule text to the engine unchanged; the engine decides per list. *server*: a settings or session-rule write may accept the two spellings on deny and ask lists — read the verdict through `judgeRuleToolName(name, roster, behavior)` / `compileAllowList(…, "deny", roster)` instead of exempting them by hand, and match a `namespace` row through `namespacedRuleNameCovers`. Everyone else: nothing, unless (a) applies.
|
|
169
|
+
- **#2155 (no change).** The ticket asked what happens when two servers whose names normalize to one prefix (`a.b` and `a_b` both mint `mcp__a_b__`) meet a server-level rule and a tool-level rule. The engine already refuses the pair whole at the mount, before any dial and before any rule is read (`config.mcp_server_name_collision`, both raw spellings named; since #377), so no rule reaches two servers by one spelling; the reference merges such a pair silently (its tool pool keeps the first of a name), and the engine is stricter on purpose — a refresh of one of two colliding servers would unmount the other's tools, and a first-wins pool depends on list order. INTEGRATION-CORE §4.6g now says so; no code moved.
|
|
170
|
+
- **Pins.** `test/backlog2150-protocol-wide-names.test.ts` (real Runner, two stdio servers: a deny rule of either spelling refuses both servers' calls with the servers receiving nothing; an ask rule raises a card per call; the name lists, the approval factory, the tool selector, the entry reader, the session-rule store and the name judgment per behavior; an allow of either spelling refuses the run at prepare before a model turn, and the constructors refuse it where it is written; the frozen replay of a stored allowlist holding the whole-protocol name denies (base: deny too) and of a stored deny hides every MCP tool (base: no opinion); a rule policy built directly refuses a partial wildcard in a deny or ask rule at compile — `validatePermissionRules` and `createPermissionRulePolicy` — with the base readings: compiled clean and answered allow); `test/backlog2155-server-name-collision.test.ts` (the pair refused before a rule is read and before a dial; one server's rules reach exactly it; the raw spelling in a rule refused with the mint's spelling); re-pinned readings in `test/backlog1303-skill-grant-unit.test.ts`, `test/backlog1327-allow-entry-unit.test.ts`, `test/backlog1372-skill-deny-unreadable.test.ts`, `test/backlog1474-rule-entry-reader.test.ts` (their exemplar of "a deny entry that reads as nothing" is now `mcp__git*`) and `test/backlog1156-legacy-tool-name-session-rules.test.ts` (the judgment's parameter list); `test/backlog1355b-unmintable-segment-judge.test.ts` (a wildcard peer has no mint spelling: its own law cell, no `currentName`); `test/backlog1866-session-deny-fail-closed.test.ts` and `test/backlog1876-indented-key-under-value.test.ts` (the stamp is 3; a row stored in grammar 2 holding an entry no grammar reads now reads it as `earlier_grammar`; a stamp above the engine's is 4); `test/safety-merge-corpus.test.ts` runs the changed table; the per-fixture mutation table is in the car receipt.
|
|
171
|
+
|
|
172
|
+
### The wiring manifest says, per leg, whether the legs that leg delegates re-read its session's rules: `WiringManifest.sessionRules.childrenReadLive` (#2208; additive; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: +1 optional `WiringManifest.sessionRules` (`{ childrenReadLive: boolean }`), +1 optional `WiringFacts.sessionRulesChildrenReadLive`) (@cli @server @client-core)
|
|
173
|
+
|
|
174
|
+
- **Before.** Whether a rule added to a session while an agent runs reaches that agent depended on the engine's version: from 7.55.0 (#2181) every delegated leg re-reads its parent session's rules at each tool gate, before it kept the rules it started with. A shell that wants to tell the person "this binds the agents already running" had nothing to read it from but the version.
|
|
175
|
+
- **Now.** An effective wiring manifest carries `sessionRules?: { childrenReadLive: boolean }`, on every leg the engine prepares (root, child, resume). `childrenReadLive: true` means the leg enforces its own session-rule layer (a `sessionPolicyStore` is wired and the task carries a `sessionId`) and has handed that session's key on to the legs it delegates — sync, background, forked, workflow, retained, resumed and observer-sidecar legs, at any depth — each of which re-reads the row at every tool gate, so a rule written mid-run binds the agent's next call whether or not the session had a row when the agent was spawned. `false` means no layer, so no key is handed on: no store is wired, or the task names no session (a one-shot task). The bit is read off the wiring that hands the key on (the policy-chain phase's published key, the seat the delegation chain reads at spawn), never written as a constant: take the publication away and the bit goes with it, and the pins read the bit and the thing it promises in the same cell (a deny written to the session mid-run, refused at the next gate of a background, a foreground, a depth-2 and a workflow agent).
|
|
176
|
+
- **Presence.** Present on every effective manifest a 7.55.0 engine mints, `true` or `false`; **absent means an older engine (or an external `deriveWiringManifest` caller that passed no fact): assume nothing**. The static half (`describeStaticWiring`) never carries it — the bit follows the task's `sessionId`, a per-task seat. A leg-start snapshot, like the rest of the object, and outside `configFingerprint` (a root with a session and a one-shot root of one assembly fingerprint alike; every fingerprint is what it was).
|
|
177
|
+
- **What it does not say.** It speaks for the assembly the manifest can see. A delegation tool mounted over a Runner with no `sessionPolicyStore` of its own cannot re-read: its legs keep the rules they were spawned with and announce `config.session_rules_unreadable` once per ancestor session (#2181), while the root still reports `true` and each such leg's own manifest reports `false`. A Runner with a different store reads the key there, silently (#2197).
|
|
178
|
+
- **Who is hurt.** Nobody: one optional member beside the others; a consumer ignores fields it does not know, and `schemaVersion` does not move. Wire typeKeys (`npm run gate:wire -- v7.54.0 HEAD`): `WiringManifest` +`sessionRules` (optional), +`sessionRules.childrenReadLive`; `WiringFacts` +`sessionRulesChildrenReadLive` (optional).
|
|
179
|
+
- **Who else.** *cli* (L-1778): read `manifest.sessionRules?.childrenReadLive` and speak by the bit, not by the version — `true` ⇒ a rule added now binds the agents already running at their next call; `false` or absent ⇒ say nothing about running agents. *server* (S-1911): classify the new section key in the `wiring_manifest` projection (tenant and operator faces) and pass the member through to the capability read verbatim. *client-core*: type `sessionRules?: { childrenReadLive: boolean }` on the manifest.
|
|
180
|
+
- **Pins.** `test/backlog2208-session-rules-children-read-live.test.ts` (R1 a root leg's bit through a real prepare, four seatings — store and `sessionId` ⇒ `true`, any other ⇒ `false`, never absent; B1 / B2 / B3 a background, a foreground and a depth-2 agent: the legs say `true` and the deny written mid-run binds the agent's next call; W1 a workflow `agent()` leg, the same; S1 a delegation Runner with no store: the root says `true`, the agent's own leg says `false`, the rules it was spawned with are all it enforces and it announces that once; D1 the derivation: an absent fact states nothing, the static half carries none, a non-boolean fact is refused, the fingerprint does not move); `test/prepare-wiring-manifest.test.ts` (the phase relays the policy-chain fact both ways).
|
|
181
|
+
|
|
182
|
+
### A workflow run that is still running while one of its legs is parked at an approval gate says so: `WorkflowRunSummary.parked` and the TaskOutput poll body carry `parked: true`, derived by `runParkedOf` / `parkedAgentsOf` (#2215; additive; behavior change: none on any recorded state; hard-breaking: none; closed sets: none — `WorkflowRunStatus` keeps its three words; exports: +2 functions (`runParkedOf`, `parkedAgentsOf`); members: +1 optional `WorkflowRunSummary.parked?: true`) (@cli @server @client-core @test)
|
|
183
|
+
|
|
184
|
+
- **Before.** A workflow run's own faces say `running | completed | failed` and nothing more. A run in which one leg had parked at an approval gate (#642) while a sibling leg was still working read `running` on the list row, the TaskOutput poll body and the fleet row alike, so nothing at run level told a person they were being waited on; only the parked leg's own row said `parked`, in the per-agent drill-down.
|
|
185
|
+
- **Now.** Two states, one word each. (1) While the run is still `running` and at least one agent row is `parked`, the run carries `parked: true`: on `WorkflowRunSummary` (what `listWorkflowRuns` returns and `summarizeWorkflowRun` builds), and as the `parked` key of the TaskOutput poll body of a workflow run and of its structured details. The member is `true` or absent, never `false`. (2) Once the sibling legs have settled, the run suspends on the park: it is recorded `failed` with `errorCode: "workflow.agent_parked"` and its parked rows beside it (unchanged since #642). That code is the "awaiting approval" word of this state, so the bit is absent there rather than a second way of saying it. A consumer shows both arms as awaiting approval. The bit is derived on every read, `runParkedOf(run)` over `parkedAgentsOf(run.agents)` — the one definition of a parked row — and is not stored on the run, not journaled and not an event (`agent_end` with `status: "parked"` already names the leg). A settled record answers absent on purpose: a resume that decides the park leaves the old record as written, `failed` with its parked row in place, so reading that row would say "waiting" on a decided park for ever. `agents_parked` in the completion notice's `<usage>` block now counts through the same row predicate; that block is built only for a run that completed, and a completed run never carries a parked row, so the key does not appear today.
|
|
186
|
+
- **Closed sets.** None. `WorkflowRunStatus` stays `running | completed | failed`: a parked leg is a sub-state of a running run, and a fourth word would break every exhaustive table over the three.
|
|
187
|
+
- **Consumers (all additive, one optional key).** *server:* the fleet workflow row (`buildFleetWorkflowRow`) and the workflow detail (`summarizeWorkflowDetail`, served at `GET /v1/workflows/:id`) call `runParkedOf(run)` — the way they already call `deriveAgentDisplayStatus` — and carry `parked: true` when it holds; the contract appendix and the openapi document gain one optional key, and the sdk follows in step. *client-core:* `fleetProjection` and `coerceWorkflowStatus` read the one optional key; a status table that was exhaustive over the three words needs no change. *cli:* the `/workflows` header row's parked / awaiting-approval arm is the one already written for a parked agent row, so no shell change is expected (the cli request, B-079②). *test:* re-check B-079② — a run with a parked leg and a sibling still working reads `parked: true` beside `running`, and the same run after the siblings settle reads `failed` + `workflow.agent_parked` with the key absent.
|
|
188
|
+
- **Pins.** `test/backlog2215-run-parked-bit.test.ts` (a real two-leg run — one leg parks at an approval gate, the other holds a tool open: the stored row, the pure derivation, the summary, the listing and the real TaskOutput poll all read `parked: true` beside `running`; the same run drained reads `failed` + `workflow.agent_parked` with the key absent on every face; a resume that decides the park leaves the old record and the completed resume absent; the derivation on hand-built shapes; the single-source shape); the per-fixture mutation table is in the car receipt.
|
|
189
|
+
|
|
190
|
+
### A session reaches the reference agent's sessions — and is reached by them — over their own Unix socket: the opt-in `RunnerDeps.peerChannel` seat, the bundled `createCcUdsPeerChannel` and `createCcPeerDirectory` (#518 slice 3, #519; NEW I/O FACE — a socket listener a host opens; behavior change: none where neither the seat nor a row with a socket is wired; behavior narrowing, open to veto: a directory row with a socket is appended to only when its capability bit names the sender's own mailbox store; wire shape: the capability bit names the store (`sema_mailbox_v1_<16 hex>`, @cli @server); behavior change on a security face, a loosening, open to veto: `vetPeerRegistryDirectory` follows the reference's rules; hard-breaking: none; closed sets: a successful `PeerSendVerdict`'s `via` was always `store`, now `store` or `uds`, and SendMessage's `details.status` gains `peer_delivered`; new `PEER_CHANNEL_DROP_REASONS` (9, registered in `docs/CLOSED-SETS.md`); exports: +24 (12 runtime, 12 types); members: +1 optional `MailboxStore.identity` (`FileMailboxStore` implements it), +1 optional `RunnerDeps.peerChannel`, +1 optional `SendMessageToolOptions.peerChannel`, +1 optional `CcPeerFrameFields.fromPlugin`) (@cli @client-core @server @test)
|
|
191
|
+
|
|
192
|
+
- **Before (v7.54.0).** The cross-session lane had one transport, the recipient's durable box, and no reader of a directory row's `sockPath` / `peerFeatures`. A row that listed a reference-agent (Claude Code) session — a socket path, no box anywhere — resolved like any other: SendMessage appended the message to `session.<its id>`, a box nothing drains, and answered `Message queued … it is parked durably`. No bundled directory listed such sessions, so only a host writing such a row reached it. `decodeCcPeerFrame` refused a CC 2.1.296 frame carrying its sixth attribute, `from-plugin`. `vetPeerRegistryDirectory` refused every link on a path and every ancestor not owned by this uid (a `stopAt` aside), so the reference's own default directories failed on macOS (`/tmp` and `/var` are root-owned links) and on Linux (`/tmp` is root-owned).
|
|
193
|
+
- **Now — one lane reading per row.** `peerSessionLane(row, ownStore)` (`src/agents/peer-directory.ts`) is the one answer to "how does a message reach this peer": the box (`store`) only where a parked message is provably read — a row with no socket (every row a host writes today; the host's own directory vouches that its sessions drain its store), or a row whose capability bit names the SENDER's own mailbox store; every other row with a socket — a reference-agent (Claude Code) session, a session of this engine whose host drains another store, a row or a sender whose store cannot say what it is — is reached over its socket (`uds`). The bit is `sema_mailbox_v1_<16 hex>` (`semaMailboxFeature(identity)`, `SEMA_MAILBOX_FEATURE_PREFIX`: the first 16 hex of the sha-256 of the store's identity; 32 characters, inside the reference's bit grammar), and the store states its identity: the new optional `MailboxStore.identity` (two stores with the same identity serve the same boxes; absent = unknown = the socket); `FileMailboxStore.identity` is the sha-256 of its boxes directory's physical path. SendMessage's peer leg reads the lane once per resolved row, against its own `mailbox`: `store` keeps the box append exactly as before and, with the seat wired and the row live with a socket, then rings the socket once with a content-free `{"type":"control","action":"mailbox_nudge"}` frame (`details.doorbell: true|false`; its loss changes latency, never delivery — the recipient's host decides whether to start a turn); `uds` sends the message itself over the socket through the seat — the send-point admission gate first, as on the box leg — at most once, and the receipt says what it can confirm (`status: "peer_delivered"`, `verdict: { ok: true, disposition: "delivered", via: "uds" }`, "the message was written to its socket once and the connection closed cleanly, which is all this receipt confirms" — the reference's protocol answers no acknowledgement, and its own sender reads the same clean close as a completed send); a transport failure is the existing `peer_send.*` word (`not_reachable` / `stale_socket` / `socket_busy` / `timeout` / `too_large` / `send_uncertain` / `invalid_target` / `other`); `notify_when_idle` to such a session refuses `invalid_target`; with NO seat a `uds` row refuses `peer_send.not_reachable` and nothing is appended. With the seat wired, SendMessage's description gains one sentence after the peer-session one ("A peer whose box is not in this host's mailbox store — a Claude Code session, or a session of this engine on another store — is reached over its socket instead: nothing is parked for it, the message is sent once and only while it runs, and its receipt says so."); without it the description is unchanged. A receipt says "parked durably" only for a box this lane proves the recipient drains.
|
|
194
|
+
- **Now — the channel (`createCcUdsPeerChannel({ mailbox, socketPath, registryDir, lane?, registration? })`, contract `PeerChannel` in `src/core/peer-channel.ts`).** `listen({ sessionId, scope, onNudge?, onEvent? })` publishes in a fixed order: the sockets directory and the registry directory (a missing one created 0700, an existing one never re-moded, both vetted; one listener per process and registry, a second is `already_listening`) → the socket (0600) → the key file `<pid>.<sha256 of the socket path>.key` (0600, `{ peerToken: <32 hex>, procStart? }`) → the registry row `<pid>.json` (0600; the reference's field names, `peerProtocol: 1`, `peerFeatures: [semaMailboxFeature(mailbox.identity)]` — the capability broadcast, naming the store this listener appends into; none when the store cannot say — and `messagingSocketPath`). Any refusal throws `CcPeerChannelError` (`code`: one of the six vetting codes, `unsupported_platform`, `socket_path_too_long`, `address_in_use`, `already_listening`, `publish_failed`) with nothing left bound; `close()` withdraws all three. An inbound connection must authenticate on its FIRST line with this session's token; a 30 s first-line deadline and a 1 MiB line wall close a connection; something already at the socket path is replaced only when it is this uid's socket the kernel refuses a connection to (a regular file, a live listener or an inconclusive probe is `address_in_use`); after that the listener only checks a frame's shape, its addressee (`session_id`) and the box bound (`peerLane.maxQueuedPeerMessages`, also bounding the frames waiting for the store before any is queued) and appends the frame's typed fields into this session's own box — `peerMeta { fromSession, fromMode, senderKey (the s-axis token), kind: "peer_message" }`, the hop chain as carried, the envelope's body. It judges nothing: admission, mode parity and holds stay the drain's (one judgment point, as for every record). A dropped connection or frame is one `onEvent({ kind: "dropped", reason })` from `PEER_CHANNEL_DROP_REASONS`, never thrown. `readCcPeerLine` (`src/agents/cc-peer-wire.ts`) is the one reader of an untyped wire line (#519). `dial(address, message)` writes the reference's sender frame — the peer's published token on an auth line (none published: no auth line, the reference's own POSIX form), then `{ msgV: 1, msg_id, type: "user", message: { role: "user", content: <the envelope from encodeCcPeerFrame> }, priority: "next", from: "uds:<own socket>" }` (the reply address only when the sending session is the one this channel listens for — another session sharing the process names none), this engine's hop tokens in their wire spelling; a peer socket must sit in a vetted directory and not be a link. `ccPeerDefaultLocations({ env, homedir, pid, uid })` answers the reference's own placement.
|
|
195
|
+
- **Now — the reference registry as a directory (`createCcPeerDirectory({ registryDir, scope?, probe?, onSkip? })`).** Lists the rows of `<configDir>/sessions/<pid>.json` that are live and advertise a socket, as `PeerSessionRecord`s (an unnamed session is `(unnamed session)`, the reference's label). Live = the pid exists and, when the row carries `procStart`, the process start token (`ps -o lstart=` under `LC_ALL=C TZ=UTC`, `ccProcessStartToken`) equals it — a recycled pid is not the session. A file it cannot vouch for (not this uid's, group/world-writable, too large, not JSON, an invalid row, no socket, not live) is skipped and reported on `onSkip`; a registry directory that fails its vetting THROWS `CcPeerDirectoryError` naming the code, never an empty list. Dead rows are not returned — the reference deletes them, so this registry is not the name truth for offline delivery to a session of this engine; a host that needs both composes its own directory beside it.
|
|
196
|
+
- **Now — the loop identity across the wire.** The self-hop check reads a token in either spelling: this engine's own (`peerAxisToken`) and its wire form (`peerWireHopToken`: the first 24 hex of its sha-256; an entry already 24 hex is carried unchanged), so a chain that went out over a reference session and came back counts its self hops.
|
|
197
|
+
- **Now — `vetPeerRegistryDirectory` (CC 2.1.296 rules, the six codes kept).** The directory itself must be a real directory (never a link) owned by this uid with no group/world write bit, as before; an ANCESTOR may be owned by this uid or by root, and may be world-writable when it is sticky; an ancestor that is a link owned by this uid or root is followed and its target judged by the same rules; more than 16 links is `symlink`, a dangling one `missing`. The reference's nine refusal kinds land on the six codes (`directory_rule` → `foreign_owner` or `group_or_world_writable`, `leaf_shape` → `symlink` / `not_directory` / `foreign_owner`, `dangling_link` → `missing`, `symlink_loop` → `symlink`, `internal` → `not_absolute`). `decodeCcPeerFrame` / `encodeCcPeerFrame` take `fromPlugin`, after `from-mode`, canonicalized like `from-name`.
|
|
198
|
+
- **New I/O face (listed on its own).** A host that calls `listen` opens a Unix-domain socket server that accepts connections from any process of this uid able to read the published token; nothing in the engine opens one, and a run never listens. Identity is "same user" only — node exposes no peer credentials — so nothing here, and nothing a host renders from it, may claim a verified pid or a verified session.
|
|
199
|
+
- **Who is hurt (open to veto).** (1) A deployment whose directory lists a row with `sockPath` whose bit does not name the sender's store (or carries none): it is no longer appended to (refused `peer_send.not_reachable` without the seat, sent over the socket with it). No shipped host writes such a row: the server's rows never carry `sockPath`, the cli wires no directory. A backend that implements `MailboxStore` without `identity` (the server's SQL twins today) is never the box lane toward a socket row — every such send is a full frame over the socket and needs the recipient's listener up; implement `identity` (equal for every host on the same database) to get the box lane and the doorbell (@server). (2) A caller of `vetPeerRegistryDirectory`: two more shapes now pass — a root-owned or sticky ancestor and a link owned by this uid or root — a LOOSENING on a security face, the reference's own rule. (3) A caller of `decodeCcPeerFrame`: a frame with `from-plugin` decodes instead of being `not_an_envelope`. (4) A chain already carrying a 24-hex entry equal to a recipient's wire token counts it as a self hop — reachable only through the reference's wire.
|
|
200
|
+
- **Compensation.** None for a deployment that wires neither. A host that wants the lane: `createCcUdsPeerChannel` + `listen` at session start (`close()` at its end), wire the same channel as `RunnerDeps.peerChannel`, and give `RunnerDeps.peerDirectory` a directory that lists the reference's sessions (`createCcPeerDirectory`, or its own composed with it); start a turn on `onNudge` within its wake budget (`peerLane.maxWakesPerHour`). Hosts that should reach each other through the box (and the doorbell) give their sessions mailbox stores with one `identity` — the same file root, or a shared backend that states it; anything else is reached over the socket, at most once.
|
|
201
|
+
- **Differs from the reference implementation (CC 2.1.296), open to veto.** The auth line is REQUIRED on POSIX (the reference makes it optional there): its sender always presents the published token, so it is not refused; a bare injection without the auth line is. Windows is refused (`unsupported_platform`) instead of a named pipe. Over the socket this engine takes messages and its own doorbell only: idle subscriptions and their notices, `peer_message_status` receipts, file attachments (a frame with them is dropped whole, `unhandled_frame`), rename and artifact-yield controls are dropped and counted; `from-plugin` is not carried to the model face. A receipt the drain owes a reference-agent sender (a hold) is still routed to `session.<its id>`, a box nothing drains.
|
|
202
|
+
- **Pins.** `test/design385-s3-cc-wire.test.ts` (the line layer, the one wire reader, the key-file name, the default locations, the registration row, the lane reading, the wire hop token and the self-hop check, `from-plugin`, the vetting codes and the reference's acceptances, the system temp directory vetting clean), `test/design385-s3-cc-uds-channel.test.ts` (C1–C8 the listener on real sockets: publish order and withdrawal, the refusals before bind, only a proven-stale socket of this uid replaced (a regular file kept), the backlog bounded before anything is queued, a reference frame landing with its typed fields, the auth wall, the line wall, the first-line deadline, dropped frames, the inbound doorbell; D1–D3 the registry directory: mapping, skips, PID reuse, the scope wall, the vetting throw, a real child's start token; S1–S3 the dialer's frame and verdicts; E1 the silent-loss form refused, E1b the seat-absent invariance (receipt and description), E2 the socket arm through a Runner, E3 the box arm + doorbell, E3b the same store by identity (another object over the same root, through a link), E4 the drain injecting what the listener appended, E5 the listener judging nothing under `refuse`, E6 the wire-spelled self hop refused at the drain, E7 a session of this engine on ANOTHER store reached over its socket — the message lands in the store it drains, none in the sender's, refused without a channel; F1 one `FileMailboxStore.identity` per physical boxes directory), `test/public-knob-liveness.test.ts` (`peerChannel` row), `test/export-surface.snapshot.json` (+24).
|
|
203
|
+
|
|
204
|
+
### A task can name an ADVISOR model: the model calls a no-argument `advisor` tool, the engine asks the stronger model once over the conversation, and its guidance is the tool result (#1055; additive; behavior change: none for a leg whose spec names no advisor model — byte-identical, pinned — and one for every deployment: each side-call `brain.call` trace row now carries `lane`; hard-breaking: none; closed sets: new `AdvisorOutcome` (3), `AdvisorNotAttachedReason` (9), `AdvisorHoldDisposition` (3), registered in `docs/CLOSED-SETS.md`; `ENGINE_NOTICE_CODES` +2 (`advisor.not_attached` audience `user`, `route.seat_detached` audience `operator`); `CHECKPOINT_CONFIG_REFUSAL_CODES` +2, 42 → 44 (`config.advisor_tool_invalid`, `config.advisor_model_invalid`); `SideCallLane` +`advisor`; the `tool_end.structured` card set +`advisor`; the tool catalog +1 row (`advisor`); exports: +10 (4 runtime: `advisorAttachVerdict`, `advisorHoldOf`, `ADVISOR_NOT_ATTACHED_REASONS`, `ADVISOR_OUTCOMES`; 6 types: `AdvisorAttachVerdict`, `AdvisorNotAttachedReason`, `AdvisorOutcome`, `AdvisorHold`, `AdvisorCard`, `WiringAdvisorFact`); members: `TaskSpec.advisorModel?`, `RunnerDeps.advisorTool?: "disabled"`, `WiringManifest.advisor?`, `SideQueryResult.errorCode?` / `errorKind?` / `inputTooLongRuledOut?`, `brain.call` trace `lane?`, `MaybeCompactOptions.advisorRestate?`) (@server @cli @settings-schema @test @client-core)
|
|
205
|
+
|
|
206
|
+
- **Before.** The engine had no advisor: CC's `advisorModel` (an Anthropic server tool, `advisor_20260301`) has no counterpart on a third-party provider, and the setting reached nothing.
|
|
207
|
+
- **Now.** `TaskSpec.advisorModel` takes any ref `model` takes. Prepare judges it with `advisorAttachVerdict` — the deployment switch, then the advisor (`unknown_model`, `advisor_unranked`, `weakest_tier` = the `lite` tier), then the main model (`main_unranked`, `pairing` = the advisor is on a weaker tier); the tier reading is the classifier seat's own (`tierIndexOf`), so a deployment without `RunnerDeps.tiers` ranks nothing and attaches nothing. An `on` verdict passes the mount chain — the tool selector's valve (`excluded`), the conversation's hold (`held`; an unreadable transcript ⇒ `hold_unknown`), the shared derived-seat route pre-flight in a third posture, `detach` (a pairing the credentials cannot serve ⇒ `route_refused`, announced to the operator as `route.seat_detached`) — and the tool mounts, labelled with the advisor model's id. The model is told when to call it by CC's own `# Advisor Tool` text on the leg's first request (model lane only, no `steering_injected` echo), withdrawn or re-attached in CC's two short forms as the mount changes, judged against what this conversation was told since its latest compaction; a compaction inside a leg that mounts the tool restates the text right after it lands. A call asks the advisor model over the request the main lane last sent (the engine-level context, after the request reducers), the asking turn cut to this call, and a closing tool result; the reply is `reviewed` (usable text), `declined` (the provider's normalized refusal) or `unavailable` (with the failure's own code / kind / stop reason; `isError`), and the card carries no free text. An `auth` failure, or an `invalid_request` ruled out as over-window, switches the advisor off for the conversation until it is compacted — the tool result is the record, read by `advisorHoldOf` at the mount, at every call and by a host. The advisor's request is a side call on its own lane (`derivedLegBrain` — the compaction brain's factory, now with a `stream` arm that books too): re-priced from the deployment's table, in `costMicroUsd` and `costBreakdown.sideCallMicroUsd`, inside `llmRootMicroUsd` (the reconciliation is unchanged), behind the same status and reasoning shields. The setting rides the parent spec seat: a child judges it against its own model and tells nobody; a fork keeps the tool and is told "Never consult the advisor tool."; the observer sidecar excludes it by name. `RunnerDeps.advisorTool: "disabled"` turns it off for the deployment (the setting is ignored unread; manifest `disabled`; no notice); any other word refuses prepare (`config.advisor_tool_invalid`), and while it is on, an `advisorModel` that is neither a non-empty ref nor a Model refuses prepare (`config.advisor_model_invalid`). Both doors are value doors of the config-door phase, so a resume meets them before the claim: a bad switch or advisor model re-supplied on a resume is refused as `checkpoint.config_refused` (`detail.code` the same word, `detail.field` `deps` / `taskConfig`) and the row stays pending. The `plan-mode` conformance vector names `advisor` in its `absent` list (its `expected` set is unchanged).
|
|
208
|
+
- **This was the only, now one of two** (name a consumer that keys on the old closed reading): `ENGINE_NOTICE_CODES` gains two rows — a host's per-session whitelist that projects `user` notices sees `advisor.not_attached` (dedup unit: the conversation); `route.*`'s kept-seat twins now read `detail.action` in two words (`refused`, `detached`); the `tool_end.structured` `type` set gains `advisor`; a resume's `checkpoint.config_refused` `detail.code` gains two words (a retry policy that maps codes reads `detail.field` beside them); a `brain.call` row with no `turn` is no longer only the compaction summarizer — its `lane` names it (`compaction` / `classifier` / `mod` / `advisor`).
|
|
209
|
+
- **Who is hurt.** Nobody on an existing face: every new member is optional and absent unless a spec names an advisor. A trace consumer that inferred "no `turn` ⇒ compaction" now reads `lane`. A caller tool named `advisor` on a leg that mounts the advisor refuses prepare (`config.reserved_tool_name`).
|
|
210
|
+
- **Three sentences (closed-set and wire-key additions).** What changed: `ENGINE_NOTICE_CODES` +2, `CHECKPOINT_CONFIG_REFUSAL_CODES` +2, `SideCallLane` +1, the `tool_end.structured` card set +1, the tool catalog +1 row, three new closed sets, and optional keys on the wiring manifest, the announced-listing session record (`advisor`) and the `brain.call` trace row. Who breaks: a table keyed by one of those sets and checked for totality (a server wire-code register that must place every user-audience notice code, client-core's generated notice and error tables, cli's notice list, sdk's wire code book, a test that pins the tool catalog's names or counts); nobody reading the optional keys. How to migrate: add the rows (`advisor.not_attached` is audience `user`, `route.seat_detached` audience `operator`; the two refusal words are prepare's own, `detail.field` beside them).
|
|
211
|
+
- **Differs from Claude Code** (to be registered in the divergence ledger): the advisor runs engine-side on any catalog provider (no server tool, no beta header, no remote flag); the advisor's request may hit a provider cache and toggling it costs one prefix-cache miss where tool definitions are cached; a hold survives a resumed conversation (sema re-reads the transcript every run) and is cleared by a compaction or a new conversation; an API refusal at call time answers the model with an `unavailable` result instead of a silent resend; a saved setting the operator's allowlist later blocks is the server's fresh-submission refusal (the allowlist door is the host's); the advisor reads the engine-level request rather than provider bytes; the observer sidecar excludes the tool. The advisor-side system prompt and handoff text are sema's own.
|
|
212
|
+
- **Pins.** `test/backlog1055-advisor-verdict.test.ts` (the verdict in command order, raw deps read as the Runner's, only-lite deployments, one tier reading with the classifier seat, the hold read off the transcript, the hold-cause table, the closed sets, which reasons tell the person, the shape door); `test/backlog1055-advisor-mount.test.ts` (G1 attach / instructions / manifest / label and its control; no advisor ⇒ byte-identical request, also under `"disabled"`; G7 pairing told once per conversation, weakest tier, same tier attaches; G8 unranked / unknown / bad shape; G12 the switch and its bad word; G9 `excludeAllTools` / `excludeTools`; the reserved name; the instructions' three states and a fresh conversation); `test/backlog1055-advisor-call.test.ts` (G2 the projection; G3 reviewed and the full guidance in the next request, the bounded output; G4 rate-limit, a stream with no final message, a tools-only reply; G5 the auth hold — no request on the next call, the next run held and withdrawn — the ruled-out / plain / over-window invalid request, a thrown cross-realm value; G6 declined with a 70,000-character reason; G11 the account); `test/backlog1055-advisor-children.test.ts` (a child on pro and on max; the observer; the fork sentence and its control; the route detach; an unreadable transcript; the hold re-read after a compaction inside a leg; the compaction restate, absent when unmounted, withheld on a halted batch); re-pinned: `test/tool-catalog.test.ts` (53 rows, the card set, the budget-exempt set), `test/governance-codes.test.ts` (the user-audience set), `test/backlog1859-answer-conflict-verdict.test.ts` (the pre-flight's outcome object), `test/backlog1468-k3-engine-backend.test.ts` (a switch whose only word is `"disabled"` is not a store seat), `test/public-knob-liveness.test.ts`, `test/date-anchor-session.test.ts` (the date-anchor helpers' new module, `runner/conversation-segment.ts`), `test/export-surface.snapshot.json` (2 840 → 2 850), `test/backlog1950-resume-config-doors-preclaim.test.ts` (unchanged: its discovered-seat sweep now covers both advisor doors), `test/backlog1279-child-undrained-to-parent.test.ts` (100 notice codes), `test/core-context-coverage.test.ts` (the budget-exempt set), `test/design469-r3-boundary-legs.test.ts` (the leg table), `test/side-call-usage-account.test.ts` (the compaction and advisor lanes through the one factory), `test/runner-layer-order.test.ts` (three unlayered runner modules), `test/legacy-residue-gate.test.ts` / `test/module-size-ratchet.test.ts` (banked down), `scripts/verify-phase-api.mjs` (the phase field floor 1 111).
|
|
213
|
+
|
|
214
|
+
### (#2207) A deny or ask rule naming a parameter no face of its tool declares is refused at compile time without `ruleFaces` too: `WebFetch(domain:<host>)` no longer compiles into a rule that never fires (#2207; security; behavior narrowing (configuration face), open to veto; hard-breaking: none on the type surface; closed sets: none — the existing `unsupported.param_unknown` issue code; exports: none; members: none) (@server @cli @client-core)
|
|
215
|
+
|
|
216
|
+
- **Before.** `createPermissionRulePolicy` / `validatePermissionRules` without `ruleFaces` — the path a host compiles its settings on — judged a rule's parameter against the static catalog, and the catalog answered `WebFetch`, `WebSearch` and `Artifact` with their primary argument only (`url` / `query` / `action`) and no vocabulary — an absent vocabulary skipped the check — and the 36 other tools that are neither a path tool nor a shell (`Agent`, `SendMessage`, `TodoWrite`, the task-list, scheduler, memory, plan, worktree and background-task tools, …) with nothing at all, so their content rules compiled unjudged. So `WebFetch(domain:evil.com)`, the reference's host-rule spelling, compiled as a deny or an ask into a rule on an argument `domain` no call carries: no issue, and a fetch of `https://evil.com/x` answered `allow` — a deny that never fired. `WebFetch(foo:bar)`, `WebSearch(domain:…)`, `Artifact(foo:bar)`, `Agent(domain:…)` and `TodoWrite(foo:bar)` the same. With a leg's `ruleFaces` they were already refused.
|
|
217
|
+
- **Now.** One reading: a face's vocabulary is its `params`, and a face that states none declares its `primaryParams` only. The catalog is the one list of a tool's rule-readable arguments: every row that is not a path tool or a shell declares its fullest mount's scalar vocabulary for the static view, as the shell rows already did (WebFetch `url`, `prompt`; WebSearch `query`; Artifact its thirteen scalar arguments; `Agent` `subagent_type`, `description`, `prompt`, `name`, `cwd`, `model`, `isolation`, `run_in_background`; `TodoWrite`, `AskUserQuestion`, `EnterPlanMode`, `CronList`, `TaskList` none), and the static face of a name is every definition's under it, aliases included (`TaskOutput`: the shell pair's and the registry's; `ListPeers` reads `ListAgents`). A mount's instance carries the row's primary only, so a leg's roster still reads its vocabulary off the schema it mounts (a WebFetch without a summarizer: `url`; an argument a host added to a factory product's schema is on the row as before); an argument only some mounts carry is accepted without `ruleFaces` and can never fire on a mount that lacks it (KL-2207-b). The `Skill` name lane's retired-spelling check reads the same row instead of a list of its own. A rule naming any other parameter is `unsupported.param_unknown`. The two paths answer a rule with the same code; the sentence lists the parameters that path sees: `"domain" is not a scalar parameter of WebFetch (it carries prompt, url; arrays and objects are not rule-readable) — the rule would compile and never match. Name one of its parameters, or write the rule on the whole tool (`WebFetch`).` without `ruleFaces`, `(it carries url; …)` on a leg whose WebFetch has no summarizer. The sentence says "scalar" because the catalog lists scalar arguments only: an array argument (`WebSearch(allowed_domains:…)`, `TodoWrite(todos:…)`) is `unsupported.param_unknown` with that true sentence without `ruleFaces`, and `unsupported.param_non_scalar` with a leg's `ruleFaces` (the roster reads the argument's type). This head replaces "is not a parameter of" for every face, path tools included (its path-glob advice is a path tool's only); a face that declares no parameter says so (`it declares no rule-readable parameters`).
|
|
218
|
+
- **`StructuredOutput` (behavior narrowing, open to veto).** What: its catalog row declares no vocabulary (its schema is each task's output schema), so without `ruleFaces` a content rule on it is refused — `"<param>" cannot be judged on StructuredOutput here (its parameters are not declared statically: each mount's schema is its own) — compile with the leg's \`ruleFaces\`, or write the rule on the whole tool (\`StructuredOutput\`).` Who is hurt: a deployment that wrote a deny / ask on one of its task's output fields (`StructuredOutput(verdict:ship)`) and compiles without `ruleFaces` — the rule compiled and fired before. How to migrate: compile with the leg's `ruleFaces` (the roster reads the task's schema and the rule compiles), or deny / ask the whole `StructuredOutput` tool.
|
|
219
|
+
- **Who is hurt.** A deployment whose rule set holds such a rule: under the default `onInvalidRule: "throw"` the policy is not built (`config.invalid_permission_rules`, the message naming the rule and the sentence) — a host that compiles per request refuses the request; under `onInvalidRule: "skip"` the rule is dropped and reported once on `onIssue` (default: one aggregated warning). The rule never protected anything either way. A host's own `ruleFaces` resolver whose faces omit `params` now has only their `primaryParams` admitted. A host that mounts its own tool under one of these three names, or extends a factory product's schema, with an argument the built-in lacks, and compiles a rule on that argument without `ruleFaces`: the rule is now refused (it compiled and fired before) — the static path judges a catalog name by the catalog's vocabulary, as it already did for a path tool's or the shell's name; compile with the leg's `ruleFaces` (`ruleFacesFromRoster`), where the mounted schema is read, or give the tool a name the catalog does not carry (KL-2207-a).
|
|
220
|
+
- **How to migrate.** Remove the rule, or write the deny / ask on the whole tool (`WebFetch`); a deny or ask by host is the host grammar's (#1489). A host compiling settings passes no `ruleFaces`: the static path answers as a leg's roster does. A resolver of a host's own returns `params`.
|
|
221
|
+
- **Unchanged.** Every rule on a built-in's own argument that compiled and fired still does, `StructuredOutput` aside: `WebFetch(prompt:…)`, `Artifact(url:…)`, `Artifact(title:…)`, `Agent(subagent_type:…)`, `SendMessage(to:…)`, `TaskStop(task_id:…)`, `CronCreate(cron:…)`, `memory_search(query:…)`, `EnterWorktree(name:…)` without `ruleFaces`; `Skill(<name>)` (the name lane) and the refusal of its retired `Skill(skill:…)` spelling; a primary argument stays `invalid.primary_field` unless `primaryFieldGeneric: "allow-lexical"`; the bare `WebFetch` rule; the allow layer's `WebFetch(domain:<host>)` (`TaskSpec.allowedTools` / `RunnerDeps.allowedTools`, the host grammar since 7.49.0).
|
|
222
|
+
- **Who else.** *server*: its settings compile (`createRuleListPolicy`) and its pre-judge (`validatePermissionRules`) pass no faces, so both now refuse these rules with the code and sentence above; its docs listing `WebFetch(domain:<host>)` as a deny form, and pins of the silent compile, turn. *cli*: a settings `permissions.deny` / `ask` entry projected as written reaches the same refusal. *client-core*: a rule editor can show the sentence. `unsupported.param_unknown` is now also answered without `ruleFaces` for these three tools. `catalogRuleFaceOf` / `catalogRuleFaceView` answer a face for every name the catalog carries (aliases included; `undefined` only for a name it does not carry), with `params` where a definition declares them, and `describeToolCatalog()` rows carry the declared `ruleFace.params`; a mounted tool's face and roster row are unchanged (its vocabulary is still its schema's).
|
|
223
|
+
- **Pins.** `test/backlog2207-unbound-rule-content-refused.test.ts` (the throw arm's message and issue and the skip arm's `onIssue` and warning, word for word; the dry-run report with and without the catalog view; the bare rule fires; three other unbound spellings; the rules that fired before still fire; a path tool keeps its path-glob advice; an array argument's sentence without `ruleFaces` and its `param_non_scalar` through a roster; a resolver face without `params`, and one with no parameter at all; the name lane's vocabulary; no catalog face without a vocabulary; a real prepared leg's roster answers the same code, and with a summarizer the same sentence; a real Runner mounts a no-summarizer WebFetch through `TaskSpec.tools` and enforces a parameter rule on the call; the fullest mount's schema equals each row's declared vocabulary and every declared parameter compiles and matches; a prepared leg's roster row equals what the schema alone derives, order included; a mount's instance carries the row's primary only; a host's extension of a factory product's schema stays on the row and a rule on it fires through the roster); `test/backlog2207-catalog-vocabulary-lockstep.test.ts` (one cell per catalog row: the declared vocabulary equals the fullest mount's schema, the instance states none, every declared argument compiles and fires without `ruleFaces`; one cell per name: an undeclared argument is refused under one code without `ruleFaces` and through a roster built from the real instance; `Skill(<name>)` still fires; `StructuredOutput` refused statically and judged by a task's schema on a leg; an alias; a two-definition name); `test/tool-catalog.test.ts` (the two rows' faces re-pinned); `test/tool-conformance.test.ts` (L6 holds a hand definition to its own row).
|
|
224
|
+
|
|
225
|
+
### (#2221) Security fix: a session rule written while a call runs refuses the next call even when the provider repeats its tool-call ids across responses — every pass of every tool gate reads the session's rules, and no read is kept for a later one (#2221; behavior narrowing, open to veto: a deny written while a call is being decided — by a caller policy, or by the person answering its question when the answer edits the call — now refuses that call; an approval that edits nothing runs as before (#2225); behavior change: more session-store reads, see Who is hurt; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @test)
|
|
226
|
+
|
|
227
|
+
- **Before.** The session-rule layer re-read the session's rules row at every tool gate, but its live reader handed each read on under the call's `toolCallId`, and the next judgment carrying that id took the handed read instead of reading (unless the enforced row had moved meanwhile). A provider's id is unique within one assistant response only, and some OpenAI-compatible gateways number each response's calls from `call_0`, so the id repeats from one response to the next. On a run with no caller policy (the fold judges each call once) every second call then judged the previous call's snapshot: with six calls and a deny written while call k ran, 2, 2 and 4 calls ran for k = 1, 2, 3 where unique ids gave k. The same leftover read reached an approver's edit with unique ids too: the edit's re-check judged the snapshot the call's first judgment had read, so a deny written while the person was deciding did not refuse the edited call. With a caller policy in the fold, the narrowing pass after it took the read of the pass before it, so a deny written while the caller policy decided (one that asks a person, say) did not refuse that call, and a rewritten call was judged against the snapshot read before the rewrite.
|
|
228
|
+
- **Now.** Every ask of a session-rule layer is a store read, floored as before, and nothing is kept between asks: the call-id handoff, its 256-entry bound and the re-check of a handed read are gone. Each pass of the fold reads — the narrowing pass after a caller policy judges the rules as they stand once that policy has decided — and so does the next call under the same id, an approver's edit and a visibility probe. With repeated ids, 1, 2 and 3 calls run. The contract sentence `session_rules.every_gate_re_reads` is unchanged and now holds for these providers and for every pass of a gate that reaches the layer (an earlier refusal in the fold still short-circuits before it).
|
|
229
|
+
- **Who is hurt.** Store reads go up: a gate whose fold has a caller policy (or a parent's live policy on a delegated leg) reads at both of its passes — one read per live session-rule source per pass, so a fold with one source and a caller policy reads twice per call where it read once, and a delegated leg carrying its own and an ancestor's source reads four times where it read twice (one SQL query each on a SQL store); an approver's edit repeats every pass, so it costs as many reads again; the turn-boundary visibility probes read once per path they screen (consecutive probes under their shared synthetic id used to share reads). On a store whose reads hang, each extra read is one more bounded wait (KL-978b), so such a gate waits up to two bounds. A call whose decision overlapped a deny written for its tool is now refused where it used to run.
|
|
230
|
+
- **Who else.** *server*: no action; its runs judge through the engine's layer, and a deployment `toolPolicy` makes every tool call cost one extra read per live session-rule source. *test*: the repeated-id shape is a provider fact, reachable with any OpenAI-compatible gateway that numbers calls per response. No type changes: `SessionRuleSource.read` keeps `(toolCallId, signal)`; the engine's own reader no longer keys anything on the id.
|
|
231
|
+
- **Unchanged.** The floor law (the ratchet to the highest rev served, the lineage check, the vanished-row guard); one read per call on a fold without a caller policy; the person's persisted rules, which keep one read per call attempt.
|
|
232
|
+
- **Pins.** `test/backlog2221-session-rules-repeated-call-id.test.ts` (real Runner, six calls each carrying the id `call_0`: a deny written while call k runs refuses call k + 1, k = 1, 2, 3; unique ids as the control; the read count of a fold with a caller policy is one per call above one without; a deny written while a non-rewriting caller policy decided refuses that call; a rewriting caller policy and a deny written during its judgment refuse the rewrite; an approver's edit and a deny written while the question was open refuse the edited call; driven directly, the same request judged again reads and sees a write made in between, and so does a new request under the same id; through the fold, an overlapping judgment that advanced the floor and the waiting one's trailing pass both refused, and each pass reads); `test/backlog978-session-rules-every-gate.test.ts` G10 and G12 re-pinned (they pinned the call-id handoff); the per-fixture mutation table is in the car receipt.
|
|
233
|
+
|
|
234
|
+
### A refused model binding states its seat and its arm: every refusal of `config.unknown_role` — a role seat, a tier binding, the model a task names, a role word outside the vocabulary — carries `detail { field, reason }`, and the terminal of a fresh run states it as its `coded` account (#2152; additive; behavior change and behavior narrowing, open to veto — a falsy value written at a model seat is refused, not read as unset; hard-breaking: type only, open to veto — a host's exhaustive table over `CheckpointErrorReason` must name the new word; closed sets: `CheckpointErrorReason` 27 → 28 (+`unknown_role_word`); exports: none; members: none — `resolveModel` gains an optional third parameter `seat`) (@cli @client-core @server)
|
|
235
|
+
|
|
236
|
+
- **Before.** `config.unknown_role` was built in three places. A role seat or a tier binding the engine could not read carried `detail { field, reason }` (7.53.0). A `roles` key or a `modelRole` value outside the six-word vocabulary carried the code and a sentence and no `detail`. The model word a task names (`TaskSpec.model`, `TaskSpec.compactionModel`) and a role seat the fallback walk of a task that names no model reads (`roles.default`, the role the task asks for) carried no code at all: the failed terminal read `Unknown model ref "<name>". Provide it in RunnerDeps.models or pass a Model object.` with nothing beside it (and a `TaskSpec.model` that was a number ended on a `TypeError` from further down). A consumer that wanted the seat or the arm of a refusal read them out of the sentence.
|
|
237
|
+
- **Now.** One constructor builds the error: the code, and `detail { field, reason }` where `field` is the seat the sentence names (`roles.consolidate`, `roles.consolidate.model`, `tiers.pro`, `TaskSpec.model`, `TaskSpec.compactionModel`, `TaskSpec.limits.degrade.to`, `modelRole`, or — for a refused key — the map it was written in, `RunnerDeps.roles` / `TaskSpec.roles`) and `reason` is `unknown_model_ref` (a word the catalog does not hold), `not_a_model_ref` (a value that is no model reference: a number, a boolean, a list, `null`, a function) or `unknown_role_word` (a role word outside the vocabulary; a non-string `modelRole` is that too). Tier bindings, a role's `model` member and a task's model word are judged by one function (`resolveModel`, which takes the seat), so the unknown-word and not-a-reference arms are one rule at every seat. A fresh run's terminal states the pair as it states any thrown error's detail: `terminal.detail = { kind: "coded", detail: { field, reason } }` on `TaskResult.terminal`, on the stream's `done` event and on `result()` (one value, three reads); a resume's screen before the claim carries `reason` on `checkpoint.config_refused` as it did for the other two words. The word the caller wrote stays in the sentence (and `Unknown model ref` still opens it, bounded to 80 characters and quoted as JSON).
|
|
238
|
+
- **Closed sets.** `CheckpointErrorReason` +1, `unknown_role_word` (27 → 28; `docs/CLOSED-SETS.md`). The `reason` of `config.unknown_role` was one of two words when it was present and absent on the vocabulary arm; it is now always present and one of three. Downstream that listed the two words (a retry-policy table keyed by the reason) names the third.
|
|
239
|
+
- **Behavior change (open to veto).** (1) A task whose `model` or `compactionModel` — or, when it names no model, the role seat its fallback walk reads (`roles.default`, `roles.verifier` …) — is a word the catalog does not hold now ends `failed` with code `config.unknown_role` (it had no code), and its sentence reads `Unknown model ref "<name>" in TaskSpec.model. …` / `… in roles.default. …` (it read `Unknown model ref "<name>". …`; the name is JSON-quoted and cut at 80 characters, where it was spliced in raw). (2) A `TaskSpec.model` (or `compactionModel`, or a role's `model` member on the fallback walk) that is no model reference now ends `failed` with `not_a_model_ref` at its seat (a number ended on a `TypeError` from further down; the other truthy kinds were handed on as if they were a model). `resolveModel` called by a host with such a value (it handed it back as a model) now throws the same refusal. (2′, behavior narrowing) **At a task's model seats only an absent value (`undefined`) is "not set"; a value written there is judged, a falsy one too.** `0`, `false`, `null`, `""` and `NaN` written at `TaskSpec.model`, `TaskSpec.compactionModel`, a role in `RunnerDeps.roles` or `TaskSpec.roles` (as the role, or as the `model` of its record) are refused at their seat — the empty string as a word the catalog does not hold (`unknown_model_ref`), the others as no model reference (`not_a_model_ref`) — and `modelRole: null` as a role word outside the vocabulary (`unknown_role_word`). Before, each read as "not set" and the run went on silently: `TaskSpec.model` on the default role's model, `compactionModel` ignored, a role record's `model` unset (an uncoded `No model for role`, or the base binding kept), `modelRole` as `"default"`; a `null` role in either map was unset, and a `0` / `false` / `NaN` role in `TaskSpec.roles` over a role the deployment also bound ended on a `TypeError` raised by the merge. The values of both role maps, and the `model` each record names, are now shape-judged before the merge reads them — whichever role the task resolves through, and whether or not the task names its own model — so a bad value cannot vanish into an override or sit unread in a role nobody asked for; a WORD the catalog does not hold is still judged when its role is read, as before. The workflow lane followed: a script's `ctx.agent({ modelRole: null })` used to default to the subagent role and now reaches the same refusal (only an absent role defaults). (`0` / `false` / `NaN` as a role in a map on its own, and a `modelRole` that is any non-string other than `null`, were already refused.) **Not changed here — the same reading survives at other producers, a class ticket's list:** a `model` of an agent definition or of the delegation tool's options (a written `null` is passed on and the child falls to the role chain), `verifierModel` (`verify.ts`, a truthiness test) and `suggestNextPrompts.role` (a nullish default, inside a resolution that falls back to the task's model on any failure); the Agent tool's per-call `model` argument is not among them — it already refuses a non-string (`model.invalid`) and a blank (`model.empty`) at its own gate. (3) For `roles.<role> = { model: <not a reference> }` the `detail.field` is `roles.<role>.model` (7.53.0 stated `roles.<role>`, the role, while the sentence named the member). (4) An unresolvable `limits.degrade.to` is still optional (the run goes on), and the error handed to `onError` now carries the code and `detail` (it was a plain `Error`). (5) `sideQuery` with a model word the catalog does not hold rejects with the coded error; its `field` reads `TaskSpec.model`, the seat the shared resolver names.
|
|
240
|
+
- **Hard-breaking (type only, open to veto).** *What changed:* the closed set `CheckpointErrorReason` gains `unknown_role_word`. *Who is hurt:* a host or a backend that keeps an exhaustive table over the type (`Record<CheckpointErrorReason, …>`, or a `switch` that ends on an `assertNever` arm) stops compiling until it names the word; nothing changes at runtime. *How to migrate:* add the word to the table; a retry-policy table judges it as it judges `unknown_model_ref` (the refused seat is the caller's or the deployment's own configuration — corrected and sent again). **Also hurt, behavior narrowing (2′):** a caller or a config layer that fills an absent model option with `null`, `""`, `0` or `false` to mean "unset" (a settings layer that writes every key, a form that sends empty strings) is now refused at the seat it wrote; leave the key out, or pass `undefined` (a workflow script that passes `modelRole: null` to `ctx.agent` likewise). The engine's own neighbours were read for this: the server builds `model` from a resolved catalog name and writes `modelRole` nowhere on a task, and the shell and client-core never write it (read from the existing checkouts, not run).
|
|
241
|
+
- **Not in this change.** The word the caller wrote is not a member of `detail` (it stays in the sentence). `No model for role "<role>": …` (no role supplies a model) still carries no code.
|
|
242
|
+
- **Consumers.** *client-core:* the synthetic API error row and the result frame are minted in client-core; the terminal's `coded` account is already read there (`readRunTerminal`), and projecting it onto the row and the frame is what lets a shell read the seat by position (#2152). *cli:* once that projection lands, the seat and the arm come from `detail.field` / `detail.reason` and the sentence-shape anchors for the two `Unknown model ref` forms retire — there is one form now. *server:* `WORKER_SWAP_REDEEMABLE` (resume-legs) gains `unknown_role_word`; the side-query route may read `config.unknown_role` + `reason` for the model-word arm instead of the sentence prefix (the `No model for role` arm stays on the prefix).
|
|
243
|
+
- **Pins.** `test/backlog2152-unknown-role-terminal-detail.test.ts` (every arm on a real Runner, three surfaces compared; the vocabulary arm; the exact terminal key set and a JSON round trip; the task path's function called directly; the tier table at construction and the resume screen; the five falsy values at every model seat against the absent-seat controls; controls); `test/backlog1950-resume-config-doors-preclaim.test.ts`, `test/model-roles.test.ts`, `test/model-tiers.test.ts` (existing cells unchanged); the per-fixture mutation table is in the car receipt.
|
|
244
|
+
|
|
245
|
+
### Gate scripts make their throwaway directories through one helper, `scripts/scratch-dir.mjs`, and a run that fails or exits early leaves none behind; `gate:all` keeps no log directory; the delegated-car cards stop passing a `mktemp -d` data directory to vitest (#2222; release tooling and tests only; `scripts/` is not in the package; no `src/` change; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
246
|
+
|
|
247
|
+
- **Before.** Twelve scripts under `scripts/` made a directory with their own `mkdtempSync` and removed it with their own `rmSync`; six of them left it behind on an ordinary path. (1) A removal in a `finally` that `process.exit(1)` inside the `try` or `catch` never reaches — the path a FAILED run takes: `run-blackbox-gate`, `run-live-gate`, `verify-fresh-install`. (2) No removal at all: `repro/B-002`'s home on an early exit or a throw; `repro/shell-read-corpus` made up to two fixture trees per corpus row and removed none; `gate-all` made a `gate-all-XXXXXX` log directory per run, and every test that spawns it made another (one machine's temp directory held 438). `AGENT_DATA_DIR=$(mktemp -d)` — the form the delegated-car cards told a car to pass to vitest — made one more never-removed directory per command, although vitest mints and removes its own data root unless the launcher names one (`test/global-data-root.ts`).
|
|
248
|
+
- **Now.** `scripts/scratch-dir.mjs` is the one lifecycle: `makeScratchDir(prefix)` creates and registers the directory, `removeScratchDir(dir)` removes it (idempotent), `withScratchDir(prefix, fn)` is both around a sync or async callback. The registry is swept by one `exit` listener (it runs for `process.exit()` and for an uncaught throw); a removal that fails is said on stderr and the directory stays registered, so the sweep at exit tries once more. No signal listener is installed, on purpose: an interrupted script still leaves its directory, as every one of these did, because a listener cannot run while a script is blocked in a synchronous child (most of them are) and merely holding one turns an interrupt that killed the script into one it finishes its remaining steps through (measured: a three-step script signalled at 1 s ran on to 4.9 s and exited 0). `run-live-gate`, which handles SIGINT / SIGTERM itself, now removes its directory in those handlers. Every `mkdtemp` under `scripts/` is gone but the helper's (a pin scans for it, so a new script cannot skip the net by minting its own). `gate-all` no longer writes logs: a gate that does not pass has its own output echoed under the verdict table — each line behind ` | `, so a gate's own `FAIL …` line is never read as a verdict row, the first 120 lines with the command for the rest named — and the `logs in <dir>` line and the path column are gone. `gate:all` now ends through `process.exitCode` rather than `process.exit()`, because that text goes to stderr and, piped to a slow reader, an explicit exit drops what is past the pipe buffer (measured: 115 lines of 2 000 bytes arrived as 65 536 bytes). A failed run's evidence is its text in the output, not a kept tree; the one way to keep a scratch tree for a person to look at is the env name `SEMA_KEEP_SCRATCH=1` (the path of each kept directory is named on stderr; any other non-empty value but `0` is said aloud once and read as "remove"). The three `.claude/agents/car-*.md` cards now say vitest commands pass no `AGENT_DATA_DIR` and a tsx / node probe that needs a data directory makes its own and removes it when the probe ends.
|
|
249
|
+
- **What stays.** Test fixtures that make directories (`test/`, the `b<ticket>-*` family) are #2194's, 7.56.0. `bench/` and `eval/` keep their own cleanup. `scripts/publish-release.sh` already removes its work directory in an `EXIT` trap. The `sema-negctl-*` whole-repository copies seen in a temp directory are made by the shell repository's negative-controls runner, not by this repository: `gate:negative-controls` here (`test/gate-negative-controls.test.ts`) removes its own directories in `afterAll`, and what it cannot do (a worker killed outright) is #2194's fixture discipline, not this change.
|
|
250
|
+
- **Who is hurt.** Nobody at runtime: `src/` is untouched and nothing in the package changes. A person or script that read `gate:all`'s `logs in <dir>` line, or opened a per-gate log file under it, finds neither; the same text is in the run's output below the table, and `npm run -s <gate>` gives the rest of a cut one. A grep of `gate:all`'s output for lines starting `FAIL` still finds only verdict rows. A test fixture that copies a script by name into a throwaway repository must copy `scripts/scratch-dir.mjs` with it (`test/wire-changes-checker-fallback.test.ts` did not and failed until its list gained it).
|
|
251
|
+
- **Pins.** `test/backlog2222-scratch-dir-lifecycle.test.ts` (the helper on return, throw, async resolve / reject and the symlink-resolved form; `SEMA_KEEP_SCRATCH` unset / empty / `0` / `1` / a bad value; in a real child process: `process.exit()` inside a `try` and inside a `catch`, an uncaught throw, four directories at once, a removal that fails and is retried by the exit sweep; no signal listener is added, and SIGTERM to a script blocked in a synchronous step kills it at once by that signal; the single-source scan over `scripts/` with a negative control for the detector; `runRoster` with a fake spawn: nothing under the temp directory, a passing gate's text not echoed, a failing / refused / silent gate's text echoed indented and cut at the cap, and the entry ending through `process.exitCode`; and `ship-post-lint --tree-fingerprint <missing ref>` leaving the temp directory empty).
|
|
252
|
+
|
|
253
|
+
### A host rule — `WebFetch(domain:<host>)` — is read in deny, ask and allow, on the host the call reaches: one rule dispatch, one host reading, and a fetch tool that follows a redirect only to the host the rules read (#1489 car A; behavior narrowing, open to veto; upgrade behavior change, open to veto: a parked approval binds the URL slot; differs from Claude Code's shape, open to veto; hard-breaking: type only, open to veto — `RuleFaceView.pathTargeting` and `ruleFacesFromRoster`'s `pathTargeting` member are removed (ruled C-R686); closed sets: none — the roster row gains an optional member; exports: +1 type `ToolHostTarget`; members: +1 optional `hostTarget` on `ToolSpec`, `ToolFaceDeclaration`, `McpServerSpec.toolFaces[<tool>]` (refused at that door in this version), `ToolCallFace`, `ToolRosterEntry` and the parked approval row (`PendingAction`, `tool_approval`); −1 optional `RuleFaceView.pathTargeting`; `ruleFacesFromRoster`'s answer −1 `pathTargeting`) (@server @cli @client-core @connectors @test)
|
|
254
|
+
|
|
255
|
+
- **Before (v7.54.0).** A host rule had an allow form only: a person's stored deny / ask by host was refused (`unsupported.tool`), a deny list read it as nothing (`content_unsupported`; a skill whose `disallowed-tools` held one did not activate). The rule policy compiler did not consult the rule model's grammar at all: compiled with no leg resolver, `WebFetch(domain:evil.com)` became a rule on a parameter named `domain` — zero issues, every real call answered `allow`, and only a call that also carried `domain: "evil.com"` was refused; compiled with `ruleFaces` the same text was refused `unsupported.param_unknown`. Which tool a host rule reached came from a hand-kept table (`DOMAIN_RULE_FACES`, one row); the host was read by name from that table. The fetch tool followed a redirect whose host differed only by a leading `www.`, so it could fetch a host no rule had been read on.
|
|
256
|
+
- **Now.** One face member names the URL slot a tool's call reaches — `hostTarget: { param }`, beside `pathTarget` and exclusive with it (the catalog's `WebFetch` row declares `url`; a caller's `ToolSpec` under a host-rule name; the roster row, the call face and the parked approval row carry it). Which grammar reads a rule is one reading of the rule's tool NAME as the catalog declares it (`ruleToolGrammarOf`; the catalog's host-target rows replace `DOMAIN_RULE_FACES`), and the rule policy compiler dispatches every content by one function of (name, content) (`ruleLaneOf`) before any lane's own check — a leg's roster says which parameters a tool carries, never which grammar its name takes — so `WebFetch(domain:…)` is the host lane's whatever tool a leg mounts under that name, and both compiles take the same lane for every rule (a content rule on a tool name the catalog does not know is checked for its parameter only when `ruleFaces` are passed — KL-1489-b). Every judge reads the call's host through one function (`callHostTargetOf`): a deny or an ask on the face's own slot, else the catalog's slot for the name; an allow on the face's own slot only. The host is read by one URL reading (`hostOfUrl`) on both sides of a rule (lower case, IDN in its punycode form, a full-width dot folded, an IPv4 shorthand expanded, trailing dots read off) and must read back as itself. The fetch tool reads the host it connects to through the same reading and follows a redirect only to that same host.
|
|
257
|
+
- **Narrowing, a deny or an ask by host is in force.** What changed: `WebFetch(domain:<host>)` / `WebFetch(domain:*.<domain>)` as a deny or an ask is read on the host the call reaches in a rule policy (compiled with or without `ruleFaces`), a person's stored rows, a session's deny bucket, a skill's `disallowed-tools` and a sub-agent's deny list. Who is hurt: a deployment that wrote such a rule and has been running with it inert — its calls to that host are now refused or asked; a skill whose `disallowed-tools` holds one now activates (it no longer reads as an entry that cannot be enforced). How to migrate: nothing to change; review host deny / ask rules written while they had no effect.
|
|
258
|
+
- **Narrowing, a redirect that adds or drops `www.` is handed back (open to veto).** What changed: the fetch tool follows a redirect only when the target's host is the call's host as the rules read it (same port, no credentials, an http → https upgrade allowed); `example.com` → `www.example.com` is returned to the model as `REDIRECT DETECTED`, and the model's next call is judged again. Who is hurt: a fetch whose site redirects between its apex and its `www.` host takes one more model turn. How to migrate: nothing; a deployment that wants both hosts unasked allows both (`WebFetch(domain:example.com)`, `WebFetch(domain:www.example.com)`).
|
|
259
|
+
- **Narrowing, a call whose host cannot be read is refused by a host deny (open to veto).** What changed: a call whose URL does not parse, is not http(s), carries credentials or names a host that does not read back as itself (`https://2130706433../`) is refused at the gate by a host deny rule of its tool (whatever host the rule names; the refusal says the host cannot be read), asked in the fail-closed form under a host ask rule (`ruleUnreadable`, a real approval), and granted by no allow; the fetch tool itself refuses a URL whose host does not read back as one host. Who is hurt: such a call was refused by the fetch tool anyway; now the gate refuses it first under a host deny, and an ask rule asks for it. How to migrate: nothing.
|
|
260
|
+
- **Narrowing, an allow by host reaches only a tool that declares its URL slot (open to veto).** What changed: a `WebFetch(domain:…)` allow (a stored row, an entry list, a session's allow bucket) reaches a call only when the call's face declares `hostTarget`; the engine's `WebFetch` does. Who is hurt: a host that mounts a tool of its own under the name `WebFetch` and relies on a host allow for it — those calls are now asked. How to migrate: declare `hostTarget: { param: "<the URL argument>" }` on that tool.
|
|
261
|
+
- **Narrowing, a URL slot is declared only on a host-rule name (open to veto; ruled C-R686).** What changed: `hostTarget` is accepted only on a tool whose own name the catalog gives host rules — today `WebFetch` (an alias is not enough: it may resolve to another tool of that name, and then no host rule reads this tool's slot); on any other tool it is refused with `config.tool_face_invalid` at the caller door, and every MCP `toolFaces` overlay that declares one is refused at the reader and at the mount (no MCP tool is named so). A host rule's grammar belongs to the rule's tool name, so a slot on another tool would never be read. Who is hurt: a host that wanted host rules for a fetch tool of its own under another name, or for an MCP browser tool — it cannot have them in this version (design/1489 car B, #2212, reopens them). How to migrate: mount the tool under the name `WebFetch`, or keep using whole-tool rules on its own name.
|
|
262
|
+
- **Behavior change, the lane of a rule is the same with or without a leg resolver (ruled C-R686).** Two path cases answered differently by the two compiles before: a catalog path name (`Write`) mounted as a tool that declares no path target — the static compile took the path lane, a compile with `ruleFaces` refused `invalid.param_split`; now both take the path lane, judged on the catalog's slot for the name (the protective floor). A tool under a name the catalog gives no path rules, declaring a path target — the static compile refused `invalid.param_split`, a compile with `ruleFaces` `invalid.command_rule`; now both `invalid.param_split`.
|
|
263
|
+
- **Hard-breaking (type only, open to veto; ruled C-R686), the rule-face resolver no longer says whether a tool targets a path.** What changed: `RuleFaceView.pathTargeting` and the `pathTargeting` member of `ruleFacesFromRoster`'s answer are removed — no reader is left once the lane is the rule name's grammar. Who is hurt: code that reads either member, or returns an annotated `RuleFaceView` literal carrying it from its own `ruleFaces` resolver (it stops compiling); a grep of the server, cli and client-core sources finds zero readers. How to migrate: drop the member; a tool's path target is on its roster row (`ToolRosterEntry.pathTarget`) and the catalog's on `pathTargetOf(name)`.
|
|
264
|
+
- **Behavior change, the fetch tool's allowlist and private-host floor read the host as the rules read it.** `WebFetchConfig.allowHosts` and the private-host floor are judged on `hostOfUrl`'s host (trailing dots read off), on the first URL and every redirect target, and each allowlist entry is read the way a host rule's host is (an `example.com.` or `Example.COM` entry names `example.com`), so `https://example.com./x` is the host `example.com` for the gate and for the tool alike (before: the gate cleared it and an `allowHosts: ["example.com"]` tool refused it). A redirect to a URL carrying credentials, or to a host that does not read back as one host, is refused instead of being handed back.
|
|
265
|
+
- **Upgrade behavior change, a parked approval binds the URL slot (open to veto).** What changed: a parked tool approval's row carries the slot its host rules were read on (`hostTarget`, off the same roster row as `pathTarget`); the approval object compares it, so a resume whose tool now names another slot is vetoed (`resume.target_moved`, not run, the approval spent), and the pre-claim recheck of an edited input judges the host on it. Who is hurt: an approval of a `WebFetch` call parked by an earlier engine (its row carries no slot) and resumed by this one with its input unchanged is vetoed — the model re-issues the call and a new card is asked; an edited input on such a row is still judged, on the catalog's slot for the name. A checkpoint backend that copies a parked row member by member must keep the new member (`checkpointStoreContract` checks it). How to migrate: drain the parked `WebFetch` approvals before upgrading, or accept that each is vetoed once on 7.55.0 with `resume.target_moved` (the refusal says the approval is spent and the call has to be re-issued); a checkpoint backend keeps `pendingAction.hostTarget` verbatim.
|
|
266
|
+
- **Behavior change, the fetch card offers the exact host rule.** An ask a stored allow could clear, on a tool that declares its URL slot, carries `ruleOffers: [{ kind: "single", rule: "WebFetch(domain:<host>)", match: "host", command: "<host>" }]` (before: no offer); a tool under the name that declares no slot answers `ruleOffersAbsence: "lane_cannot_speak"`. `prepareCardApproval({ toolName: "WebFetch", command: <host>, … })` mints that one-rule record (before: `undefined`; no edit row), and the redeemed row is the one the next call meets.
|
|
267
|
+
- **Differs from Claude Code (open to veto).** The reference follows a redirect whose host differs by a leading `www.` (this engine hands it back, so a rule is never by-passed by that hop); the reference keys a URL it cannot parse as `input:…`, which no host rule reaches (this engine refuses it under a host deny, as the reference's own managed-browser site check does); the reference's rule side only lower-cases a host (this engine reads both sides through the URL parser, so a Unicode spelling and its punycode name one host). Unchanged and stricter than the reference: `domain:*`, a `*` inside a label and a `*.` over an address read as nothing.
|
|
268
|
+
- **Consumers.** *server*: a deny / ask `WebFetch(domain:…)` in a rule policy is now enforced, with or without `ruleFaces` (S-1910); a `hostTarget` on a tool under a name that is not a host-rule name is refused `config.tool_face_invalid` (the whole prepare), and `RuleFaceView` has no `pathTargeting`; the SQL checkpoint store keeps the parked row's new `hostTarget` member verbatim (the kit case says so). *cli*: the engine judges deny / ask host rules it is handed (L-1779); a same-process reading of exact-key host rules in the shell can retire on the shell's schedule; the rule editor's sentence for a host rule names the same rule — host rules are written for `WebFetch` only in this version. *client-core*: the rule editor's grammar for `WebFetch` gains deny and ask; the generated roster row type gains the optional `hostTarget`. *connectors*: an MCP `toolFaces` overlay cannot declare `hostTarget` in this version (refused at the door; car B reopens it); a host rule on an MCP tool name is still refused (`unsupported.mcp_paren`, as in Claude Code). *test*: G1–G4 / G8 in the pins below, and one upgrade cell: a `WebFetch` approval parked on 7.54.0 and resumed unchanged on 7.55.0 is vetoed once (`resume.target_moved`, nothing fetched).
|
|
269
|
+
- **Pins.** `test/host-rule-lane-static-vs-roster.test.ts` (G1: the same rule set compiled without and with the leg's real roster answers every cell the same — the exact host and its spellings, `*.` below and never the apex, a decoy `domain` argument, a look-alike, the four unreadable forms; a mounted tool that takes the `WebFetch` name with its own `domain` argument and no slot; a `domain:` content on a name with no host rules is a rule on that tool's own argument in both; the two path cases above; a real Runner refuses the engine's WebFetch before it fetches); `test/backlog1489-host-grammar.test.ts` (the face member and its doors, a slot on a name with no host rules refused at the caller door, the MCP reader and the mount alike; G2 the redirect hand-back, the same-host follow, a trailing-dot host one host for the gate and the tool's allowlist; G3 IDN both ways, a Cyrillic look-alike neither refused nor allowed, an address in three spellings, a same-name tool judged on its own slot and on the catalog's; G4 a stored deny / ask row, no rule, a deny list; G8 the offer, its absence, the consent round trip; a parked fetch edited to a denied host refused before the claim; a resume onto a moved URL slot vetoed; a row minted before the member); `checkpointStoreContract` (a backend keeps `pendingAction.hostTarget`); existing cells re-pinned to the three-behavior grammar (`backlog1352`, `backlog1327`, `backlog740`, `backlog1303`, `backlog1372`, `backlog1318`, `backlog1973`); the per-fixture mutation table is in the car receipt.
|
|
270
|
+
|
|
271
|
+
### The sentence that says why a matching allow rule did not clear a `probe_mandate` ask is the shell station's own account of its finding — no longer one sentence about "a read outside the directories" (#1516; behavior change, user-visible: the clause on the card, in the headless refusal and rendered from a parked row; hard-breaking: `SHADOW_REASON` loses its `probe` row, open to veto; closed sets: none — the word `probe_mandate` and the class `probe` are unchanged; exports: none added or removed at the root — `SHADOW_REASON` loses one key, `allowRuleShadowSentence` and `cardDisclosureOf` accept an optional `probeReason`; members: none) (@cli @client-core @server)
|
|
272
|
+
|
|
273
|
+
- **Before.** `probe_mandate` is one word for every finding of the shell read station that no allow rule may clear: a path outside the allowed directories (read or written) and a command whose reads it could not place (a text the shell rewrites, a command text it does not parse). Its disclosure was one table sentence — "the reversibility check declared this call structurally gated (the built-in shell check raises this for a read outside the directories allowed for this session) — it is cleared by confirming this call, never by a standing allow rule" — so a card for `cat $F` (nothing outside the task root is named), for `sudo -s` and for a `cp` that writes outside the directories all said a read outside them, while the same card's `probeReason` seat carried the right sentence. `probe_unanswered` ("the reversibility check did not answer") added "so whether the call reads outside the directories allowed for this session is unknown" for any tool whose probe threw, a wire-funds tool included. A policy's ask that the station also mandated carried the mandate and no account.
|
|
274
|
+
- **Now.** The clause after the dash is the ask's own `probeReason` — the sentence the station minted for this call — verbatim, and the table has no general sentence for the word: `… (this run's allowedTools (Bash) matches this call but does not clear the ask — reads outside the allowed directories for this session: "/out/data.txt" is not under /root — not auto-allowed)`, `… — where this command reads could not be established: "$F" is rewritten by the shell before the program sees it, so it cannot be confirmed to read only inside the allowed directories for this session (/root) — not auto-allowed)`, `… — writes outside the allowed directories for this session: "/out/w.txt" is not under /root — not auto-allowed)` (each cut at the account's own 200-character cap). Three surfaces say it: the card's `message`, the headless refusal (it embeds the card's message), and a parked row — `riskDescriptor.probeReason` is the account and `allowRuleShadowSentence(shadows, { askClass, mandate, probeReason })` renders from the row's members the sentence the card carried. The class `probe` with no mandate word (a real-approval ask the station also mandated) is worded the same way. A mandate merged onto an ask another layer raised now brings its account: the first layer to mandate an ask stamps `probeReason` (replacing one the ask arrived with, which only its author's prose could be — and removing it where the mandating layer gave no account, so that prose is never read as the reason); the ask's message, reason and every other member are untouched. A mandate stated with no account — a deployment probe's `mandated: true` without a `reason` — has no clause: the sentence ends at "do not clear the ask" (a probe that wants a reason on the card supplies `reason`). The account is taken whole or not at all: text the engine could not have minted (longer than its cap, blank) is another producer's and is dropped. `probe_unanswered` says "… did not answer for this call (it timed out or failed), so whether the call is reversible is unknown — …".
|
|
275
|
+
- **Behavior change (user-visible).** (1) The clause text above, on every card that discloses a matching allow rule beside a `probe_mandate` ask; the card's `message` grows by the account (at most 201 characters). (2) `AskRequest.probeReason` is now present on an ask a policy raised and the station mandated (it was absent), and replaces a `probeReason` that policy declared on its own ask. (3) A deployment probe's `mandated: true` with no `reason` loses the clause it had. (4) The station's account for a text it must treat as handed to a shell (`bash -c`, `sh`, `eval`, `source`, `sudo -s`, `su`, a here-document fed to a shell …) names the programs the plan resolved, each once in text order, and names the three ways the text can arrive (as an argument, on standard input, or in the file it names), the same list for every form — `the text given to "bash" — as an argument, on standard input, or in the file it names — runs as shell text this station does not parse — not auto-allowed` (`the text given to "bash" and "sh" — …` for `bash -c 'cat a' && sh -c 'cat /etc/x'`; `"sh"` for `sudo -s` and `su`) — where it quoted the first segment as it was spelled and called the text an argument (`"bash -c 'echo x'" runs its argument as shell text …`, `"bash <<'EOF'" runs its argument …`, naming only `bash` for a compound whose second shell was the one reaching outside): this is the `probeReason` text of every such card, with or without an allow entry, and a here-document form and its `-c` form are one sentence, a script on standard input is no longer called an argument, and no program of a compound is left out (the dual-run fixture of the shell readers carries the attribution `shell_text_subject` for the 56 inputs it changes, its reach ceiling 640 → 740). Nothing is decided differently: the mandate, the class, which lanes may answer, and whether a rule clears the ask are unchanged.
|
|
276
|
+
- **Hard-breaking (open to veto).** *What changed:* `SHADOW_REASON`, the frozen per-class reason table, has no `probe` key (it holds a row for every class that has a reason of its own; `probe` is the class of a finding about the very call, and the finding's account is its reason). *Who is hurt:* a host that indexes `SHADOW_REASON` by every `AskClass` reads `undefined` for an ask of class `probe` where it read the general sentence; no consumer was found in the shell, the server or client-core checkouts (client-core words the disclosure itself and does not read this table; read from the existing checkouts, not run). *How to migrate:* render with `allowRuleShadowSentence(shadows, ask)` passing the ask's `probeReason`, or read `probeReason` directly.
|
|
277
|
+
- **Who else.** *cli, client-core:* the user-visible sentence changes (the clause after the dash in the card's disclosure line, where the engine's `message` is rendered as it comes; client-core's own one-line "your rule is still there, but it cannot clear this call" is unchanged and takes none of this); copy the new sentences into any golden text. **`AskRequest.probeReason` is newly present on the cards of a policy-first ask and of a delegated child's ask under a host policy that asks (a `probe`-class card the shell station mandated), where it was absent: a card renderer that shows `probeReason` as its own line now shows the station's account there too, beside the same sentence in the disclosure clause.** *server:* none; its frames carry the engine's `message` and the row's `riskDescriptor.probeReason` as before.
|
|
278
|
+
- **Unchanged.** The word `probe_mandate` and the class `probe`; `PERSISTED_RULE_MANDATES`; the lanes (a rule never clears the ask); the `probeReason` seat of an ask the station tightens itself; the sentences of every other mandate word and class; `probeCause` (still not merged onto another layer's ask).
|
|
279
|
+
- **Pins.** `test/backlog1516-probe-mandate-disclosure.test.ts` (real Runner and the real shell station: the three findings' cards end with their own `probeReason`, the unplaced read inside the directories does not say "outside" and the write does not say "read"; the retired sentence has no source line, the table no row; a missing or foreign account makes no clause; the headless refusal and the parked row carry the card's account, and the exported sentence renders it from the row; a policy-first ask brings the account and a policy's own `probeReason` is replaced; two mandating layers keep the first's; the class path; a deployment probe with and without a reason, and a reasonless mandate over a policy's own `probeReason`; the renderer reads a caller's accessor once; the here-document and `-c` forms of one shell are one sentence, a compound names every re-entered program, a script on standard input is not an argument; `probe_unanswered`; controls); `test/backlog1474-rule-entry-reader.test.ts` C1a, `test/backlog502-out-of-root-read-mandate.test.ts` and `test/backlog1421-launcher-reread.test.ts` re-pinned (they pinned the general sentence, or the segment as spelled); `test/backlog1417-stdin-script.test.ts` ⑩ green again (its lanes compare the two forms' sentences); `test/backlog994-s1-dual-run.test.ts` attribution `shell_text_subject` and ceiling; the per-fixture mutation table is in the car receipt.
|
|
280
|
+
|
|
281
|
+
### A `Model.extraBody` that is not a plain object is refused instead of being spread into the request body or left out: one judge, `judgeExtraBody`, at the prepare step and at every request build (#1549; behavior narrowing, open to veto; hard-breaking: none; closed sets: none — the existing `config.invalid_argument`, `detail.field` `Model.extraBody`; exports: none; members: none — the internal `applyExtraBody` takes the model instead of its `extraBody`) (@server @dgx)
|
|
282
|
+
|
|
283
|
+
- **Before.** `applyExtraBody` ran `Object.entries` over any truthy `extraBody`. A string became numbered keys on the wire (`"garbage"` ⇒ `{"0":"g","1":"a",…}`), an array likewise (`["temperature", 0.7]` ⇒ `{"0":"temperature","1":0.7}`), a class instance its own fields; a number, `true`, `""`, `0` and `false` were left out without a word. At the task's own model spec, `null` failed the run with a bare `TypeError` ("Cannot convert undefined or null to object") from the prepare step's own key probe — no code, and nothing on `onError`, which is not a channel for a prepare refusal. At a model spec a host brain hands the adapter for one request (and the compaction model, and a side query's model), `null` was ignored.
|
|
284
|
+
- **Now.** `Model.extraBody` is absent or a plain object (prototype `Object.prototype` or none). Anything else is refused as `config.invalid_argument` with `detail.field` `Model.extraBody`, and the sentence names the model and the kind of value (`null`, a string, an array, a number, a boolean, an object that is not plain), never the value, and the way out (write a plain object, or remove it). The judge runs at two seats and says the same sentence at both: the prepare step (the task's own model, before the session is acquired — a refused request never touches the session; the terminal's code is `config.invalid_argument`) and the request build, `applyExtraBody`, which the three wire adapters reach and nothing else (a host brain's per-request rewrite, the compaction summary, a side query: a `BrainError` `invalid_request` whose message leads with `config.invalid_argument:`, the shape a pre-flight route refusal has; a refused compaction summary arrives on `onError` as `Turn prefix summarization failed: [invalid_request] config.invalid_argument: …`). Nothing reaches the wire. The adapters hold no guard of their own.
|
|
285
|
+
- **Who is hurt.** A host that hands the engine a model whose `extraBody` is not a plain object: a string or an array it meant as parameters (they went out as numbered keys, which a provider mostly ignored or answered with a 400), `null` or `false` or `""` or `0` it meant as "none" (they were ignored; `null` at the task's own model failed the run uncoded), a `Map` or a class instance (its own fields went out), an object built in another realm (a `vm` context, a worker — its prototype is not this realm's `Object.prototype`). The run now ends `failed` (or the compaction / side call fails) with the sentence above. A catalog the server builds is not hurt as far as its source shows (read from a checkout, not a published build): `MODEL_EXTRA_BODY` refuses a non-object at boot, and a catalog entry with a non-object `extraBody` is dropped by the settings schema before the engine sees it.
|
|
286
|
+
- **How to migrate.** Write a plain object, or leave `extraBody` undefined (delete the key rather than writing `null`); copy a `Map` or a class instance into a plain object (`Object.fromEntries(map)`, a spread).
|
|
287
|
+
- **Not in this change.** Prepare still judges the task's own model only; the compaction, advisor and role models are judged where their request is built (above), so a bad value there ends that call, not the door. Which of the engine's other pre-flight refusals could carry the engine's code word as the terminal's own `code` at the request seat (today the request seat's terminal code is the brain's `invalid_request`) is a separate question, not built here.
|
|
288
|
+
- **Pins.** `test/backlog1549-extra-body-shape.test.ts` (nine non-object values × both seats × the three wires, the sentence and the code at each seat, the two seats' sentence identical, the compaction model's summary call refused and said, the controls — a plain object, a null-prototype record, `{}` and absent ride through — the judge's totality over a bigint, a symbol, a function and a value whose `toString` throws, the value judged being the value merged — one read of the model's `extraBody` and one walk of its keys, so a getter or a Proxy trap that answers differently on a second read cannot pass one answer and send another — and the adapters' source holding no guard of their own); `test/backlog1298-resume-at-leaf-untouched-on-refusal.test.ts` ⑫b (a string, an array and `null` are refused before the acquire — the session log is byte for byte as it was); `test/request-params.test.ts` and `test/brain-protocol-coverage.test.ts` (`applyExtraBody` takes the model).
|
|
289
|
+
|
|
290
|
+
### `max_completion_tokens` in a model's `extraBody` has one law at both seats: refused as `config.extra_body_output_cap` for the task's own model spec at prepare, as before, and now also when a model spec a host brain hands the adapter for one request carries it (#1550; behavior narrowing, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @dgx)
|
|
291
|
+
|
|
292
|
+
- **Before.** The task's own model spec carrying `max_completion_tokens` in `extraBody` was refused at prepare (`config.extra_body_output_cap`, on every wire; on the OpenAI wire the reason is that stripping it would silently un-cap a deployment that used the spelling as its only bound, and passing it on lets a constant fight the overflow retry's retuning of the cap). A model spec swapped in for one request — a host brain's per-request rewrite, the compaction model, a side query's model — was not judged: the key was not in the wire's reserved set, so it reached the wire beside the engine's own cap (`max_completion_tokens: 5` beside `max_tokens: 4096` on the OpenAI wire; beside the lane's own cap key on the other two), where on the OpenAI wire a provider may reject the doubled cap or let the constant override the overflow retry's adjustment (not measured against a live provider). One key, two readings.
|
|
293
|
+
- **Now.** One judge (`judgeExtraBody`, the same one as above, in `src/brain/request-params.ts` beside the reserved tables) refuses the key at both seats, on every wire, with one wire-neutral sentence ("Model.extraBody of model \"<id>\" supplies \"max_completion_tokens\" — an output-cap spelling the engine owns: it writes the per-request cap itself, so a cap is never taken from extraBody. Set it via limits.maxOutputTokens (per request) or Model.maxTokens; on the OpenAI wire Model.compat.maxTokensField picks the spelling."; the reason is that the engine owns the output cap — on the OpenAI wire it is also the key the overflow retry lowers; the other two wires' endpoints do not read it, and the refusal at the task's own model never depended on the wire, so the request seat does not either): at prepare, before the session is acquired, with the terminal code `config.extra_body_output_cap` (unchanged); at the request build as a `BrainError` `invalid_request` whose message leads with `config.extra_body_output_cap:` and carries the same sentence, before any key is written to the wire. The prepare step's own inline arm is gone: it calls the judge. The sentence now names the model (it did not). `max_tokens` is unchanged: reserved, stripped and announced once as `route.extra_body_reserved_dropped`. The engine's own write of `max_completion_tokens` (a model that declares `compat.maxTokensField: "max_completion_tokens"`) is not `extraBody` and is untouched.
|
|
294
|
+
- **Who is hurt.** A host or catalog whose per-request model rewrite (or compaction / side-query model) carries `max_completion_tokens` in `extraBody`: that call now fails with the sentence above instead of sending both caps. A host that relied on the constant riding next to the engine's cap had a request that two spellings of one limit contradicted.
|
|
295
|
+
- **How to migrate.** Set the cap with `limits.maxOutputTokens` (per request) or `Model.maxTokens`; declare the spelling the model wants with `Model.compat.maxTokensField`.
|
|
296
|
+
- **Two tables, two questions.** `OPENAI_RESERVED` lists the keys the adapter strips (and announces); `max_completion_tokens` is not in it — it is refused, never silently removed. `OUTPUT_CAP_KEYS` lists the keys each wire READS as its output cap (the overflow retry's read, and which key the engine's cap is written under); `openai` keeps both spellings because the adapter writes whichever the model declares. They answer different questions and are not merged.
|
|
297
|
+
- **Pins.** `test/backlog1550-max-completion-tokens-one-law.test.ts` (both seats × the three wires, the two seats' sentence identical, nothing sent, the second request of a run judged on its own, `max_tokens` still stripped and announced at both seats, the engine's own `max_completion_tokens` write untouched, the two tables); `test/brain-protocol-coverage.test.ts` and `test/backlog1298-resume-at-leaf-untouched-on-refusal.test.ts` ⑫ (the prepare seat, unchanged); `test/brain-context-overflow-selfheal.test.ts` — four cells re-pinned: they used `extraBody.max_completion_tokens` to put a second cap key on the wire, which the law now refuses, so they pin the same properties through the engine's own cap key (a smaller wire cap stops the recovery; a cap written under the declared spelling bounds it under that one key) and pin the new fact (a model spec that gains the key between the send and the rebuild is declined at the rebuild: "could not be rebuilt with a smaller cap").
|
|
298
|
+
|
|
299
|
+
### A run can turn the deployment's project context off: `TaskSpec.projectMemory: "disabled"` — the leg's prepare does not call `RunnerDeps.loadProjectMemory`, and the wiring manifest says so (#1563; additive; behavior change: none for a run that omits the key — the same request bytes, pinned — and one for every leg: the manifest gains a cell; hard-breaking: none; closed sets: none — the refusal reuses `config.invalid_argument`, already in `CHECKPOINT_CONFIG_REFUSAL_CODES`; the clamp table gains a row; exports: none; members: +1 optional `TaskSpec.projectMemory?: "disabled"`, +1 optional `WiringManifest.projectMemory?: "on" | "disabled" | "absent"`, +1 optional `WiringFacts.projectMemory`, +1 optional `ParentSpecSeat.projectMemory`) (@server @cli @client-core)
|
|
300
|
+
|
|
301
|
+
- **Before.** A deployment's instruction files reach a run through one seat, `RunnerDeps.loadProjectMemory`, called at every prepare. Keeping them out of one run had no word: the only switch was not wiring the seat, which is the whole deployment's. A shell flag meant to run one session without them (the reference implementation's `--bare` and `--safe-mode`) had nothing to send.
|
|
302
|
+
- **Now.** `TaskSpec.projectMemory: "disabled"` — one word, `fileHistory`'s spelling — and the leg's prepare does not call the seat. Everything the seat feeds is absent with it: the fenced project layer, the declared instruction sources (so the instruction-change probe never runs), the seat's files seeded as already read, the compaction lanes' instruction-file text. Not that seat and untouched: the engine's own git-status lane, the memory engine (its index seed included; `memory.enabled: false` is its own word), and the skills, hooks, MCP servers and agents a host hands in. Omitted ⇒ the seat is called as before; the key present as `undefined` is the same as omitted.
|
|
303
|
+
- **The reference.** Claude Code 2.1.296 has two launch modes and one CLAUDE.md surface: `--bare` (`CLAUDE_CODE_SIMPLE`) and `--safe-mode` (`CLAUDE_CODE_SAFE_MODE`) each make that surface's predicate true, and the user context then loads no instruction file at all — managed, user and project alike. The one difference on this surface is that `--bare` turns it back on when directories are explicitly given for their CLAUDE.md files (`--add-dir`); `--safe-mode` has no such exception. Each mode switches off other surfaces too, a different set each (`--bare`: hooks, skills, plugins, agents, LSP servers, an agent's own MCP servers; `--safe-mode`: nearly every customization — skills, plugins, MCP servers, agents, output styles, workflows — while managed hooks still run); here those are seats a host already fills or leaves empty per run. So one surface is one word, and the two flags are two producers of it — a shell sends the word for `--safe-mode`, and for `--bare` unless directories were explicitly given.
|
|
304
|
+
- **Inheritance.** A clamp-table row (`restricting`, value `"disabled"`, carrier `specSeat`, `script: false`): the parent spec seat hands it to every engine-built child (delegated, background, forked, workflow, observer sidecar), a revived cycle takes a waking run's word, `tightenTaskSpec` keeps it across an override that leaves it unset and refuses one that states anything else, and a governed workflow script cannot set it for its child (its baseline can) — stripping a deployment's standing instructions is not an untrusted layer's call.
|
|
305
|
+
- **The door.** Any other value refuses prepare with `config.invalid_argument` and the sentence `TaskSpec.projectMemory must be "disabled" when present (got …)`, before the seat or the model is called; a resume whose re-supplied config carries one is refused before its claim (`checkpoint.config_refused`, `detail.code` `config.invalid_argument`, `detail.field` `taskConfig`), the row stays pending. The door reads the key once, and that reading is the leg's seat (a getter that answers differently later is not asked again). The door is the deployment seats' one-word reader, now spelled per seat (`RunnerDeps.x` / `TaskSpec.x`); the five deployment refusals it already served are byte-identical.
|
|
306
|
+
- **Resume.** A resumed leg runs on the word its re-supplied config carries, like the request members that are not re-seeded from the row (`permissionMode` / `prePlanMode` / `shellGate` are, via the row's recorded gate); the checkpoint row keeps no copy of it.
|
|
307
|
+
- **Presence.** `WiringManifest.projectMemory` is present on every effective manifest a 7.55.0 engine mints: one word per state — `"on"` when the leg called the seat (what the seat answered is the deployment's), `"disabled"` when a seat is wired and the request turned it off, `"absent"` when no seat is wired, whatever the request said (`"absent"` is the manifest's existing word for a seat nothing supplied: `ask`, `question`). The member missing means an older engine or an external `deriveWiringManifest` caller that passed no fact. Never on the static half; outside `configFingerprint` (a per-task seat, the `fileHistory` reasoning). One value screen reads both this cell and `fileHistory`, each against its own words.
|
|
308
|
+
- **Who is hurt.** Nobody: optional members; a consumer ignores fields it does not know, and `schemaVersion` does not move. Wire typeKeys (`npm run gate:wire -- v7.54.0 HEAD`): `TaskSpec` +`projectMemory` (optional), `WiringManifest` +`projectMemory` (optional), `WiringFacts` +`projectMemory` (optional), `ParentSpecSeat` +`projectMemory` (optional).
|
|
309
|
+
- **Who else.** *server* ([9484], the pass-through half): carry the top-level request key to `TaskSpec.projectMemory` verbatim (the `outputStyle` shape) and advertise it by its capability bit; classify the new `wiring_manifest` section key. A leg that runs with the word does not call your loader, so your own `wiring_manifest.instructions` segment is absent on it, as on a leg with no loader. *cli* (L-891): `--safe-mode` sends the word; `--bare` sends it unless directories were explicitly given for their instruction files; read `manifest.projectMemory` — `"disabled"`: the run loaded no project instructions because the request said so; `"absent"`: the deployment has no project-context seat — to say which; KL0168 retires on arrival. *client-core*: type `projectMemory?: "on" | "disabled" | "absent"` on the manifest.
|
|
310
|
+
- **Pins.** `test/backlog1563-project-memory-off.test.ts` (the five cells — zero seat calls, no project layer in any request, zero probe polls, manifest `"disabled"` — beside the control on the same deployment; absent ≡ `undefined`, same bytes; the door's seven bad values; the door's one reading through a flipping getter, both ways; the manifest's three words as a leg fact over five seatings (a `null` seat reads as no seat, as before) and through the derivation; the parent spec seat, a real delegated child both ways, the clamp row's merge / script / revival laws; a resume both ways and a refused resume before its claim); `test/backlog1052-clamp-set.test.ts`, `test/prepare-wiring-manifest.test.ts`, `test/public-knob-liveness.test.ts` (rows for the new seat).
|
|
311
|
+
|
|
312
|
+
### (#2229) Security fix: a persisted rule bucket carries the rule grammar its rows were written in, and a bucket — or a rule-sync state — a newer grammar wrote is refused whole; while the person's persisted rules cannot be read, every call is asked about, whatever its tool (#2229; security; behavior narrowing, ruled C-R689, open to veto: a damaged or newer-grammar store now holds back calls of every tool, not only of the tools a rule grammar names; hard-breaking, open to veto: the bucket file and the sync wire gain a `grammar` member that an engine of this version judges — see Hard-breaking; closed sets: none — one new error code, `permission_rules.later_grammar`, on the open `code` face of the store's refusals; exports: +1 function `readRuleGrammarStamp`, +1 constant `PERMISSION_RULES_LATER_GRAMMAR_CODE`; members: +1 required `RuleSyncRequestBody.grammar`, +1 optional `RuleSyncResponseBody.grammar`, +1 optional `ToolCallFace.engineMounted`) (@server @cli @client-core @test)
|
|
313
|
+
|
|
314
|
+
- **Before.** A session-rules row carried its grammar stamp (an engine given a later one refuses every call); a persisted rule bucket carried none. The file backend's read face served rows as stored, never judging them by grammar, and the gate's matcher read a row in a form it does not know as reaching no call. A sync round judged rows in this engine's grammar: a LOCAL row it could not read moved into quarantine (out of the live view, back only through a new consent) and an INBOUND one was dropped. So a deny a newer grammar writes — the two the rule-grammar design brings, `Agent(<type>)` and a mid-command `*` — would stand in the bucket and refuse nothing on an older engine. Measured on the tree before this change, with a planted bucket: a `Bash(echo * main)` deny, and `echo x main` ran; one sync round moved that deny into quarantine. Separately, while the person's store could not be read (a damaged bucket), only calls of a tool a persisted rule grammar names (the shell, the path tools, `Skill`, `WebFetch`) were asked about; any other call ran.
|
|
315
|
+
- **Now.** (1) One stamp per bucket: every write of the file backend stamps `grammar: SESSION_RULES_GRAMMAR` — the number a session-rules row carries; a stored rule text is the `Tool(content)` entry the same parser reads, so one counter covers both stores. (2) One judge, `readRuleGrammarStamp` — the session store's stamp judge, now exported and read by both stores and the sync wire: every read of the bucket (`list`, `quarantined`, a write's read of its base in `nextDot` and `apply`, and `readRaw`, the sync round's local read) refuses a later grammar whole, before any row is judged, with `code` `permission_rules.later_grammar`. Reads answer zero rules under the store's existing unreadable law (disclosed), writes refuse, the bytes stay. A stamp that is no whole number is damage, refused whole without the code. A bucket without the member reads in this engine's grammar, and its next write stamps it. (3) The sync wire: the request states this engine's grammar (`grammar`), and a response stamped later refuses the whole round with the same code and zero local effects, so a newer grammar's row is never screened as damage here. (4) One "this reader cannot read it" law at the gate (C-R689): while a partition of the person's store is unread — damaged, or stamped later — the rule lane judges every call, where it used to answer nothing for a call no persisted grammar names. An allow becomes a real-approval ask (origin `rule_store_unavailable`, class `rule_unreadable`, bypass-immune), a deny or ask row of the run's last successful read still stands, and a headless run refuses. The run's `permission.rule_store_unavailable` notice now says every call is asked about, whatever its tool. The content-routing tools the engine mints stay outside the lane, as its question tool always was: the lane's skip seat now holds the class — the engine's question, its answer (`StructuredOutput`) and its blocked report (`ReportBlocked`), ruled C-R689 — since such a call performs no action and no stored rule governs it, so a run over an unread store keeps its answer and can still say it is blocked. The seat used to read the wire name; it now reads who mounted the tool — `ToolCallFace.engineMounted`, minted from the roster row (one of the engine's own mount sites mounted it: not a caller's `TaskSpec.tools` entry, not a protocol peer's tool), the one identity a caller cannot declare — and then which of the three definitions (the contract its row carries). A caller's tool mounted under one of these names (legal where the engine has not reserved it — `StructuredOutput` without an `outputSchema`, `AskUserQuestion` where the question tool is not mounted), or carrying one of their contracts, is judged like any other call (behavior narrowing, open to veto: before, a caller tool named `AskUserQuestion` skipped the persisted lane, so a store that could not be read never held it back). And a caller's `TaskSpec.tools` spec may no longer declare an engine contract by hand: a `core.` contract id on a spec must ride the contract object the engine's catalog minted for one of its definitions (`toolFace`; recorded when the run snapshots its roster) — an engine definition a host mounts by hand, such as the delegation tool under whatever name it declares, passes, and so does a spread of one (a wrapper keeps the object) — else the run is refused at mount with `config.tool_face_invalid` (behavior narrowing, open to veto: a hand-written `core.` id used to mount the spec as a built-in — its row's source `builtin`, its card namespace `builtin:<name>` — without being one; a host that wore an engine contract on its own tool declares an id of its own, or none). What a host cannot forge is the object, not the string; the public `defineTool` and `attachToolContract` still put any contract on a product, which the content-routing seat does not read.
|
|
316
|
+
- **Behavior narrowing (open to veto, C-R689).** *What changed:* while a person's store is damaged or was written by a newer engine, calls of tools no persisted rule grammar names — the delegation tool, a caller's tool, an MCP tool, the task-list tools — are asked about too, and a headless run refuses them; before, they ran. *Who is hurt:* a deployment whose person store is damaged or newer: every call but the engine's question, answer and blocked report asks (headless: is refused) until the store reads; and every call of such a tool costs one store read per call where it cost none (a file read; one query on a SQL store) whether or not the store is readable — on a store whose reads hang, each such call now waits the read's bound (14 s) before it is asked about. *How to migrate:* repair or remove the damaged bucket, or run an engine that reads the newer grammar; nothing changes on a readable store beyond the read.
|
|
317
|
+
- **Hard-breaking (open to veto).** *What changed:* the bucket file gains a `grammar` member and an engine of this version refuses a bucket whose `grammar` is above its own, so the next rule grammar revision (each of the two rule-grammar cars moves `SESSION_RULES_GRAMMAR`) makes every bucket a newer engine writes unreadable here; `RuleSyncRequestBody` gains a required member. *Who is hurt:* a deployment that downgrades across a grammar revision — the person's persisted rules stop applying (every call is asked about) until a reading engine runs again; an engine before this version ignores the member and reads the rows, where a newer grammar's deny protects nothing (KL-2229-a), and its next write drops the stamp; a host that builds a `RuleSyncRequestBody` value itself (a test transport, a twin's fixture) must add `grammar`. *How to migrate:* before downgrading across a grammar revision, rewrite newer-grammar deny rows into forms the older engine enforces (a whole-tool deny on a tool it names, a covering prefix deny), or keep the newer engine; a twin follows Consumers.
|
|
318
|
+
- **Consumers.** *server:* the SQL twin (`src/plugins/permission-rule-store-sql.ts`) keeps one grammar per bucket (a column — its `decodeBucketRow` already refuses a bucket holding a row the validator refuses; it judges the stored stamp with `readRuleGrammarStamp` first and throws with `PERMISSION_RULES_LATER_GRAMMAR_CODE` for a later one) and stamps `SESSION_RULES_GRAMMAR` on each write; a sync twin, when one exists (the SQL backend refuses `sync-join` today), states its grammar on the response and refuses a request stamped above its own; the downgrade sentence for its deploy prerequisites is KL-2229-a. *cli / client-core:* a store refusal carrying `permission_rules.later_grammar` reads as "saved by a newer version" (the way out is an upgrade), not "repair the file"; the card and the run's notice still say only "could not be read" (KL-2229-c). *test:* the downgrade cells below, read on a real store directory.
|
|
319
|
+
- **Unchanged.** The session rules store's law (its unreadable rules refuse every call); the store's damaged-row posture inside a bucket of this engine's grammar (a sync round still quarantines a damaged local row); the persisted store keeps no earlier-grammar reading — a revision that changes what an existing persisted text means owes the store one (stated at `SESSION_RULES_GRAMMAR`); the store-level stamp refuses a bucket a newer engine wrote even when every row in it reads here (KL-2229-b).
|
|
320
|
+
- **Pins.** `test/backlog2229-rule-store-grammar-stamp.test.ts` (S1–S2: every write stamps, an unstamped bucket reads and its next write stamps it; R1–R2: every face of a later-grammar bucket refuses with one code and leaves the bytes, a stamp that is no whole number is damage; R3, W1–W2: a sync round over a later-grammar bucket refuses before the transport and quarantines nothing, a response stamped later refuses the round and lands nothing, a response in this grammar or unstamped lands; L1–L4 on a real Runner: a newer engine's `Agent(general-purpose)` deny asks with an approver and refuses headless, its `Bash(echo * main)` deny refuses `echo x main` headless, a damaged bucket asks about a caller's tool, a headless `outputSchema` run over a damaged bucket still returns its structured answer and a headless run still ends `blocked` through `ReportBlocked`, a caller's own tool named `StructuredOutput` (no `outputSchema`) over a damaged bucket is refused headless and asked with an approver, a readable bucket leaves a caller's tool alone); `test/name-readings-oracle.test.ts` and its frozen oracle lose the two rows of the retired name-keyed skip seat; `test/backlog7340rj-unread-path-rows.test.ts` and `test/rev7340rm-rule-store-partitions.test.ts` re-pinned (the notice sentence); the per-fixture mutation table is in the car receipt.
|
|
321
|
+
|
|
322
|
+
### A rule refusal says what a person can do and names no coordinate of the library: the option, allow-list seat or spec field only a host program holds is the issue's optional `PermissionRuleIssue.libraryOption`, and `invalid.primary_field` no longer ends "Opt in with primaryFieldGeneric" (#1484; user-visible sentences changed in nine refusal arms; no rule is judged differently; hard-breaking: none; closed sets: none; exports: none — the issue's mint `ruleIssue` is module-internal; members: +1 optional `PermissionRuleIssue.libraryOption`) (@server @cli @client-core)
|
|
323
|
+
|
|
324
|
+
- **Before.** `invalid.primary_field` ended `Opt in with primaryFieldGeneric:"allow-lexical" if you understand this.` — a way out that exists only for a program that builds the rule policy itself. A serving layer's request refusal (HTTP 422) and a refused boot carry the sentence to a person who cannot set an option of the library, and the layer relaying it had two readings of one refusal. Four more arms did the same: the wildcard rule `*` (`` … or state the answer for every call no rule reaches with `defaultAction` ``), a declared path base (`` … the `pathBases.<word>` this policy declares … ``), a tool whose face states no vocabulary (`` … compile with the leg's `ruleFaces`, or write the rule on the whole tool ``), and the size caps (`N rules > maxRules 256`, `pattern exceeds maxPatternLength 256 or maxStars 16`). Three more named the host API's own seats, which a person writing settings reaches no better: an allow rule (`` … put the entry in `TaskSpec.allowedTools` (a request's) or `RunnerDeps.allowedTools` (the deployment's) … ``), a list that is no list (`TaskSpec.allowedTools must be a list of permission-rule strings …`, `RunnerDeps.allowedTools[1] must be a permission-rule string …`, and the thrown text's `TaskSpec.allowedTools: 1 invalid entry — …`), and an MCP rule with parentheses (`… use the toolAxes system`). The issue had no member for any of it.
|
|
325
|
+
- **Now.** One rule: a rule refusal's `message` says what a person can do with the rule and names no library coordinate: no key of `PermissionRulePolicyOptions` (nor a `caps` / `pathBases` entry), no allow-list seat of the host API, no field of the MCP server spec. The coordinate the refusal relates to is the issue's optional `libraryOption: { name, value? }`, present where the engine offers it as the way out of the refusal or names it as the declaration the refusal rests on (a rule set over its cap, a declared base that is no path, a list that is no list), and absent as a key everywhere else; every issue is minted at one place (`ruleIssue` — the allow-list screen's `invalid.entry_type` issues too). `name` is one of `primaryFieldGeneric`, `defaultAction`, `ruleFaces`, `pathBases.root` / `pathBases.cwd` / `pathBases.home`, `caps.maxRules` / `caps.maxRuleLength` / `caps.maxPatternLength` / `caps.maxStars`, `TaskSpec.allowedTools`, `RunnerDeps.allowedTools`, `toolAxes`; `value` is present only where one value is the way out (`primaryFieldGeneric` ⇒ `"allow-lexical"`). The sentences, with `<…>` the rule's own words (a host that freezes them copies these):
|
|
326
|
+
- `invalid.primary_field` (`libraryOption { name: "primaryFieldGeneric", value: "allow-lexical" }`): `` "<param>" is <Tool>'s primary field, and a rule on it matches the WHOLE value lexically — no shell or path semantics — so `<Tool>(<param>:git push*)` would NOT match `cd /x && git push`. Write the rule as `<form>`, or on the whole tool (`<Tool>`). `` — `<form>` is the content form the rule model reads on that tool, and only that: `Bash(<command>)` for `Bash` and for every other shell-face tool (`Monitor`: a command rule is written on the command word and reaches `Monitor` as it reaches `Bash`), `<Tool>(<path>)` for a path tool (`Read`, `Write`, `Edit`, `Grep`, `Glob`, `NotebookEdit`, `LSP`, `RepoMap`), `WebFetch(domain:<host>)` for `WebFetch`; a tool the catalog gives no content grammar (`WebSearch`, a host's own tool whatever face its leg mounts) is offered the whole tool only (`` Write the rule on the whole tool (`<Tool>`). ``). One line of the compiler, so the static policy, a leg's roster and the dry-run report answer it alike. Every spelling it offers compiles as a deny and as an ask and reaches the call the refused spelling meant (pinned, per tool); a rule written on another tool's name would not (a `Read` ask leaves a `Write` call alone).
|
|
327
|
+
- `invalid.tool_name` for `*` (`{ name: "defaultAction" }`): `` `*` names no tool — a rule reaches the tool it names, so this one would compile and never match; name the tool ``.
|
|
328
|
+
- `unsupported.path_base` (`{ name: "pathBases.<root|cwd|home>" }`, the declared base the rule reads): `"<rule>" is relative to <the working directory | the project root | the home directory of the execution environment>, and the one this policy declares for it is not an absolute path of its tree's family — the rule could not be judged on any call`.
|
|
329
|
+
- `unsupported.param_unknown` on a face that states no vocabulary (`{ name: "ruleFaces" }`): `` "<param>" cannot be judged on <Tool> here (<its parameters are not declared statically: each mount's schema is its own>) — write the rule on the whole tool (`<Tool>`). ``
|
|
330
|
+
- `invalid.cap_exceeded`: the whole set — `<N> rules — a rule set holds at most <cap>` (`{ name: "caps.maxRules" }`); a rule — the sentence is unchanged (`rule longer than <cap> chars`) and gains `{ name: "caps.maxRuleLength" }`; a pattern — `a pattern holds at most <cap> characters and <cap> stars` (`caps.maxPatternLength` when the length crossed, else `caps.maxStars`).
|
|
331
|
+
- `unsupported.allow` (`{ name: "TaskSpec.allowedTools" }`; the deployment's home, `RunnerDeps.allowedTools`, is said in words only — the member names one coordinate): `an allow rule does not belong to a policy — allow entries live on the allow layer (the request's allow list, or the deployment's); deny / ask rules stay here`.
|
|
332
|
+
- `invalid.entry_type` (`{ name: <the seat the value was handed at> }`, `TaskSpec.allowedTools` or `RunnerDeps.allowedTools`): `<the request's allow list | the deployment's allow list> must be a list of permission-rule strings (got <type>)`; `entry <i> of <the request's allow list | the deployment's allow list> must be a permission-rule string (got <type>)`; and the thrown text's prefix reads `<the request's allow list | the deployment's allow list>: <n> invalid entr(y|ies) — refusing rather than running without them:` (it read the seat key).
|
|
333
|
+
- `unsupported.mcp_paren` (`{ name: "toolAxes" }`): `` MCP rules do not support patterns in parentheses (CC parity); a rule names a whole tool (`mcp__<server>__<tool>`) or a whole server (`mcp__<server>`), and what a tool does is declared per tool in the MCP server's spec ``.
|
|
334
|
+
- **Behavior.** No rule is judged differently: the same rules compile, the same are refused, with the same codes in the same order, and `primaryFieldGeneric: "allow-lexical"` still reads the rule as a whole-string lexical match (pinned). The thrown error's text (`createPermissionRulePolicy: N invalid/unsupported rule(s): …`) carries the new sentences and nothing else.
|
|
335
|
+
- **Who is hurt.** A host or a test that freezes or pins one of the sentences above takes the new text (a serving layer's frozen sentence table, its refusal tests, a shell's copy). A host that read `primaryFieldGeneric` out of the sentence to decide whether to offer the opt-in reads `libraryOption` instead. A deep-equal of a whole issue (`{ rule, code, message }`) sees one more key on the arms above and none on the others. Nothing else changes for a host that passes the issue through.
|
|
336
|
+
- **Not changed.** Issue codes, their count and order; what a request or a deployment may hand over (`screenAllowedToolsValue`'s seats are the same two); the retired-`scope` refusal of `createAllowDenyPolicy` (`config.deps_retired_key`, which a program passing that option meets, not a person writing rules) and the remedy line of an approval question for an ungranted tool both still name `RunnerDeps.allowedTools` / `TaskSpec.allowedTools` — other surfaces than a rule refusal.
|
|
337
|
+
- **Consumers.** *server*: the two readings of one refusal retire — the request refusal and the refused boot both relay the engine's sentence (it reads `issue.message` at `src/config.ts:2494-2501` and the thrown text at `src/permission-rule-list.ts:75-100`; inferred from a read-only look at its checkout, not re-verified at its origin/main), which is now complete for a person; it may render `libraryOption` where it exposes the option, and its frozen copies of the sentences above change. *cli*, *client-core*: copy the sentences if a table holds them; a surface that shows an issue may show `libraryOption` as a hint to an operator, never to an end user.
|
|
338
|
+
- **Pins.** `test/backlog1484-primary-field-sentence.test.ts` (the new sentence verbatim on the dry-run report for eight tools, each with the form its grammar reads, on the static policy and its thrown text, on a real prepared leg's roster and a host resolver, whose own tool is offered the whole tool only; the member present on each arm that has a way out — the allow rule, the MCP rule with parentheses, the wildcard, three path-base words, a face that states nothing, the four caps, the length-over-stars order — and absent as a key on six arms that have none; the allow-list screen's two arms at both seats say the list in words and carry the seat, and the two seat sentences are pinned verbatim; the names are real keys (a type-level lockstep); a battery asserting no sentence or thrown text names an option; every spelling the sentence offers, read out of the message, compiles as a deny and as an ask and reaches the call it was meant for — a `Read` ask leaves a `Write` call alone; the opt-in still reads lexically); `test/backlog644-home-base.test.ts`, `test/backlog2207-unbound-rule-content-refused.test.ts`, `test/backlog2207-catalog-vocabulary-lockstep.test.ts`, `test/permission-rules.test.ts`, `test/backlog1327b-policy-allow-retired.test.ts`, `test/backlog1327-allow-entry-unit.test.ts` re-pinned to the new sentences; the per-fixture mutation table is in the car receipt.
|
|
339
|
+
|
|
340
|
+
### (#1517 car A) A delegated agent type is one identity for the delegation tool and its rules: `Agent(<subagent_type>)` is a rule in every behavior; the dontAsk refusal says the way out; an allow the allow layer made names who made it (#1517; behavior narrowing (configuration face), open to veto; behavior change on model-visible text, open to veto; differs from the reference implementation, open to veto: the allow reading of `Agent(<type>)` is this engine's; hard-breaking, open to veto: the session-rule grammar stamp moves 3 → 4, the parameter spelling `Agent(subagent_type:<type>)` is refused, the delegation tool's preparation names the plain delegation `general-purpose`; closed sets: `PermissionRuleIssue.code` +`invalid.name_ambiguous` (a type-only union); exports: none; members: +1 optional `ToolRuleFace.nameValues`, +1 optional `ToolCallFace.nameValues`, +1 optional `ToolRosterEntry.ruleFace.nameValues`, +1 optional `RuleFaceView.nameValues`, +1 optional `GateDisposition` (allowed) `clearedBy`; `ruleFacesFromRoster`'s answer gains `nameValues`; `SESSION_RULES_GRAMMAR` is 4) (@cli @client-core @server @test)
|
|
341
|
+
|
|
342
|
+
- **Before.** The delegation tool read its `subagent_type` one way — exact first, then the reference's fold, several matches refused — and every rule another: `Agent(Explore)` as a deny or an ask was `invalid.param_split` at compile, as an allow it read as nothing (`content_unsupported`), and a deny on the parameter spelling `Agent(subagent_type:Explore)` compared the raw string, so it missed `explore` / `EXPLORE` (which run `Explore`) and the plain delegation's two spellings (the preparation deleted an explicit `general-purpose`). The dontAsk refusal was one literal for every class (`… run under a mode with an approver, or change the call`). An allowed disposition said nothing of which allow rule cleared the ask.
|
|
343
|
+
- **Now — one identity.** One resolver (`canonicalNameValue`): an exact member of the mount's agent types first, else the reference's fold — one hit ⇒ that type, several ⇒ refused, none ⇒ refused. The tool's argument preparation writes the canonical type, its input check refuses an unresolved one before any gate (the sentence its execution returns: `Sub-agent not started: subagent_type "Code Review" is ambiguous — it matches code-review, code_review. Use the exact name.`), and execution reads the same resolver for a direct caller. The mount declares its agent types as data (`ToolRuleFace.nameValues`); the roster row and every call's face carry them; an `Agent(<type>)` rule resolves its value against the same set, in the rule policy, a person's stored rows, the allow layer's lists, a session's buckets and the card's offer. So `Agent(Explore)` reaches `explore`, `EXPLORE` and `Explore `; on a mount offering `code-review` and `code_review`, `Agent(code-review)` reaches `code-review` only; `Agent(codereview)` (the fold lands on both) is refused at compile on a leg's `ruleFaces` (`invalid.name_ambiguous`) and, without them, a deny / ask reaches both and an allow neither. A call whose face carries no types cannot be read against such a rule: a deny list and a stored deny row refuse it, the rule policy asks a person in the fail-closed form, no allow grants it. A workflow's `agent(…, { agentType })` step is not a delegation-tool call and is not reached (KL-1517-e).
|
|
344
|
+
- **Hard-breaking, the plain delegation is named (open to veto).** What changed: an omitted `subagent_type` (and an empty one) is read as `general-purpose` while no definition takes that name, and an explicit `general-purpose` is kept (it used to be deleted) — so a PreToolUse hook, a policy, a card and a rewrite read `subagent_type: "general-purpose"` on the plain delegation. Who is hurt: a policy or hook that recognises the plain delegation by an absent `subagent_type`; a test pinning the old fold. How to migrate: recognise it by `subagent_type === "general-purpose"`, or write `Agent(general-purpose)`.
|
|
345
|
+
- **Hard-breaking, one spelling of a type rule (open to veto).** What changed: `Agent(subagent_type:<type>)` is refused at compile (`invalid.command_rule`, naming `Agent(<subagent_type>)`) — it compiled as a rule on the raw string until now (and #2207's "Unchanged" bullet in this release still listed it). Who is hurt: a deployment holding one; under `onInvalidRule: "throw"` the policy is not built. How to migrate: write `Agent(<type>)`. A rule on another argument (`Agent(model:<model>)`, `Agent(isolation:worktree)`) is the generic parameter lane's, as before; `Agent(<type>:*)` and any `*` are refused (no prefix form; the reference has none).
|
|
346
|
+
- **Narrowing, a `<argument>:<rest>` deny or ask also reads as the agent type (open to veto; the reference's shape).** What changed: one rule for such a content — a tightening reaches the call under every reading of its text, a grant reads exactly one way. `Agent(model:opus)` as a deny or an ask is the rule on the `model` argument AND the agent type `model:opus` (an agent type may carry a colon), either reaching applies it — and every reader answers alike: the rule policy (compiled with or without a leg's `ruleFaces`), a deny list (a skill's `disallowed-tools`, an agent definition's), a session's `toolDeny`, and the person's stored rows. A stored row is one text, read as the type and, by the same argument predicate the rule policy's parameter lane uses (`paramRuleReaches`, the one reader of a `key:pattern` over a call's arguments), as the argument rule — so a person's store now takes the `Agent(model:opus)` deny it used to refuse as `refuses nothing as written`, and enforces it. An argument rule with a `*` (`Agent(model:op*)`) is the rule policy's only: a list or a store refuses it loudly, as before (a stored row's value has no `*` form). As an allow it is the argument rule only, which an allow list has no form for: it reads as nothing (`grammar`) and never grants the type `model:opus`. The reference reads the content as an agent type for its deny filter (`cms`) and as `key:value` for its generic parameter check (`te`, `Eh`) alike, over one rule set (all three at `(cli296.js:242324)`). Who is hurt: a deployment offering an agent type spelled `<one of the tool's arguments>:<…>` with a deny on that argument (the type is refused too); a host relying on such an allow to grant a colon-spelled type (KL-1517-f). How to migrate: write the whole-tool allow, or rename the type. An argument a host's own `Agent` tool adds beyond the catalog's is no argument of a rule's grammar (#2248): a compile with the leg's `ruleFaces` sees the face and refuses a deny / ask keyed by it (`unsupported.param_unknown`: `"<arg>" is an argument declared only by the Agent tool this leg mounts, which is not rule-readable (#2248) — …`; a narrowing of the configuration face, open to veto); a compile without them cannot see it and reads the type (KL-1517-g).
|
|
347
|
+
- **Behavior change, an empty type names no type.** What changed: `subagent_type: ""` reads as no type to every reader (the rule policy, the lists, the stored rows, the pre-claim edit check of a resumed approval), as it already did to the tool that runs it; where a definition takes the name `general-purpose`, every `Agent(…)` deny or ask refuses it, as it refuses the omitted type. The call face's agent types are its own frozen copy, so no judge of a call can change them for the next.
|
|
348
|
+
- **Narrowing, `Agent(<type>)` deny / ask is in force; widening, an allow grants (open to veto).** What changed: as above, in every behavior. Who is hurt: a deployment that removed such a deny because it was refused (it now applies — a tightening); one that wrote `Agent(<type>)` allow expecting it to do nothing (it now grants that delegation where something asks: an ask rule, a default-closed rule set, `auto`'s classifier). How to migrate: drop an allow you do not mean; a deny needs nothing.
|
|
349
|
+
- **Differs from Claude Code (open to veto).** The reference compares an `Agent(<type>)` rule's content with the agent type exactly — a deny on the raw requested type and again on the type its fold resolves to, `cli296.js:254811` — and has no allow reading of it: its delegation tool allows a call itself in every mode but `auto` (`cli296.js:254814`). Here the rule's value is resolved like the call's (`Agent(explore)` reaches `Explore`, a tightening), and an allow reads (it clears only what an allow rule may clear; it matters where something asks).
|
|
350
|
+
- **Hard-breaking, the session-rule grammar stamp moves 3 → 4 (open to veto).** What changed: `SESSION_RULES_GRAMMAR` is 4 — a row holding `Agent(<type>)` is written in a grammar whose engines read it as a rule. Who is hurt: an engine of grammar 3 (7.54.0, and 7.55.0 before this car) given a row this engine writes refuses every call on that session until an operator rewrites the row through it — the intended fail-closed reading, so a downgraded engine never reads an `Agent(…)` deny as nothing. How to migrate: none needed on upgrade (a grammar-3 row reads as before); a downgrade rewrites the session's rules through the older engine.
|
|
351
|
+
- **Who is hurt — a mixed-version window on the person's stored rows (#2229).** The person's rule store carries a grammar stamp from this release (#2229, the section above): every write stamps the bucket, and an engine of this release refuses a bucket a later grammar wrote. The window that stays is an engine BEFORE this release: it ignores the stamp and reads a stored `Agent(<type>)` (or `Agent(<argument>:<value>)`) deny row as nothing (KL-2229-a) — a fail-open window while such an engine shares one store with this one, or after a downgrade below it. Do not run an engine older than this release against a store holding such rows; before such a downgrade, rewrite each such deny in a form the older engine enforces (the whole `Agent` tool, or a deny of the delegation that covers it).
|
|
352
|
+
- **Behavior change, the dontAsk refusal says the way out (model-visible text, open to veto).** What changed: `Permission to use <tool> has been denied because this run is in the dontAsk permission mode (this call needed approval: <askClass>). IMPORTANT: you may try to accomplish this with other tools that would naturally be used for it … If you believe this capability is essential to complete the user's request, STOP and explain to the user what you were trying to do and why you need this permission, and let the user decide how to proceed. To let a call like this run: <remedy>.` — the reference's head and guidance, then the one remedy function's `dontAsk` arm (`headlessApprovalRemedy`): where the ask's row lets an allow rule clear it, `an allow rule covering this call answers it under dontAsk — add one in this tool's rule shape (\`<shape>\`) to the allow rules (a settings file's \`permissions.allow\`, or the request's or the deployment's allow list), or run under a mode with an approver` — where allow rules live, in a person's words and no seat of the library (#1484's law; a host reads the same answer off the disposition below and offers its own seat) — the shape read off the call's grammar by the one rule-shape speller (`ruleShapeOf`, which also writes a compile refusal's way out: a tightening on the tool's own word, a grant on the face tool — `Edit(<path>)` / `Read(<path>)` by the call's face, `Bash(<command>)`, `WebFetch(domain:<host>)`, `Skill(<skill>)`, `Artifact(action:<action>)`, `Agent(<subagent_type>)`, else the tool's name — never a rule minted for the call); otherwise `no allow rule answers this ask (<askClass>) — only a person can: run under a mode with an approver, or change the call`. The literal `refused by the dontAsk permission mode: … the mode answers no ask; …` is gone. Who is hurt: a host or probe matching the old sentence. How to migrate: read `tool_end.gate.disposition` (`deniedBy: "mode"`, `mode`, `askClass`, `classes`), never the sentence. A hook's own ask under dontAsk is still put to the approver (unchanged).
|
|
353
|
+
- **Additive, who cleared the ask.** An allowed disposition carries `clearedBy: { member, entries, omitted?, positions? }` when an allow-layer member cleared a standing ask and nobody answered it (`hook` / `deployment` / `persisted` / `skill` / `request`; entries sanitized and capped like `allowRuleShadowed`; `positions` on a list member — the indices of those entries in the list the run received, a string listed twice giving both); absent on a call nothing asked about, a mode's release or acceptance, the read-only reader's, the classifier's and the sandbox's allow, and a person's yes. `screenGateOutcome` reads it (I9: allowed arm only, no settlement, no release, a member of the closed set, positions only on a list member).
|
|
354
|
+
- **Consumers.** *cli*: render the engine's dontAsk refusal (retire the shell's own `dontAskDenyReason`); the L-1168 D-dontAsk cell reads this sentence; read `clearedBy` for "which rule let this run" and map `positions` onto the declaration it sent; a stored `Agent(…)` rule and a settings `Agent(<type>)` entry are read by the engine. *client-core*: the rule editor's grammar for `Agent` gains the type form in every behavior and refuses the parameter spelling; the generated disposition type gains `clearedBy`; refusal text pins turn. *server*: `tool_end.gate.disposition.clearedBy` is a new member of `GateDisposition` — the key-set guard there (`src/trace/core-keyset-guard.ts` `GateDispositionProjected`) is closed and turns red at pickup until the key is dispositioned (project it: closed words, a capped list of rule texts, a list of indices); a settings `Agent(<type>)` deny / ask now compiles (it was refused `invalid.param_split`), `Agent(subagent_type:…)` is refused; the session rules SQL twin stamps grammar 4 through the judge. *test*: T-A1 / T-A1b / T-A2 / T-A3 in the receipt.
|
|
355
|
+
- **Pins.** `test/backlog1517-a-agent-type-rules.test.ts` (A1-id the roster-aware identity on real Runner lanes — exact over fold, the ambiguous call refused before the gate, the ambiguous operand refused at compile and tightening both / granting neither statically; A1-ctx a face with no types — rule policy, lists, stored rows; A1 every behavior, the spellings, the default, the fork, a shadowed `general-purpose`, a host judging a call it did not read, the compiler's three refusals, a session `toolDeny`, the catalog vocabulary against the fullest mount; A2 the refusal on real lanes — a covered write, an unstated tool, an ask rule, a delegation — the shapes, one source; A3 `clearedBy` for all five members, positions, a capped entry, the absent cases, the screen, a real lane; A4 a `~/` allow under dontAsk with and without the environment's home; A5 the grammar); re-pinned: `test/backlog1974-judge-what-runs.test.ts` ③, `test/backlog1974b-keyless-and-unreadable-resume.test.ts` ③ ⑧, `test/backlog1318-skill-rule-cc-form.test.ts` ⑤; the per-fixture mutation table is in the car receipt.
|
|
356
|
+
- **Known limit (KL-1517-g, #2248).** An argument only the mounted tool declares — one the catalog's `Agent` row does not — is no argument of the rule grammar (the lane is the catalog's, C-R686). The two compiles know different facts and say so: a compile with the leg's `ruleFaces` sees the face and refuses a deny / ask keyed by it at compile (`unsupported.param_unknown`, C-R708 — the loud path); a compile without them cannot see it and reads `Agent(region:prod)` as the type `region:prod` — the type arm fires for that type, and the call's argument is not judged (not an error: a face the static path cannot know is not judged). The roster carrying mounted parameters as the one source is #2248 (7.56.0).
|
|
357
|
+
|
|
358
|
+
### A Bash rule may be a pattern — `Bash(git * main)` — in deny, ask and allow: a star anywhere but the two trailing markers is the reference's wildcard, matched against one simple command (#1517 car B; behavior widening, open to veto: an allow pattern grants what it covers; behavior narrowing, open to veto: a deny / ask pattern is in force; differs from Claude Code's shape, open to veto, six points, see below: an allow's first-word star and an interpreter-led allow pattern refused, the auto strip, a deny / ask pattern read per segment, an expansion read fail-closed, a chain pattern refused; hard-breaking: type only, open to veto — an exhaustive table over `PersistedRuleMatch` must name the new word; closed sets: `PersistedRuleMatch` / `PERSISTED_RULE_MATCHES` 9 → 10 (+`pattern`; a Bash row's match was one of three words and is now one of four; registered in `docs/CLOSED-SETS.md`); exports: none added or removed — `PERSISTED_RULE_MATCHES` gains a word; members: none) (@server @cli @client-core)
|
|
359
|
+
|
|
360
|
+
- **Before (v7.54.0).** A Bash rule had three forms: the colon-star prefix (`Bash(git push:*)`), the trailing space-star (`Bash(git push *)`) and exact. Any other `*` — `Bash(git * main)`, `Bash(* --no-verify*)`, `Bash(cat src/**/key)` — was refused `unsupported.wildcard`: a rule policy (`createPermissionRulePolicy` / `validatePermissionRules`) refused it as `invalid.command_rule` (a host refused the whole request), an entry list (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`, a skill's lists, a session's buckets) read it as nothing (`grammar`), the settings import skipped it, the card's edit face refused it.
|
|
361
|
+
- **Now.** One string, one form: a trailing `:*` is the prefix form, exactly one trailing ` *` the wildcard form, any other unescaped `*` the pattern form (`match: "pattern"`, `command` = the folded pattern as written, spelled back `Bash(<pattern>)`), none the exact form — read off the folded content, so the stored spelling reads back as the same row (`Bash(echo<TAB>*)` and `Bash(echo * )` are the wildcard `Bash(echo *)`), and a content that folds to the lone `*` (`Bash( * )`, `Bash( *)`) is `Bash(*)`, the whole tool, in every lane (one reading): the store refuses it as it refuses `Bash(*)`, an entry list and a session's bucket read it as the whole shell tool, a rule policy compiles it as the bare-tool rule, a name set (`createAllowDenyPolicy`) holds it as the name `Bash` — never a pattern. A pattern names ONE simple command and is matched whole: `*` is any text, newlines included; `\*` a literal star; a `/` `**` `/` run matches `/` or `/…/` (once its star has read text the closing `/` is required: `Bash(rm safe/**/key)` reaches `rm safe/key` and `rm safe/a/key`, never `rm safe/monkey`) — the reference's reading of the spelling (cli296.js:237460:1297). An allow (a person's stored row, an entry list) is matched against the command's spelling segment by segment — a compound is covered when every segment is, so `git push x && rm main` is not covered by `Bash(git * main)` although the whole line matches it. A deny or an ask (a rule policy, a stored row, the org layer, a deny list, a session's `toolDeny`) is matched against the words each program run receives — every segment, quotes read off, wrappers peeled (`cd x && sudo git push "--force"` is reached by `Bash(git * --force*)`); a word carrying an expansion is text the reader cannot see, so the reach holds when the pattern matches whatever it holds, is `not_reached` when no text could match, and is otherwise `unreadable` — the fail-closed ask (`git push $F`). The matcher walks the subject once against the set of live pattern positions (no backtracking). Refused: a pattern naming more than one command (`Bash(git * && rm *)`, `unsupported.wildcard`), a star inside a prefix body, a backslash other than `\*`.
|
|
362
|
+
- **Widening, an allow pattern grants (open to veto).** What changed: `Bash(git * main)` in a person's stored rows, an entry list or a session's `toolAllow` now admits the commands it covers (before: refused, or read as nothing — the call asked, and under `dontAsk` was refused); `Bash( * )` in an entry list grants what `Bash(*)` grants, the whole shell tool (before: read as nothing). Who is hurt: a deployment or person that wrote such an allow and relied on it granting nothing. How to migrate: remove the allow, or narrow it to a prefix or an exact rule.
|
|
363
|
+
- **Narrowing, a deny / ask pattern is in force (open to veto).** What changed: `Bash(<pattern>)` as a deny or an ask compiles and reaches every run whose words it matches, in every lane above; `Bash( * )` as a deny or an ask is the bare-tool rule (before: refused in a rule policy, read as nothing in a deny list). Who is hurt: a deployment whose settings held such a deny or ask — its requests were refused at the door before and now run with the rule in force; a command whose words carry an expansion where the pattern reads asks (the fail-closed answer) instead of running. How to migrate: nothing to change; review the deny / ask patterns written while they were refused.
|
|
364
|
+
- **Two allow floors, stricter than Claude Code (open to veto).** A star in the first word (`Bash(* --version)`) has no allow form (`unsupported.wildcard` — an allow names the program it grants; a deny or an ask may leave it open; a list reads the entry as nothing, with no interpreter re-read). A pattern headed by an interpreter or launcher (`Bash(python * x.py)`, `Bash(env * x)`) is refused by the store (`invalid.bare_interpreter_prefix`, as `Bash(python:*)` is), and a written list grants it — the colon-star form's law, unchanged. The reference accepts both as allows.
|
|
365
|
+
- **Auto mode strips more than Claude Code (open to veto).** In `auto` a pattern allow that could run a program the strip names (`node * x.js`, `python * x.py`, `npm * build` — `npm run build` is a run of `npm run` —, `sudo * restart`) stands down to the classifier. The program is the one the read station reads — past the launchers and wrappers in front of it (`nice python * y`, `timeout 5 python * y`, `command python * y`, `sudo -u root node * x.js`), by basename (`/usr/bin/python * end`), on the words a run receives (`npm "run" * build`) — and a program word the pattern leaves open (`doas * x`, `env * x`) could be any; the reference strips a rule by its trailing star only and keeps `node * x.js`. `git * main` and `nice git * main` are not stripped; neither is `uv run * x` (`uv` is no launcher the read model knows and no program the strip names).
|
|
366
|
+
- **Three more points that differ from Claude Code (open to veto).** A deny / ask pattern is read segment by segment only (the reference also matches a deny wildcard against the whole line when its answer would be ask, so `Bash(git * main)` denies `git push x; rm main` there and not here — no single run of `git … main` happens); a word carrying an expansion is the fail-closed ask (the reference matches the raw text: `git push $F` does not match `git * --force*` and runs); a pattern naming a chain is refused (`Bash(git * && rm *)`; the reference reads it as an allow that matches nothing and a deny that matches the whole line).
|
|
367
|
+
- **Same as Claude Code, stated (KL-1517-h).** An allow pattern's `*` is any text: it crosses words and `/` — `Bash(rm -rf /tmp/*/cache)` covers `rm -rf /tmp/a / /cache`, `Bash(cat src/**/key)` covers `cat src/x /etc/shadow /key` (the reference compiles `*` to `.*` the same way). An allow pattern grants what its text matches, not the shape it looks like; write a prefix or exact rule, or a path rule (`Read(…)` / `Edit(…)`) for the files.
|
|
368
|
+
- **Known limit, as for every Bash form (KL-1517-i).** A deny / ask pattern matches the program word as spelled: `Bash(git * --force*)` does not reach `/usr/bin/git push --force` or `./git push --force`, as `Bash(git push:*)` does not; deny the path-qualified spelling too, or the whole `Bash` tool. One reading of the program across deny and strip is #2254.
|
|
369
|
+
- **Across versions — who is hurt.** A session's rule row holding a pattern is stamped with this version's session-rule grammar, 4 — `Agent(<type>)` rules and Bash patterns, the same version: an engine of grammar 3 refuses the row (every call), and even read in grammar 3 a pattern entry reads as no rule (`grammar`), which in `toolDeny` refuses every call — fail-closed. A person's persisted rule bucket carries the grammar its rows were written in from this version on (#2229): an engine of 7.55.0 or later refuses a bucket a newer grammar wrote — fail-closed. An engine before 7.55.0 reads no stamp: it keeps a pattern row its file store reads, its matcher reads `pattern` as no form, and a deny pattern there does not fire; its sync screen drops the row. So the one fail-open window left is an engine before 7.55.0 against a store that holds pattern deny rows — a mixed-version replica set, a shared rule directory, or a downgrade below 7.55.0. How to migrate: keep every replica on 7.55.0 or later; before a downgrade below it, rewrite each pattern deny as a form the earlier engine enforces (a prefix deny that covers it, or the whole `Bash` tool).
|
|
370
|
+
- **Unchanged.** The prefix, wildcard and exact forms and what they reach; the suggestion face (its open form stays the trailing-star wildcard — a card never offers a pattern); the colon-star compound prefix stays the one spelling of a chain; `Bash(*)` is still the whole tool.
|
|
371
|
+
- **Consumers.** *server*: `PersistedRuleMatch` has one more word, so the transcription in `src/permission-rule-vocab.ts:45` and its compile-time pin against the type turn red on the upgrade (read the exported `PERSISTED_RULE_MATCHES` instead), the exhaustive card-edit match switch in `src/rules-consent.ts:888` turns red until it names `pattern` (a Bash command form — an edit may persist one), and the SQL rule store's and the rules route's `z.enum(PERSISTED_RULE_MATCHES)` accept the word once they read the export; `settings.permissions.deny` / `ask` patterns now compile in `createRuleListPolicy` / `validatePermissionRules`. *cli*: a settings entry written as a pattern is now read by the engine in every direction; the shell's own per-segment reading of these rules has an engine answer to defer to. *client-core*: no action (a card's offers never carry `pattern`; a content-form entry is sent as written).
|
|
372
|
+
- **Pins.** `test/backlog1517-b-bash-pattern-rules.test.ts` (G the four forms, the refusals, the rule policy's compile, the store's re-read screen; B1 allow coverage cell by cell — one segment, never across `&&` / `;` / `|` / a line break / a substitution / a redirection, a quoted connector is text, a compound covered segment by segment; B2 deny / ask reach — a chain, `||`, `sudo` / `env` / `nohup`, a quoted flag, a quoted newline, an expansion that a star absorbs, one that may be empty, a program-name expansion; B3 the two floors and the list's re-read; B4 the trailing-star form unchanged; B5 `\*` and the `/` `**` `/` run; B6 the auto strip (the program read past `nice` / `timeout 5` / `command` / `sudo -u root`, an open program word, `uv run` not); B7 an imported pattern lands in the store and judges the same, the card's edit face, the suggestion face mints none; X1–X4 the closing `/` of a `/` `**` `/` run, the strip on the run's words and the program's basename, the form read off the folded content, a leading expansion that may yield no word; W1–W6 `Bash( * )` / `Bash( *)` / `Bash(\t*)` answer as `Bash(*)` in the store, an entry list, a session's bucket, a rule policy (`> out` refused by the deny) and a name set (`createAllowDenyPolicy`), `Bash(**)` stays a deny-only pattern; R1 a real Runner under `dontAsk` with `allowedTools: ["Bash(touch * marker)"]`; R2 a real Runner under `bypassPermissions` with a rule-policy deny pattern through a chain and a wrapper); re-pinned: `test/backlog490-cc-wildcard-import.test.ts` (a mid-pattern entry imports), `test/backlog1468-k1-vocabulary-twins.test.ts` (`pattern` is named as pending the server's adoption, apart from its transcription; a pending word the transcription already has is red); the per-fixture mutation table is in the car receipt.
|
|
373
|
+
|
|
374
|
+
### (#2159) On a target device, an off-target read the exit refuses no longer sets the off-target-read bit, and a session record that cannot be written is reported once per state; (#2160) as filed does not reproduce — its true shape is KNOWN-LIMITS KL-469-c, left to a design ticket (security, device lane; behavior change: fewer asks and fewer notices, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none; signatures: none) (@server @cli @client-core @test)
|
|
375
|
+
- **Widening (open to veto; r18).** A device call whose exit is judged while a sibling's off-target read is still writing its own record is allowed; it used to be asked about as unconfirmed. Nothing read beyond the device has reached a model context at that point (the read has not been let run), so this is not a fail-open; a slow child store widens the window (lane-level pin; no slow-store cell yet).
|
|
376
|
+
|
|
377
|
+
- **Before (v7.54.0).** An off-target read (a call whose legs reach beyond the device to read) set the tree's off-target-read bit at its exit *before* its session record was written; when that write failed on a governed (`multi_tenant`) tree the read was refused and never ran, but the bit stayed, so the next device call (`Bash`, `Write`, …) was put to a person for a read that never happened. `config.cross_boundary_record_unwritten` was minted per failed write: under `single_user` with a failing store, the state `{armed, observed}` was reported at the read's exit and again at the leg's settle; on a governed tree the prepare's state and the settle's same state were each reported.
|
|
378
|
+
- **Now.** The bit a call owes joins the tree when its exit lets it run — after the record that carries it is written, before the call runs; a call the exit refuses sets nothing. A record that cannot be written and refused nothing is reported once per state: a write of the same state that fails again (an exit's, then the leg's end) mints no second notice; a state that grew is reported again. On a tree that is not governed the read still runs and still sets the bit when its write fails (no judgment reads that record).
|
|
379
|
+
- **#2160 as filed does not reproduce; what is true is this.** No writer lowers the off-target-read bit: the record reader joins every record on the branch, and a leg's tree and its recorded state only grow. In the reported shape (a background child reads beyond the device while the parent's settle record is in the store; the settle's first record lands, its catch-up record fails), the bit never reached any carrier the next leg reads — the next leg is a new tree, starting from the parent session's newest record, which holds the state before the child's read and has no content after it. Measured on the test's g2c shape, the same before and after this change (and as the test read v7.54.0): at the next leg's prepare the parent's branch holds the prepare's record, the leg's content, then the settle's first record `{armed, governed}` with no entry after it; the next leg's device `Bash` runs without a person (inject), where the catch-up that landed `{armed, observed, governed}` puts it to one (control). Covering only up to the last record written changes nothing here — that record already sits after all the content. This is the same root as the accepted half of KNOWN-LIMITS KL-469-c (a child's bit set after its parent's leg ended): both are a governed leg ending while another lane of its tree is still live. The row now states both forms; they close together with the design ticket that makes such a leg end leave its content uncovered, so the next leg reads it as off-target through the reader that exists (live lanes tracked per tree; a narrowing — after a background child, the next leg's device calls go to a person; open to veto). The not-governed sentence of `config.cross_boundary_record_unwritten` adds that a failure of the same state is not reported again.
|
|
380
|
+
- **The notice tells the truth about the gap (behavior unchanged — v7.54.0 already let this through; open to veto).** What changed: on a governed tree, a failed write whose earlier record of the same write landed (the catch-up arm — the shape above) mints its own sentence: the catch-up record that carries the tree's newer bits has not been written; until #2160 lands the next leg may read the content before that earlier record as covered and may not route its device calls to a person (KNOWN-LIMITS KL-469-c); the terminal shape is fail-closed. Every other governed failure keeps the sentence it had (the next leg reads the content after the newest record as off-target, so its device calls go to a person — true there). Who is hurt: an operator who read the old sentence on this arm as a protection that was not there. How to migrate: nothing; treat that sentence as an open gap until #2160's ticket ships.
|
|
381
|
+
- **Widening (open to veto).** What changed: on a governed target tree whose store fails the record of an off-target read, the device calls after that refused read run on the policy's answer as they would have before it — no `cross_boundary` ask. Who is hurt: nobody by the read (it never ran); a deployment that counted on that extra ask as a signal of the failing store reads `config.cross_boundary_record_unwritten` instead (minted at the prepare or settle that failed; a governed exit's refusal is its own report). How to migrate: nothing to do.
|
|
382
|
+
- **Fewer notices.** What changed: one `config.cross_boundary_record_unwritten` per state a leg could not write; `detail.phase` names where that state first failed. Who is hurt: a projector or test that counted one notice per failed write (the test's g2 S1 shape reads two, `prepare` and `exit`, where it read three). How to migrate: count states, not writes; the not-governed sentence gained a clause — match on `code` and `detail`, not on the sentence.
|
|
383
|
+
- **Unchanged.** The obligation table, the station, both exits' refusal rule, the record's shape and reader, the suspend's refusal of its row, the settle's catch-up writes, and the host lane (a tree that never armed writes, traces and reports nothing).
|
|
384
|
+
- **Consumers.** *server* / *cli* / *client-core*: no code reads the notice's text or counts it (checked against their present checkouts: no `config.cross_boundary_record_unwritten` reference in their sources); a notice projector that rows it by `code` is unaffected. *test*: two cells — g2 S2-governed-fail2nd (after the refused off-target read, the device `Bash` runs with no `cross_boundary` ask; v7.54.0 asked once) and g2 S1-single-failall (two record notices, `prepare` and `exit`; v7.54.0 three); g2c#inject reads as before (KL-469-c; its design ticket carries the cell that turns it).
|
|
385
|
+
- **Pins.** `test/backlog2159-cross-boundary-bit-on-run.test.ts` (the lane: a refused read sets nothing and the next device exit is not a person's, with the healthy-store control asking; not governed, a failed write still runs the read and sets the bit; reports once per state, a grown state reported again; on a Runner: g2 S2-governed-fail2nd, its healthy control, g2 S1-single-failall; g2c inject / control with a background child and a forced order — inject pins what the store holds, every parent record `{armed, governed}`, one settle notice). `test/design469-r3-launch-gate.test.ts` P18 single_user re-pinned (the settle's failure of the exit's state is not reported again), and its concurrent-exit pin split in two: a sibling's read let run while a device exit waits on a record it owes refuses the device call (the wait is now a real owed write), and a device call leaving while the sibling's own record is still being written leaves allowed — that read has not been let run; `test/design469-r3-boundary-legs.test.ts` reads the not-governed sentence's new clause.
|
|
386
|
+
|
|
387
|
+
### (#2117) A pattern segment of a read-deny row or a sensitive-path pattern is matched by the engine's `*` glob instead of an expression built from it, so stars a literal apart (`*a*a*a*b`) no longer take minutes to judge (src only; security hardening; matching result unchanged: every pattern accepts and refuses the same segments; hard-breaking: none; closed sets: none; exports: none; members: none) (@server)
|
|
388
|
+
|
|
389
|
+
- **What changed.** `compileSegmentPattern` (`src/tools/fs/read-deny.ts`), the one compiler behind the read-deny rows, a task's or deployment's read-deny additions and the sensitive-path write list, built one regular expression per pattern segment. v7.54.0 folded adjacent stars into one `[^/]*`; stars a literal apart still backtracked, with the number of stars as the degree. Measured on this tree before the change, against a segment of `a`s that never reaches the final `b`: fifteen stars (`(*a)×14 *b`) against 28 characters took 1.1 s, seventeen against 32 took 23 s, a read-deny table of eight such rows 101 s, and seventeen stars through the sensitive-path policy's file check 35 s on a loaded host. The match runs synchronously on every judged path, on the shared event loop, so a pattern any layer can write was a denial-of-service lever. A compiled segment is now a small matcher over `wildcardMatch`, the `*` glob the rule grammar already uses (`src/core/namespaced-rule-name.ts`, a package-root export whose signature and answers do not change): `*` stands for any run of characters but a separator, every other character for itself, and no expression is built from the pattern — the escape set is gone with it. The same shapes take well under a millisecond (the table of eight rows, 0.1 ms). `wildcardMatch` itself no longer walks character by character: it checks the literal before the first star and the one after the last at the two ends and puts each literal between two stars at its earliest place with the engine's native substring search — measured on this tree, one entry of a star and a literal tail of about 250 characters judged against a 65 536-character segment took 534 ms walked and 1.9 ms placed (an expression: 0.3–1 ms), and sixteen such entries over twelve 4 096-character segments 4.9 s walked, 4.7 ms placed (an expression: 4.8 ms); the pins compare it with the walk over 3.6 million pairs. Its cost is at most the product of the pattern's and the segment's lengths and in no case a function of how many stars there are or how they are arranged; a 100 000-character segment against sixteen separated stars is judged in one pass of its length. `SegmentMatcher` (`test`, `anything`) replaces `RegExp` as the type of `CompiledSegmentPattern.segments`; the one reader that looked at the compiled source (`isAnySegment`, whether a segment is "any one segment") reads `anything`. `CompiledSegmentPattern.fold` (`"none" | "ascii" | "unicode"`) replaces `foldAscii`: the pattern is folded when compiled and a path's segments once per path, by the table `SEGMENT_FOLDS`. None of `SegmentMatcher`, `SegmentFold`, `SEGMENT_FOLDS` or `ignoreCaseFold` is a package-root export (`export-surface` green).
|
|
390
|
+
- **Matching result unchanged.** A segment containing a separator never matches, as before. The "unicode" fold (the sensitive-path write list's, kept answer for answer) used to be the `i` flag; it is now the fold that flag applies — one UTF-16 unit at a time, the upper-case mapping when it is a single unit that does not step from a non-ASCII unit into ASCII — and was compared with the engine over all 65 536 × 65 536 pairs of units: 0 disagreements. It is applied to the pattern when it is compiled and to a path's segments once per path, with the path's other readings (the ASCII fold's way), and the glob then compares exactly. The pins compare the compiler with the compiler as it first stood (one `[^/]*` per star) pair by pair: 22 680 enumerated pairs and a seeded random corpus under all three folds, 20 hand-written readings of a glob whose literals may not overlap, and the fold unit for unit.
|
|
391
|
+
- **Who is hurt.** Nobody: a match is the same answer, and the shapes measured are as fast or faster, the case-insensitive fold of the sensitive-path write list (macOS and Windows hosts by default) staying at v7.54.0's cost: a 24-segment path of non-ASCII segments through 40 rows takes 0.26–0.34 ms with the fold and 0.20–0.25 ms without, where v7.54.0's `i` flag took 0.18–0.43 ms and 0.17–0.44 ms (one host, interleaved runs); a fold repeated inside every test would have taken 1.5–11 ms on the same shapes, which is why the segments are folded once per path. *server* (`@server`): the request-side star caps in `src/tighten-only-seats.ts` (`MAX_READ_DENY_STARS_PER_SEGMENT` = 2, `MAX_READ_DENY_STARS_TOTAL` = 16, and the paragraph in `docs/ASSISTANT-WIRE-CONTRACT.md` that says the engine makes each `*` a backtracking point) rest on a cost this version removes from the engine; once a deployment runs this version they can go, and a request with more stars is then accepted (the entry-count and length bounds are unrelated). Read in the local checkout of server's `origin/main` (e9d9ccf0), not re-fetched. *cli* / *client-core*: nothing to do.
|
|
392
|
+
- **Pins.** `test/backlog2117-read-deny-star-fold.test.ts`: F1a–c the pair-for-pair comparison (subjects now include the units whose case mapping is not ASCII), F1d no segment is an expression and `anything` is true of exactly the segments of stars, F2a–d the runs of stars of v7.54.0, F4a–g separated stars through both consumers (fifteen and twenty-five stars, a table, the sensitive-path policy, a 100 000-character segment, the overlap and order readings, and the glob's worst shape), F5a–b the fold unit for unit, F6a–c the glob against the character walk it replaced (every pattern up to six characters over {a, b, *} against every value up to seven, a seeded random corpus, the readings a placement-based glob can get wrong), F7a–b the case-insensitive fold costs a deep path one fold of its segments — a count through `SEGMENT_FOLDS.unicode`, no clock: forty rows over a 24-segment path fold as many segments as one row does, and a second judgment of the path folds none — and the folded rows still fold; `test/workflow-governance-tightening-keys.test.ts` adds sixteen entries at the 256-character length bound over a deep path; the clocks are upper bounds (`< 50 ms` ×5, `< 200 ms` ×3, `< 1000 ms` ×2) declared in `test/flaky-guard.test.ts`, each new one the fastest of three runs, and each cost pin reads the kind of the compiled segment before its clock, so a regression to an expression fails at once instead of hanging the file. Mutations (each reverted): the separator guard dropped, a lower-case fold, the step-down-into-ASCII guard dropped, the single-unit guard dropped, the unicode fold never applied, `anything` always false and too wide, the v7.54.0 compiler restored, and in `wildcardMatch` the character walk back, a middle literal allowed into the tail's room, head and tail allowed to share a character, the latest placement instead of the earliest, and the fold repeated per row (the count 936, not 24) or not applied to the segments at all — each red in the pins named for it.
|
|
393
|
+
|
|
394
|
+
### (#2117) A workflow script's deny pattern may hold several `*` in one path segment: the governed child spec's per-segment wildcard cap is retired (src only; behavior loosening, open to veto; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
395
|
+
|
|
396
|
+
- **What changed.** `buildGovernedChildSpec` read a script's `readDenyPatterns` (`src/core/child-clamp-seats.ts`) and refused an entry with more than one `*` in a path segment (`*a*b`, `a/*b*c/d`) — `has a path segment with N "*" wildcards — a workflow script's deny pattern allows at most 1 per path segment`. The cap existed because the segment matcher backtracked per star; with the previous section's glob it guards nothing, and a cap whose reason is gone is a rule to explain, not a bound. The entry-count (16) and length (256 characters) bounds are unchanged. A script's deny pattern the engine refused before is accepted now; the list is deny-only, so a pattern can only deny more.
|
|
397
|
+
- **Who is hurt.** Nobody: a refusal turned into an acceptance in an add-only list. A host or a script author that matched the old refusal text finds it gone. *How to migrate:* nothing; entries written as several one-star entries to get around the cap keep working.
|
|
398
|
+
- **Pins.** `test/workflow-governance-tightening-keys.test.ts`: several stars in one segment are admitted and the admitted entry is the entry the child's matcher judges (not a trimmed one); sixteen entries of stars a literal apart over a deep path and over a 65 536-character segment are judged in under a second, the matches kept; the hostile-element pins (an own `map`, a Proxy, a shifting accessor) read the length bound now, the refusal that remains.
|
|
399
|
+
|
|
400
|
+
### The leg's effect table follows the member that answers to each name after every mount and unmount, so a selector that keeps a caller tool by its alias and unmounts the engine's same-named tool no longer leaves the engine's effect behind (#2107; safety narrowing and one widening, both open to veto; hard-breaking: none; closed sets: none; exports: none; members: none) (@server @cli)
|
|
401
|
+
|
|
402
|
+
- **Before (v7.54.0).** A caller tool named like an engine tool and carrying an alias the leg's selector names (`Read{effect: "write", aliases: ["X"]}` with `allowTools: ["X"]` on a full-hands leg): the engine's `Read` mounted later and its `read` was written under `Read`; the selector then unmounted the engine instance (it does not answer to `X`) and kept the caller's, and the table kept `read`. Measured with a real Runner under a session `allowDirs` confinement: the caller's write tool ran (1), called as `Read` and as `X`; the same tool under a name of its own was refused (0). A caller tool that declared nothing there went out on its roster row as `read` (the row folds the table for an undeclared tool). The reverse shape — a caller `TaskStop{effect: "read"}` kept the same way — was refused on the engine's `write`. The alias `X` itself had no entry.
|
|
403
|
+
- **Now.** One rule in the roster builder, run after every mount, unmount and domain refresh over the names the change can have moved: each holds the declaration of the member the dispatcher resolves it to on the live set — the same resolution the roster's rows are minted by, and the declaration read off that member as its row reads it. A member that declares no effect leaves no entry (every reader takes write), except an MCP / A2A tool, whose effect is the leg's protocol fold; a name nothing answers to has none. A name never reads a member that does not answer to it. The safety scan no longer writes the table (its caller declarations feed the wake reconcile's pre-mount reading only), and the end-of-prepare sync that filled missing entries is gone (it could no longer fill anything). The three safety axes (irreversibility tier, probe, egress) are unchanged: they stay keyed by name — they do not yet follow the member that answers to a name (#2266, 7.56.0; the two behavior changes of this section are open to veto under C-R714).
|
|
404
|
+
- **Safety narrowing (open to veto).** (1) **What changed:** in the shape above the caller's tool reads its own declaration — declared `write` or nothing ⇒ `allowDirs` refuses it, `verify.wroteThisRun` counts it, an interrupted call re-read by its raw spelling is UNKNOWN, and an undeclared tool's roster row says `write` (provenance `default`). (2) **Who is hurt:** a deployment whose own write tool shares an engine tool's name and is kept by an alias, and that relied on it passing `allowDirs` as a read. (3) **How to migrate:** declare the tool's real `effect`, or give it a name of its own.
|
|
405
|
+
- **Widening (open to veto).** (1) **What changed:** the same shape with a caller tool that declares `read` under an engine write name now reads `read`: `TaskStop{effect: "read", aliases: ["X"]}` with `allowTools: ["X"]` is admitted under `allowDirs` (0 → 1), as its roster row always said. A tool under a path-confinable catalog name (`Write` / `Edit` / `NotebookEdit`) is still refused there when it declares no path target: that refusal is the confinement's own. (2) **Who is hurt:** a deployment that relied on the unmounted engine tool's `write` to refuse its own read-declared tool. (3) **How to migrate:** declare `effect: "write"` on it.
|
|
406
|
+
- **Safety narrowing, a deferred name's direct call (open to veto).** (1) **What changed:** a deferred name's placeholder resolved the tool its direct call runs (and its scheduling and static face) as the FIRST instance mounted under the name, while the harness — once the name is activated — and the effect table read the later one. With `deferTools: ["Read"]` and a caller `Read{effect: "write"}` on a full-hands leg, a direct call ran the caller's write tool while the table said `read`: `allowDirs` admitted it (1). Now the placeholder resolves the instance the harness dispatches the name to (0: the engine's `Read` answers, and the call's arguments are judged against its schema). (2) **Who is hurt:** a deployment whose deferred caller tool shares an engine tool's name and relied on direct calls reaching its own. (3) **How to migrate:** give the tool a name of its own.
|
|
407
|
+
- **Cost.** The rule settles, after a mount, only the names that mount can move (its name, its aliases, and the aliases of the member it displaces) and, after a removal, every name; one index per change resolves them (`toolNameIndex`, the indexed form of `findToolByName`'s rule — `findToolByName` is now defined through it). Measured, 2 048 distinct tools mounted one by one: v7.54.0 0.48 s, this change 1.2 s (an earlier draft that settled every name per mount took 86 s with aliases). With one alias on every tool the same mount sequence takes 1.1 s on the previous release and 4.3 s here (measured); `findToolByName` now builds its index per lookup on the dispatch path (not measured on the loop; KL-2107-a).
|
|
408
|
+
- **Unchanged.** Plan mode already judged a call by the tool that answers (no reading change). The wake reconcile's pre-mount reading. Which tool answers to a name when a caller tool and an engine tool share it (the later mount; #1434 is that question).
|
|
409
|
+
- **Consumers.** *server* / *cli*: a roster row for an undeclared caller tool in this shape now says `write` / `default` where it said `read`; a display or a server-side check reading the row sees the tool's own word. No wire key or type changes.
|
|
410
|
+
- **Pins.** `test/backlog2107-alias-effects-identity.test.ts` (F1 the ticket's shape, by name and by alias, table = row; F2 an undeclared tool: no entry, row write / default; F3 the reverse shape; F4 controls; F5 plan mode, a control green on v7.54.0 too; F6 the alias answers to the caller again; F7 the table equals the roster for every row and alias under alias-keeping selectors; F9 a deferred name's direct call runs the instance the harness dispatches to, and a deferred caller tool under its own name still runs; F8 a protocol member's fold survives an unrelated unmount and a domain refresh, a roster of caller tools alone carries their declarations, a displacing mount moves the displaced member's aliases at once, and a caller member that declares nothing never keeps another member's entry). Re-pointed: `test/name-readings-behavior.test.ts` and `test/backlog126-definetool-axis-carry.test.ts` read the scan's alias mirror in its wake reading (the scan returns the table empty); `test/design388-write-fences.test.ts` pins the builder's rule where it pinned "a caller mount never writes" (the post-scan mutation that guarded against is closed by the frozen roster face, pinned on the real lane by `test/prepare-task-phase-pins.test.ts` T1).
|
|
411
|
+
|
|
412
|
+
### Two more store vocabularies on the package root — the rule offer's `single` / `batch` and the denial bound's `consecutive` / `total` — and an owner read from data has one reading, `readRuleOwner` (#2102, #2103; #1468 stage one; behavior narrowing, open to veto; hard-breaking: the root type `AdoptionSource` is removed, open to veto; closed sets: two newly registered sets, `RuleOffer["kind"]` and `DenialLimitBound` — no word added to or removed from any set; exports: +5 / −1 — +4 runtime `RULE_OFFER_KINDS`, `DENIAL_LIMIT_BOUNDS`, `isDenialLimitBound`, `readRuleOwner`, +1 type `DenialLimitBound`, −1 type `AdoptionSource`; `RuleOwner`, `RULE_OWNER_KINDS` and `sameRuleOwner` move to `src/core/rule-owner.ts` with their names and values unchanged) (@server)
|
|
413
|
+
|
|
414
|
+
- **Before (v7.54.0).** The engine published the offer discriminant and the denial bound only as types, and its own readers spelled the words again: the consent record check (`offer.kind !== "single" && offer.kind !== "batch"`), the offer read-back judge, the stored-fallback reader and the decision receipt's value fence. An owner read out of data had five readings: the rule-directory adoption marker accepted a `principal` source with an empty name, the root adoption marker refused it, the root adoption entry checked the name and the kind in two statements (reading `from.kind` before checking `from` is an object), the rule-directory adoption entry checked nothing, and the consent lane read any `kind` other than `principal` — a stored record's `owner`, a caller's `owner` option — as the local owner. The owner equality was written three times, and `AdoptionSource` was a second definition of `RuleOwner`.
|
|
415
|
+
- **Now — the words.** `RULE_OFFER_KINDS` (`single`, `batch`) is the one spelling of both offer unions' discriminant — the card offer `RuleOffer` and the consent record's option form, which stores the same choice by candidate index; four compile fences bind both unions to it in both directions. `DENIAL_LIMIT_BOUNDS` (`consecutive`, `total`) is the bound that tripped; `DenialLimitBound` is derived from it, `DenialLimitCounts.limit` is typed by it (the same two words), and `isDenialLimitBound` is its membership. Every engine reader above reads the tuple; each judges every plain value as the spelled-out comparison did (pinned value for value; a member held by an accessor, see (6) below).
|
|
416
|
+
- **Now — one owner reading.** `readRuleOwner(raw)` (package root, beside `RuleOwner`, `RULE_OWNER_KINDS` and `sameRuleOwner` in `src/core/rule-owner.ts`) answers an owner only for a plain record (prototype `Object.prototype` or none — members inherited from a prototype other than `Object.prototype` are not read) whose `kind` is a member and, on the `principal` arm, whose `principal` is a string that is not empty or whitespace-only (kept as written, never trimmed) — a fresh copy holding only those members — and `undefined` for anything else. Both adoption markers' readers, both adoption entries' request checks, a stored approval record's owner and the consent entries' `owner` option read through it; `sameRuleOwner` is the one equality. An adoption entry therefore writes only a source its marker's reader reads back. Not yet (open, listed for the next car): the store's `addPersistedRule` / `removePersistedRule` target, `resolveOwnerStore` / `effectivePermissionRules` and `syncPermissionRules` dispatch on a typed `owner` without it — an `owner` whose `kind` is not `local-owner` takes their principal path.
|
|
417
|
+
- **Behavior narrowing (open to veto).** What changed: (1) a stored approval record whose `owner` is not an owner owns nothing — confirm answers `record_not_found` and a redemption refuses it as another principal's (before: `{ kind: "bogus" }` was the local owner's record and the local owner could confirm it; `null` threw a `TypeError`); (2) a caller's `owner` option that is not an owner is refused before any record is minted, `<entry> requires a verified principal or the local owner — …` (before: an unknown kind minted into the local-owner bucket); (3) a rule-directory adoption marker whose source names a principal name that is empty or whitespace-only is a marker without a readable shape — owner resolution over that directory refuses until it is repaired (before: read as a principal source); (4) `adoptFilePermissionRuleStore` refuses a source that is not an owner before writing anything (before: it completed an adoption from an empty or whitespace-only principal name and wrote the marker of (3); an unknown kind threw a `TypeError` from the file-name hash); (5) `adoptLocalDataRoot` refuses `from: null` with `adoption_bad_request` (before: a `TypeError`), and its three source refusals are one sentence; an owner is an object, so a function carrying a `kind` is no owner on any of these faces (the two marker readers already refused one; the consent `owner` option read its `kind`); (6) `readDenialLimitFallback` (the inbox summary's read of a parked fallback) reads each member once and answers the values it judged (before: `limit` was read once per word compared and again for the answer, so a member held by an accessor could be answered with a word it was not judged on), and reads a plain record only — members inherited from a prototype other than `Object.prototype` are not read (before: a member on the value's prototype, or a class instance's, read as the fallback's); a stored record's owner is still read before its `principal`, each once; (7) a principal name that is empty or whitespace-only (`" "`, `"\t"`) is no owner in any spelling — the `owner` arm, the consent entries' v1 `principal` parameter and a stored record's v1 `principal` field (before: only `""` was refused; `" "` named a bucket — both adoption entries adopted from it and wrote their marker, and a consent caller `" "` minted and confirmed its records); (8) members inherited from a prototype other than `Object.prototype` are not read: an owner is a plain record, so a value whose `kind` or `principal` comes from its prototype, or a class instance, is no owner on any of these faces (before: read through the prototype chain — a live store's record whose owner was `Object.create({ kind: "local-owner" })` was the local owner's, and such a caller `owner` minted). Who is hurt: a host whose store holds such a record or marker — written by hand, by a foreign writer, or (for the marker) by an `adoptFilePermissionRuleStore` call with an empty or whitespace-only principal name, an entry with no caller in sema-server, sema-cli, sema-client-core or sema-test origin/main; a JavaScript caller passing a malformed `owner`. Compensation: the refusals are loud and name the shape; a marker is repaired by hand (the refusal asks for that) — its `from` names the bucket's real source — and a record by re-triggering its card.
|
|
418
|
+
- **Hard-breaking, `AdoptionSource` is removed (open to veto).** What changed: the package root no longer exports the type `AdoptionSource`; the root adoption marker, its report, `AdoptLocalDataRootOptions.from` and `AdoptionStatus` type their source as `RuleOwner` (the same shape). Who is hurt: a TypeScript consumer importing `AdoptionSource` from the package — none in sema-server (`e9d9ccf0`), sema-cli (`8ff3383b`), sema-client-core (`55af349`) or sema-test (`7d4878db`) origin/main (the server's `AdoptionSourceSchema` in `src/adoption/wire.ts` is its own form, unaffected). How to migrate: import `RuleOwner`.
|
|
419
|
+
- **Unchanged.** The words and their order; every judgment of an offer kind or a bound; an adoption from a valid source; the owner of a record written with a valid owner; `sameRuleOwner`'s answers.
|
|
420
|
+
- **Consumers.** *server* (stage two; nothing in this release turns a server build red): read `RULE_OFFER_KINDS` where the two rule stores spell the record option's arms (`src/plugins/permission-rule-store-file.ts:92` / `:95`, `src/plugins/permission-rule-store-sql.ts:270` / `:273` — e.g. a per-word arm table keyed by the tuple, so a new word is a missing key at compile time); the card offer shapes (`src/approval-card.ts:143` / `:146` / `:195` / `:198`) and the batch member arms (`:114` / `:125` / `:202` / `:203`, the already-exported `RULE_OFFER_BATCH_MEMBER_KINDS`) are the structure `readRuleOffers` already judges — read through it and keep the display caps and redaction; `z.enum(DENIAL_LIMIT_BOUNDS)` at `src/approval-card.ts:919`; read a stored owner with `readRuleOwner` — the file store's two owner unions (`src/plugins/permission-rule-store-file.ts:109` / `:190`, which accept an empty or whitespace-only principal name the engine reads as no owner) and the SQL store's approval reader, which reads any `owner_kind` other than `principal` as the local owner (`src/plugins/permission-rule-store-sql.ts:1121`).
|
|
421
|
+
- **Pins.** `test/backlog2102-offer-kind-denial-bound-vocab.test.ts` (a frozen tuples and tsc type equalities with the four offer fences; b root identity; c the engine's pre-change spelling and the server's transcriptions; d every reader against the spelled-out verdict over 21 values — `readRuleOffers`, the consent record check including a store that hands back accessors, `readDenialLimitFallback` and `isDenialLimitBound` with one read per member, the decision receipt's value fence); `test/backlog2103-rule-owner-one-reading.test.ts` (a the judge over 25 shapes, blank and padded names and prototype-supplied members included, and one read per member; b root identity and `AdoptionSource` gone at tsc; c no other owner judge or equality spelled in `src/`; d the lanes above on their real functions, and a record's identity read owner first; e each adoption entry completes from a source exactly when its marker reader accepts a marker naming it); `test/backlog2097-rule-vocab-twins.test.ts` and `test/file-adoption-root.test.ts` read the moved names from their new module.
|
|
422
|
+
|
|
423
|
+
### Five small corrections: an approval feedback that cannot be delivered names its cell, `detail.field: "feedback"` (#2240); the refusal of a session log whose parent chain loops says what a person can do (#2245); the durable-approval park refuses in the approval judge's own sentence and the row holds as deep as the judge binds (#2116); `settledForkOptions` is a package-root export and refuses a parent loop instead of climbing it for ever (#2098); three test files call git through the seam (#2237) (#2240, #2245, #2116, #2098, #2237; behavior change on host-visible text, one widening (#2116, arguments nested 129–256 levels now park) and one correction (#2098, a fork keeps a fully answered last tool turn), open to veto; hard-breaking, type only, open to veto: `CheckpointErrorField` gains a word (#2240); closed sets: `CheckpointErrorField` 9 → 10 (+`feedback`; the `detail.field` union, now named and registered in `docs/CLOSED-SETS.md`); exports: +1 function `settledForkOptions`; members: none) (@server @cli @client-core @sdk)
|
|
424
|
+
|
|
425
|
+
- **Hard-breaking, type only (#2240; open to veto).** (1) **What changed:** the words of `CheckpointError`'s optional `detail.field` are a named closed set, `CheckpointErrorField` (an inline union before; not a root export), and gain `feedback`: an allow's `feedback` refused pre-CAS — written on a `deny`, written on a decide a machine made, or carrying the engine's `<system-reminder>` tag, opening or closing — is `checkpoint.invalid_outcome` with `detail: { field: "feedback" }` (it carried no `detail` before); the sentences and the pending row are unchanged. (2) **Who is hurt:** a consumer that keeps an exhaustive table over `NonNullable<CheckpointError["detail"]>["field"]` — the server's `CHECKPOINT_ERROR_FIELD_ROLE` (`src/http/resume-legs.ts:392`, a `Record` over the whole union, read in the server checkout) — stops type-checking on this version until it places the word; at runtime a server that has not yet done so reads the word as outside its table, so it has no decision cause and answers these refusals exactly as before (`approval_binding_mismatch`, "re-fetch the pending list" — the answer that sends a shell back to the same refusal; this is what the change exists to end). (3) **How to migrate:** add `feedback` to the table in the role whose recovery is "change the words and decide again" (the server's `outcome_cell`, wire code `resume_outcome_invalid`); a shell then shows the person that their note is what to change or drop, not that the card moved. The non-string refusal of `feedback` (`not a plain string`) carries the word too; the deny `reason` and plan `editedPlan` refusals (the sentinel tag and the non-string alike) still carry no `field`: a word for each is a separate closed-set widening, not made here.
|
|
426
|
+
- **Before / Now (#2240).** The three refusals of `refuseUnfitFeedback` (`src/core/runner/approval-feedback.ts`) were built three times with no `detail`, so a host that sorts an `invalid_outcome` by its `field` had no word for them. They now leave through one construction, `feedbackRefusal`, with `{ field: "feedback" }`. `docs/INTEGRATION-CORE.md` (the `feedback` row) and `docs/CLOSED-SETS.md` say so.
|
|
427
|
+
- **Now (#2245).** The `invalid_session` sentence of a parent chain that loops (7.54.1's guard, `ParentChainGuard`) ends in the way out: `Entry <id> is its own ancestor (parent chain cycles at <id>) — start a new session, or fork from an entry above the loop`. The second half is true and pinned: the branch verb (`SessionRepo.fork` at an explicit entry) forks at an entry the loop does not touch, and is refused, in the same sentence, at an entry on the loop and at a leaf that walks into it. The fork-point judge of the whole-session fork takes the same guard (#2098, below). A host that renders the sentence as written needs no change.
|
|
428
|
+
- **Now (#2116).** The park mint (`prepare-park-ask.ts`) asked "can an approval be bound to these arguments?" twice, in two sentences of its own (`cannot be bound into the approval row's digest (<kind> at pendingAction.args…)` from a row-digest walk, and `hold a value no exact digest binds`), neither the one the synchronous seat speaks for the same value. It now asks the one judge, `bindableArgsOf`, and refuses in its sentence (`no approval can be bound to the arguments of "<tool>": they hold <what> at args… — …; denied fail-closed`); the row-digest walk the mint used as a pre-check (`unencodableIn`, module-internal) is gone. The row digest's own depth cap, a second constant (128) beside the approval judge's 256, is the judge's: the row holds exactly the 256 containers an approval can bind, so arguments nested 129 to 256 levels — bindable, but refused by the park before — now park, carry a digest and resume (model-produced JSON of that depth is the case; widening, open to veto); 257 are refused by the judge, before the remote VM is paused or anything is swept. The one extra level the row had — the record that holds the digested members counted as a container — is removed by giving the serializer a start depth: `canonicalize(value, standsAt = 0)`, and the row digest passes its holder at −1, so a member counts its containers from 0 as a value digested alone does. The depth only moves where the serializer's sentinel stands, so the digest of every row below it is unchanged (read on the base and the head trees on four rows: plain, branded values, 127 levels, a JSON store's; pinned as literals), and one level past the old bound the sentinel would have made the digest blind to the innermost member — measured: lifting the cap without the start depth gave two 256-level rows that differ only in their innermost member the same digest. On this path the deny reason carries the sentence twice — the seat's own refusal and the park-failure note that quotes the cause. `ownArgs` / `unownedArgsMessage` (the gate's own copy; a different question) are not changed.
|
|
429
|
+
- **Now (#2098).** `settledForkOptions(source)` — the options that cut a whole-session fork before an in-flight assistant turn (one with a tool call that no tool result behind it answers — the agent loop's own reading of an open batch, `findUnansweredToolBatch`), `{}` otherwise — is exported from the package root; `TtlSessionStore.fork` (the Fork tool's fork and `warmResume`) calls this very function. One answer is corrected on the way out (behavior change, open to veto): a session whose LAST tool turn is fully answered — every call has its result — is settled and now forks whole (`{}`); the private function it was exported from cut such a turn off (answering the turn's parent), so a person's fork or a warm resume of a finished session lost its last tool turn. An in-flight turn is cut as before, whether its open call is the leaf or tool results already sit behind it. The shared store kit's `[fork]` case follows: its "sequential" fixture (one call, answered by its one result) was a settled turn read as open, and now makes two calls with one answered; a new assertion in the same case pins that a tool turn every call of which is answered is kept, and a mutant of the bundled store that cuts it (M7b) reddens the case. Server's `LocalSessionStore.fork` (`src/plugins/local-session-store.ts:1002–1005`, read in the server checkout) forks the whole log (`repo.fork(meta, {})`), so a child opens with the in-flight call and wake-time reconciliation gives it an `[INTERRUPTED]` result. The walk takes the shared `ParentChainGuard`: a leaf on a loop of tool-result messages alone — which the walk climbed for ever, in the Fork tool's own fork (`TtlSessionStore.fork`) as in the exported function — is refused `invalid_session`, in the sentence `getPathToRoot` refuses a loop with (behavior change: a hang becomes a refusal). The import validator keeps its own loop (#2253, 7.56.0).
|
|
430
|
+
- **Who is hurt.** (#2240) the type-only break above, nothing else at runtime. (#2245, #2116) a host or model that matches the text of these refusals; the sentences are longer or different by the stated parts. (#2116) a host that relied on arguments nested 129–256 levels being refused at the park gets a park. (#2098) a log with a tool-result-only parent loop that a fork used to hang on now answers `invalid_session`; a host that relied on a fork of a finished session dropping its last tool turn (`warmResume` of a run that ended after its tools) now gets that turn in the child. (#2098, #2237) nobody: an added export; tests only.
|
|
431
|
+
- **How to migrate.** *server*: add `feedback` to `CHECKPOINT_ERROR_FIELD_ROLE` (above); replace the whole-log fork in `LocalSessionStore.fork` (`src/plugins/local-session-store.ts:1002–1005`) by `const source = await repo.open(meta); const options = await settledForkOptions(source); await repo.fork(await source.getMetadata(), options)` (a session whose last tool turn is fully answered forks whole, as a text-only turn does; only a turn with a call no result answers is cut before) and run the session-store kit: its `[fork] a fork never inherits an in-flight assistant turn` case (`src/core/store-contracts/session-store-contract.ts:582`) is the cell the ledger names as the one real divergence of the server's store (not run against it here). *client-core* / *sdk*: both list the field words of `resume_outcome_invalid` in comments and docs (`sema-client-core/src/engineErrorCodes.ts:956`, `sema-sdk/packages/sdk/dist/errors.d.ts:1052`, read in the checkouts; no closed table seen) — `feedback` joins that list. *cli*: render the `invalid_session` and park-refusal sentences as written; once the server maps the `feedback` word, the pre-CAS refusal means "change or drop the note" (inferred: not run against a shell).
|
|
432
|
+
- **Pins.** (#2240) `test/backlog2240-feedback-refusal-field.test.ts` — the three arms on a real Runner over a durable park (each: code, `detail` exactly `{ field: "feedback" }`, the sentence unchanged, the row pending and then redeemed with clean words), the control that a moved binding keeps `boundCallId`, and a total table over `CheckpointErrorField`; per-arm mutations each turn their own cell red. (#2245) `test/backlog2239-session-path-cycle.test.ts` L8 (the branch verb above, on, and into the loop; child process, 2 s budget), L1–L7 re-pinned on the new sentence. (#2116) `test/backlog2116-park-one-judge.test.ts` (S1 five unbindable rewrites: the error face and the model's deny carry `bindableArgsOf`'s own sentence, nothing put or paused; D1 129 / 200 / 255 / 256 levels park, digest, resume and run once on exactly those arguments; D4 the digest sees a change of the innermost member at 256; D2 257 levels refused in the shared sentence; R on a remote environment 256 levels pause the VM once and park, 257 are refused with zero suspend calls and nothing put; G1 the row digest of four rows is the base tree's byte for byte, G2 the serializer's start depth moves nothing below the sentinel, G3 the row digest's bound is 256 containers exactly), `park-mint-backend-fidelity`, `backlog1320-reader-requirements` H.encode ④ and `backlog1391-approval-object` P-exact re-pinned; seven mutations each turn a cell red. (#2098) `test/backlog2098-settled-fork-options.test.ts` (root identity; the answer for a settled turn, an in-flight turn, tool results behind it, a fully answered last tool turn — one call, three calls answered out of order, a stray result that answers nothing the turn asked, one call answered twice —, a user / non-message leaf, an empty log, a parentless turn; the bundled store's fork and a backend's own give the same child, for an in-flight source and for one whose last tool turn is answered, and the whole-log fork copies the in-flight call) and `test/backlog2239-session-path-cycle.test.ts` L9 (a tool-result-only parent loop: the exported judge and the bundled store's fork both refuse `invalid_session` in the shared sentence — red on the base tree as "no answer in 2 s" —, and a sound in-flight session is still judged). (#2237) `test/gate-git-unavailable.test.ts` "no file spawns the git binary directly" — `backlog2154-publish-checkout` (two clones), `backlog2154-publish-release-run` (the tag clone) and `retirement-lines-gate` (init and base commit) call `gitRun`; a raw call put back at any of the five sites turns that cell red.
|
|
433
|
+
|
|
434
|
+
no-notes: #1645(anchor-claim ledger 1672 → 192: comment-level edits over 398 files that drop or pin CC version provenance; no behavior, no export; C-R648), #1608(TaskOutput retirement re-windowed to 7.57.0 — design/1056 R1–R7 alternative read seats unlanded; C-R675 → C-R716 ③: car-1608-B becomes the design + split car in 7.56.0 W2, the deletion lands 7.57.0), #1055(comments and docs made true after the #1056 teacher retirement; no behavior), #1318(KL-1372-a 行改真:`Skill(<name>)` deny 自 #1318 起可执行;文书,无行为面), #2223(`roles.ts` 两处注释改真,指向待实施票;无行为面), #11c(非票号 —— commit 消息里引用的 r15 回执行号 11(c)), #2246(KL-1517-a 引用其票号 —— park 行携 nameValues 的实施在 7.56.0,本版无行为), #1876(钉改读 SESSION_RULES_GRAMMAR 常量,文法 3 自 #1517 起为 earlier;只测试), #2253(the fork walk is guarded in this release by #2098's own ParentChainGuard; the ticket now covers the import validator only), #17(非票号 —— commit 消息引用的 r16 回执行号 17), #3(非票号 —— car-2107 commit 消息引用的 codex sol 回执条目 3), #5(非票号 —— car-2107 commit 消息引用的 codex sol 回执条目 5), #6(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #8(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #9(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #10(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #11(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #12(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #3(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit), #4(r17 review-finding number in a stage commit title, not a ticket; disposition in the same commit)
|
|
435
|
+
|
|
436
|
+
## 7.54.1 — 2026-10-20
|
|
437
|
+
|
|
438
|
+
**Hard-breaking changes:** none. **Closed sets:** none (`invalid_session` is an existing code). **Exports:** +0 / −0 names; no type or signature change. **Behavior:** a session log whose parent chain loops back on itself is refused `invalid_session` by `getPathToRoot` and by the resume-at ancestry scan, where each used to hold the thread until the process aborted; the shared store kit gains one `[tree]` case that says so. Present in every earlier version.
|
|
439
|
+
|
|
440
|
+
**Packages:** `@sema-agent/mods-kernel` is re-cut at 7.54.1 beside core — packages are cut with core at the same version (`scripts/verify-packages.mjs`) — with contents identical to 7.54.0 (no kernel source changed in this patch).
|
|
441
|
+
|
|
442
|
+
### `getPathToRoot` and the resume-at ancestry scan refuse a parent chain that loops back on itself instead of walking it forever (#2239; fix; a log that could not be read at all; hard-breaking: none; closed sets: none; exports: +0 / −0) (@server)
|
|
443
|
+
|
|
444
|
+
- **Before (v7.54.0; the same in every earlier version).** `BaseSessionStorage.getPathToRoot` followed `parentId` upward with no memory of the entries it had stepped onto. A log in which an entry is its own parent, or two entries are each other's parent, or a leaf walks into such a pair, never reached a root: the walk is synchronous, so it held the whole thread (no timer, timeout or abort could run) while the path it was building grew until the process aborted out of memory (measured on a 256 MB heap: about 13 s, then `SIGABRT`). Every bundled storage (memory, file) and the example adapters inherit this one walk. The resume-at phase had a second walk of the same kind: with `resumeAtMode: "before"` it climbs the target's ancestors, one awaited read at a time, until it finds a conversation message; a log whose ancestry above the target is only non-message entries that loop (for example two labels that parent each other) never ended it, and because each read resolves as a microtask the loop starved timers as well. A store that implements the storage interface directly with a walk of its own had no kit case to say a looping log must not be walked.
|
|
445
|
+
- **Now.** Both walks take one small guard (`ParentChainGuard`, internal; not a package export) that remembers the entries a walk has stepped onto. An entry stepped onto twice ends the walk with `SessionError("invalid_session", "Entry <id> is its own ancestor (parent chain cycles at <id>)")`, naming the entry the chain came back to (for a leaf outside the loop, the first loop entry the walk reached; for the resume-at scan the target itself counts as stepped onto, so a loop that closes through it is refused before any branch is written). Claude Code's own chain builder stops at the repeat and returns the part it had walked (cli296.js:250676:3080); this engine refuses instead, because a loop is a damaged log and a truncated history would read as a shorter conversation (stricter, completeness-first; open to veto). A declared floor still ends the walk at the floor without reading its parent, so a loop that closes through the floor, or lies above it, is not walked and nothing is refused; a floor the walk never meets does not shield the loop. A chain with no loop reads exactly as before (a 1 000-entry chain returns whole, root first). `sessionStoreContract` gains the case `[tree] a parent chain that loops back on itself …`: an entry that is its own parent, two entries that parent each other, a leaf that walks into such a pair, and a ring of three must each read `invalid_session` naming an entry of the loop, within 2 s; a backend that refuses the looping write itself is stricter than the rule and passes.
|
|
446
|
+
- **Who is hurt.** Nobody who reads a sound log. A host that wrote a looping log (a hand-edited or damaged file, or a store with no write-time check) now sees `invalid_session` on the read where it saw a stalled thread and then an aborted process. A store that implements `SessionStore` directly and walks the parent chain itself fails the new kit case until its walk refuses a loop (the server's SQL store is the known consumer: it retires its own guarded walker for this one in its 7.134.0).
|
|
447
|
+
- **Compensation.** None needed. A store that wants the engine's walk extends `BaseSessionStorage`; one that keeps its own walk adds a visited set (or a depth bound) and the refusal above, and runs the kit.
|
|
448
|
+
- **Pins.** `test/backlog2239-session-path-cycle.test.ts` L1–L5 (each walk runs in a child process, because a walk that never returns holds its whole thread and no in-process timeout could fire; red on v7.54.0 as "no answer in 2 s" for L1, L2, L3 and the off-path floor of L5), L6 (the resume-at scan: a loop above the target hangs on v7.54.0; a loop through the target and the two controls), L7 (the guard) and K1–K2 (the kit case on the bundled memory and file lanes; six wrong walks each redden it).
|
|
449
|
+
|
|
1
450
|
## 7.54.0 — 2026-10-19
|
|
2
451
|
|
|
3
452
|
### A table of the faces kept alive for compatibility, a gate that keeps it honest, and one ruler per word (#1336, #2100; release tooling and src comments only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
|
|
@@ -276,6 +725,8 @@ no-notes: #794(③ 按新律重钉:allow 层之外的表态不再免 unstated
|
|
|
276
725
|
|
|
277
726
|
## 7.53.0 — 2026-10-17
|
|
278
727
|
|
|
728
|
+
> Erratum (recorded in 7.55.0, #2191): this release's materials called the before-forms of the delegation refusal quoting a worktree's output and an undeclared extraTools name (#2045, #2051) and of the host store lane reading a row's getter twice per list read (#2012) a defect of the unreleased 7.53.0 stage; v7.52.0 already had all three, and 7.53.0 fixed them. The criteria document is corrected in place (`docs/release-criteria/README.md`).
|
|
729
|
+
|
|
279
730
|
### Docs and pins: the task-stream thinking arm, the classifier attribution sentence, the TaskEvent roster, one re-anchored pin (#1042, #1961, #2029, #1432)
|
|
280
731
|
|
|
281
732
|
- **#1042 (docs).** The task stream's thinking arm is `reasoning_delta` with no start / end frame — a sidecar that listens for the provider-layer name `thinking_delta` counts nothing (`docs/sdk/05-streaming-and-http-server.md`, one pointer sentence in `README.md`). INTEGRATION-CORE states the two host-side facts beside it: a host that renders a local date passes `TaskSpec.clientContext.timeZone` (erratum #2072: the first edition said `EnvironmentFacts.timeZone`, which is the facts carrier the engine fills from this field — a host driving a Runner passes the zone on `TaskSpec.clientContext`); a cache-stable deployment keeps one tool block across modes and refuses at call time rather than varying the block (`KL-1042-a` records the `</think>` leading newline as the template's own).
|