@sema-agent/core 7.50.1 → 7.51.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 +275 -0
- package/dist/agents/child-failure.d.ts +5 -16
- package/dist/agents/child-failure.js +4 -24
- package/dist/agents/subagent-steps.js +2 -2
- package/dist/agents/subagent.d.ts +4 -13
- package/dist/agents/subagent.js +94 -204
- package/dist/agents/tool-filter.d.ts +2 -2
- package/dist/brain/anthropic.d.ts +1 -1
- package/dist/brain/anthropic.js +7 -5
- package/dist/brain/open-responses.d.ts +1 -1
- package/dist/brain/open-responses.js +3 -2
- package/dist/brain/openai.d.ts +1 -1
- package/dist/brain/openai.js +3 -2
- package/dist/brain/request-route.d.ts +2 -2
- package/dist/brain/route-adjudicator.d.ts +16 -7
- package/dist/brain/route-adjudicator.js +23 -7
- package/dist/brain/route-conformance.js +23 -5
- package/dist/brain/stream-engine.js +9 -3
- package/dist/core/a2a.js +6 -8
- package/dist/core/allow-entry.d.ts +45 -26
- package/dist/core/allow-entry.js +48 -24
- package/dist/core/arg-summary.d.ts +8 -85
- package/dist/core/arg-summary.js +4 -386
- package/dist/core/ask-origin.d.ts +35 -33
- package/dist/core/ask-origin.js +9 -11
- package/dist/core/checkpoint-requirements.d.ts +6 -0
- package/dist/core/checkpoint-requirements.js +1 -0
- package/dist/core/checkpoint-store.d.ts +15 -34
- package/dist/core/checkpoint-store.js +5 -4
- package/dist/core/classifier-unavailable-arm.d.ts +12 -8
- package/dist/core/classifier-unavailable-arm.js +7 -2
- package/dist/core/credential-vocabulary.d.ts +3 -2
- package/dist/core/credential-vocabulary.js +27 -18
- package/dist/core/delegation-frames.d.ts +40 -15
- package/dist/core/delegation-frames.js +34 -0
- package/dist/core/engine-notice.d.ts +35 -11
- package/dist/core/gate-fold.js +9 -7
- package/dist/core/gate-lanes.js +6 -4
- package/dist/core/gate-outcome.d.ts +87 -11
- package/dist/core/gate-outcome.js +171 -61
- package/dist/core/governance-codes.d.ts +7 -1
- package/dist/core/governance-codes.js +9 -0
- package/dist/core/hooks.d.ts +4 -4
- package/dist/core/hooks.js +4 -3
- package/dist/core/mcp-connection-pool.d.ts +2 -0
- package/dist/core/mcp-connection-pool.js +2 -1
- package/dist/core/mcp-failure.js +2 -1
- package/dist/core/mcp-server-spec.d.ts +27 -5
- package/dist/core/mcp.d.ts +13 -38
- package/dist/core/mcp.js +28 -23
- package/dist/core/media-byte-cap.d.ts +25 -0
- package/dist/core/media-byte-cap.js +18 -2
- package/dist/core/memory-engine/consolidation-driver.js +1 -1
- package/dist/core/mode-policy.d.ts +25 -1
- package/dist/core/mode-policy.js +28 -13
- package/dist/core/own-property-receipt.d.ts +18 -0
- package/dist/core/own-property-receipt.js +39 -0
- package/dist/core/permission-rule-model.d.ts +32 -11
- package/dist/core/permission-rule-model.js +12 -7
- package/dist/core/persisted-rule-arms.d.ts +3 -3
- package/dist/core/prompt-section-seat.d.ts +54 -0
- package/dist/core/prompt-section-seat.js +49 -0
- package/dist/core/protocol-http-auth.d.ts +44 -0
- package/dist/core/protocol-http-auth.js +45 -0
- package/dist/core/read-face-seats.d.ts +30 -0
- package/dist/core/read-face-seats.js +38 -0
- package/dist/core/redaction-passes.d.ts +82 -0
- package/dist/core/redaction-passes.js +378 -0
- package/dist/core/remote-env.d.ts +4 -3
- package/dist/core/resume-startup-cause.d.ts +56 -0
- package/dist/core/resume-startup-cause.js +43 -0
- package/dist/core/rule-denial-sentence.d.ts +2 -2
- package/dist/core/runner/announce-once-ledger.js +2 -2
- package/dist/core/runner/classifier-ladder.d.ts +17 -13
- package/dist/core/runner/classifier-ladder.js +12 -10
- package/dist/core/runner/construction-doors.js +9 -20
- package/dist/core/runner/contracts.d.ts +4 -4
- package/dist/core/runner/gate-exit.js +1 -1
- package/dist/core/runner/memory-consolidation-write-judge.d.ts +53 -9
- package/dist/core/runner/memory-consolidation-write-judge.js +39 -1
- package/dist/core/runner/permission-rule-lanes.d.ts +8 -13
- package/dist/core/runner/person-path-rows.d.ts +4 -3
- package/dist/core/runner/policy-throw.js +4 -4
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -3
- package/dist/core/runner/prepare-caps-and-workflow.js +8 -6
- package/dist/core/runner/prepare-gate-stations.js +3 -3
- package/dist/core/runner/prepare-hands-readface.js +1 -19
- package/dist/core/runner/prepare-inherited-gate.js +10 -9
- package/dist/core/runner/prepare-policy-chain.d.ts +3 -5
- package/dist/core/runner/prepare-policy-chain.js +10 -13
- package/dist/core/runner/prepare-project-context.d.ts +4 -4
- package/dist/core/runner/prepare-project-context.js +2 -1
- package/dist/core/runner/prepare-prompt-assembly.js +1 -0
- package/dist/core/runner/prepare-prompt-inputs.d.ts +1 -1
- package/dist/core/runner/prepare-prompt-inputs.js +4 -4
- package/dist/core/runner/prepare-protocol-tools.d.ts +4 -3
- package/dist/core/runner/prepare-protocol-tools.js +3 -2
- package/dist/core/runner/prepare-task.js +6 -4
- package/dist/core/runner/prepare-turn-wiring.d.ts +16 -13
- package/dist/core/runner/prepare-turn-wiring.js +8 -7
- package/dist/core/runner/run-harness-handlers.js +2 -2
- package/dist/core/runner/run-leg.js +10 -9
- package/dist/core/runner/run-settle-and-teardown.js +5 -5
- package/dist/core/runner/run-turn-boundary.js +3 -2
- package/dist/core/runner/runtask.d.ts +1 -2
- package/dist/core/runner/runtask.js +8 -12
- package/dist/core/runner/session-rule-policy.d.ts +15 -11
- package/dist/core/runner/session-rule-policy.js +43 -33
- package/dist/core/runner/stream-lifecycle-verbs.js +2 -2
- package/dist/core/runner/stream-settle-backstop.js +2 -1
- package/dist/core/runner/sub-agent-deny-lists.d.ts +12 -8
- package/dist/core/runner/sub-agent-deny-lists.js +23 -12
- package/dist/core/runner/terminal-account.js +3 -3
- package/dist/core/runner/tool-output-projection.js +3 -8
- package/dist/core/runner/tool-result-media-seat.d.ts +19 -0
- package/dist/core/runner/tool-result-media-seat.js +13 -0
- package/dist/core/runner/turn-attachments.d.ts +13 -12
- package/dist/core/runner/turn-attachments.js +11 -7
- package/dist/core/runner-deps.d.ts +4 -3
- package/dist/core/safety-merge-corpus.js +21 -21
- package/dist/core/secret-env.js +1 -1
- package/dist/core/session-policy-store.d.ts +89 -72
- package/dist/core/session-policy-store.js +100 -93
- package/dist/core/shell-read-silence.d.ts +38 -4
- package/dist/core/shell-read-silence.js +102 -0
- package/dist/core/side-query.d.ts +7 -8
- package/dist/core/skill-grant-binding.d.ts +32 -11
- package/dist/core/skill-grant-binding.js +11 -3
- package/dist/core/skill-grant.d.ts +4 -3
- package/dist/core/skill-grant.js +5 -4
- package/dist/core/skills-directory.d.ts +12 -1
- package/dist/core/skills-directory.js +17 -8
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +3 -0
- package/dist/core/swappable-deps.d.ts +15 -1
- package/dist/core/swappable-deps.js +25 -1
- package/dist/core/synthetic-read-verdict.d.ts +16 -14
- package/dist/core/synthetic-read-verdict.js +28 -8
- package/dist/core/task-notification.d.ts +13 -3
- package/dist/core/task-notification.js +12 -1
- package/dist/core/task-spec.d.ts +14 -15
- package/dist/core/tool-name-readings.d.ts +0 -4
- package/dist/core/tool-name-readings.js +0 -1
- package/dist/core/tool-policy.d.ts +40 -9
- package/dist/core/tool-policy.js +25 -7
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/core/untrusted-egress.d.ts +60 -11
- package/dist/core/untrusted-egress.js +120 -9
- package/dist/core/untrusted-text.js +16 -0
- package/dist/core/wiring-manifest.d.ts +22 -8
- package/dist/core/wiring-manifest.js +12 -4
- package/dist/engine/harness/agent-harness.d.ts +2 -1
- package/dist/engine/harness/agent-harness.js +4 -1
- package/dist/engine/harness/types.d.ts +3 -0
- package/dist/engine/llm/types.d.ts +7 -9
- package/dist/engine/loop/agent-loop.d.ts +8 -3
- package/dist/engine/loop/agent-loop.js +26 -9
- package/dist/engine/loop/types.d.ts +10 -0
- package/dist/index.d.ts +17 -5
- package/dist/index.js +14 -5
- package/dist/orchestration/run-workflow-tool.js +8 -8
- package/dist/orchestration/workflow.js +2 -2
- package/dist/prompt-assembly/assemble.d.ts +3 -1
- package/dist/prompt-assembly/assemble.js +6 -0
- package/dist/prompts/cowork.d.ts +42 -0
- package/dist/prompts/cowork.js +529 -0
- package/dist/prompts/default.d.ts +14 -1
- package/dist/prompts/default.js +3 -0
- package/dist/stores/file/session-policy-store.d.ts +6 -3
- package/dist/stores/file/session-policy-store.js +5 -16
- package/dist/tools/artifact/artifact-tool.js +2 -0
- package/dist/tools/fs/image-admission.d.ts +57 -4
- package/dist/tools/fs/image-admission.js +85 -0
- package/dist/tools/fs/path-containment.d.ts +2 -0
- package/dist/tools/fs/path-containment.js +5 -0
- package/dist/tools/fs/shell-read-check.d.ts +31 -6
- package/dist/tools/fs/shell-read-check.js +2 -4
- package/dist/tools/fs/shell-read-environment.d.ts +10 -25
- package/dist/tools/fs/shell-read-environment.js +6 -18
- package/dist/tools/fs/shell-read-establish.d.ts +3 -2
- package/dist/tools/fs/shell-read-establish.js +5 -5
- package/dist/tools/fs/shell-read-judge.d.ts +38 -8
- package/dist/tools/fs/shell-read-judge.js +12 -10
- package/dist/tools/web.js +2 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +94 -10
- package/wire-changes/core@7.50.2.json +42 -0
- package/wire-changes/core@7.51.0.json +2421 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,278 @@
|
|
|
1
|
+
## 7.51.0 — 2026-10-14
|
|
2
|
+
|
|
3
|
+
### A tool list naming a tool nobody answers to no longer fails the run — the entry is kept and told once; only a spelling no tool name can have is refused (#1736; **BREAKING**: behavior change, the reversal of 7.49.0 hard-breaking ①; hard-breaking: one, open to veto (C-R449); closed sets: none; exports: none; members: none)
|
|
4
|
+
|
|
5
|
+
**Hard-breaking (open to veto, C-R449).** (1) **What changed:** a name no tool of the task answers to in `TaskSpec.allowTools` / `excludeTools`, an agent definition's `allowTools` / `denyTools` (`TaskSpec.agents`, a delegation tool's `agents`) or a delegation tool's tool-level lists no longer ends the run at prepare with `config.agent.unknown_tool` / `config.tool_face_control` (`detail.reason: "unknown_name"`): the run starts, the entry stays in the list as written, and the host is told once per entry on `RunnerDeps.onError` (phase `"config"`); `detail.reason: "unknown_name"` now arrives only for a spelling no tool name can have. (2) **Who breaks:** a host or a test that pinned the refusal or the 400 it produced — server's acceptance-door copy (`judgeDeniedToolNames` and the 400 it answers) now refuses what the engine runs; cli L-1388 / B-220 (the KL0228 disclosure and the shell's own lazy deny rule) and client-core (its half, named by cli) carry a workaround for the refusal. (3) **How to migrate:** align the server door to the engine's reading (refuse a spelling no tool name can have, nothing else) or retire it; retire the shell-side lazy rule and the disclosure; read the account on the config phase where the refusal was read.
|
|
6
|
+
|
|
7
|
+
- **Before (v7.50.2 — the same since 7.49.0 hard-breaking ①).** `judgeToolNameList` (`src/core/runner/sub-agent-deny-lists.ts`) refused the whole run at prepare when an entry named no tool of the task's universe, or a stale spelling of one (a case twin, a retired name, an unmintable protocol peer); only a delegation tool whose pool the engine could not list (an `extraTools` factory with no declared roster) turned that into a notice. A user's `.claude/agents/*.md` projected as `TaskSpec.agents` with `denyTools: ["mcp__nosuch__x"]` (a server this deployment does not connect) made every request of the session fail before the model ran, with or without the agent being used.
|
|
8
|
+
- **Now.** The reference's shape (CC 2.1.296 filters an agent's `tools` by name; a name nothing answers never matches and never fails the run): such an entry is kept as written and told once per entry, naming the agent, the entry, the cause and what the entry does — `TaskSpec.agents: agent "deny-mcp" declares tool "mcp__nosuch__x" in its denied tools: no MCP server named "nosuch" is declared for this task, so it denies nothing today and stays in force for a tool of that name mounted later. If the name is misspelt, fix the entry.` (a name of no protocol: `no tool of this task answers to that name`; the allowed side: `it grants nothing today and stays in force …`; a near miss or a retired name carries the spelling to write). A server the task declares answers for its namespace whether or not it is reachable or lists that tool — no account, as before. The compiled selector (`compileToolSelector`, `src/agents/tool-filter.ts`) reads every entry by name and drops none, so a kept entry reaches a tool of that name wherever it mounts: the valve of a later run's or a resumed leg's prepare, an MCP refresh or re-dial of a declared server (`mcp-redial.ts`), a child's own prepare over what its delegation tool injected at the spawn. An allowed list names tools: a rule form there (`Bash(git:*)`, `Echo(x)`, `Echo()`) is matched as written and grants nothing — never widened to the whole tool — and is told once (`… declares "Bash(git:*)" in its allowed tools: an allowed list names tools, not content, so this entry grants nothing — name the tool "Bash" to grant it, or move the content rule to a denied list or a permission rule.`); it was refused as an unknown name while the pool could be listed. The denied side keeps the reference's whole-tool reading of `Tool(content)`. Still refused: a spelling no tool name can have, not a rule form either (whitespace, an unbalanced parenthesis, an empty or invisible-character name — `grammar`: `"Bash("`, `"a b"`, `""`), a value that is not a list, an entry that is not a string, `*` on a denied list, `mcp__*` on an allowed list, a denylist reaching a tool the leg relies on. The workflow leg's reading (`deniedToolNamesOf`) is unchanged: grammar refused, any other name denied as written. Rules: the judgment loses two refusal arms (the unknown name, the stale name while the pool can be listed) and the listable / unlistable split; it gains none.
|
|
9
|
+
- **Who is hurt.** A deployment that relied on the refusal to catch a misspelt deny gets a started run whose entry guards nothing until a tool of that spelling mounts; the account names the entry (and, for a near miss or a retired name, the spelling to write). See the hard-breaking paragraph for the pinned refusal.
|
|
10
|
+
- **Compensation.** The account on the config phase, once per session (the channel the unlistable-pool notice already used — no new code); the grammar refusal and every structural refusal unchanged. Consumers named: server (the acceptance-door copy `judgeDeniedToolNames` / its 400: align to the grammar-only refusal or retire — consumer line), cli L-1388 / B-220 (retire the KL0228 disclosure and the shell's lazy deny rule), client-core (its half, named by cli).
|
|
11
|
+
- **Pins.** `test/backlog1736-unknown-tool-name-lazy.test.ts` (U1–U8: an undeclared server's tool in an agent's deny, unused and used; the allow side; a typo of a built-in on both sides; grammar and the structural refusals; the run's own lists; the lazy reach — a tool a delegation tool injects at the spawn is hidden by the deny and admitted by the allow, a later run that declares the server unmounts the denied tool and keeps its sibling; the workflow leg's reading; U8 controls — a declared server without that tool, a declared unreachable server: no account). Re-pinned to the new law: `tool-face-controls` ([1043]③, #297 control), `backlog1623-tool-selector` (③, ③b, ③c, ⑦), `backlog1433b-one-universe` (⑦), `backlog1432-deny-universe` (①, ④), `backlog1377-subagent-deny-names-one-judgment` (① ② ③ ⑤ ⑦ ⑧ ⑨ ⑮), `backlog1385-entry-grammar-one-render` (⑥, ⑦), `per-task-agents` ([901], [886]①c), `prepare-listings-transcript` (agents audit).
|
|
12
|
+
- **Known limits.** A tool of a server the deployment does not declare cannot arrive in the same run — a caller tool may not carry `__` (`src/core/runner/prepare-safety-scan.ts:189`) and a refresh or re-dial reaches declared servers only — so a kept `mcp__<undeclared>__…` entry takes effect from the first run (or resumed leg) whose deployment declares that server (pinned, U6). `detail.reason: "unknown_name"` keeps its word for the grammar refusal (no closed-set change).
|
|
13
|
+
|
|
14
|
+
### A background child's fleet terminal is the projection of its completion frame — `result` rides it, redacted before the cut; the terminal word set is one exported table (#1482 core half, #1528; #1698 root located; behavior change: the report in a background completion frame is redacted, one fleet summary corrected; closed sets: none; exports: +2 names, 0 removed; members: `BackgroundChildEvent.result?`)
|
|
15
|
+
|
|
16
|
+
- **Before (v7.50.0).** Each of the eight terminal sites of the background, fork and revived lanes (settle, thrown run, session-release reap) built the `BackgroundChildEvent` terminal by hand beside the `<task-notification>` frame, mirroring `seq` / `stoppedBy` / `completionId` / `summary` / residuals / `usage` field by field. The fleet terminal carried no report, so a host that wakes an idle conversation from it (server `bg_notification`) had either the summary alone or a second, differently-cut copy from the task registry. The revived cycle's thrown-run terminal said `(resumed) failed` with no reason while its frame named it, and the thrown-run legs read the clock once per face, so a slow observer could make the two summaries disagree. The notification terminal words were a module-private table (`completed` / `failed` / `killed` / `cancelled`), copied by the notify verb's validator and by consumers.
|
|
17
|
+
- **Now.** One function, `backgroundChildTerminal(frame, row)` (`src/core/delegation-frames.ts`), builds every fleet terminal from the frame the same settle routes: status, cycle (the frame's, else the row's registry generation), `stoppedBy`, `summary`, **`result`** (new: the frame's report, the same string cut once at the frame's bound with its disclosure), residuals, `resumable`, `usage` (re-spelled `tool_uses` → `toolUses`, `duration_ms` → `durationMs`) and `completionId`. The row supplies only the row facts (task id, lifetime scope, session and transcript handles, delegating call). The frame's report is redacted BEFORE it is cut (`notifyResultField`; the redaction the task row's copy already had), so the parent's `<result>`, the fleet terminal and the row carry one report under one rule, and a credential straddling the 2 000-character cut is never half-shown. `TASK_NOTIFICATION_TERMINAL_STATUSES` (frozen) and `TaskNotificationTerminalStatus` are exported; `TaskNotificationStatus` is derived from them, `isTerminalTaskNotification` and `TaskStream.notify`'s status check read them.
|
|
18
|
+
- **Who is hurt.** Nobody loses a field; a consumer that compared the revived cycle's fleet summary to the old reason-less text reads the frame's sentence now. A parent model now reads a background / fork / revived child's report redacted in the completion notification, as `TaskOutput` already showed it; nobody legitimate is hurt — only a parent that relied on a raw credential in a child's report. #1698 (two `<task-notification>` blocks for one completion in one request): the second block is the host's own short form (task id / status / summary only), not an engine mint — read on test's evidence (sema-test `repro/_cc-agent-teams-comparison-material/lifecycle-cc296-vs-sema1147/evidence/sema-tui-r3/requests.jsonl`); it ends when client-core retires the short form (CC-505).
|
|
19
|
+
- **Compensation.** None needed (additive). Consumers named: server S-1296 (`fleet-bus.ts` `handleTerminal` → `bg_notification` reads `e.result` instead of the registry), sdk (type lockstep for `bg_notification.result`), client-core CC-505 and cli (generate the terminal word set from `TASK_NOTIFICATION_TERMINAL_STATUSES` instead of the hand-kept `CORE_TASK_NOTIFICATION_TERMINAL_STATUSES` copy).
|
|
20
|
+
- **Pins.** `test/backlog1482-child-completion-record.test.ts` (C1–C9; C9 = a credential-bearing report, one token straddling the cut); `test/design508-agent-wire-batch.test.ts` G3c re-pinned on the projection (the camel-case `delegatedGranularityFields` twin is retired — module-level, not on the package index).
|
|
21
|
+
- **Known limits.** The engine half of "a completion wakes an idle conversation with no second body" is not in this release: a run that opens with no objective and delivers only the parked frames needs the empty-objective door (`src/core/runner/prepare-config-doors.ts:648`), the objective's human-input frame (`src/core/runner/run-leg.ts:636-637`) and the turn-open drain (`src/core/runner/run-identity-wiring.ts:328`) — until then hosts keep their wake path. The workflow lane's per-agent `wa*` terminals (`src/orchestration/workflow.ts:2209`) carry no paired notification and are not projected.
|
|
22
|
+
|
|
23
|
+
### One reading of why a resume could not run its approved action: a configuration refusal reopens as `config_refused`, and the parked-resume receipt names the cause (#1448, #1161; **BREAKING**: closed-set addition; behavior change; hard-breaking: one, open to veto; closed sets: `ReopenReason` +`config_refused`; exports: +5 names, 0 removed; members: parked-resume receipt `details.cause?`)
|
|
24
|
+
|
|
25
|
+
**Hard-breaking (open to veto).** (1) **What changed:** `ReopenReason` (and `REOPEN_REASONS`, now exported) gains `config_refused`: a resumed leg whose configuration was refused after the claim (a `config.*` door, the pre-claim configuration screen's code, a re-supplied constraint chain that is missing or does not match its installation record) returns its consumed checkpoint to pending under `config_refused` — a fresh decision is accepted, like `tool_unavailable` — where it used to reopen as `env_failed`, a system retry that must replay the recorded decision. (2) **Who breaks:** a consumer with an exhaustive switch or a closed-set pin on the reopen reason — server `src/plugins/checkpoint-store-sql.ts:63` / `:1264` (stores and reads the column; `VARCHAR(32)` fits) and `src/http/resume-legs.ts:89-98` (its own copy of the reopen-class code family); cli, client-core and sdk carry no reader on origin/main. (3) **How to migrate:** add the member with fresh-decision semantics like `tool_unavailable` (the row is pending; the person may decide again, or resume the same decision once the configuration is corrected); do not auto-retry it; read the reason through `resumeStartupCauseOf` / `reopenReasonOf` rather than a copied code list.
|
|
26
|
+
|
|
27
|
+
- **Before (v7.50.0).** The thrown exit picked the reopen reason inline (two tool codes ⇒ `tool_unavailable`, every other code ⇒ `env_failed`); the returned exit always said `env_failed`. A permanent configuration refusal after the claim therefore came back as a system retry: the next resume had to replay the recorded decision, refused any other with "reopened after an env-restore failure (env_failed)", and the same configuration failed it again. The parked-resume startup receipt carried `details: { error: "parked_resume.startup_failed", task_id }` only; the drive's wrappers dropped the failure's code, so its two opposite outcomes (the decision can succeed after a correction vs. this approval can never run) were told apart only by prose.
|
|
28
|
+
- **Now.** `src/core/resume-startup-cause.ts` holds one closed cause set, `RESUME_STARTUP_CAUSES` = `environment` / `session_gone` / `tool_unavailable` / `configuration` / `decision_rejected`, read off a failure's CODE by `resumeStartupCauseOf` (a code the table does not name is absent — not known), and one map to the reopen reason, `reopenReasonOf` (absent ⇒ `env_failed`, as before). Both reopen exits read it; the returned exit's sentence names the reason (the `env_failed` sentence is unchanged). The parked-resume receipt carries `details.cause` from the same table; the drive keeps the failure's code on its wrappers (`cause` chain), speaks `resume.session_not_found` for the preflight's authoritative session miss and `resume.tool_unavailable` for an inherited tool it cannot hand over.
|
|
29
|
+
- **Who is hurt.** See the hard-breaking paragraph. A host that auto-retried every reopened approval keeps retrying a configuration refusal only if it ignores the new reason.
|
|
30
|
+
- **Compensation.** The decision is never lost: the row is pending either way, and a corrected resume (or a fresh decision) redeems it. Consumers named: server (S ticket: 422 `parked_resume.startup_failed` body passes `details.cause` through as `cause`, unrewritten, absent when absent; `isCheckpointReopenedFailure` reads the table), sdk (`ParkedResumeStartupError.startupCause` already reads the body's `cause`), client-core (CC-225 / CC-244: the word set is `RESUME_STARTUP_CAUSES`).
|
|
31
|
+
- **Pins.** `test/backlog1448-resume-startup-cause.test.ts` (R1–R10: the table; a post-claim `config.reserved_tool_name` reopens as `config_refused` and takes a fresh decision; the `env_failed` replay guard unchanged; the receipt's cause for each of the five causes and absent for an unnamed code).
|
|
32
|
+
- **Known limits.** The returned exit's configuration arm has no real fixture (no returned failure carries a `config.*` code before the action starts today; inferred). `src/core/runner/run-leg.ts:991` still reads the tool-unavailable family itself (to rethrow to the thrown exit), and `runtask.ts`'s post-claim abort arm reopens `env_failed` by construction. The drive's own refusals with no code (a claim no longer seated, a row that moved under the resume) leave the cause absent.
|
|
33
|
+
|
|
34
|
+
- **Reopen reason of the decision-rejected codes.** `checkpoint.gate_mismatch` / `checkpoint.invalid_outcome` / `resume.constraint_rejected` / `resume.constraint_unprojectable` now read as cause `decision_rejected` and reopen as `constraint_rejected` (fresh decision), where the base reopened every non-tool failure as `env_failed` (replay); the reopen exit is reached only when the action never started, so this arm is believed unreachable in practice (no fixture carries it) — disclosed as a reading change.
|
|
35
|
+
|
|
36
|
+
### One approval route; a child's auto-mode face rides the chain; the entry-floor clamp's notice row (#1168, #1571, #1493, #1527; **BREAKING**; behavior change; hard-breaking: three, each open to veto; closed sets: notice catalog +`permission.entry_mode_clamped`; exports: +3 names (`APPROVAL_ROUTES`, `approvalRouteOf`, `type ApprovalRoute`), 0 removed; members: `ClassifierUnavailableSeat.approvalSeat` → `route`, `ClassifierUnavailableInfo.disposition` → `route`, `InheritedGate.autoModeFace?` added, `InheritedGate extends InheritedAutoModeFace`)
|
|
37
|
+
|
|
38
|
+
**Summary.** "Where does an ask a station hands on go" is one decision with three words — `approvalRouteOf` → `live_approver` / `durable_park` / `none` (new export `APPROVAL_ROUTES`, `approvalRouteOf`, `type ApprovalRoute`) — read by both auto-mode classifier stations (the leg's gate from `wiring_manifest.ask`, an ancestor's frozen approver through the same table with no park), and the fork, its observer, the `classifier.unavailable` detail's `disposition` (now the route's projection) and the notice's sentence all read that one word.
|
|
39
|
+
|
|
40
|
+
**Hard-breaking (each open to veto).**
|
|
41
|
+
1. **`ClassifierUnavailableSeat.approvalSeat: boolean` is now `route: ApprovalRoute`, and `ClassifierUnavailableInfo.disposition` is now `route` (#1571).** What changed: a host driving `runToolGate` states where a no-ruling ask goes as a route word (`approvalRouteOf(wiring_manifest.ask)`), and its `onUnavailable` observer hears the route instead of the two-word disposition; only `live_approver` / `durable_park` are believed, anything else is nobody (a stale `approvalSeat: true` now reads as no seat and the call is refused — fail-closed). Who breaks: a host that hand-wires `ToolGateInput.autoMode.unavailable` or reads `ClassifierUnavailableInfo.disposition` (no neighbour does: `git grep` of server / cli / client-core / sdk origin/main finds neither member). How to migrate: `approvalSeat: x` → `route: x ? "live_approver" : "none"` (or `approvalRouteOf(manifest.ask)` for the leg's real seat); `info.disposition` → `info.route === "none" ? "denied" : "asked"`. The wire notice's `detail.disposition` is unchanged.
|
|
42
|
+
2. **`config.permission_mode_unavailable`'s `detail.requested` is the word the fold folded, and every fold of a child leg also reaches the parent's stream (#1493 a/b).** What changed: a child that requested nothing and inherited `auto` was told `permission mode "default" is not available … it runs under "default"` (`requested ?? "default"`); it now says `permission mode "auto" (inherited) is not available …` with `detail.requested: "auto"`, and the deployment's folds (`face_absent`, `operator_deny`) are announced on the parent's stream with `childSessionId` as the ceiling fold already was. Who breaks: a reader that took `detail.requested` as the caller's literal word on an inheriting child (it read `"default"`, a word nobody requested), or that counted one notice per child fold. How to migrate: read `requested` as "the mode that is not available on this leg"; dedupe a child's two lines by `childSessionId` as for the ceiling fold.
|
|
43
|
+
3. **The notice catalog gains `permission.entry_mode_clamped` (#1527; additive closed-set member).** What changed: `ENGINE_NOTICE_CODES` has one more member (90 → 91 here; 92 with #1070's `config.read_deny_swapped`), audience `user`, minter `server`; this engine never mints it. Who is hurt: a consumer with an exhaustive switch or a frozen copy of the notice catalog (client-core `engineNoticeTables.ts`, cli `engineNoticeCodes.ts`, sdk's catalog test) compiles or asserts red until it adds the row. Compensation: regenerate the table / add the row; an unknown code is otherwise rendered by its `message`.
|
|
44
|
+
|
|
45
|
+
**Before (v7.50.0).** The gate's station passed a boolean "reaches a person" and the inherited station a different boolean (`isLiveApproverSeat`); the notice said "handed on to a person (a live approver or a durable park)" whichever it was, and "this run has no approval seat" when there was none. A delegated child on a runner whose own deps wired no auto-mode face folded `auto` to `default` (`face_absent`) even under a parent classifying in `auto` — every child call became a card; the fold notice reached only the child's stream and named `"default"` as the unavailable word. The serving layer's entry-floor clamp (`AUTONOMY=plan`) had no catalogued notice code to disclose it with.
|
|
46
|
+
|
|
47
|
+
**Now.**
|
|
48
|
+
- The sentence names the route taken at the fork (never whether a park then commits): "handed on to this run's live approver", "handed on to this run's durable approval park, for a person to answer later", or "this run has no approver and no park facility, so the call was refused" (`that layer's …` at the inherited station). A run that declared `approvalReach: "none"` takes the `none` route only when no park lane is armed: an armed durable park is the deployment's own statement that an answer can wait for a person, so such a run parks (`durable_park`) — the declaration silences the live callback, not the park. A print / SDK-stdio lane whose approver callback cannot reach a person declares `approvalReach: "none"` (client-core `approvalReachDeclaration`); the route is then `none` and the engine — not the lane — says the call was refused.
|
|
49
|
+
- `InheritedGate.autoModeFace` (new optional type key; opaque half, never serialized; present only when the ancestor's deps — or its own chain — carry an auto-mode face): `autoModeFaceOf(deps, chain)` is the one reading of "which face does this leg arm from" — the leg's own `RunnerDeps.autoMode`, else its ancestor's. A child whose runner wires none arms its own classifier (its own transcript, model and denial tracker) from the ancestor's deployment face; its own face wins where it has one; `RuntimeCaps.autoMode: false` still folds it.
|
|
50
|
+
- `permission.entry_mode_clamped` joins the notice catalog (closed-set addition; audience `user`, `NOTICE_MINTER` `"server"`, `detail: { requested, effective, ceiling, sessionId, runId }`, once per run): the serving layer mints it where its entry floor clamps (server S-1306); this engine never mints it — its own folds stay `config.permission_mode_unavailable`. Readers: client-core / cli generated notice tables.
|
|
51
|
+
- C-R138 (bypass: a card nobody answers parks on a durable seat, else is refused) holds on every K-ASK cell, including an approver that answers `unavailable` (`approver_unavailable`): pinned, no behaviour change.
|
|
52
|
+
|
|
53
|
+
**Behavior change (narrowing of the person's reach).** A delegated child whose own runner wires no auto-mode face is classified under its ancestor's face (#1493 c): calls that went to a person in v7.50.0 because the child folded to `default` are now answered by the classifier like the parent's own calls; the deployment's deny bit (`RuntimeCaps.autoMode: false`) still wins. **Who is hurt.** A host that hand-wires the gate's classifier seat or reads the observer's `disposition` (hard-breaking 1). A deployment that relied on a face-less child runner refusing `auto` now gets a classifying child — the parent's face is the deployment's own trust gate; deny it per principal with `RuntimeCaps.autoMode: false`. **Compensation.** Fail-closed screen on the route word; the wire `disposition` keeps its two words; the parent stream now carries every child fold.
|
|
54
|
+
|
|
55
|
+
**Pins.** `backlog1571-approval-route` (R1–R6), `backlog1493-child-auto-face` (A1, B1, B2, C1, C1′, C1″, C2 cross-runner through the delegation tool), `backlog1527-entry-mode-clamped` (E1, E2), `backlog1168-bypass-unanswered` (+ U cells); `backlog1125` / `backlog1353` / `backlog1214` / `backlog1240` / `governance-codes` re-pinned to the route words.
|
|
56
|
+
|
|
57
|
+
**Known limits.** The face rides the opaque half only: a child redeemed in another process from a checkpoint re-arms only if its own runner wires a face (else `face_absent`, announced). The spawn-review and hand-back reviews do not consult the route — they stay fail-open and loud by ruling (C-R289, #1447, 7.52.0). The UX-7 `-p` cell turns `denied` only once the print lane declares `approvalReach: "none"` (cli).
|
|
58
|
+
|
|
59
|
+
### One rule-entry reader: a session's rule buckets read the entry grammar, one write judge, one refusal family, one effective-entry law (#1474 / #1480 / #1497 / #1522 / #1501; **BREAKING**; behavior change; hard-breaking: three, each open to veto; behaviour narrowing: one; closed sets: `SessionPolicyError.code` −`legacy_tool_name` +`inert_entry` +`invalid_shape`; exports: +11 names, −4 removed)
|
|
60
|
+
|
|
61
|
+
**The rule.** What a rule entry means under a direction is answered by ONE exported reader, `readRuleEntry(raw, behavior, roster?)` (the row it compiles to, or the reason it reads as nothing with the facts a sentence names); every station that compiles, validates, compares, matches or names a list entry reads that answer — the entry lists (`compileAllowList`), a session's `toolDeny` / `toolAllow` (now entries of that grammar, matched by the one list matcher a skill's `disallowed-tools` and the allow lists use — `toolAllow` in the matcher's `narrowing` posture, a list outside of which nothing runs), the session store's write judge and its refusal sentences, and the tighten comparison (the entries as the layer enforces them, on both sides). Rules removed: the session lane's whole-name set and covering matcher, the argv[0] command buckets, the store's tool-name judge with its own refusal code, the per-field `toolDeny` exemption in the tighten check, the `TypeError` shape refusal beside the `SessionPolicyError` family, the file store's own shape check, and the three stores' hand-copied write sequence (one judge now).
|
|
62
|
+
|
|
63
|
+
**Hard-breaking (each open to veto).**
|
|
64
|
+
1. **A session's `toolDeny` / `toolAllow` entries are rule entries of the one grammar; `commandAllow` / `commandDeny` are retired (#1480; C-R316).** (1) **What changed:** `toolDeny: ["Bash(git push:*)"]` refuses every call whose command line runs `git push` (a pipe or a connector chain included; a line that cannot be read against the rule is refused too) — it was stored and matched nothing; `toolAllow` entries admit what the allow-list matcher admits in the `narrowing` posture (a shell command rule — every segment of the line named by an entry, see the narrowing below —, a path rule judged where the target resolves, a namespace, a tool name) and every other call is refused; a path rule (`Edit(//etc/**)`) is judged on every view of the call's target. A rules value carrying `commandAllow` / `commandDeny` — or any key that is no bucket — is refused at the write (`invalid_shape`, the sentence naming the `Bash(<command>)` form), is a malformed row on the file store's read (`corrupt`), and, served to a run from a store or a parked checkpoint's ancestor snapshot, refuses every call (the sentence says the rules hold a key that is no rule bucket; no stored text reaches the model). An entry that reads as nothing (a row an older release stored) guards and grants nothing; the prepare-time audit names it (a stale tool name fails prepare, as before). (2) **Who breaks:** a writer of `commandAllow` / `commandDeny` (server `session-policy-wire.ts` `RULE_FIELDS`, `test/core748-pickup.test.ts`, `session-policy-http.test.ts`; client-core `hitl/sessionPolicyWire.ts:100`, `sessionPolicyDeliverable.ts`, `scripts/run-session-policy-wire-test.mjs`; cli `SessionRecordTab.tsx:49`, `sessionPolicyWire.ts`, `safety-merge.conformance.test.ts`; sdk `types.ts:5098`, `spec/openapi.yaml:13754 / 13784`, `test/spec.test.ts:308`); a stored session row or a parked checkpoint carrying one (it no longer runs until rewritten); a `commandAllow` that narrowed only the shell while every other tool stayed free — the grammar has no shell-only allowlist, `toolAllow` narrows the whole roster; a `toolAllow` entry written in rule form that used to admit nothing (it now admits what it names). (3) **How to migrate:** `commandDeny: ["rm"]` → `toolDeny: ["Bash(rm:*)"]`; `commandAllow: ["git"]` → `toolAllow: ["Bash(git:*)", …every other tool the session may use]`; a stored row is rewritten by an operator (the file store refuses the old row on read: fix or delete the file, then write the new form).
|
|
65
|
+
2. **The store's refusals are one family (#1497).** (1) **What changed:** a rules value whose shape is wrong (a bucket that is not a list of strings, an `allowDirs` blank, a key that is no bucket, a value that is not an object) is refused with `SessionPolicyError` `invalid_shape`, `detail { field, got }` (the TYPE, never the value) — it was a `TypeError`; an entry that reads as nothing (any reader reason — a stale tool name, `*`, `mcp__*`, a spelling the grammar refuses, a rule form the tool has not, an empty or blank entry) is refused with `inert_entry`, `detail { field, entry, reason, verdict?, guidance?, currentName? }` (the reader's own reading) — it was `legacy_tool_name` with `{ field, name, verdict, guidance?, currentName? }`, and only for stale names (the rest were stored). (2) **Who breaks:** server `sessionPolicyErrorHttp` (exhaustive over the code: −`legacy_tool_name`, +`inert_entry`, +`invalid_shape`), `session-policy-wire.ts` reading `e.detail.name` and `e instanceof TypeError` (`:83`, `:152`), and the wire's `rules.legacy_tool_name` / `request.field_invalid` mapping; client-core's and cli's projections of `rules.legacy_tool_name`; sdk's error code list. (3) **How to migrate:** map `invalid_shape` and `inert_entry` (both a caller's 400); read `detail.entry` for `detail.name`, `detail.reason` beside `detail.verdict`.
|
|
66
|
+
3. **Retired exports (#1474 / #1497).** (1) **What changed:** `compileAllowEntry` (a lossy projection of the reading) → `readRuleEntry`; `assertSessionRuleNamesCurrent` and `stripRev` → `judgeSessionRulesWrite` (the whole write judgment; `normalizeRules` drops the store's stamps itself); `SessionRuleNameRefusal` → `SessionRuleEntryRefusal`. (2) **Who breaks** (`origin/main` grep, 10-14): `compileAllowEntry` — server `src/task-settings.ts` + 2 tests, client-core `src/permissionsDeclaration.ts` + 1 script, cli 2 manifests; `assertSessionRuleNamesCurrent` — server `src/plugins/session-policy-store-sql.ts:64`, `src/session-policy-wire.ts`, `src/namespaced-rule-name.ts` + 5 tests, sdk 2 tests; `stripRev` — server `session-policy-store-sql.ts:341`, `session-policy-wire.ts`, `session-sync.ts:49 / 271` + 2 tests; `SessionRuleNameRefusal` — server `src/session-policy-wire.ts` + 1 test, cli 1 manifest. (3) **How to migrate:** `readRuleEntry(raw, b, roster)` answers `{ kind: "row", compiled }` or `{ kind: "inert", reason, … }`; a durable store calls `judgeSessionRulesWrite(prior, rules, opts, mountedToolNames)` where it stamped after the four-step sequence, and reads its rows back through `readSessionRulesShape`; `normalizeRules(r)` for `normalizeRules(stripRev(r))`.
|
|
67
|
+
|
|
68
|
+
**Behaviour narrowing.** A session `toolAllow` is a narrowing: a command line is admitted only when EVERY segment is named by an entry — `toolAllow: ["Bash(git:*)"]` refuses `cat x` and `git status && cat x` alike, and the refusal names the segment no entry covers (`it is not in the session-rule allowlist (no entry covers "cat x")`). The persisted allow matcher takes the list's posture (`findAdmittingRule`'s call `posture: "grant" | "narrowing"`, absent = `grant`, set only by the session layer); a person's persisted allow row and the allow layer's lists are grants and keep the reference's per-segment form (a read-only segment of a compound allows itself), byte for byte. Who is hurt: a session list that relied on read-only segments riding free (none existed in v7.50.0 — a rule-form entry matched nothing and `commandAllow` refused every compound; the narrowing is against the shared matcher's grant reading). Hard-breaking: none (no exported type of the package root changes).
|
|
69
|
+
|
|
70
|
+
**Before (v7.50.0).** `toolDeny` / `toolAllow` were whole tool names in a set (a rule spelling was stored and matched nothing — the deny was silently open); command rules were argv[0] names in two more buckets (any compound command refused under any command rule); the write refused stale tool names (`legacy_tool_name`) and a non-list bucket (`TypeError`) and stored every other entry; removing an empty `toolDeny` entry was a loosening a non-operator could not make; `SHADOW_REASON` was module-private.
|
|
71
|
+
|
|
72
|
+
**Now.** See the rule and the hard-breaking items. The tighten comparison reads each rule bucket as the layer enforces it (`compileAllowList` over the reader): removing an entry that reads as nothing relaxes nothing and adding one widens nothing (#1522 — an owner takes out what an older release stored), and an allowlist a `*` voids admits nothing, so dropping the `*` is a widening. A session refusal names no stored text — no rule entry (the deny arm stays `tool "X" is denied by a session rule`) and, a narrowing of v7.50.0's sentence, no `allowDirs` directory (the confinement arm said `… outside the session-rule allowDirs (<the stored directories>)`; it now names the call's own target only — a stored directory can carry anything, newlines included, and the sentence reaches the model). The prepare-time name audit reads the names the entries speak for (`Bash(rm:*)` is a `Bash` rule, not an unmatched name; an entry that reads as nothing is named as written). The start-of-run notice's `detail.entries[]` for a stale tool name gains the judge's `guidance` (additive). New on the root: `readRuleEntry`, `RuleEntryReading`, `AllowEntryInertDetail`, `judgeSessionRulesWrite`, `readSessionRulesShape`, `SessionRuleShapeRefusal`, `SessionRuleEntryRefusal`, `SessionRuleEntryField`, and the card's per-class reason table `SHADOW_REASON` (frozen, one row per ask class) with its two readers `allowRuleShadowSentence` / `cardDisclosureOf` (#1501 / #1621 — client-core CC-447 ⑦, CC-609).
|
|
73
|
+
|
|
74
|
+
**Who is hurt.** The hard-breaking items' consumers; a session whose stored rows carry a command bucket (refused until rewritten — fail-closed); the narrowing's.
|
|
75
|
+
|
|
76
|
+
**Compensation.** The migration sentences above; every refusal names the bucket, the entry or the key and the way to write it.
|
|
77
|
+
|
|
78
|
+
**Pins.** `test/backlog1474-rule-entry-reader.test.ts` (R1 / S1–S8 / W1 / T1 / C1 — every behaviour cell red on 338054b24, the controls S2c / S6a / S6c green on both); re-pinned: `backlog7480-smallfix` (#1471 cells: `invalid_shape` / `inert_entry`), `backlog1156` / `backlog1355b` / `backlog1410` (`inert_entry`, `entry` / `reason` in the detail; an inert allow entry widens nothing), `backlog978-rule-denial-sentence` (a shell command rule's refusal and a shape fault's in the one grammar), `session-policy`, `file-session-policy-store`, `file-adoption-root`, `backlog1003-1000` (the shape reading's sentence is the file store's reason), `f012-sandbox-admission-domain`, `safety-merge-corpus` (command vectors in the entry grammar; two effective-entry vectors), `name-readings-oracle` (the session lane's own name site is retired — it reads through the list matcher's).
|
|
79
|
+
|
|
80
|
+
**Known limits** (`docs/KNOWN-LIMITS.md` KL-1480-a / -b / -c). A path rule in a session bucket follows no symlinked head of the rule (the leg's head resolution is not read for session rows — a deny on a linked head reaches the link's spelling, not the target's; an allow is only stricter); a session path rule refuses at the gate and does not join the engine's deny tables (search results under it are not withheld), the reach a skill's `disallowed-tools` path row has; the tighten comparison compares entries as written, so narrowing `toolAllow: ["Bash"]` to `["Bash(git:*)"]` reads as a widening (operator write); no allowlist narrows the shell alone (`toolAllow` narrows the whole roster); the policy rule compiler (`createPermissionRulePolicy` / `validatePermissionRules`, deny / ask with `param:pattern`) keeps its own per-rule reading — the second deny grammar is the rest of the cluster (7.52.0).
|
|
81
|
+
|
|
82
|
+
- **Members, contracts and the one loosening (review F3).** `SessionPermissionRules` loses `commandAllow?` / `commandDeny?` (a stored row or a write carrying either is refused by name); `SessionPolicyError.code` loses `legacy_tool_name` and gains `invalid_shape` / `inert_entry`; `normalizeRules` now refuses an unknown key as `invalid_shape` and throws `SessionPolicyError` where it dropped the key or threw a `TypeError` (server's session-policy store comment relying on the drop is now false — S-1291); the start-of-run name audit's `detail.entries[]` gains `guidance` (additive); `SAFETY_MERGE_CONFORMANCE_CORPUS` renames one vector (`command-allow-widened`) and drops the two `commandAllow` / `commandDeny` vectors. **One shape is looser than the retired `commandDeny`:** `commandDeny: ["curl"]` refused a program spelled by its path (`/usr/bin/curl x`); the rule grammar matches the command text (`Bash(curl:*)` is a text prefix, as the reference implementation reads it), so `toolDeny: ["Bash(curl:*)"]` admits `/usr/bin/curl x` — of 24 probed shapes the new reading is stricter on 16 and looser on this one; a session that needs the path-spelled program refused writes `Bash(/usr/bin/curl:*)` beside it until #1727 (7.52.0) decides whether the program word matches the command word's last path segment for session and persisted rows alike.
|
|
83
|
+
|
|
84
|
+
### One reading per approval-card fact: the card's two absence words are closed sets minted once, `governance_unavailable` folds into `rule_unreadable`, and the removal breaker is one row (#1515, #1534, #1688, #1702; **BREAKING**; behavior change; hard-breaking: three, open to veto — C-R445; closed sets: `AskClass` −`governance_unavailable`, `RuleOffersAbsence` −`shadowed` +`allow_outranked` +`ask_rule_matched`, new `BoundGrantAbsence`; exports: +4 names (`RULE_OFFERS_ABSENCES`, `RuleOffersAbsence`, `BOUND_GRANT_ABSENCES`, `BoundGrantAbsence`), 0 removed, `RULE_OFFERS_ABSENCE_BASELINE` keys changed; members: `AskRequest.boundGrantAbsence?`, `PendingAction.tool_approval.boundGrantAbsence?`, `CheckpointSummary.boundGrantAbsence?` — optional, additive)
|
|
85
|
+
|
|
86
|
+
**Hard-breaking (open to veto).** (1) **What changed:** `ASK_CLASSES` loses `governance_unavailable`: an organization snapshot that could not be read, a rule store or partition that could not be read, and a call that could not be read against the rows are one fact — whether a rule forbids the call is unknown — and carry the one class `rule_unreadable` (same row: bypass-immune, no classifier, no sandbox, no rule, no hook floor; #1702, C-R445). **Who breaks:** a consumer that reads `governance_unavailable` — an approval card's `askClass` / `classes`, `tool_end.gate.disposition.askClass` / `classes`, `MODE_ARBITRATION.governance_unavailable`, a table generated from `ASK_CLASSES` (server 3 sites, cli 3, client-core 7, sdk 3, test 66 by `git grep` on their main): the word never arrives again and the lookup is `undefined`. **How to migrate:** read `rule_unreadable` wherever `governance_unavailable` was read — no alias, no compat member; which source could not be read is said by the ask's `message` and the run's `permission.rule_store_unavailable` notice. (2) **What changed:** `ruleOffersAbsence: "shadowed"` is retired; an allow entry that matched and is outranked by the question is `"allow_outranked"`, an explicit `ask` rule that matched is `"ask_rule_matched"` — on the live `AskRequest` and the parked row, from the same one derivation; `RULE_OFFERS_ABSENCE_BASELINE` loses `shadowed` and gains the two (#1515). **Who breaks:** a surface that renders or switches on `"shadowed"` (cli's approval card, client-core's word tables) and a reader of `RULE_OFFERS_ABSENCE_BASELINE.shadowed`. **How to migrate:** render the two words from the baseline table — `allow_outranked` points at no rule-writing (another allow is outranked the same way), `ask_rule_matched` at the person's existing ask rules. (3) **What changed:** auto mode's classifier no longer answers a removal whose target the command text cannot resolve (`unverifiable_delete`; `MODE_ARBITRATION.unverifiable_delete.classifierEligible` `true` → `false`) — a person does, as for every removal the guard stops (#1534). **Who breaks:** an `auto` deployment that relied on the classifier clearing `rm -rf $UNSET/`-shaped removals or a removal after a base move the plan cannot follow: it now gets a card, or a refusal where no approver is wired. **How to migrate:** spell the removal target literally, or answer the card.
|
|
87
|
+
|
|
88
|
+
- **Before (v7.50.0).** The card's "why no standing rule" word `shadowed` covered two facts (an allow entry outranked; a person's ask rule), and its baseline sentence ("a rule you already have … keeps deciding; review your existing rules") was false for the first. The word's closed set was spelled literally four times (the request, the row, the offer factory, the baseline table). `boundGrantDigest` rode a `Skill` activation's card and nothing rode any other card, so a consumer told "binds no grant" from "binding lost" by the tool name. `governance_unavailable` and `rule_unreadable` had identical rows and one unreadable partition put both on one card. `unverifiable_delete` and `unverifiable_shell_text` differed in the classifier column only, with a sentence explaining it.
|
|
89
|
+
- **Now.** `RULE_OFFERS_ABSENCES` (`mandated`, `allow_outranked`, `ask_rule_matched`, `lane_cannot_speak`) is the one closed set; `ruleClearanceOf` mints the three door words in the display order it always used (an outranked allow before an ask rule), the offer factory adds `lane_cannot_speak`, and the request, the row and the baseline table read the type (the table fenced). The Skill-binding factory mints exactly one arm on every card: the digest and the value it hashes for a `Skill` activation, `boundGrantAbsence: "not_bindable"` for every other call (the Artifact tool's own consent card included); the parked row carries the same member; the inbox projection is total over a `tool_approval` row — the digest when it is a string, else the row's word when it is a member, else `"unreadable"` (a digest that is not a string, or a row that says neither — one parked before 7.51.0). `rule_unreadable` is the one class of the unreadable-rule family. `dangerous_delete`, `unverifiable_delete` and `unverifiable_shell_text` hold one frozen row.
|
|
90
|
+
- **Who is hurt.** See the hard-breaking paragraph. A row parked by 7.50.x for a call that is not a `Skill` activation reads `boundGrantAbsence: "unreadable"` in the inbox (it does not say), so a host keyed on `not_bindable` offers no whole-tool standing answer for it until it is decided. The store-contract kit's fixture row and `EXPECTED_LISTBYSCOPE_SUMMARIES` carry the word now: a third-party `CheckpointStore` that keeps only the `pendingAction` members it knows fails `checkpointStoreContract` until it carries `boundGrantAbsence` (meanwhile its inbox says `unreadable` for every card).
|
|
91
|
+
- **Compensation.** The three words and the binding word let server / client-core retire their tool-name tables (server S-1493 `grantBinding` = `bound` / `none` / `unreadable`, client-core CC-660).
|
|
92
|
+
- **Reference.** CC 2.1.296 builds every dangerous-removal question — a protected target, a possibly-empty variable path, a substitution's output — through one function with `classifierApprovable: false` (`uw` (cli296.js:249620), `circuitBreaker: "dangerousRemoval"`), and its auto handler puts it to a person by default (`Ut` (cli296.js:249673); the dangerous-removal timeout config `wge` (cli296.js:249209) `enabled: true`) — followed.
|
|
93
|
+
- **Pins.** `test/backlog1515-offer-absence-words.test.ts` (W1–W5, real Runner live and parked); `test/backlog1534-removal-breaker-row.test.ts` (R1 real Runner `auto` with a classifier that allows everything, R2 control, R3 one frozen row); `test/backlog1688-bound-grant-absence.test.ts` (B1 live, B2 parked + inbox, B3 the projection over damaged rows, B4 census of every request mint, B5 the factory); `test/backlog1702-rule-unreadable-one-class.test.ts` (U1–U4, real Runner on an unreadable partition). Re-pinned: `backlog490`, `backlog502`, `backlog696`, `backlog714`, `backlog758`, `backlog767`, `backlog769`, `backlog791`, `backlog997`, `backlog1079`, `design382` (`shadowed` → the word of each case); `backlog564`, `backlog625`, `backlog688`, `backlog1111`, `backlog1262`, `backlog1293b`, `backlog1653` ×2, `backlog7340rj` (`governance_unavailable` → `rule_unreadable`); `backlog1320` (the new SHOWN member's read site); `backlog1348` ⑫c (the factory's other arm); `backlog803` ⑦ (the request's key set gains the member); `tool-policy` RB-203 (under `auto` a person answers the unverifiable delete — re-anchored on the reference's current default); `concurrent-seam-scale` ③, `file-cross-backend` §6.3 (summaries carry the word; a helper row that says neither reads `unreadable` on both backends).
|
|
94
|
+
- **Known limits.** KL-1653-h reworded: the store form and the call form share the class, and which one it was is said by `message` only. The gate still records two facts (`ruleCallUnreadable` / `ruleStoreUnreadable`) that now feed one class and one origin; folding them into one fact is a follow-up in the gate's lane file.
|
|
95
|
+
|
|
96
|
+
- **Exported tables that lose the retired word.** `RRA_EXPLAINERS` (package root) loses its `governance_unavailable` row with the class; `MODE_ARBITRATION` and `ASK_CLASSES` likewise (named above). A renderer indexing `RRA_EXPLAINERS` by a stored class word from a 7.50.x card reads `undefined` for `governance_unavailable` — read `rule_unreadable` for it.
|
|
97
|
+
|
|
98
|
+
### A deployment credential goes only to the deployment's own root; the `unpinned` posture is retired (#1531; **BREAKING**; security; hard-breaking: two, open to veto; closed sets: `RoutePairingPosture` −`unpinned` (so `RoutePairingStatus` −`ok:unpinned`); exports: +0 names, 0 removed; members: `RoutePairingConfig.laneDefault?` added, optional)
|
|
99
|
+
|
|
100
|
+
**Hard-breaking (open to veto).** (1) **What changed:** a credential on a brain's construction config (`config.apiKey`, or an auth header in `config.headers`) now rides only to the root that brain sends to when an entry names none — `config.baseUrl`, else the lane's own default endpoint (`https://api.anthropic.com` for `createAnthropicBrain`; `createOpenAIBrain` and `createOpenResponsesBrain` have none); a model entry carrying a different `baseUrl` and no credential of its own is refused with `route.credential_mismatch` before a byte is sent, and `RoutePairingPosture` loses `"unpinned"`. (2) **Who breaks:** a deployment that puts its key on the config and the URL only on the model (`createOpenAIBrain({ apiKey })` with `model.baseUrl`) — every such request is now refused; a consumer that lists `"unpinned"` / `"ok:unpinned"` (the sdk's `RoutePairingStatus` union, the server's assistant wire contract `routePairing` row — the server states its assembly never produced it). (3) **How to migrate:** declare the key's root on the config (`createOpenAIBrain({ apiKey, baseUrl })`), or give the entry its own credential (`getApiKeyAndHeaders`, or an auth header on `Model.headers`); drop `unpinned` from posture tables.
|
|
101
|
+
|
|
102
|
+
**Hard-breaking (open to veto).** (1) **What changed:** a first-party brain's construction config (`createAnthropicBrain` / `createOpenAIBrain` / `createOpenResponsesBrain`) and its `headers` bag are received ONCE by the engine's one receipt law for caller objects — the same door `new Runner` reads `RunnerDeps` through — so a config object that is not a plain object (a class instance, `Object.create(x)`, a Proxy) is refused at construction with `config.invalid_argument`, as is a member getter that throws and an object whose getter moves its prototype while it is read; its own descriptors are captured in one call before any getter runs and each member is taken from its captured descriptor exactly once (a getter deleting or adding another member changes nothing), and the brain keeps a frozen copy (the header bag included). The same receipt now serves `new Runner`'s deps, which also refuses a Proxy before its retired-seat door looks at the object. (2) **Who breaks:** a host that builds the config as a class instance, inherits members through a prototype, passes a `Headers` instance as `headers`, or mutates the config (or its header bag) after construction to rotate a key or move the root. (3) **How to migrate:** pass a plain object (`{ ...config }` keeps own enumerable members — move a prototype-supplied one onto the object), a plain record for `headers`, and build a new brain to change the root or the key (or rotate per model with `getApiKeyAndHeaders`).
|
|
103
|
+
|
|
104
|
+
- **Before (v7.50.0).** `adjudicateModelRoute` judged a deployment credential against `config.baseUrl` only when the config declared one; with none declared it answered `{ ok: true, posture: "unpinned" }` whatever the entry's root, so one catalogue entry with its own `baseUrl` (a third-party host, a typo, a hostile row) received the deployment's key on the first request — the failure surfaced, if at all, as that host's 401.
|
|
105
|
+
- **Now.** The deployment credential has ONE root: `config.baseUrl`, else `RoutePairingConfig.laneDefault` (the brain's own constant, passed by `createAnthropicBrain` at its request gate and its `adjudicateRoute` face). A request to that root is `paired`; a request anywhere else is `route.credential_mismatch`, and the fix hint says which root the key is paired with (`… is paired with this brain's default endpoint https://api.anthropic.com (config.baseUrl is unset)` / `… names no root of its own (config.baseUrl is unset)`). `createAnthropicBrain({ apiKey })` with `fable5Model()` (its root is the lane default) stays paired. Keyless requests and per-model credentials are unchanged.
|
|
106
|
+
- **Redirects (behaviour change; the same law).** Before (v7.50.0): the three first-party brains sent every request with the transport's default redirect handling, so a 3xx from the judged root was followed — the body went to the new location, and on a cross-origin hop the transport drops only `authorization`, so the Anthropic lane's `x-api-key` rode along to a root the pairing law never judged. Now: every brain request is sent with `redirect: "manual"` and a redirect is refused with `BrainError("network", "… endpoint answered with a redirect (HTTP <status>); it was not followed and no credential was sent anywhere else — declare the final endpoint as the brain's baseUrl")` — no second request, no retry, the target not named. One rule for every request (same origin or not, with a credential or keyless): the request goes only where the law judged.
|
|
107
|
+
- **One reading of the construction config.** Each first-party brain receives its config ONCE at construction by the receipt law above (`brainConfigSnapshot` over `receiveOwnProperties`, the one helper `retainRunnerDeps` now also reads through); the request gate and the pre-flight judge (`adjudicateRoute`) read that one frozen copy, so they cannot disagree about the root a credential is paired with — an own non-enumerable `baseUrl` is the root (never dropped to the lane default), a prototype-supplied one is refused. Before (v7.50.0): both read the config object live at every call, so a mutation moved the root and the key mid-life, and a getter ran at every request. Who is hurt: see the second hard-breaking paragraph.
|
|
108
|
+
- **Who is hurt.** See the hard-breaking paragraph. In this repository the shape was the default way tests and the live gate built a brain; they declare the root now. A deployment whose endpoint redirects (an `http` root answered by a redirect to `https`, a gateway that moved, a trailing-slash redirect) now fails loudly on its first request instead of silently following.
|
|
109
|
+
- **Compensation.** None needed beyond the one-line migration; the read face (`routePairingStatus`) says `broken:credential_mismatch` for such an entry before any request. A redirecting endpoint: declare the final endpoint as `baseUrl`.
|
|
110
|
+
- **Pins.** `test/backlog1531-deployment-credential-one-root.test.ts` (P1–P8: the hostile catalogue entry on a key-only config refused with nothing sent — sent on v7.50.0; the same through a Runner catalogue; the Anthropic lane default paired and sent; the same key to another root refused; a declared root sent; the read face; keyless and per-model unchanged; P9–P11 on the real transport: a 307 from the declared root to another origin is one request with the key reaching nothing else — followed on v7.50.0, `x-api-key` delivered to the other origin — a 307 to the same origin is not followed either, a 200 is unchanged; P12 per lane: a config mutated after construction moves neither the judge nor the request; P13 an own non-enumerable `baseUrl` is the paired root and the key never reaches the lane default; P14 a prototype-supplied `baseUrl` refused at construction; P15 the `headers` member read once and the kept bag immune to later edits; P16 a throwing getter one coded refusal; P17 null-prototype and frozen configs accepted); `test/route-adjudication.test.ts` (the quick-start cell re-pinned as a refusal, the posture census three members); the conformance corpus: `deployment-unpinned` replaced by `deployment-undeclared-entry-root` (refused), `deployment-lane-default` (paired), `deployment-lane-default-entry-elsewhere` (refused).
|
|
111
|
+
|
|
112
|
+
### A contained skills root loads no skill from outside the directory it declares; "is this path inside that root" has one answer (#1625; behavior change; hard-breaking: one additive closed-set member (`SkillsDirectoryWarningCode` +`outside_root`), open to veto; closed sets: `SkillsDirectoryWarningCode` +`outside_root`; exports: +0 names, 0 removed; members: `SkillsDirectoryOptions.containWithin?` added, optional)
|
|
113
|
+
|
|
114
|
+
- **Before (v7.50.0).** `createSkillsFromDirectory` followed every symbolic link among a root's entries (and a `SKILL.md` that was itself a link) with no containment: a repository's `.sema/skills/x → /anywhere` loaded `/anywhere` as a skill. Attachments were contained in their skill directory by a local lexical helper of the loader's own.
|
|
115
|
+
- **Now.** `containWithin` names the directory a root's skills must physically lie inside: every skill directory and every `SKILL.md` resolves (links followed) inside it, or the skill is skipped with an `outside_root` warning; a `containWithin` that is not a readable directory throws, like an unreadable root. Absent, each root entry is a mount the deployment declared (unchanged — a shared library assembled from links still loads). The containment question — here, in the attachment walk, and for the next seats that ask it — is answered by one helper reading both sides physically (deepest existing ancestor resolved, missing tail rejoined; the write fence's own segment-aware comparator), not by a local lexical copy.
|
|
116
|
+
- **Who is hurt.** A consumer with an exhaustive switch over `SkillsDirectoryWarningCode` sees one more member.
|
|
117
|
+
- **Compensation.** A host loading a repository-written skills root passes the repository root as `containWithin` (cli's repository and project-local layers).
|
|
118
|
+
- **Pins.** `test/backlog1625-skills-root-containment.test.ts` (C1–C7: a root entry linked out, a `SKILL.md` linked out and a root that is itself a link out — each loaded on v7.50.0 — skipped and disclosed; an in-repository link loads; no `containWithin` loads as before; a bad `containWithin` throws; a containment root spelled through a link; H1–H3 the helper: sibling prefix, not-yet-existing target through its deepest ancestor, link-spelled root against a physical target); `test/backlog1303b-loader-and-surface.test.ts` (the warning-code type pin).
|
|
119
|
+
|
|
120
|
+
- **Hard-breaking (additive closed-set member, open to veto).** What changed: `SkillsDirectoryWarningCode` gains `outside_root` (minted only when `containWithin` is set), and `SkillsDirectoryOptions` gains the optional `containWithin`. Who is hurt: a consumer with an exhaustive switch over the warning codes (test's `repro/cli1142-*/bundle/lib/tap.mjs:113` reads them as data; cli L-1083's repository layer is the first caller of `containWithin`) compiles or asserts red until it adds the arm. Compensation: add the arm; a warning with an unknown code is still rendered by its `detail` sentence.
|
|
121
|
+
### The Cowork prompt base: the desktop app's Cowork default base carried verbatim as data, rendered by what is true here (#1561, #1562; hard-breaking: none; closed sets: none; exports: +2 names (`coworkPromptProvider`, `COWORK_PROMPT_SECTIONS`), 0 removed; members: `StablePromptContext.envFacts?` added, optional)
|
|
122
|
+
|
|
123
|
+
- **Before (v7.50.0).** No Cowork base: a deployment wanting it would bind the neutral default or write its own text.
|
|
124
|
+
- **Now.** `src/prompts/cowork.ts` holds the base byte for byte (37,811 characters, `{{…}}` placeholders as written; its digests are pinned only in `test/cowork-base-lock.test.ts`), cut into 28 sections in template order. `coworkPromptProvider()` renders the sections a run admits as identity declarations `sema/cowork.<section>`, a placeholder-free section declaring the sha256 of its bytes (`prompt.assembled` carries it as `contentHash`): a section renders only where every operational claim in it is established by a fact here (a product or tool name spelled the desktop's way is a name, not a claim). That admits ten: the seven behaviour sections always, `ask_user_question_tool` where `AskUserQuestion` is mounted, `unnecessary_computer_use_avoidance` where `Write` is, `producing_outputs` where `Write` and `Edit` are (it directs creating and then iteratively editing a file) and only with the one placeholder read from the run, `{{workspaceFolder}}` = the task's working directory. Eighteen never render — each states something no fact here establishes: the desktop app's identity and product, a fixed knowledge-cutoff date, a task-list widget and subagent verification, `computer://` links as sources and as the way files are handed over, `.docx` / `.pptx` creation, fetch failures as legal restrictions that forbid the shell download the web tools here recommend, a directory-request tool, an artifact rendering runtime, a SKILL.md directory and its reminder, the desktop sandbox machine and its package setup, its upload paths and worked examples, the environment facts the environment block owns, the host's model-identity line. The two grouping elements' own tags are not rendered. A task's own `systemPrompt` replaces the base whole (no Cowork section composes). `COWORK_PROMPT_SECTIONS` lists every section that can render, with the digests where it has no placeholder, for a scenario's description. `StablePromptContext.envFacts` hands a provider the run's environment facts without the date (a stable section never carries a value that changes daily).
|
|
125
|
+
- **Behaviour narrowing (C-R446, amending C-R359; open to veto).** The admission is one rule — every operational claim established, names-only residue tolerated — and the eighteen never-sections are its consequence: eight more than the ruled ten (`todo_list_tool`, `citation_requirements`, `file_creation_advice`, `web_content_restrictions`, `artifacts`, `high_level_computer_use_explanation`, `package_management`, `examples`). The admission vocabulary is therefore `never` or the tools a section needs: no isolation, MCP, any-of-tools or reference-closure member, and no `{{cwd}}` reading. A later section is judged by the rule, and a never row returns only by a new ruling. Hard-breaking: none — the admission vocabulary is not on the package index; the public faces (`coworkPromptProvider`, `COWORK_PROMPT_SECTIONS`) are new in this release.
|
|
126
|
+
- **Who is hurt.** No one: additive. A host that would have relied on those eight sections or on `{{cwd}}` has nothing to migrate from (the base is new). The auto-mode classifier assets' header no longer restates the digests the lock test holds (#1562).
|
|
127
|
+
- **Compensation.** —
|
|
128
|
+
- **Pins.** `test/cowork-base-lock.test.ts` (L1 template bytes; L2 the cut and the lines between sections; L3 every section's bytes; L4/L5 no prompt-asset module restates a digest — L5 red on v7.50.0; R1–R10 what renders: the seven always-sections, the ten renderable in order, the eighteen never-sections (red on a nineteenth never or an eleventh renderable), the one placeholder reading, a section without its fact, each tool section exactly with its tool, nothing more admitted by isolation / an artifact tool / a scratch directory / web or MCP tools, digests, the caller's own prompt, the published list; E1–E3 through the Runner: manifest ids and digests, the caller's prompt wins, a provider sees the facts without the date).
|
|
129
|
+
- **Known limits.** KL-1561-a: the 21 conditional sections the desktop assembler appends are not carried yet; none would render here today. KL-1561-b: rendered sections keep the desktop's own names ("The Claude desktop app includes an AskUserQuestion tool" in `ask_user_question_tool`; "computer tools" in `unnecessary_computer_use_avoidance`). #1722: `{{workspaceFolder}}` spells the working directory raw, a second raw reading beside the environment block's `Primary working directory` line — one model-facing cwd spelling for both is that ticket.
|
|
130
|
+
|
|
131
|
+
### A host disposes of an MCP server's `instructions` as a named prompt section entry: include, drop or replace, read once by both lanes (#1519; behavior change for hosts that opt in; hard-breaking: one additive REQUIRED member (`McpInstructionsEntry.author`), open to veto; closed sets: +`DisposablePromptSection` (one member); exports: +6 names; members: `PromptProvider.section?` added, optional; `MaterializedMcp.serverInstructions` / `instructionsDelta.pendingAdds` entries gain `author`)
|
|
132
|
+
|
|
133
|
+
- **Before (v7.50.0).** A server that answered `initialize` with non-empty `instructions` had them injected, fenced, into the stable system prompt (`# MCP Server Instructions`) and onto the boundary delta lane, with no host lever: `replaceAll` could not remove them, an `allowTools` subset still carried text naming tools the leg never mounted.
|
|
134
|
+
- **Now.** The text is one entry of the named section `core/discovery.mcp-instructions` (keyed by server). The provider in force (`TaskSpec.promptProvider ?? RunnerDeps.promptProvider ?? default`, one helper read by both the prompt phase and the MCP phase) answers `section(entry)` — `entry = { section, server, text, mountedTools }`, frozen — with nothing (include), `{ op: "drop" }`, or `{ op: "replace", text }`. The answer is read ONCE, where the server's text enters the run (the dial, and each re-dial asks again), so the stable block, the delta's add and the delta's removal all read the disposed entry: a dropped server is never announced as disconnected. A replacement is host text: it renders as the reference's per-server block `## <server>` + text, unfenced; server text keeps the untrusted fence. Both carry the same per-entry bound (8192 characters) and the same disclosed cut, so the instructions frame's size bound holds for the entry that actually rides. A throw, a Promise, a blank replacement, extra keys or any other value includes the entry as computed (the reference's `prompt.section` posture) and raises an operator warning `prompt.section_answer_invalid` on `MaterializedMcp.warnings`. The op words are the prompt overlay's (prompt-v2), so the seat is the overlay's sugar, not a second override path.
|
|
135
|
+
- **Three sentences (new seat member, additive).** What changed: `PromptProvider` gains the optional `section`, and `MaterializedMcp` instruction entries gain a required `author`. Who breaks: only code that constructs those entries by hand (no consumer found: server pins zero consumption points, `docs/ASSISTANT-WIRE-CONTRACT.md:7843`; cli lists `MaterializedMcp.instructionsDelta` in `scripts/wire-consumption-manifest.json:6450`). How to migrate: add `author: "server"`; a host that wants the lever implements `section` on the provider it already passes (server's per-scenario providers, `src/boot/resolve-spec.ts:1281`, shadow a deployment one).
|
|
136
|
+
- **Who is hurt.** A consumer constructing `MaterializedMcp` entries by hand must add `author` (`"server"` for a server's own text). A host that never answers `section` sees no change.
|
|
137
|
+
- **Compensation.** None needed.
|
|
138
|
+
- **Pins.** `test/backlog1519-prompt-section-seat.test.ts` (S1–S6 stable block through a Runner, A1–A5 the seat's input, answer law, a rejected async answer, a token-bearing throw kept off the operator line, a bounded stray-key list, D1–D2 delta removal and re-dial). The operator line redacts credential-shaped spans in every value the host's code produced, names at most five stray keys, and is capped at 600 characters.
|
|
139
|
+
- **Known limits.** The prompt manifest does not yet record each entry's author and size (the K13 manifest design, 7.52.0). The seat is asked once per dial and is synchronous (a Promise answer — rejected or not — is consumed, included and warned). KL-1519-a: an answer that turns from include / replace to drop on a mid-task re-dial withdraws a queued add but not text the model was already shown (the same as a server that stops advertising instructions on a re-dial; the delta lane's only removal sentence says "disconnected").
|
|
140
|
+
|
|
141
|
+
- **Hard-breaking (additive required member, open to veto).** What changed: a `MaterializedMcp` instruction entry (`McpInstructionsEntry`, now exported) carries a required `author` (`"server"` | `"host"`), and the wire manifest lists it as a required key gained. Who is hurt: a host that hand-builds instruction entries (server's `center-prompts.ts` / `memory-notice.ts` / `resolve-spec.ts` read entries but do not build them — verify at pickup) compiles red until it sets `author`; a reader of entries sees one more member. Compensation: set `author: "host"` on hand-built entries; readers may ignore it.
|
|
142
|
+
### A protocol declaration's credentials go only to the endpoints it names, and a header name has one owner (#1491; **BREAKING**; behavior change; hard-breaking: three, open to veto; closed sets: none; exports: +0 names)
|
|
143
|
+
|
|
144
|
+
**Hard-breaking (open to veto).** (1) **What changed:** a static header whose name equals (in any case) the declaration's `principalHeader`, or two static spellings of one header name, now refuse that MCP server / A2A peer at its dial (`config.protocol_header_collision`; the MCP status reads `invalid_config`, `delivered: "no"`) — it used to be sent merged beside the principal (`svc-default, alice`), or alone when the task had no principal. **Who breaks:** a deployment whose MCP / A2A declaration spells the principal header among its static headers (server `src/config-provider.ts:515` carries `principalHeader` onto MCP http specs; client-core `src/cloudConfigWireCaps.ts:165-170` builds them). **How to migrate:** remove the static entry — the engine sends the principal under that name, or nothing when the task has none. (2) **What changed:** an A2A peer whose card names a JSON-RPC endpoint on an origin outside the declaration's `url` / `cardUrl` is refused while the declaration carries headers or a principal (`config.protocol_endpoint_unbound`), and such a peer's requests no longer follow redirects. **Who breaks:** an A2A deployment (server `src/config-types.ts:310`, `src/http/routes/sessions.ts`; sdk `TaskRequest.a2aPeers`) whose peer serves its card on one origin and its RPC on another, or sits behind a redirect, with credentials declared. **How to migrate:** declare the RPC origin as `url` and the card's location as `cardUrl`; point the declaration at the final location instead of a redirect. (3) **What changed:** a declared A2A header owns its name in any case — `A2A-Version: 0.3` replaces the engine's `a2a-version` default instead of being sent beside it (`1.0, 0.3`). **Who breaks:** nobody who relied on the documented override; a peer that parsed the merged value. **How to migrate:** none.
|
|
145
|
+
|
|
146
|
+
- **Before (v7.50.0).** `resolveProtocolHttpHeaders` (in `mcp.ts`) spread the principal over the static headers by object key — case-sensitive — and the A2A client sent the declared headers to whatever endpoint the peer's card named, following redirects.
|
|
147
|
+
- **Now.** One module, `src/core/protocol-http-auth.ts`, answers the rule for both protocols: `headerOwnershipCollision` (one owner per name, case-insensitively), `assertEndpointBound` (declared origins only), `redirectPolicy` and `underDeclaredHeaders`. The declaration is read once into a frozen copy (an A2A peer's endpoints and headers alike): the copy is what is judged, sent and bound, so a header added to the declaration after the mount never travels. Each defect is that peer's own: refused at its dial, siblings still mount. The MCP transport already dialed only the declared URL with redirects not followed.
|
|
148
|
+
- **Who is hurt.** See the hard-breaking paragraph.
|
|
149
|
+
- **Compensation.** The refusal names the colliding spellings or the unbound origin.
|
|
150
|
+
- **Pins.** `test/backlog1491-protocol-credential-binding.test.ts` (H1–H4 over a recording MCP listener, B1–B7 over recording A2A listeners); `test/mcp.test.ts` and `test/principal.test.ts` re-pinned from "the principal overrides a same-named static header" to the refusal.
|
|
151
|
+
- **Known limits.** The per-request credential resolver (references instead of values, shared with model keys) is #1494's design (7.52.0); this module is where it plugs in.
|
|
152
|
+
|
|
153
|
+
- **Mount status of every configuration refusal at the dial.** A `config.*` refusal while a protocol server is dialled — the header collision here, an invalid fingerprint (`config.mcp_fingerprint_invalid`), an unreadable declaration — surfaces as that server's `status: "failed"` with `errorCode: "invalid_config"` and the specific code on the warning's `cause.code`; its siblings still mount. Only the collision is new in this release; the status word is the one the base already used for a bad declaration.
|
|
154
|
+
### The read-deny declaration is hot-swappable; the MCP revocation probe is asked with the mount's identity (#1070; behavior addition; hard-breaking: two additive closed-set members, open to veto; closed sets: `SwappableDepSeat` +3, `EngineNoticeCode` +`config.read_deny_swapped`; exports: +2 type names; members: `McpServerSpec.fingerprint?`, `RunnerDeps.mcpRevocations` typed `McpRevocationLedger` whose `isRevoked` takes an optional second argument)
|
|
155
|
+
|
|
156
|
+
- **Before (v7.50.0).** `readDenyPatterns` / `readDenyBuiltinTiers` / `readDenyBuiltinExclude` were read at construction only: `swapDeps` refused them as "not a hot-swappable seat", so a host had to restart a Runner to change the deny face. `isRevoked(serverName)` could not tell one mount generation of a server from a re-declared one.
|
|
157
|
+
- **Now.** The three seats join `SWAPPABLE_DEP_SEATS`, screened by the gates a prepare applies (`screenReadDenySeat`, the one array door and the one compiler's entry law; the built-in tier / row-name law) over a copy of each entry taken before the judgment (a later write to the caller's entry object does not move the swapped policy; `new Runner` keeps the same kind of copy of the three seats, so a declaration written after construction does not move the policy either), written by the one presence-law loop, and announced once per swap as `config.read_deny_swapped` (`detail: { seats, patterns, tiers, exclude, occurrenceId }`, counts and closed-set tier names only, audience operator). In-flight legs keep the deny face they prepared under; a resumed leg compiles against the seats in force at its prepare (the checkpoint keeps additions only). A server declaration may carry `fingerprint` (non-empty, ≤ 128 characters, minted by the host); when present the probe is asked `isRevoked(name, { fingerprint })`, when absent `isRevoked(name)` exactly as before. A fingerprint that is not such a string refuses the server at its dial (`config.mcp_fingerprint_invalid`). A declared fingerprint is part of a pooled connection's identity (`McpConnectionPool.keyOf`): a re-declared server with a different fingerprint dials its own connection, so a revoked mount's connection never serves the new mount; without a fingerprint the key is the one it always was. Server S-668 asked for this pinned to core 7.50.0; it lands in 7.51.0.
|
|
158
|
+
- **Three sentences (closed-set and wire-key additions).** What changed: `SWAPPABLE_DEP_SEATS` +3, `ENGINE_NOTICE_CODES` +`config.read_deny_swapped` (audience operator, engine-minted), `McpServerSpec.fingerprint?`, and `isRevoked` may receive a second argument. Who breaks: a table keyed by the notice catalog or the seat set that is checked for totality (client-core generated tables, cli's notice list, sdk's wire code book — below); a ledger whose `isRevoked` rejects a second argument does not (extra arguments are ignored by a one-parameter function). How to migrate: regenerate / add the row; a host minting fingerprints passes them on `TaskSpec.mcp[]` entries.
|
|
159
|
+
- **Who is hurt.** A consumer that mirrors `ENGINE_NOTICE_CODES` or `SWAPPABLE_DEP_SEATS` by hand gains one row (client-core `src/generated/engineNoticeTables.ts`, cli `src/sema/engineNoticeCodes.ts`, sdk wire code book `packages/sdk/test/s185-server-769-wire.test.ts`; server reads the set through `isSwappableDepSeat`, `src/web-admin-client/read-face.ts:75`, so its three deny keys move from boot-baked to refresh-swappable with no edit there).
|
|
160
|
+
- **Compensation.** None needed.
|
|
161
|
+
- **Pins.** `test/backlog1070-1089-hot-swap-and-run-identity.test.ts` R1–R6 (swap reaches the next prepare; one line; refused candidates atomic; explicit `undefined` clears; the swap and the construction door each keep their own copy), F1–F3 (fingerprint rides; fingerprint-less control asks with one argument; bad fingerprint refused), F4–F5 (an old mount revoked and a re-declared one allowed dial two connections; fingerprint-less control: the key unchanged, one shared connection).
|
|
162
|
+
|
|
163
|
+
- **Hard-breaking (additive closed-set members, open to veto).** What changed: `SwappableDepSeat` gains `readDenyPatterns` / `readDenyBuiltinTiers` / `readDenyBuiltinExclude` (three more seats `swapDeps` accepts; `SWAPPABLE_DEP_SEATS` grows), and `EngineNoticeCode` gains `config.read_deny_swapped` (minted once per swap, detail: the seats named and the three values as counts). Who is hurt: a consumer with an exhaustive switch over the seat words or a frozen copy of the notice catalog (client-core `engineNoticeTables.ts`, cli `engineNoticeCodes.ts`, sdk's catalog test; server's `engine-notice-wire.ts` table) compiles or asserts red until it adds the rows. Compensation: regenerate the tables; an unknown seat word is still refused by `swapDeps` with the closed set named, an unknown code is rendered by its `message`.
|
|
164
|
+
### The project-memory seam's ctx carries the run's id (#1089; additive; hard-breaking: none; members: `loadProjectMemory` ctx `runId?` added)
|
|
165
|
+
|
|
166
|
+
- **Now.** `RunnerDeps.loadProjectMemory` receives `runId` — the id the `task.start` trace frame, `noteTaskRun` and `TaskResult.runId` carry — always filled by the Runner, so a host's `instructions.source_changed`-style notice can name the run. **Three sentences.** What changed: the ctx gains the optional `runId`. Who breaks: nobody (an added optional ctx key; server reads the ctx at `src/boot/runner-deps.ts:1002`, `src/run-local.ts:1159` and is the asker). How to migrate: read `ctx.runId`. **Pins.** `test/backlog1070-1089-hot-swap-and-run-identity.test.ts` P1.
|
|
167
|
+
|
|
168
|
+
### Every exit that redacts does it with one pass, its tier's, over every string member: typed egress tiers (#1573, with #1572 #1545 #1553 #1689 #1690 #1717; behavior change — more is redacted; hard-breaking: one, open to veto (C-R447); no type or closed-set member is removed or renamed; closed sets: none; exports: +2 type names, 0 removed; members: `redactSecrets` / `boundedRedactedSummary` return `Egress<"same-principal">` and `redactHostLeaks` / `untrustedEgressForHuman` return `Egress<"human">` — branded strings, assignable to `string`, the two tiers no longer assignable to each other)
|
|
169
|
+
|
|
170
|
+
**Hard-breaking (open to veto; C-R447).** (1) **What changed:** a delegated child run's rejected value reaches the parent's tool loop as a data snapshot ALWAYS — a plain `Error` (on `Error.prototype`, never the thrower's class, whose serializers and accessors would run again on it) carrying the class's `name`, the `message`, `code` and `stack` and every own member as own data passed through the `same-principal` tier, the sentence byte-identical when nothing in it is a credential — never the value the child threw (in v7.50.0 an unchanged value went on by identity and a changed one was re-minted on the thrower's prototype with its message alone redacted). **Who breaks:** a consumer that tests the rejected value with `instanceof <the thrower's class>`, compares it, its `cause` or a member by identity, or reads a `Date` member as a `Date` (it is now its ISO text, JSON's reading) — none found: core's only reader throws it on (`src/agents/subagent.ts`, `throw childRejection(e)`) to the tool loop, which reads it by `name` and members, and `childRejection` is not exported (server / cli / client-core / sdk origin/main: no reference). **How to migrate:** read the class by `name` (and `code`, `message`), not `instanceof`, and read a date member as text.
|
|
171
|
+
|
|
172
|
+
- **Before (v7.50.0).** Each exit redacted by its own rule. The activity summary ran a shape-only pass with no URL row and stripped a URL's `user:pass@` only from a field named `url` (a password in `query`, `description`, `pattern` or any other field went out in clear); it picked its catch-all field with a private credential-word regex. The tool loop's thrown-error station (`agent-loop.ts`: a tool's, a hook's or a nested run's throw) wrote the thrown text and its `details` marks into the tool result unredacted. A child run's rejected value was re-minted with its `message` redacted and every other member (`cause`, `details`, `errors`, an own `name`) carried as it was. Every head word (a vendor prefix, `Bearer`, a keyword) refused to start right behind a quote spelled with a trailing digit (`%22`, the `u0022` escape, `\x22`, `\042`), so a header written behind any of them showed its token. The keyword row's value run ate the backslashes escaping a closing quote, so a credential nested two JSON layers deep left the outer document unparseable. `npm_` publish tokens, `ya29.` OAuth access tokens and the `ABIA` / `ACCA` / `A3T…` AWS key ids were not redacted on any face. The `SEMA_` env-namespace rule read its own hand-copied list of thirteen credential words. An `Authorization` value in any scheme but Bearer was read by the keyword row, which took the scheme word alone: `Authorization: Basic <credentials>` came out `Authorization: [redacted] <credentials>` (Digest's `response=`, Negotiate's blob and SigV4's `Signature=` likewise; `Proxy-Authorization` and a JSON key the same).
|
|
173
|
+
- **Now.** Two tiers, the two existing profiles: `same-principal` (the credential vocabulary — the model, the launching script, the host's event and error lanes, logs) and `human` (plus every URL and absolute path). `egressText(tier, text)` is a tier's one pass over a text, `egressThrownText(tier, err)` over a thrown value's text, `egressValue(tier, value)` over every string member of a value — an error re-minted as a plain `Error` with every own member passed, any other object read as JSON reads it (`toJSON(name)`, else its own enumerable members), a string member whose name is a credential name (the vocabulary's record-key view) replaced whole, a function not carried at any depth (code, not data; one handed in comes back `undefined`), an error re-minted as a plain `Error` with its `name` / `message` / `code` / `stack` read once through its chain as own data (a `DOMException` still reads `AbortError`; no serializer or accessor its class brought runs on the copy), and the result ALWAYS a data snapshot — never the value itself, so no getter, serializer or proxy trap runs again after the one read (cycles and shared members stay shared) — depth 32 / 2 000 members bounded (past the depth, the marker; past the count, the rest is not carried). What a tool RETURNS (its content, or a returned `isError` receipt) is the model's own requested data and is not redacted, as before and as in the reference implementation. What a tier returns is typed `Egress<Tier>`; `redactSecrets` / `redactHostLeaks` (the hosts' names for the two passes), `boundedRedactedSummary` and `untrustedEgressForHuman` return `Egress<"same-principal">` / `Egress<"human">`. No engine source outside the tier's home calls a redaction primitive (a guard test says so; `mcp.ts` and `checkpoint-store.ts` are swapped at the wave merge). The activity summary meets the `same-principal` tier on whichever field it came from and picks its field by the record-key view; the loop's thrown-error station passes the text and the marks; a child's rejected value is `egressValue`; one head-word start (after no letter or digit, or right after a quote in any spelling of the vocabulary's quote table) for every row; one value-run end (never inside the backslashes escaping a quote) for the keyword row and the bearer tail; rows `npm-access-token`, `google-oauth-access-token` and the AWS prefixes `ABIA` / `ACCA` / `A3T…` (egress only — the refuse-write face is unchanged); the `SEMA_` namespace reads the vocabulary's whole-segment words. One header row, `authorization-header` (finding kind `authorization-header`, high): an `Authorization` / `Proxy-Authorization` header in any case, as a JSON key too, keeps its name, separator and opening quote and loses its WHOLE value — scheme word included, no scheme listed — read by the credentials grammar: a token or a comma-separated parameter list (a quoted parameter such as Digest's `response="…"` whole, white space around its `=` included; a quoted parameter runs to its own closing quote at any length, or to the line's end when it has none), ending at white space that does not continue the list, at a quote that closes a string around it, or at the line's end; the keyword row no longer reads `authorization`. The record-key view also reads `authentication`, `oauth`, `authn` and `authz` as credential names (an activity summary never shows such a field; a structured value's member so named is the marker). The Bearer header reads as before (`Authorization: [redacted]`).
|
|
174
|
+
- **Who is hurt.** A host or test that pinned a tool error's text or `details` with a credential-shaped string in it, or a `details` member named like a credential (`token`, `apiKey`, `password`, `x-api-key`), now reads the marker. See the hard-breaking paragraph for the child's rejected value; its snapshot's `stack` is the original's, redacted (it was a fresh stack). An activity-summary consumer sees `[redacted-credentials]` / `[redacted]` where a URL password or signed-URL capability stood, and may see a field whose name only contained a credential word as a substring (`author`, `keyboard`) where it saw nothing; a field named `cookie` / `pat` / `dsn` / `pwd` is no longer chosen. `SecretEnvFinding.kind` reads `namespace-rule` instead of `name-rule` for a `SEMA_` name whose credential word was not on the old list (`SEMA_X_AUTH`, `SEMA_DB_DSN`); what is withheld is unchanged. A variable inferred from `redactSecrets(…)` and later reassigned a plain string no longer type-checks (annotate it `string`). A header's token followed on its line by `, <word>` loses that word too (it reads as the next parameter); a structured member named `authentication` / `oauth` / `authn` / `authz` reads the marker; a rejected value carrying a `URL` member or a member behind a JavaScript getter is a copy holding what was read; a findings consumer sees the new kind `authorization-header` where it saw `keyword-value` (and, for a vendor token inside a Bearer header, where it saw `bearer-token`).
|
|
175
|
+
- **Compensation.** None needed for the redactions (over-redaction on egress is the declared direction). The marks a host keys on (`errorKind`, `timedOut`, `server`, codes) are kept as they were.
|
|
176
|
+
- **Package exports** (`npm run handoff:diff v7.50.0 HEAD`): +`Egress`, +`EgressTier` (types); 0 removed. The pass engine moved from `arg-summary.ts` to `redaction-passes.ts` unchanged (internal; the package exports through the index only); `scrubSecrets` (internal, never exported) is retired into the `same-principal` tier.
|
|
177
|
+
- **Pins.** `test/backlog1573-egress-tiers.test.ts` (A loop station × thrown Error / string, and the returned-content control; B child value × cause / details / name / AggregateError / identity / cycle / accessor / depth / JSON reading / proxy / a getter or `toJSON` answering twice / a self-returning `toJSON`, a bound getter, a `Date` delegating to its own `toISOString`, a credential-named member serializing to `Basic …`, a `DOMException` re-minted, a subclass whose prototype `toJSON` / `toString` hands back raw text (top level, nested, through the loop's station); C activity summary × four fields / signed URL / field choice / authentication names; D quote spellings × faces; E nested JSON × depth 1–3; H header schemes × faces, Bearer control, shape kept, white space on either side of `=`, a quoted parameter at 1 024 / 1 025 / 1 500 characters inside one and two JSON layers with the fields after it kept, a bound cutting through the value; F new rows + namespace words; G guard + type pins); `test/fixtures/secret-detect-fixtures.json` (+26 fixtures: npm, ya29, ABIA / ACCA / A3T, encoded quotes, header schemes); `test/backlog1099-sema-namespace-secret-env.test.ts` (oracle = the derived words); `test/backlog1508-subagent-failure-redaction.test.ts` ③ / ⑨ re-pinned as "same class, same own members, sentence byte-identical" (a snapshot, not the live value); the `scrubSecrets` face retired from the #532 / #954 / #958 / #1583 / #1675 / #1691 / RB-209 pins.
|
|
178
|
+
- **Known limits.** A credential carried by a PROTOTYPE accessor of a thrown class (`get message()` reading a closure) is not a member and is read only by the text face; KL-1717-b: white space after a parameter's `=` continues a header value, so a token68 that ends in `=` padding reads the next word as its value (`Authorization: Basic <…=> was sent` → `Authorization: [redacted] sent`) — the cost of not leaving `response= <value>` in clear; a field whose lower-case name glues a credential word to another word (`licensekey`) is no credential name to the record-key view (the old activity-summary filter matched substrings); a string member named with a credential word that holds no credential (`tokenCount: "5"`) reads the marker; the resume leg's thrown-error station (`run-leg.ts` — the `Error:` text and the marks of a thrown tool, both through the same-principal tier) and the compaction failure reason (`run-turn-boundary.ts`) are wired at the wave merge, so the live loop and the resumed leg mint the same redacted result; KL-1717-a: a credential passed as a program's flag argument (`curl -u alice:<password>`, `--password <value>`) is no text shape the vocabulary reads (it reads header grammar, not per-program flag grammar) — which flag takes a credential is a per-program grammar, the shell-argument model's, not the text vocabulary's.
|
|
179
|
+
|
|
180
|
+
### A read station's answer is read one way by every reader: the resumed leg's belt refuses a returned silence (#1708; fix; security; behaviour narrowing; hard-breaking: none; exports: +0 / −0) (@server @test)
|
|
181
|
+
|
|
182
|
+
- **Before (v7.50.1; the same in v7.49.5).** The durable-resume leg re-judges an approved shell call on its own read station before it runs. It refused when the station's judgment THREW or timed out (the `unanswered()` fallback), but a judgment that RETURNED `{ mandated, unanswered, environmentSilent }` — a resolver answering an error result — passed on an already-seen call and the call ran: a link re-pointed onto a deny-listed file while the call was parked, with the resolver giving no answer on the resumed leg, put that file's content in the transcript under a person's yes to a different question. The gate's fold read the same silence before its arms (7.50.0); the belt read it twice.
|
|
183
|
+
- **Now.** One reading for every reader of a station's answer — the gate's fold, the approved edit's re-judge and the belt (`readStationAnswer`, `src/core/shell-read-silence.ts`): a refusal; the station's SILENCE (the finding `unanswered`, or nothing found beside a silence mark); an answer that contradicts its own finding (next entry); or the table's disposition. The belt refuses a refusal, the silence (thrown and returned alike — the separate "did it answer" flag is retired), an unbelieved answer, and any question about edited bytes; the same arm, the same sentence form.
|
|
184
|
+
- **Who needs it / who is hurt / compensation (behaviour narrowing).** A person whose deny rows must hold across a park needs it. Hurt: a parked call whose station is STILL silent on the resumed leg is refused even when the card the person answered was the silence's own question (in v7.50.1 that resume ran); a guards-nothing station whose judgment threw is admitted on the belt as at the gate (the fallback's `{ vouch, none }`; in v7.50.1 the belt refused it — the file tools' station answers that way, and their own fence still refuses an out-of-root read nobody admitted). Compensation: re-issue the call once the environment answers; the refusal says the approval is spent.
|
|
185
|
+
- **Pins.** `test/backlog1708-belt-one-silence.test.ts` B1 (returned silence on a re-pointed link ⇒ refused, content absent; RED on v7.50.1), B2–B4 controls (thrown silence, deny row, an answered read resumes).
|
|
186
|
+
|
|
187
|
+
### A host station's answer is not believed against its own finding — `ShellReadVerdict` is a union (#1709; one reading; **hard-breaking for host-mounted read stations**, open to veto; exports: +0 / −0; type kind: `ShellReadVerdict` interface → type) (@server @cli @test)
|
|
188
|
+
|
|
189
|
+
**Hard-breaking (open to veto).** (1) **What changed:** `ShellReadVerdict` is `ShellReadVouch | ShellReadQuestion` — a `vouch` carries the finding `none` and no `environmentSilent`, `rowsUnread`, `face`, `sentence`, `details`, `readRootCandidate` or `unestablished` (a type error where a host spells one); and every reader (`runToolGate`'s fold, the approved edit's re-judge, the resumed leg's belt) checks the answer against the decision table: a disposition the table never gives the verdict's finding on any seat, under any form (`dispositionsOfRow`) — a `vouch` beside `deny_hit` / `outside_roots` / `unestablishable` / `declined` / `identity_changed`, an ask beside `deny_hit` / `identity_changed`, an ask beside `none`, a word outside the closed sets, a non-object — is NOT believed: the call is refused (`deniedBy: "read_boundary"`, a sentence naming the station's words only where they are closed-set words), one `onHookError` report, nothing of the answer applied (no fact, card or admission). A `vouch` beside a silence mark is the station's silence (a question only a person answers, bypass-immune). The answer is received by the engine's one receipt for a caller's object — the one a policy decision meets (`receiveContract`): an ordinary object whose members are its own enumerable data properties, taken off their descriptors with none of its code run — and then member by member, by kind: a word is a primitive string (a `String` object or anything carrying its own methods is not), a mark is `true`, `face` is `"write"`, a record (`details`, `readRootCandidate`, `unplacedOptions`) is copied as the engine's own data from its descriptors (a Proxy or an accessor anywhere in it is never touched), the admission is a function the applying reader calls (one that throws admits nothing and is reported). An answer held by an accessor, a member its prototype or a non-enumerable property supplies, a Proxy, or a member not of its kind is not believed — refused and reported, never an exception out of the gate. (2) **Who is hurt:** a host that drives `runToolGate` with its own `ToolGateInput.shellReadCheck` and answers outside the table — every such answer was a contradiction (the engine's own stations never spell one; their verdicts are byte-identical, pin H6b); and a host station whose verdict carries a member on an accessor, on its prototype or as a non-enumerable property, or is a Proxy — now refused, where a mark so held was read (or lost) by whichever reader touched it. No sibling repository mounts a host station or produces a `ShellReadVerdict` (`origin/main`, read-only, 10-14: sema-server `b4924bea` — `docs/core-consumption/LEDGER.json` records `SHELL_READ_DECISION` / `judgeShellRead` as zero-import and `runToolGate` as never driven; sema-cli `37f37540`, sema-client-core `28015df`, sema-sdk `a32bca5`: no `src/` producer). (3) **How to migrate:** answer a plain object — `{ disposition: "vouch", finding: "none" }`, or a disposition from the finding's row (`SHELL_READ_DECISION[finding]`); a host that wants a question with no finding names the finding it asks about.
|
|
190
|
+
|
|
191
|
+
- **Before (v7.50.1).** `disposition` and `finding` were independent: the fold read the disposition alone, so a host's `{ vouch, deny_hit }`, `{ vouch, outside_roots }` or `{ vouch, none, rowsUnread }` was allowed with nobody asked — beside a stored allow rule and under every mode (2 688-cell matrix, KL-1709-a); `{ mandated, deny_hit }` became a card a person could clear; the approved edit's re-judge passed any `vouch`.
|
|
192
|
+
- **Now.** See the hard-breaking paragraph. The compile-time fence `VouchOnlyOnTheNoneRow` keeps the table's vouch on the `none` row, so a vouch has nothing else to carry.
|
|
193
|
+
- **Pins.** `test/backlog1709-host-verdict-one-reading.test.ts` H1–H5 (every contradicting shape × mode × stored rule refused, nobody asked, one report; the silence beside a vouch; closed-set words; the edit re-judge — RED on v7.50.1), H7 (accessors — shifting, flipping, throwing —, prototype and non-enumerable members, a Proxy: not believed at the fold and the edit re-judge; own marks read — control), H8 (the one reading every reader takes, the belt included; records copied, not aliased), H9 (a Proxy `readRootCandidate` with no trap run, a `sentence` whose `trim` throws, a `String` sentence, accessors and Proxies nested in records, marks that are not `true` — refused and reported, the gate never throws), H9b (the same members as plain data applied), H6 / H6b controls (believed answers applied as before; the engine station's verdicts exactly).
|
|
194
|
+
|
|
195
|
+
### One decision table: the redirection override folds into the cell grammar (#1681; one reading; hard-breaking: the exported table's cell shape, open to veto; exports: +0 / −0)
|
|
196
|
+
|
|
197
|
+
**Hard-breaking (open to veto).** (1) **What changed:** `SHELL_READ_DECISION.outside_roots.full_shell` is the object `{ narrowed: "mandated", open: "mandated", redirect: "ordinary" }` (it was the string `"mandated"`; the private `SHELL_REDIRECT_DECISION` override is retired) — a cell is a disposition or `{ narrowed, open, redirect? }`, read by one reader (`decideShellRead` → `dispositionOfCell(cell, context)`). Every reading is unchanged (pin T1: all 7 × 3 × 6 cells equal the 7.50.1 two-table reader). (2) **Who is hurt:** a consumer that reads `SHELL_READ_DECISION[f][s]` as a string — sema-cli's dev fence `verify/_bash-gate-danger-matrix.mjs:1019 / :1051 / :1063` (`origin/main` `37f37540`; it already compares this cell against a string and `decideShellRead` against the raw cell, as it does for `unestablishable × full_shell` since 7.49.2). (3) **How to migrate:** read a cell through `decideShellRead(finding, seat, context)`, or treat an object cell's members as its readings.
|
|
198
|
+
|
|
199
|
+
- **Pins.** `test/backlog1681-one-decision-table.test.ts` T1 (the whole matrix against the 7.50.1 reader), T2 (the reader reads the exported table and nothing beside it — RED on v7.50.1), T3 (each row's dispositions).
|
|
200
|
+
|
|
201
|
+
### A followed sed backup the station cannot follow is still a write (#1684; fix; security; behaviour narrowing; hard-breaking: none; exports: +0 / −0)
|
|
202
|
+
|
|
203
|
+
- **Before (v7.50.1; the same in v7.49.1).** GNU `sed -i<suffix> --follow-symlinks` backs up the file the operand's link chain lands on. Where the station could not follow the chain — a link whose content the text cannot place, a working directory the text does not settle, an isolated lane's lexical reading (no filesystem asked) — the backup's fallback read carried no write mark, so the judge read it as a name the program only names: no finding, and the command was vouched although what it writes was never placed (KL-1672-c).
|
|
204
|
+
- **Now.** The fallback read carries the write mark at every seat it is written at, as every unplaced write target does: the write face's `unestablishable` question — ordinary on the full shell, refused on the read-only face. A chain that stopped in the filesystem (an answer the environment did not give, the hop bound) stays `unanswered` (recorded at the door).
|
|
205
|
+
- **Who needs it / who is hurt / compensation (behaviour narrowing).** A deployment whose write face is its fence needs it. Hurt: `sed -i<suffix> --follow-symlinks` on a link the station cannot follow now asks (ordinary: a whole-tool allow, bypass, the classifier or a person releases it); on an isolated lane (lexical reading) EVERY such edit asks — the lexical reading follows no link. A plain `sed -i` and a followed chain the station can follow are byte-identical (pin S5). Compensation: answer the card, or drop `--follow-symlinks` / the backup suffix.
|
|
206
|
+
- **Pins.** `test/backlog1684-sed-backup-written-mark.test.ts` S1–S3 (RED on v7.50.1), S4 / S5 controls.
|
|
207
|
+
|
|
208
|
+
### A resolver that fails for a whole command is not an adapter without a file face (#1694; fix; security; behaviour narrowing; hard-breaking: none; exports: +0 / −0) (@server)
|
|
209
|
+
|
|
210
|
+
- **Before (v7.50.1; the same in v7.49.1).** The environment door took back every inconclusive lookup when the resolver (declaring no authority) had answered NOTHING in the whole command, reading a failing resolver like an adapter with no file face: the command took the lexical reading, deny rows matched spellings only — a symlink to a deny-listed `.env` was read on both faces, and the read-only seat executed (KL-1672-f).
|
|
211
|
+
- **Now.** `not_supported` is an adapter's answer that it has no file face — conclusive, the lexical reading stands; every other non-answer (`permission_denied`, `timeout`, `unknown`, any code outside the conclusive set) is recorded wherever it falls and the command is `unanswered` — mandated on the full shell, refused on the read-only face. The door's take-back (`mark` / `silence` / `answered`) is retired.
|
|
212
|
+
- **Who needs it / who is hurt / compensation (behaviour narrowing).** Every deployment whose deny rows guard files behind links needs it. Hurt: an adapter that answers a missing file face with a failure code instead of `not_supported` now asks on every command its station judges (refused on the read-only face). Server's seven lanes all have a file face and mint no `not_supported`; a broken face answers `unknown`; e2b / local-docker declared `canonicalPathAuthoritative: true` in server 7.126.0 — no server lane is hurt (server's reply on the board, 10-13). A throwing adapter is unchanged (the judgment throws; the gate takes the station's fallback). Compensation: an adapter without a file face answers `not_supported`.
|
|
213
|
+
- **Pins.** `test/backlog1694-failing-resolver-is-not-no-face.test.ts` E1 / E2 (three failure codes × two faces, the `.env` symlink — RED on v7.50.1), E5 (the test A shape on a real Runner lane, both faces — RED on v7.50.1), E0 / E3 / E4 controls; re-pinned: `backlog1672` ⑩ ① ⑤ ⑦ (nothing answered ⇒ unanswered), `backlog787` K2 / K3 (the lexical control now on `not_supported`; a failing undeclared lane asks).
|
|
214
|
+
|
|
215
|
+
### A persisted gate record has one reading: every word any version minted, total over what JSON holds; the engine's own records are read as their producer (#1731; one reading; behaviour change — the default reading widens; hard-breaking: three, each open to veto; closed sets: none move — a registry of retired members is added; exports: +2 names (`RETIRED_MEMBERS`, `isRetiredMember`), 0 removed; members: `screenGateOutcome(g, opts?)` — `g` widens to `unknown`, `opts.reading` added) (@server @test)
|
|
216
|
+
|
|
217
|
+
**Hard-breaking (open to veto).** (1) **What changed:** `screenGateOutcome(g: unknown, opts?: { reading?: "archive" | "producer" })` — a second, optional parameter; the default reading is `"archive"`. An options value that is not an object (or `undefined`), or a reading outside the two, throws a `TypeError`. **Who breaks:** a caller that hands the screen as a callback with a second argument — `records.map(screenGateOutcome)` passes the index, which now throws (nothing in core, server, cli, client-core or sdk does; `origin/main`, 10-14). **How to migrate:** `records.map((g) => screenGateOutcome(g))`. (2) **What changed:** the export `RETIRED_MEMBERS` — a frozen registry, one list per closed set a gate record carries (`askClass`, `modeReleaseReason`, `askOrigin`, `persistedRuleMandate`, `deniedBy`, `settlementKind`, `settlementParty`, `classifierDenyCause`, `permissionMode`, `autoModeFallbackCause`), each entry `{ word, retiredIn }`. **Who breaks:** nobody (additive); a consumer that pins the package root's name list gains a row. **How to migrate:** none. (3) **What changed:** the export `isRetiredMember(set, word)` — the registry's one predicate; a set name outside the registry throws. **Who breaks / how to migrate:** as (2).
|
|
218
|
+
|
|
219
|
+
- **Before (v7.50.2).** 7.50.2 read a release's class as shape, not policy — but the screen still read every closed-set word against TODAY's set, and members retire between versions. A record a 7.49.5 engine minted (`released.reason: "rule_unreadable"`, reason retired 7.50.0) or a 7.50.x engine minted (`disposition.askClass` / `classes: ["governance_unavailable"]` on a mode refusal, class retired in this version) screened as "outside the closed set", so a reader of persisted records still recorded lawful history as a defective producer. The engine's producers composed two calls (`screenGateOutcome` beside `releasePolicyDefects`, the 7.50.2 entry's "call both functions"). The screen read a class word only on a mode refusal and a release, skipped `released.mandate`, and threw on a member JSON can hold in the wrong shape (`released: null`, `settlement: null`, `classifier: null`, a word `{ "toString": null }`), so server kept a second reading of the same record in front of it.
|
|
220
|
+
- **Now.** One reading per caller: `"archive"` (the default — a replay, a ledger, a durable row) reads every closed set as its live members plus `RETIRED_MEMBERS`; `"producer"` reads the live sets and this engine's release law, and the gate's exit and its station screen each call `screenGateOutcome(outcome, { reading: "producer" })` once (the 7.50.2 two-call form is retired). Retired words, every one the record's sets have had: `askClass` — `write_protection_unresolved` (7.49.0), `governance_unavailable` (7.51.0); `modeReleaseReason` — `write_protection_unresolved` (7.49.0), `rule_unreadable` (7.50.0); `askOrigin` — `unresolvable` (7.14.0); `persistedRuleMandate` — `write_protection_unresolved` (7.49.0); `deniedBy` — `plan_mode` (7.34.0); the other five sets — none. The producer reading names a retired word's version (`… is outside the closed set (retired in 7.51.0)`). Every class, reason, origin and mandate word is read wherever the record carries it — `disposition.askClass` / `classes` on any deny, `released.mandate` beside `origin` and `reason`, a `released` on a denied record (itself a defect, `I7: disposition.released stands only on an allowed disposition`); a deny's member on an allowed record is `disposition.<member> stands only on a denied disposition`. The release law reads the whole set: one face must release every member of `released.classes` (7.50.2 asked the disclosed word only, so a release of `["transcript_write", "rule_unreadable"]` passed the producer check though the arbitration never releases it). Every member is read as whatever it holds: a wrong shape is one defect naming its position (`I7: disposition.released is not an object (null)`, `settlement is not an object (string)`, `disposition.cause (an object) is outside the closed set`), never an exception, in both readings. A retired word has no row in today's word-indexed tables (`DENIED_BY_MAY_VETO`, `DENIED_BY_MAY_CARRY_CAUSE`) and is read as granted nothing by them — exact for `plan_mode`, whose rows were both `false`. Fences: no word both live and retired (`RetiredMembersAreNotLive`), every retired reason a class word of its day (`RetiredReasonsAreClassWords`).
|
|
221
|
+
- **Which record fields can carry `governance_unavailable` in a 7.50.x archive.** `disposition.askClass` and `disposition.classes` of a mode refusal (`deniedBy: "mode"`): `dontAsk` refusing an ask whose rule store, partition or organization snapshot could not be read (alone, or `["governance_unavailable", "rule_unreadable"]`), and `bypassPermissions` refusing a set holding `target_unresolved` that the class made immune. Never `released.askClass` / `released.classes`: the class was bypass-immune on every version, so no engine released it. Under the default reading those records screen clean; under `"producer"` each names `(retired in 7.51.0)`.
|
|
222
|
+
- **Who is hurt.** A caller that used `screenGateOutcome(g)` as a producer check on records it mints itself no longer gets the release-law defect or a retired word's defect from the default — pass `{ reading: "producer" }` (none known: the engine's two sites pass it). A consumer matching defect sentences: the mode-refusal sentences are now `disposition.askClass "…" is outside the closed set`, `disposition.classes must be a non-empty list of ask classes` and `I8: a mode refusal carries askClass and classes` (7.50.2: one sentence, `I8: a mode refusal carries askClass and a non-empty classes list`); a word that is not a primitive is spelled by its shape (`(an object)`), not its text; the I8 mode sentence says "only bypassPermissions and dontAsk refuse" (7.50.2 named `plan`, which the screen never accepted). A record whose deny carries a class word outside every version's set, or whose release carries a `mandate` outside it, or that carries a member on the other arm, now has a defect (no engine mints any of them); under `"producer"`, a release whose set mixes a class one face releases with one no face releases is a defect (the engine's arbitration never mints one; the gate's exit now also refuses to compose it).
|
|
223
|
+
- **Compensation.** Readers of persisted records pass nothing and are right for every record any version minted. Server (S-1542; `origin/main` `d769408e`): `src/trace/project.ts:311-327` (`modeLedgerDefect`) and the shape gate at `:392-414` are a second reading of the same record in front of `screenGateOutcome` at `:418`; every check they make is a check of this reading (pin A13 maps each sentence to the position this screen names), and their live `isAskClass` / `isPersistedRuleMandate` reads still reject a 7.50.x refusal naming `governance_unavailable` and a 7.48.x release naming the mandate `write_protection_unresolved` — the second reading can retire at 7.51.0. Test: judge by the pin file; the 7.50.2 fence `repro/core7502-screen-shape-20261014/` reads clean under the default. client-core: `scripts/run-mode-release-projection-test.mjs:171-181, :274` screens lawful fixtures and words no version had — unchanged under the default. cli / sdk: no call site (`origin/main`, 10-14).
|
|
224
|
+
- **Pins.** `test/backlog1731-gate-screen-archive-reading.test.ts` A1 (7.50.x refusals naming `governance_unavailable`; the released fields' vocabulary cell), A2 (the 7.49.5 frame captured by the test repo, verbatim), A3 (a word no version had — both readings, every position), A4 / A7 (7.48.x `write_protection_unresolved` refusal and shell release, mandate included), A5 (today's records identical in both readings; the gate's exit refuses an immune release through the producer reading; census — exactly the two producer calls), A6 (the registry: one row per set, frozen, versions, no live word, the predicate; the ever-member ratchet — a word dropped from a live set without its entry is red), A8 (≤7.33 `plan_mode`; no veto, no cause), A9 (≤7.13 `unresolvable`), A10 (a bad reading throws), A11 (every wrong shape JSON can hold: a defect at its position, zero throws, both readings; lawful records through JSON clean), A12 (a class word on a non-mode deny), A13 (server's second reading, sentence by sentence); A5 also the release law over the whole set (a mixed set refused at the gate's exit, archive clean). Re-pinned: `backlog1730` P5 and `backlog1423` (the release-law defect through the producer reading); `backlog955` STRUCTURE (the retired word `write_protection_unresolved` stays gone from every source but the registry, which must hold it).
|
|
225
|
+
|
|
226
|
+
### #1735 — the consolidation write refusal says WHY before it says WHERE (fix; no exports; no closed sets)
|
|
227
|
+
|
|
228
|
+
- **Before (v7.50.2; the same since the write judge arrived).** `refuseUnlessWriteAllowed` (`src/core/memory-engine/consolidation-driver.ts`) minted `memory consolidation refused for scope <scope> (<library path>): <why>`, and the notice dialect (`memoryConsolidationRefusedNotice`) inlines that sentence under a 200-character cap — a long data-dir path (macOS `$TMPDIR`, deep deployments) pushed the layer and the cause past the cap: an operator read `…(<dir>): a st…` and nothing else (test's 7.127.0 product lane; `backlog1653` §3 red on every macOS tree).
|
|
229
|
+
- **Now.** `memory consolidation refused for scope <scope>: <why>[ — <consequence>] (library <path>)` — the reason first, the library path last; the cap cuts the path, never the reason. `detail` is unchanged (`refusalCode`, `scope`, `occurrenceId`).
|
|
230
|
+
- **Who is hurt.** A consumer that parsed the old sentence order (`(<path>): `) — none known; server reads `refusalCode` / `scope` from `detail`. Compensation: none needed.
|
|
231
|
+
- **Pins.** `test/backlog1735-refusal-why-before-path.test.ts` R1 (a ≥260-character library path keeps the layer and the cause inside the first 200 characters and inside the minted notice; red before) / R2 (control: scope leads, path last); `backlog1653` §3 four cells green on macOS.
|
|
232
|
+
|
|
233
|
+
### The `tool_end.output` media reading is exported: one predicate, one descriptor (#1718; additive; hard-breaking: none; closed sets: none; exports: +3 names (`isMediaBlock`, `mediaBlockDescriptor`, `type MediaDescriptor`), 0 removed) (@server)
|
|
234
|
+
|
|
235
|
+
- **Before (v7.50.2).** The projector's media predicate (a block whose `type` is `image` / `document` and whose `data` is a string) and its degraded descriptor (`{ type, mimeType? (echoed only up to 255 characters), bytes = floor(len · 3 / 4), truncated: true }`) were module-private in `src/core/runner/tool-output-projection.ts`; a consumer that degrades media itself (server's ledger sink and backlogged-connection face, `src/trace/tool-output-media.ts`) carried its own copy of both, so a change to the formula or the echo bound would have split the two silently.
|
|
236
|
+
- **Now.** Both are minted once in `src/core/media-byte-cap.ts` beside `imageBytes` (whose decoded-size formula the descriptor reads) and exported from the package root: `isMediaBlock(block)`, `mediaBlockDescriptor(block)` and `type MediaDescriptor` (the union also names the `{ type: "omitted", blocks, bytes, truncated: true }` summary the projector folds the rest into, minted beside the descriptor). The projector degrades through the same functions; the request-side media strip reads the same one media-type reading (`claimedMediaType`, module-level). Byte-identical output for every block.
|
|
237
|
+
- **Who is hurt / compensation.** Nobody (additive). Server S-1533: `src/trace/tool-output-media.ts:24-55` (`isMediaBlock`, the inline descriptor) retires onto the two exports; its `trueSizeOf` (the per-block size the projector's `totalChars` sums) is a third copy this entry does not export (the projector's counter stays module-private).
|
|
238
|
+
- **Pins.** `test/backlog1718-media-descriptor-one-mint.test.ts` (four shapes: short media type / 256-character media type not echoed / no media type / document — projector output === exported function output; the 255/256 echo boundary; `bytes` = `imageBytes`; the omitted summary; the predicate; the three root names in the export snapshot).
|
|
239
|
+
|
|
240
|
+
### Every media block a tool hands back passes the one byte admission at the tool-result seat (#1719; security; behavior change; **hard-breaking: one, open to veto**; closed sets: `EngineNoticeCode` +`tool_result.media_refused`, new `MediaRefusalReason` (five words); exports: +0 names at the root; members: `AgentLoopConfig.onToolMediaRefused?` / `AgentHarnessOptions.onToolMediaRefused?` added, optional, engine-internal (neither type is a root export)) (@server @test)
|
|
241
|
+
|
|
242
|
+
- **Before (v7.50.2).** `admitImageBytes` (`src/tools/fs/image-admission.ts`: MIME closed set png / jpeg / gif / webp → raw cap → signature → base64 limit) admitted the bytes of a Read image, a `TaskSpec.images` entry and a user attachment, but not an image an MCP server returned (`src/core/mcp.ts` `boundedImageBlock` checked only the base64 length), nor a block a host tool's `execute` returned (`run-leg.ts` and the live loop placed `ran.result.content` as is), nor one a `tool_result` hook put in place. Any text could pose as an `image/png` block's `data`: it rode `tool_end.output` verbatim (a consumer does not redact a media block's `data`) and the next provider request 400'd the turn.
|
|
243
|
+
- **Now.** The loop's one post-execution half, `finalizeToolOutcome` (`src/engine/loop/agent-loop.ts`) — the function every executed call passes, live (sequential, parallel, in-stream lanes) and resumed (`AgentHarness.finalizeToolCall`), after the last hand that can change the result (the `tool_result` hook's patch) — admits every block whose `type` is `image` or `document` through `admitToolResultMedia`: an image through `admitImageBytes` (no downsampler at the seat: an oversized block is refused, a supply that can resize — the Read tool, the MCP conversion — resizes before it), a document through `admitDocumentBytes` (`application/pdf`, the Read tool's 20 MB whole-send cap, the `%PDF-` signature). An admitted block is rebuilt from the bytes it checked (a canonical block is byte-identical). A refused block becomes, in its slot, a text block naming the tool, the refusal word (`undecodable`, `mime_unsupported`, `raw_too_large`, `magic_mismatch`, `base64_too_large`), the size and the bound — never the bytes; a claimed media type is named only when it can be one (RFC 6838 restricted names). The run hears one `tool_result.media_refused` notice per call (audience operator, engine-minted; `detail: { toolName, toolCallId, refused: [{ type, reason, bytes, limit?, mimeType? }], sessionId, runId }`). The Read path is unchanged (its blocks pass the seat byte-identical). The MCP conversion keeps an image item with no payload or no media type an image (it used to fall to the unknown-item arm and ride as its JSON text, payload included), so the seat refuses it like any other.
|
|
244
|
+
- **Hard-breaking (open to veto).** What changed: a host tool, an MCP server or a hook that returned non-image bytes under an image media type (or a media type outside the four, or a document that is not a PDF, or an image over the cap) used to have the block placed verbatim; now the model and the stream read a text placeholder in its slot and the deployment hears one notice. Who is hurt: such a tool's output — including test fixtures that hand back placeholder bytes such as `"AAAA"` as `image/png` (this tree re-pinned four `backlog1668` Runner cells with real png bytes) — and an MCP server returning `image/svg+xml` or `image/bmp`. Compensation: return the bytes the block claims (a png starts with its signature) or a text block; the placeholder names the refusal.
|
|
245
|
+
- **Three sentences (additive closed-set member).** What changed: `ENGINE_NOTICE_CODES` gains `tool_result.media_refused` (92 → 93; audience operator, minter engine). Who breaks: a consumer holding a total copy of the notice catalog or its count — server `src/trace/engine-notice-wire.ts` and `test/engine-notice-wire.test.ts` ⑥ (its catalog anchor), client-core `src/generated/engineNoticeTables.ts` (regenerate), cli `scripts/lib/engine-pin.mjs` (the catalog count), sdk's catalog test — compiles or asserts red until it adds the row. How to migrate: regenerate / add the row; an unknown code is rendered by its `message`.
|
|
246
|
+
- **Server.** S-1538: zero action — "no downstream redaction of `data`" stands; the source now admits it.
|
|
247
|
+
- **Pins.** `test/backlog1719-media-admission-one-seat.test.ts` — the seat block by block (text-only untouched; text posing as png → placeholder, payload never echoed; a real png byte-identical; PDF passes, non-PDF and mis-typed PDF refused; over the raw cap names the cap; over the base64 limit names the limit; an unechoable media type; a payload-less block) and through a real Runner: a host tool's fake png (tool_end, next request, one notice, operator audience; RED on v7.50.2), a host tool's real png (control), a `postToolUse` hook's replacement (RED on v7.50.2), the Read path byte-identical with no notice (control, green on both), a resumed approved call (RED on v7.50.2), an MCP server's fake png (RED on v7.50.2), its image with an empty media type (RED before the conversion fix) and real png (control). Re-pinned: `backlog1668-one-rule` (three Runner cells), `backlog1668-tool-end-image-blocks` (the live/resume cell) and `streaming` (the two RB-210 cells) with real png bytes; `backlog1279` notice count 93.
|
|
248
|
+
|
|
249
|
+
### The consolidation write judge has one constructor, callable by a host (#1716; behavior: one composition for the engine's auto lane and a host's valve; hard-breaking: none for package consumers (the retired adapter was never exported); exports: +3 names (`createConsolidationWriteJudge`, `deliverWriteRefusal`, `type ConsolidationWriteJudgeInput`), 0 removed) (@server)
|
|
250
|
+
|
|
251
|
+
- **Before (v7.50.2).** The leg composed the judge every consolidation write asks in three prepare phases (a reader of the person's rows the run's cut does not end, the two rule lanes over it, the synthetic Write judge over them and the leg's layers) and adapted it at the settle tail (`consolidationWriteJudgeOfLeg`); a host's own valve (server's `POST /v1/admin/memory/consolidation/run`) had no constructor to call and answered "no objection" by fiat — a person's `Edit(//<library>/**)` deny bound the automatic consolidation and not the manual one.
|
|
252
|
+
- **Now.** `createConsolidationWriteJudge(input, leg?)` (`src/core/runner/memory-consolidation-write-judge.ts`) is the one composition: from `input` — `ruleStoreProvider`, `principal`, `localOwnerDeclared`, `callerPolicy`, optional `sessionId` (absent: no session row applies), `anchors: { root?, home? }` and `executionEnv` (where the library's paths and the rows' heads resolve; absent: the engine process's own filesystem, where an in-process file backend writes the library) — it builds the person's and the org's lanes (the gate's own `createPermissionRuleLanes` over a reader of the store) and the synthetic Write judge, and returns the seat `runMemoryConsolidationDriver` asks. The engine's leg calls the same function at turn wiring, adding its reader, its lane facts and the layers only a leg has (skill rows, the narrowing fold, inherited policies, the target stamp, the live write facts); `Prepared.syntheticWriteVerdict` is `Prepared.consolidationWriteJudge`, and `consolidationWriteJudgeOfLeg` is retired (no second constructor: one pin greps `src/` for it). A host's probe carries the face the engine's own `Write` wears (minted once by the roster from that definition), so the person's path rows read it as they read a leg's `Write`; its rule heads are resolved and its target stamped in that environment with the leg's own stamp, so a row or a target spelled through a link answers as on the leg. A row the host cannot anchor — a relative row with no `anchors.root`, a `~/` row with no `anchors.home` — is an objection (fail closed): the judge never guesses where a row would have pointed. A local-owner declaration the store cannot honor is refused at construction. `deliverWriteRefusal(err, scope, onNotice)` is exported beside it: a `ConsolidationRefusedError` with the write-refused code becomes the one `memory.consolidation_refused` notice (`true`); any other error emits nothing (`false`).
|
|
253
|
+
- **Who is hurt / compensation.** Nobody in a package consumer (the constructor and the notice helper are new; the internal `Prepared` seat renamed). Server S-1532: `src/boot/memory-consolidation.ts` `createOperatorValveWriteJudge` (no-objection by fiat) and the inline refusal-notice copy at `:964-970` retire onto `createConsolidationWriteJudge({ ruleStoreProvider, principal, localOwnerDeclared, callerPolicy, anchors })` and `deliverWriteRefusal`; a valve that knows the deployment's home passes `anchors.home`, or a person's `~/` deny rows refuse every manual run.
|
|
254
|
+
- **Pins.** `test/backlog1716-write-judge-one-constructor.test.ts` — under the same `Edit(//<library>/**)` deny the host-built judge and the leg's (a real Runner auto lane) both refuse, and with no row both admit; a row spelled through a link to the library binds both; a target spelled through a link is judged where it leads; the principal decides whose row (the anonymous floor reads none); an unanchored `~/` row objects, the same row with the home declared binds only what it names; the local-owner door; `deliverWriteRefusal` (one notice and `true`; `false` and nothing for any other error); the three root names; no second constructor in `src/`. Re-pinned: `backlog1653` §1b (the seat is driven through a caller-policy layer, the adapter it handed a bare judge to is retired), `prepare-turn-wiring` (input fixture).
|
|
255
|
+
|
|
256
|
+
### A synthetic probe states "nobody can be asked" on the request and hands every layer a live signal — a default deployment's automatic memory consolidation is no longer refused (#1737; P1; fix; one reading; **hard-breaking: one, open to veto**; closed sets: none; exports: +1 name (`noApproverSeatDeny`), 0 removed; members: `ToolCallRequest.approvalReach?` added, optional) (@server @test)
|
|
257
|
+
|
|
258
|
+
- **Before (v7.50.2; the same since 7.49.5).** The synthetic judge (`src/core/synthetic-read-verdict.ts`: the consolidation write probes, the changed-file and attachment visibility probes) consulted the deployment's caller policy and every inherited parent policy with one module-level `AbortSignal.abort()` to mean "there is no approver". The engine's own `createSensitivePathPolicy`, composed into a deployment's caller policy (server's default), bounds every lookup of where a write lands by the signal it is handed (`fromSource` → `withGateTimeout`): an aborted signal ended each lookup before it began, the target was "undeterminable", and the guard answered `deny (target_unresolved)`. So on a default deployment — no rules, default mode, an ordinary library directory — every automatic memory consolidation run was refused (server [10446] S-1553 minimal repro: `createSensitivePathPolicy(…).check(<Write>, AbortSignal.abort())` ⇒ deny, a live signal ⇒ allow; test 7.127.0 "a control posture with no person's rules is refused too"). One signal, two readings: "the task ended" and "there is no approver". The same signal also ended `createFsWriteGatePolicy`'s target lookup (an ask: a write-face refusal) and the session-rule layer's live read (its seed answered).
|
|
259
|
+
- **Now.** Every probe states the fact on its request: `ToolCallRequest.approvalReach: "none"` — the word of `TaskSpec.approvalReach`, stated per request, present only on a probe's request. Each policy layer is handed a LIVE signal of its own: it ends when the run's signal does, or with a `TimeoutError` reason once the judge stops waiting for that layer (it answered, or its share of the judgment's budget — one dependency timeout, or a batch's shared deadline — is spent). An abort means the task ended (or the judge stopped waiting), nothing else. Every engine factory that reached a seat after reading the abort now asks one question before it — may the seat be reached? (`unaskedSeatAnswer`, module-level: the abort first, then the statement) — so `createApprovalPolicy` (before `approve`) and `createPreToolUseConstraintPolicy` (before its hook) answer `noApproverSeatDeny(req, message, origin)` on the statement — a deny attested `no_approver`, read by the judge as `approval_seat` exactly as the `task_aborted` deny was; a real abort still answers `task_aborted`. `combinePolicies`' unasked-seat rule reads both settlements: a deny of a seat that was never asked (`task_aborted`, `no_approver`) yields to a later rule's deny, so "does any layer refuse?" answers the same in every order. The `ToolPolicy.check` contract (`policy.check.aborted_signal_reaches_nobody`) still holds; the probes no longer rely on it.
|
|
260
|
+
- **Hard-breaking (open to veto).** What changed: a probe no longer hands an already-aborted signal. Who is hurt: a deployment policy that opens an approval (awaits a person, sends a card or a message) inside `check` and relied on `signal.aborted` to skip it during probes is now asked on every probe — the judge waits for it at most the judgment's budget (`timed_out`: the write face refuses, the notice face stays silent, the attachment face withholds) and then ends that layer's signal, so a closure that honors its signal withdraws the approval; a closure that ignores both the statement and the signal leaves its approval open. Compensation: read `req.approvalReach === "none"` first and answer `noApproverSeatDeny(req, message, origin)` (or an `ask`). Server (origin/main `524fcde3`): none of its `ToolPolicy.check` implementations awaits a person — `createDurableAskPolicy`, `createDurableQuestionGate`, `createApprovalBaselinePolicy`, `createCommandAskListPolicy`, `createWorkflowAskPolicy`, `createGovernanceAskMarkingPolicy`, `createGovernanceShellGateMarkPolicy`, `createDeploymentAuthoredPolicy` and the supervisor route gate answer `ask` / `allow` / `deny` or read a store — so the change reaches it only as the fix.
|
|
261
|
+
- **Three sentences (additive member and export).** What changed: `ToolCallRequest` gains `approvalReach?: "none"` (present only on a synthetic probe's request, never on a call the gate adjudicates) and the package root gains `noApproverSeatDeny`. Who breaks: nobody that reads a request (an absent member reads as before); a consumer that builds a `ToolCallRequest` compiles as before (the member is optional). How to migrate: none; a deployment that wants to tell a probe from a model's call reads the member instead of the call id.
|
|
262
|
+
- **Pins.** `test/backlog1737-probe-states-no-approver.test.ts` — S1 server's repro (the guard alone: aborted ⇒ deny `target_unresolved`, live ⇒ allow); the one constructor with the default guard as the caller policy and no rules ⇒ `no_objection` for a library write (RED on v7.50.2: `deny`); the guard inside the composition sees a live signal and allows (RED); a guarded spelling, a data-root sub-path and a configuration-root twin are still refused. S2 a recording layer (caller and inherited) sees a live signal and the statement on both faces (RED); a real run abort reaches the layer as an abort and the approval factory answers `task_aborted`. S3 `createApprovalPolicy` and `createPreToolUseConstraintPolicy` with the statement reach no seat and answer `no_approver` (RED); through the write judge both read as `approval_seat` with approver 0 (RED); a rule deny wins in either composition order on both faces. S4 a layer that ignores the statement and waits on a person is cut at the budget (`timed_out`, no hang) and its signal then ends with a `TimeoutError` reason (RED). Re-pinned: `backlog1311a` ④c (the probe a deployment policy sees: a live signal and the statement, a model's call neither), `prepare-turn-wiring` #1311 A1 (the hanging deployment closure tells a probe by the statement). Re-anchored: `test/policy-out-of-contract-decision.test.ts` (the text anchor now reads `policy.check(req, wait.signal)`); `scripts/verify-child-clamp-seats.mjs` carries one producer row for the probe's per-request statement (not a clamp).
|
|
263
|
+
|
|
264
|
+
## 7.50.2 — 2026-10-14
|
|
265
|
+
|
|
266
|
+
**Hard-breaking changes:** none. **Closed sets:** none. **Exports:** +0 / −0 (`releasePolicyDefects` is module-level, not on the package root).
|
|
267
|
+
|
|
268
|
+
### The gate-outcome screen reads the shape of a mode release, not today's release policy (#1730; fix; truthfulness of persisted records; hard-breaking: none) (@server @test)
|
|
269
|
+
|
|
270
|
+
- **Before (v7.50.0 / v7.50.1).** `screenGateOutcome` I7 asked whether `released.askClass` is releasable under TODAY's table (`releasableAskClassesFor`). Release policy moves between versions (7.50.0 made `rule_unreadable` bypass-immune), so a record a 7.49.5 engine minted lawfully — a `rule_unreadable` ask released under `bypassPermissions` — screened as `I7: released.askClass "rule_unreadable" is not a releasable class` on 7.50.x, and a reader of persisted records (server's event-ledger replay, S-1546) recorded lawful history as a defective producer.
|
|
271
|
+
- **Now.** I7 is a shape invariant: the class word is a member of the closed set (`isAskClass`), the release carries no settlement, the mode is `bypassPermissions`, `classes` is a non-empty list of ask classes, `origin` / `reason` are members of their sets. Whether a class MAY be released is the producer's law at mint time: `releasePolicyDefects` (module-level) is read beside the shape screen at the gate's exit (`gate-exit.ts`) and at the station screen (`prepare-gate-stations.ts`), so this engine still refuses to mint a release of an immune class today, while a reader of persisted records asks only the shape.
|
|
272
|
+
- **Who is hurt.** A consumer that used `screenGateOutcome` as a producer check on records it mints itself (none known; the engine's two producer sites now read both halves) no longer gets the policy defect from the screen alone. Nobody else: a record this engine minted screens exactly as before; a lawful historical release stops reading as defective.
|
|
273
|
+
- **Compensation.** None needed for readers of persisted records. A host that mints gate records and wants today's policy judged calls both functions, or waits for 7.51.0's `screenGateOutcome(g, { reading: "producer" | "archive" })` with the engine's retired-vocabulary table (so 7.50.x records naming `governance_unavailable` read clean after its 7.51.0 retirement).
|
|
274
|
+
- **Pins.** `test/backlog1730-gate-screen-shape-not-policy.test.ts` P1–P5 (P1 red on v7.50.1); `test/backlog1423-wpu-by-face.test.ts` re-pinned: the policy defect is the producer's, the shape screen accepts the record.
|
|
275
|
+
|
|
1
276
|
## 7.50.1 — 2026-10-14
|
|
2
277
|
|
|
3
278
|
**Hard-breaking changes:** none. **Closed sets:** none — the read station's details code stays `read_unestablished` on both faces (whether a write finding gets its own code is a closed-set question, left to 7.51.0). **Package exports:** +0 / −0 names (`npm run handoff:diff v7.50.0 HEAD`); no type, signature or closed-set change (`wire-changes` preview: 0 / 0 / 0). **Behavior:** a tool policy that throws is denied in its own words with one redacted line and reported once on `onError`, never as a timeout; a write the read station could not place is carded as a write. Both defects were present in 7.49.5 and 7.50.0.
|
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import { terminalProjection } from "../core/runner/terminal-projection.js";
|
|
2
2
|
import type { TerminalCause } from "../core/types.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - {@link childTerminalFace}: a settled child's cause, projected (the report's `error:` line, the card, the task row, the
|
|
8
|
-
* notification's summary and digest);
|
|
9
|
-
* - {@link childThrownText}: the text of a rejected run;
|
|
10
|
-
* - {@link childRejection}: a rejected run's thrown value itself, for the foreground lane, where the tool loop writes the
|
|
11
|
-
* parent's tool result from the thrown value's `message`.
|
|
12
|
-
*/
|
|
3
|
+
import { type Egress } from "../core/untrusted-egress.js";
|
|
4
|
+
/** A child run's failure words can carry a credential: every parent-facing reader takes them from here, each through the
|
|
5
|
+
* `same-principal` tier, before any field bound can clip a token — a settled cause's projection, a rejected run's text, and
|
|
6
|
+
* its thrown value (every string member, not the message alone). */
|
|
13
7
|
export declare const childTerminalFace: (terminal: TerminalCause) => ReturnType<typeof terminalProjection>;
|
|
14
|
-
export declare const childThrownText: (err: unknown) =>
|
|
15
|
-
/**
|
|
16
|
-
* The thrown value of a rejected child run, re-minted with the redacted sentence: same prototype and own members (`code`,
|
|
17
|
-
* `details`, `errorKind`, `cause`) — only `message` changes. The copy's `stack` is its own, captured here: the original's
|
|
18
|
-
* first line carries the message, so it is not carried over. A value with nothing to redact is returned as it is.
|
|
19
|
-
*/
|
|
8
|
+
export declare const childThrownText: (err: unknown) => Egress<"same-principal">;
|
|
20
9
|
export declare const childRejection: (err: unknown) => unknown;
|
|
@@ -1,28 +1,8 @@
|
|
|
1
1
|
import { terminalProjection } from "../core/runner/terminal-projection.js";
|
|
2
|
-
import {
|
|
3
|
-
import { redactSecrets } from "../core/untrusted-egress.js";
|
|
2
|
+
import { egressText, egressThrownText, egressValue } from "../core/untrusted-egress.js";
|
|
4
3
|
export const childTerminalFace = (terminal) => {
|
|
5
4
|
const face = terminalProjection(terminal);
|
|
6
|
-
return typeof face.errorMessage === "string" ? { ...face, errorMessage:
|
|
7
|
-
};
|
|
8
|
-
export const childThrownText = (err) => redactSecrets(thrownText(err));
|
|
9
|
-
export const childRejection = (err) => {
|
|
10
|
-
const text = thrownText(err);
|
|
11
|
-
const clean = childThrownText(err);
|
|
12
|
-
if (clean === text)
|
|
13
|
-
return err;
|
|
14
|
-
if (typeof err !== "object" || err === null)
|
|
15
|
-
return clean;
|
|
16
|
-
try {
|
|
17
|
-
const copy = new Error(clean);
|
|
18
|
-
Object.setPrototypeOf(copy, Object.getPrototypeOf(err));
|
|
19
|
-
for (const key of Reflect.ownKeys(err)) {
|
|
20
|
-
if (key !== "message" && key !== "stack")
|
|
21
|
-
Object.defineProperty(copy, key, Object.getOwnPropertyDescriptor(err, key));
|
|
22
|
-
}
|
|
23
|
-
return copy;
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return new Error(clean);
|
|
27
|
-
}
|
|
5
|
+
return typeof face.errorMessage === "string" ? { ...face, errorMessage: egressText("same-principal", face.errorMessage) } : face;
|
|
28
6
|
};
|
|
7
|
+
export const childThrownText = (err) => egressThrownText("same-principal", err);
|
|
8
|
+
export const childRejection = (err) => egressValue("same-principal", err);
|