@sema-agent/core 7.23.5 → 7.24.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/agents/list-agents-tool.d.ts +7 -4
  3. package/dist/agents/list-agents-tool.js +12 -1
  4. package/dist/agents/observer.js +5 -8
  5. package/dist/agents/subagent.js +2 -2
  6. package/dist/agents/team.js +9 -0
  7. package/dist/brain/degrading.js +8 -9
  8. package/dist/brain/route-adjudicator.d.ts +25 -12
  9. package/dist/brain/route-adjudicator.js +89 -23
  10. package/dist/brain/route-conformance.js +9 -0
  11. package/dist/brain/stream-engine.js +11 -9
  12. package/dist/core/arg-summary.js +17 -6
  13. package/dist/core/auto-compaction.d.ts +42 -1
  14. package/dist/core/auto-compaction.js +21 -1
  15. package/dist/core/auto-mode.js +2 -8
  16. package/dist/core/exec-gate.js +3 -5
  17. package/dist/core/file-history-store.js +11 -10
  18. package/dist/core/hooks.d.ts +7 -0
  19. package/dist/core/mcp.js +7 -5
  20. package/dist/core/memory-engine/distiller.d.ts +7 -4
  21. package/dist/core/memory-engine/distiller.js +11 -3
  22. package/dist/core/memory.js +1 -0
  23. package/dist/core/runner/assemble-result.js +12 -19
  24. package/dist/core/runner/contracts.d.ts +1 -0
  25. package/dist/core/runner/prepare-file-history.js +19 -0
  26. package/dist/core/runner/run-recovery-lanes.js +4 -20
  27. package/dist/core/runner/run-settle-and-teardown.js +2 -16
  28. package/dist/core/runner/run-turn-boundary.js +4 -20
  29. package/dist/core/runner/runtask.js +3 -5
  30. package/dist/core/runner/stream-settle-backstop.js +5 -5
  31. package/dist/core/runner/tool-end-body.js +5 -4
  32. package/dist/core/runner/tool-output-projection.js +3 -2
  33. package/dist/core/runner/turn-attachments.d.ts +0 -20
  34. package/dist/core/runner/turn-attachments.js +41 -36
  35. package/dist/core/session-store.js +2 -4
  36. package/dist/core/session.d.ts +4 -1
  37. package/dist/core/session.js +2 -1
  38. package/dist/core/task-event.d.ts +19 -9
  39. package/dist/core/task-registry-shared.js +1 -0
  40. package/dist/core/thrown-value.d.ts +24 -13
  41. package/dist/core/thrown-value.js +13 -1
  42. package/dist/core/tool-errors.d.ts +6 -0
  43. package/dist/core/tool-errors.js +6 -14
  44. package/dist/core/tools.js +8 -9
  45. package/dist/engine/llm/types.d.ts +4 -4
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/orchestration/workflow.js +44 -23
  49. package/dist/stores/file/file-history-store.js +12 -11
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +7 -3
  52. package/wire-changes/core@7.24.0.json +249 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.24.0 — 2026-09-19
4
+
5
+ ### The stream engine's last two `instanceof BrainError` reads (#935, follow-on; P2; no wire key)
6
+ - **Before.** `src/brain/stream-engine.ts` kept two bare `e instanceof BrainError` tests after #935 swept the rest. Site A is in the `.catch` of a FLOATING promise (`void run().catch(...)`) — the one place a stream is ever told it failed — reading a value that a handed seat (`buildRequest` / `makeParser` / `doFetch`) may have raised. `instanceof` walks a prototype chain a revoked proxy traps, so a raise there costs the terminal frame entirely. **Measured**: with the old reading the probe never settles — no `error` event, an unhandled rejection, and a consumer waiting on `result()` forever (15 s test timeout). Site B is the in-band overload downgrade.
7
+ - **After.** Both read the BRAND — `brainErrorCodeOf`, the code word against the closed list — and site A reads the four disclosure members through `readMember`. Site B loses a condition (the brand IS the code) and gains realm-independence: a server-coded error minted outside the class (a vm context, a transport that lost the prototype) used to answer `false` there and kill a turn that should have finalized the text it had already streamed. Rules: −1 condition, −2 unguarded prototype walks.
8
+ - **Reach.** No wire key; the disclosure members ride exactly as before for a real `BrainError` (pinned). `test/backlog935b-stream-engine-guarded-brand.test.ts` drives a revoked proxy through both seats and pins the realm-independent downgrade.
9
+ - **Sibling site (merge-tree review).** `degrading.ts` `terminalOf` recognised only `"error"` as an error terminal: a bare stream whose `result()` resolved an ABORTED message became `{ type: "done", reason: "aborted" }` — a done frame with a non-done reason. It now routes `"aborted"` through the one `errorFrameOf`. Wire-visible only for that shape (error frame, reason aborted); pinned in `test/backlog935c-degrading-abort-frame.test.ts`.
10
+
11
+ ### A request root that COMPARES and a request root that RENDERS are two different values (#950; P2; BREAKING export rename; no wire key)
12
+ - **Before.** `normalizeBaseUrl` had one reading serving two jobs. The pairing law needs the query (it selects the endpoint), so the function kept `u.search` byte-identical — and every seat that RENDERS a root took the same function. `?api_key=<secret>` therefore rode the refusal `fixHint`, the exported `routeRefusalText`, all three `RouteRefusalDetail` URL fields, and the `route.base_url_changed_key_unchanged` catalog-swap notice, into whatever a host logs. The name is what licensed it: "normalize" reads as fit-for-anything.
13
+ - **After.** Two functions, each with one job. `routeKeyOf` is the comparison value (query byte-preserved, never to be rendered — the doc says so). `displayUrlOf` is the only spelling allowed into text: composed from the parse, userinfo dropped, and **every** query value replaced with `«redacted»` — no secret-name list, because a list has a blind spot at exactly the name nobody thought of (measured, test 2026-09-19: a downstream name-list redactor passes `?x_vendor_key=<secret>` through verbatim — the one secret-shaped name of twelve that its list did not carry). Query NAMES ride, because dropping the query whole would render two roots that differ only in their query as one string, and the refusal about that very pair would read "targets X … paired with X". When the names match too, the refusal names the parameters whose values diverged instead. Rules: two names for two jobs replace one name for two jobs; both refusal mints and the catalog-swap mint went from "whatever normalize returned" to the same one-line rule (compare the key, render the display).
14
+ - **Three repairs from the adversarial rounds, each reproduced first — one of them undoing a regression this change had introduced.** (a) The unparseable-spelling arm anchored its redaction on the LEADING run, so it only fired on a bare `user@host`; it now withholds by POSITION, and covers all THREE credential positions — userinfo, query, and FRAGMENT (implicit-OAuth puts a bearer token exactly there, and the parse arm drops fragments outright). (b) A first attempt moved the trailing-slash fold from the serialized URL to `u.pathname`, which **was wrong in the common case**: the fold is not this function's to choose — `resolveRequestRoot` strips trailing slashes off the SERIALIZED root and the brains append their endpoint suffix to that. Measured through the real builder, the path-level fold made `…/v1/?tenant=a` and `…/v1?tenant=a` compare EQUAL while their requests differ (a false `paired`, a credential authorized onto another path) and `…?tenant=a/` and `…?tenant=a` compare UNEQUAL while their requests are byte-identical (a refusal over a difference the wire cannot see). The fold is back where the resolver has it, and the invariant — **two roots share a key iff their requests are the same request** — is now pinned as a table with its one registered exception (a root CARRYING a fragment defeats the resolver's own strip; that is the request-construction defect below, not this fold's). (c) The residual note claimed "query" for every collapse, including two unparseable roots differing only in withheld userinfo — where there is no query at all; it now names the query parameters when it can and says only that the difference is in a withheld or folded part when it cannot.
15
+ - **A fourth repair, from the main session's own counterexample sweep.** A BARE query token (`?<token>`, no `=`) was rendered with its payload in the NAME position: `URLSearchParams` reports it as a name with an empty value, so "every value is withheld" protected nothing and the token reached `fixHint`, all three detail members and `routeRefusalText`. What rides is now judged on the RAW segment — a segment with no `=` renders as one placeholder with no name, and the explanation sentence will not name it either. Reading raw segments also retired the decode-then-re-encode step the previous shape needed. Positions this deliberately does NOT withhold — path segments, matrix parameters, subdomains, and the NAME of a `name=value` parameter — are the endpoint's identity, and redacting them would leave a refusal nobody can act on; that is `KL-950a`, with the deployment rule that a credential belongs on the `apiKey` knob or an auth header.
16
+ - **Registered, not fixed here.** `assertServableRequestRoot` returns the RAW trimmed spelling, so `resolveRequestRoot` can hand the brains a root carrying a query and/or fragment, which they then append `/chat/completions` after (`https://h/v1?tenant=a/#note/chat/completions`). That is a defect about request CONSTRUCTION, and what a base URL may carry is a contract decision above this change.
17
+ - **The example that teaches this got the law too.** `src/examples/custom-brain-provider.ts` hand-rolled `${(cfg.baseUrl ?? model.baseUrl).replace(/\/$/, "")}/chat/completions` — no request-root check, a one-slash strip where the engine strips all of them, and the precedence backwards (the ENTRY's `model.baseUrl` wins over the deployment's, which is what the pairing law assumes). `resolveRequestRoot` is therefore now **exported**: a custom brain is a seat that builds a request root and owes the same question, and an example cannot teach a law it has to deep-import to reach.
18
+ - **Reach — BREAKING.** The export `normalizeBaseUrl` is **removed**; `routeKeyOf` is the same function under the name that says what it is, and `displayUrlOf` + `resolveRequestRoot` are new (net −1/+3, count 2395 → 2397). `@sema-agent/server` imports `normalizeBaseUrl` (`src/hooks/hook-llm.ts`) and must rename the import; its behavior is unchanged. `RouteRefusalDetail.{entryBaseUrl, requestBaseUrl, configBaseUrl}` now carry the DISPLAY spelling — they were never route-identity values to compare against a catalog, and now they say so. A consumer that needs identity computes it with `routeKeyOf`. One conformance vector added (`deployment-mismatch-query-differs`): an implementation comparing origin+path only pairs a credential with someone else's tenant.
19
+ - **Sibling site (merge-tree review).** `primaryActivityArg` (`src/core/arg-summary.ts`) composed `origin + pathname`; an OPAQUE scheme (`weird:`/`blob:`/`mailto:` — `host` empty) rendered `nullu:<secret>@h/v1`. Same rule as the renderer: no authority ⇒ withhold by position. Narrowing on the activity line for opaque URLs only; `test/backlog950b-activity-arg-opaque-scheme.test.ts`.
20
+
21
+ ### The memory-distillation chat seat asks the request-root question the brains ask (#943; P2; no wire key)
22
+ - **Before.** `openAiCompatChatSeat` screened its `baseUrl` for emptiness and nothing else, then built `${baseUrl}/chat/completions` by hand. A root carrying userinfo, a `file:`/`ftp:` scheme, or a spelling that is not a URL at all constructed cleanly and failed at `fetch` — as a transport error indistinguishable from an outage, so the consolidation driver retried it for the whole backoff ladder. #933 ② closed the same three shapes at the three brain factories; the distillation seat builds a request root too and was not asked.
23
+ - **After.** The root goes through `assertServableRequestRoot`, the same law, judged at CONSTRUCTION. The ENVELOPE stays this seat's (`config.memory_consolidation_driver`, like its sibling api-key / alias-model screens) because a host screens on that code and the driver routes config-class throws differently from transport ones; only the envelope is local, the question and its wording are the adjudicator's. The trailing-slash strip moves to construction with it, so the root is read once instead of once per call.
24
+ - **Reach.** `RunnerDeps.memoryConsolidationDriver.chat = { baseUrl, apiKey, model }` with one of the three bad roots now refuses where it is configured instead of on the first distillation run; a userinfo root is named as `[redacted-credentials]@host`, never echoed. Servable roots are untouched.
25
+
26
+ ### The `compacted` event publishes the delta the engine MEASURED (#946; P2; additive wire key `freedTokens`; @cli @server @client-core)
27
+ - **Before.** The event carried `triggerTokensBefore` and `tokensAfter` and documented them as a matching pair to subtract. They are not one: the trigger gate reads a usage-ANCHORED figure whenever the provider's last usage is fresh, while the post size is always structural + the fixed prompt overhead (`promptOverheadTokens`, added unconditionally). Subtracting across them mixes coordinates and goes NEGATIVE on ordinary runs — **measured on the plain auto lane: `3037 − 4591 = −1554`**, and again on a short-session `/compact` (`4237 − 4987 = −750`), with no error anywhere in the pass. `force` / `forceUnderThreshold` remove the floor that used to hide it. The engine's own self-consistent delta, `freedTokens`, existed inside the compaction all along and never rode the wire.
28
+ - **After.** `freedTokens` is on the `compacted` event from all three mint points (turn boundary, PTL recovery lane, end-of-task settle) — one structural sum minus the same structural sum, clamped ≥ 0 at the mint. The two raw sizes stay, un-clamped and un-deleted, retyped as DIAGNOSTICS with "not subtractable" said at the type and in `docs/INTEGRATION-CORE.md`: they answer *what tripped the trigger* and *how big is it now*, which are different questions from *how much was freed*.
29
+ - **No fabricated numbers.** When the post measurement fails, `freedTokens` is now ABSENT rather than `preChars` ("fully effective" — never true of a landing that leaves a summary behind). The two floor sites dropped their `postTriggerTokens ?? (triggerTokens − freedTokens)` fallback, which re-assembled the very mixed-coordinate subtraction and only ever ran when BOTH of its inputs were unmeasured. No measurement ⇒ the anti-thrash floor does not move, which is the same non-suppressing direction the fabricated value was chosen for, without the claim.
30
+ - **One mint, not three copies.** The `compacted` frame was spelled out verbatim in all three lanes, so adding this key meant editing three identical literals and guarding them with a test that grepped their SOURCE TEXT for a matching line. They now call one `mintCompactedEvent(comp, trigger, identity)` (`src/core/auto-compaction.ts`) — `trigger` and the identity envelope are the only things that differed. Rules: −2 copies of the frame, and the drift guard becomes a real invariant (`type: "compacted"` appears exactly once in `src/`, and one `comp` through all three triggers yields the same key set). Both module ceilings that the first shape had raised are banked back down, 18 lines each.
31
+ - **The hook seat had the same pair, and took the same fix.** `Hooks.postCompact`'s `PostCompactContext` carried `tokensBefore` + `tokensAfter` with no warning at all — and its pair is worse than the frame's, because `tokensBefore` there is prepareCompaction's coordinate, which lacks the overhead term the post size always carries. It gains the same `freedTokens?`, read from the same `freedTokens` the pass computed (one source, not a second mint), and both old members now say "DIAGNOSTIC, NOT subtractable" at the type. Pinned by equality: the hook's delta and the `compacted` frame's are the same number for the same pass.
32
+ - **Reach.** Additive on the wire. `RunnerDeps.onCompaction`'s `freedTokens` becomes optional in TypeScript (it can now be absent, and was previously a fabricated number in exactly that case) — a consumer doing arithmetic on it should handle absence. **@cli**: `/context`'s compaction line should read `freedTokens` instead of drawing its arrow from the two diagnostics. **@server @client-core**: forward the new key. `test/backlog946-compaction-freed-delta.test.ts` measures both negative deltas, pins the three mint points as a SET, and drives the failed-measurement arm.
33
+
34
+ ### An unwired — or inert — file-history store says so, once (#947; P2; no wire key)
35
+ - **Before.** `RunnerDeps.fileHistoryStore` needs two halves to do anything: the store, and a filesystem-capable `ExecutionEnv`. When they disagreed, core said NOTHING. The capture-side note retired with the capture REQUEST it disappointed, leaving exactly one loud moment — somebody asks for a restore and gets `rewind.store_unconfigured`. Until then a deployment edits files believing the engine records them (`trackFileEdit` is simply absent: zero verbs, zero notes, zero words), and a host that wired a store into a stub-env shape holds a store nothing will ever write to.
36
+ - **After.** One predicate over the disagreement, announced on the config channel the sibling prepare phases already use (`deps.onError`, `phase: "config"`, advisory, never a refusal). The ARM names the half that is missing, because the fix differs: no store ⇒ "nothing is recorded, and a later `restoreFiles`/`rewindFilesTo` will refuse"; no env ⇒ "the store is INERT". Both halves absent is NOT a disagreement — no tree, no store, no expectation — and stays silent on purpose; that arm is pinned as a negative control so the silence is a decision rather than a gap.
37
+ - **Said once per Runner, per arm.** A parent plus N same-tree delegated children run through one Runner and would otherwise hear the same configuration fact N+1 times. The memo is keyed on the `onError` seat object, and a Runner wraps that seat exactly once at its deps boundary, so every run through one Runner shares one identity — **three Runners handed the same raw sink are three wrappers and therefore three sentences** (measured). It is not once per deployment. The seat is also the only key available: the no-store arm has no store, and with the machinery off the coordinate resolver answers the run's own session id and never the lineage's, so a parent and a same-tree child do not share a history scope.
38
+ - **Reach.** A Runner with hands and no store gains ONE operator line, on its first run. Nothing refuses, no note reaches `TaskResult`, no model-facing text changes, no wire key. `test/backlog947-file-history-unwired-disclosure.test.ts` pins both arms, both controls, one sentence across three runs of one Runner, a second Runner hearing it on its own, and the two arms deduplicating apart.
39
+
40
+ ### A peer row with no activity word reads `unknown`, instead of being called `idle` (#940; P2; no wire key; @cli)
41
+ - **Before.** The peer lane's state was a ternary — `dead ? "offline" : tempo === "active" || tempo === "blocked" ? "busy" : "idle"` (`src/agents/list-agents-tool.ts`). `tempo` is a member a HOST writes, and neither sema host writes one, so the else-arm was the COMMON path: every live peer session on the machine was reported to the model as `idle` — "that session is doing nothing" — about sessions nobody had asked.
42
+ - **After.** The peer half of #917's rule: one table (`PEER_ROW_STATE`: `active`/`blocked` → `busy`, `idle` → `idle`), an OWN-key lookup for the same reason `agentRowState` uses one (a directory seat is a host face, and a row that never passed `readPeerSessionRecord` can carry `constructor` as its tempo), and `unknown` for anything the table cannot answer — including the absent word. Liveness still pre-empts: a dead row is `offline` whatever it last wrote. Rules: the two lanes now state ONE rule ("a row's state is a table lookup over the word the row carries; anything else is `unknown`") instead of a table on one side and a hand-written ternary on the other.
43
+ - **The key STAYS.** `PeerSessionRecord.tempo` was proposed for retirement as a dead key (no sema writer). It is not dead: CC 2.1.259 writes it from its own session-activity classifier, with the identical word set `active | idle | blocked` (`blueprints/cc-decoded/cli259.js` offset 22754933, through the export `updateSessionActivity` — export table at offset 30739365). The engine owns the row SCHEMA and a host owns the VALUE, so the missing writer is a host-side gap, not a dead abstraction — **the CC-parity writer is pending on the shell side (@cli)**. Retiring the key would have been a divergence from an answered CC question, and the shell would have had to re-add it.
44
+ - **Reach.** No wire key added or removed; `ListAgentsRow.state` keeps its exact union. One value change on one lane: a live peer row whose host wrote no `tempo` now reads `unknown` instead of `idle` (rendered line: `name [ref] · unknown · peer session · session.<id>`). Consumers that render the word are unaffected in shape; a consumer branching on `idle` to mean "free to take work" was being told that about every peer. `test/backlog940-peer-row-state.test.ts` pins the absent word, the table, the liveness pre-empt, five prototype-key tempos and an agent-lane negative control.
45
+
46
+ ### A member turn is re-dispatched only where a second attempt could come out differently (#941; P2; no wire key)
47
+ - **Before.** `runTurn` (`src/agents/team.ts`) retried every turn that did not settle `completed`-with-text, once, with the identical objective, prompt, model and toolset — including terminals that cannot come out differently. A `blocked` cause waits for nobody by its own definition, and a code the governance table adjudicated TERMINAL is a policy question the control plane has already answered: each cost a whole extra member run to reproduce the same word, and the governance family re-asked an answered question of the control plane.
48
+ - **After.** The retry is refused only for the two terminals the engine can POSITIVELY identify as deterministic: a `blocked` cause, and a `failed` whose code the governance table adjudicated TERMINAL (read off `classifySubagentError`, the classifier the delegation faces already answer with — no second opinion about any code). Everything else keeps its second attempt, INCLUDING the classifier's `logic` bucket: that bucket is an else-arm — every code with no row of its own lands in it — so hanging "never retry" on it would make each future brain code silently non-retryable here. An independent review measured exactly that: `stream_torn` (a response lost before any content or finish reason, the textbook re-issuable failure) lives in that bucket and lost its retry under a first draft of this change. Rules ±0.
49
+ - **Reach.** No wire key, no text change: a blocked or policy-refused member still renders the same `[unavailable: <status>/<code>]` marker, and the discussion, transcript and budget accounting are unchanged in shape. What changes is spend — such a turn now costs one child run instead of two (measured on a scripted runner: 3 runs instead of 6 for a two-member, one-round discussion). `test/backlog941-deterministic-terminal-no-redispatch.test.ts` pins the blocked arm and four terminal governance codes, with six retryable codes, the whole `logic` else-arm (`stream_torn` / `refusal` / `length_empty` / a code this engine has never seen), the code-less failure and the empty-completed turn as positive controls.
50
+
51
+ ### The last bare reads of a value the engine did not construct (#942; P2; no wire key)
52
+ - **Before.** #926 and #935 made `src/core/thrown-value.ts` the engine's one rule for reading an untrusted value; nine sites had not taken it, and two of them were the load-bearing kind. A third-party `execute`'s RETURN value was read bare (`ret.content` / `.details` / `.terminate` / `.isError`) by a `normalizeContent` that ran OUTSIDE `defineTool`'s own try — so a member behind a throwing getter, a revoked proxy handed back as `content` (`Array.isArray` traps one), or a single hostile content BLOCK raised a raw `TypeError` past the tool wrapper. The `prepare()`-throw walk in `stream-settle-backstop.ts` had no guard at all, inside the backstop whose entire job is to make a thrown prepare land as a result. `errorCodeOf` wrapped its whole `cause` walk in ONE coarse `try`, where a hostile `code` at depth 1 lost every code beneath it. The card projector, the exec-gate step's catch and the MCP failure-copy read `details` / `name` / `code` bare, all three ON a failure path. And four workflow rows read a thrown cause through `instanceof Error` — the test a genuine Error minted in the script's `vm` realm fails, so the row went anonymous exactly where the cause came from the script.
53
+ - **After.** All of them read through `readMember` / `isErrorLike`. Two structural changes rather than more guards: `defineTool`'s whole result-building path — normalize, empty-output guard, the returned object — now runs inside the ONE try it already had, so every read of the returned value leaves through the formatted-tool-error door instead of escaping the wrapper (measured: an `AbortError`-named raise from a content block now reads `Interrupted`; it used to escape with the thrower's own words); and `errorCodeOf`'s coarse `try` is gone in favour of per-member reads, so an unreadable link costs THAT link and the walk continues (measured: `{ code: <throws>, cause: { code: "x" } }` answers `x`, and used to answer nothing). Rules: −1 coarse `try`, and the two class-level rules this sweep needed now have ONE home each beside the other readers — `thrownIs` (a total `instanceof`) and `thrownName` — with the four private copies that existed or were first written locally (`tool-errors`'s `isErrorInstance`, `auto-mode`'s timeout test, `exec-gate`'s, `workflow`'s) folded into them.
54
+ - **Two of the nine were NOT bare, and stay as they are.** `run-harness-handlers.ts:390` reads `.details` off an object the engine mints at `defineTool`'s exit (the one adaptation point every first-party, caller and MCP tool passes through), and the deeper `filePath` read below it is gated to three first-party tool names. Stated rather than silently skipped.
55
+ - **Four more sites, found by an independent adversarial review of this change and verified against the code before being taken.** The `retryAfterMs` read TWO statements below the walk that was just guarded, inside the very object literal that becomes the result — unconditional, ahead of the code test beside it, so the backstop that exists to make a thrown prepare LAND would itself have been the throw. `thrownErrorCode` (`subagent.ts`), one helper feeding three REJECT legs that run before registry settlement and the terminal notification, the background one inside the final catch handler. The exec-gate's class-POSITIVE arm, which still read `e.code` bare — recognizing a class does not make its accessor safe. And all thirteen `instanceof WorkflowXError` tests in `workflow.ts`, whose FIRST act on a failed leg is the class test. One of those is reached by a PUBLIC primitive: `ctx.parallel` / `ctx.pipeline` build their settled list from a script-supplied thunk, so whatever the script throws — and the script runs in its own vm realm with fresh intrinsics — lands verbatim on the fan-out fold's class test. Spelled `instanceof`, a revoked proxy raised out of the fold and rejected the whole batch, breaking the contract stated four lines above the call site ("a fan-out slot NEVER rejects the batch — every failure folds to null"). Measured both ways: the new pin folds the hostile thunk to `null` and reds on the bare spelling. On the two CATCH LADDERS the same guard is reasoned rather than reproduced — no construction staged from a test reaches them with a hostile value — and the control test there says so in as many words.
56
+ - **The shared rule, not a fifth copy.** The guarded class test now lives beside the other readers as `thrownIs` (`src/core/thrown-value.ts`), and the three private copies that already existed or would have been added — `tool-errors.ts`'s `isErrorInstance`, `auto-mode.ts`'s `isAutoModeTimeout`, and the two this change first wrote locally — are gone or now delegate. Net implementations of that rule: −2, where a first draft was +2. Three more same-class sites were swept with it: `observer.ts`'s `errorName` (compared against name CONSTANTS precisely because the value crosses a host seam, yet gated on this realm's `Error`), `session.ts`'s `isSessionConflict` and `session-store.ts`'s `isNotFound` (an `in` test — a `has` trap — plus the getter behind it), and the ten twin renders in the in-memory `FileHistoryStore`, whose own containment comment promises that a misbehaving host `ExecutionEnv` costs only the file it threw on.
57
+ - **The TENTH station, found on the real lane, not in the diff.** `reduceToolEnd` (`turn-attachments.ts`) reads a third-party `details` inside the harness's tool_end handler — outside the tool wrapper's try, and a SECOND time after the result store already walked the card. Measured end to end with a Runner, a scripted brain and a third-party `ToolSpec`: a `type` getter that answered once and raised on the second read ended the whole run as `failed` / `provider.error` on turn one, and the model never reached turn two. Every read in the reducer is now guarded, lists COUNTED and INDEXED rather than `Array.isArray`-ed and iterated (all three are proxy traps), the scan clamped rather than refused, and an unreadable card leaves the state exactly as it was. Both task cards now build their projected item through one `readTaskEntry` instead of two copies. The module ceiling moves 2221 → 2234 with that reason on the table line; the previously reported `run-harness-handlers.ts:390` is NOT itself a bare read — it reads an engine-minted result — which is what the earlier pass said, having stopped at the line instead of following the value into this reducer.
58
+ - **The ELEVENTH station, same shape, the ERROR arm.** `toolEndBodyFrom` (`tool-end-body.ts`) lifts three discriminators — `code`, `errorKind`, `delivered` — off the same third-party `details`, in the same handler, outside the same try. Measured: a `code` getter raising on its SECOND read ended the run on turn one. All three reads are guarded; unreadable is absent, which is the arm the string filters already had. A TWELFTH candidate was investigated and is NOT reachable, measured both ways: the LSP nudge's `details.filePath` read needs the lane wired (which needs hands) AND one of three tool names — and with hands mounted those names belong to the first-party fs tools, whose details this engine mints (a caller tool offering the name is shadowed; measured), while without hands the lane is closed. Both halves are pinned as negative controls so a change to either is visible.
59
+ - **The snapshot bag is DEFINED, not assigned.** `readOwnMembers` copied member by member with `out[key] = …`, and `__proto__` is an accessor on `Object.prototype` — so a value carrying an OWN `__proto__`, which is exactly what `JSON.parse` yields for `{"__proto__":{…}}` and exactly how a provider's error body arrives, swapped the bag's prototype instead of being copied: measured, `bag.errorKind` then read `"forged"` off the injected object, the key that did it vanished from `Object.keys`, and the bag was no longer an ordinary object. The copy now defines each key, so `__proto__` lands as an ordinary own data key and the bag stays on `Object.prototype`. It reaches a frame through `thrownMarksOf`, whose whole job is to carry a thrown value's `details` onto a tool result — and `errorKind` is the key that result's discriminator lift reads.
60
+ - **The abort word is decided by the NAME.** `formatToolError` folded a cancellation to `Interrupted` behind a realm-bound class test, so an `AbortError` minted elsewhere rendered as its own text and read like a failure; it now asks `thrownName`, and the file's remaining brand tests are `isErrorLike` — one brand rule where there were two.
61
+ - **Reach.** No wire key, no code word, no text change for any readable value. Three text changes where the value was hostile, each named above. One unannounced narrowing, stated: a tool result's `terminate` is now read `=== true` rather than `?? false`, so a BYOM tool returning a truthy non-`true` no longer terminates the batch — every in-tree consumer already tested `=== true`, so nothing observable moves. `test/backlog942-guarded-reads-residue.test.ts` drives the three shapes (null prototype / throwing getter / revoked proxy) through the tool wrapper, both `cause` walks, the card projector and the exec-gate step (both arms), with a positive control on each arm.
62
+ - **Regression caught at review, fixed before the tag.** The guarded list reader accepted anything with an integer `length`, so a STRING `newTodos` (`"bad card"`) cleared the todo snapshot where the base ignored it. A list is an object with a length; strings and numbers are not lists (pinned). A card with unreadable ITEMS still ingests the readable subset (KL-942e).
63
+
64
+ ### The two identities of a background delegation child are written down (#948; P2; docs only; @client-core @cli)
65
+ - **Before.** A background child carries a registry HANDLE (`a…`) and a session/run id (uuidv7), on different faces, and nothing stated which face carries which or what joins them. A consumer folding them into one address is wrong on one of the two faces — the question client-core asked, and the anchor cli's verification needs.
66
+ - **After.** `docs/INTEGRATION-CORE.md` §1.6a: one table (fleet row frame / `task_progress` / roster row × handle / session id / join key) plus three contract sentences — ① the registry id is a handle (`TaskOutput` / `TaskStop` / `SendMessage`) and a session id is a transcript/run id (`AgentTranscript`, resume), neither derived from the other, anchored once at spawn by `bindBackgroundAgentSession`; ② `task_progress.taskId === BackgroundChildEvent.transcriptId`, because a run's task id is `spec.taskId ?? sessionId` and all three background lanes set no `spec.taskId` (each says so at its own launch site); ③ roster / fleet `address` is the registry `task_id` verbatim. Every claim carries its coordinate, each one re-verified against the tree. One sentence each on the `task_progress` and `ListAgentsRow.address` JSDoc points at the table.
67
+ - **Reach.** Documentation only — no code path, no wire key, no text. Nothing to consume; it states what was already true.
68
+ ### The two credential vocabularies name the same vendors (#954; P2; narrowing; @server @cli @test)
69
+ - **Before.** This engine carries two credential-shape tables with two different head risks — the REFUSE-WRITE table (`detectSecret`, whose hit fail-closed rejects a durable memory write, so its head risk is the false positive) and the REDACTION table (`scrubSecrets`, spread into `redactSecrets` and `redactHostLeaks`, whose miss puts a credential in persisted and forwarded text). Neither had ever been pinned against the other, and four vendors had drifted apart. Measured on synthetic shapes: `github_pat_<60+>` (a fine-grained PAT) and `AccountKey=<base64 40+>` (an Azure connection-string key) were confident enough to REJECT a memory write and rode all three redaction faces **verbatim**; a PGP private-key block — banner `-----BEGIN PGP PRIVATE KEY BLOCK-----`, the exact form a write-gate bypass was once fixed for on the other table — rode them verbatim **header, body and footer**; and `hf_<20+>` was stripped from every egress text while being **accepted** into durable memory.
70
+ - **After.** Four rows, no new mechanism. The fine-grained PAT prefix joins the existing prefixed-token row's vendor list (widening its `gh[opsur]` class cannot reach it — the character after `gh?` is `t`, not the separator the row requires — and would pull every `gh?_` identifier in). The private-key row's banner takes the optional ` BLOCK` suffix on BOTH ends: widening only the opening banner leaves the closing one unmatched and the row's end-of-input fallback then eats every byte after the block. Azure gets its own high-confidence row mirroring the refuse-write shape exactly, because the keyword-value arm's lookbehind deliberately refuses a keyword glued to a preceding letter (`Key` behind `Accoun`) and dropping that would flag every `…key=` word in prose; the field name is preserved and only the value replaced, as that arm already does. And `hf_` joins the refuse-write table with that table's word boundaries AND a digit requirement — the same second discriminator its bearer row carries, and the narrower of the two spellings, which is the correct direction for the face whose head risk is the false positive. The first cut of that row had only the separator-free body and flagged plausible identifiers (`hf_HubApiListModelsPaginated`, `hf_ModelCardMetadataParser`, `hf_datasetsloadingscriptcachedirectory` — all measured as rejected memory writes); an adversarial round caught it before this shipped. A real token is a random base62 body and carries a digit; a camel-cased or lowercase symbol name does not.
71
+ - **Pinned in both directions.** `test/backlog954-credential-vocabulary-parity.test.ts` claims **every live row of both tables by identity** — the refuse-write table's label plus its regex source (parsed out of the module text, since that table is deliberately not exported), the redaction table's regex source off the exported passes — and asserts claims and live rows are the same set both ways, so a row added to or edited in one table alone reds by name. On top of that: every shape the narrow table names is stripped by all three redaction faces; every vendor row of the broad table is recognized by the narrow one **or** carries its own written reason (four are deliberate and are pinned as such: the publishable `pk_` class, a bare `Bearer` word with no `Authorization:` anchor, the two faces' different body floors, and the hyphenated word slug that is the refuse-write face's named head false positive — class-wide waivers are not accepted); fourteen false-positive neighbours stay clean on both faces; the composed tiers are shown to inherit by spread, not by copy, on shapes only the new rows catch; each credential is asserted stripped at EVERY occurrence, not just the first; and the two tiers' own four rows (URL credentials, signed-URL parameters, whole URLs, filesystem paths) are pinned by name so a new one there reds too. A row's identity is its regex text TOGETHER WITH its flags — dropping a `g` leaves the text byte-identical while turning a redaction into a first-occurrence-only one.
72
+ - **Reach — narrowing, two faces.** Egress text: `github_pat_…`, `AccountKey=…` and a PGP private-key block now leave as `[redacted]` where they previously left verbatim. Two extent changes come with that, both measured: the new prefix creates matches that start EARLIER, so `see github_pat_docs-2024-release-notes-here for details` is now redacted whole rather than untouched (over-redaction, the direction this face declares cheap — the same class as the hyphenated word slug it already accepts); and the widened closing banner BOUNDS a match that previously ran to end-of-input, so a mismatched banner pair no longer eats the rest of the argument after the block. Every consumer of activity summaries, MCP error text, checkpoint command summaries and repetition segments sees the marker instead of the value; a consumer matching on those values in engine-produced text will stop matching, which is the point. Durable memory: a note carrying `hf_<20+>` now throws `MemoryGateError("memory.secret_detected")` at the write door — the `remember` tool, consolidation ADD and consolidation UPDATE alike — where it was previously stored. No wire key, no export, no code word added.
73
+ - **Refuse ⊆ redact, one more row (merge-tree review).** The PEM banner qualifier admitted digits on the refuse-write face (`RSA2 PRIVATE KEY`) but only letters on the redaction face; the redaction row reads the same alphabet now (`test/backlog954b-pem-qualifier-digits.test.ts`). A Google key whose 35-char body ends in `-` is redacted but not refused (KL-954f).
74
+
75
+ ### A failed workflow run's machine code is on BOTH poll faces (#932 follow-on; P2; additive key; @server @cli)
76
+ - **Before.** `formatWorkflowRun` put `errorCode` on the structural `details` face and not in the `content` JSON, while the comment beside the structural one asserted the code "reached the `content` JSON text" — it never did. A consumer reading the JSON face had only the fenced human text to classify a failed run, and the code told it otherwise.
77
+ - **After.** The same key, the same absence rule, on both faces; the comment states what the code does. `test/backlog932-workflow-summary-error-code.test.ts` pins both faces equal on a coded failure and the key absent on both for a codeless failure and a completed run.
78
+ - **Reach — additive.** A new optional key on the `content` JSON of a failed workflow `TaskOutput`; nothing is removed or renamed, and a run with no code carries it on neither face.
79
+
3
80
  ## 7.23.5 — 2026-09-19
4
81
 
5
82
  ### `ListAgents` states what a row IS, instead of calling every non-live agent `finished` (#917; P2; widening; @server @cli @client-core)
@@ -49,11 +49,14 @@ export interface ListAgentsRow {
49
49
  name: string;
50
50
  /** Peer rows only: the listing-minted ref. */
51
51
  ref?: string;
52
+ /** What SendMessage accepts: on both AGENT lanes the registry `task_id` VERBATIM (no prefixing or
53
+ * re-minting), never the child's session id — a different domain, tabled in INTEGRATION-CORE §1.6a.
54
+ * Peer rows carry the `session.<id>` box handle instead. */
52
55
  address: string;
53
- /** What the row IS, right now. The peer lane has its own three words (`busy`/`idle`/`offline`), the
54
- * agent lanes carry the task record's own status word (see {@link AGENT_ROW_STATE}), and `unknown`
55
- * is the honest answer where no record can be read. `finished` means COMPLETED and nothing else —
56
- * a model reads it as "the work is done" and applies the continuable rule to it. */
56
+ /** What the row IS, right now. Both lanes are a table lookup over the word the row carries
57
+ * ({@link PEER_ROW_STATE} / {@link AGENT_ROW_STATE}), and `unknown` answers whatever the table cannot
58
+ * — on the peer lane the common case, since no host writes the activity word. `finished` means
59
+ * COMPLETED and nothing else: a model applies the continuable rule to that word. */
57
60
  state: "busy" | "idle" | "offline" | "running" | "parked" | "finished" | "failed" | "killed" | "cancelled" | "unknown";
58
61
  }
59
62
  export declare function createListAgentsTool(opts: ListAgentsToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{}>, unknown>;
@@ -21,6 +21,17 @@ const AGENT_ROW_STATE = {
21
21
  function agentRowState(status) {
22
22
  return Object.hasOwn(AGENT_ROW_STATE, status) ? AGENT_ROW_STATE[status] : "unknown";
23
23
  }
24
+ const PEER_ROW_STATE = {
25
+ active: "busy",
26
+ blocked: "busy",
27
+ idle: "idle",
28
+ };
29
+ function peerRowState(r) {
30
+ if (r.liveness === "dead")
31
+ return "offline";
32
+ const tempo = r.tempo;
33
+ return tempo !== undefined && Object.hasOwn(PEER_ROW_STATE, tempo) ? PEER_ROW_STATE[tempo] : "unknown";
34
+ }
24
35
  export function createListAgentsTool(opts) {
25
36
  return defineTool({
26
37
  name: LIST_AGENTS_TOOL_NAME,
@@ -55,7 +66,7 @@ export function createListAgentsTool(opts) {
55
66
  name: r.name,
56
67
  ref: c.ref,
57
68
  address: peerSessionBoxHandle(r.sessionId),
58
- state: r.liveness === "dead" ? "offline" : r.tempo === "active" || r.tempo === "blocked" ? "busy" : "idle",
69
+ state: peerRowState(r),
59
70
  });
60
71
  }
61
72
  let registryCapped = false;
@@ -3,7 +3,7 @@ import { defineTool } from "../core/tools.js";
3
3
  import { createSafeNotifier } from "../core/safe-notify.js";
4
4
  import { escapeEnvelopeTag } from "../core/untrusted-text.js";
5
5
  import { toolFace } from "../core/tool-catalog-entries.js";
6
- import { thrownText } from "../core/thrown-value.js";
6
+ import { isErrorLike, thrownName, thrownText } from "../core/thrown-value.js";
7
7
  export const OBSERVER_EVENT_TAGS = ["tool-call", "user-message", "tool-result", "turn-ended"];
8
8
  const TAG_ESCAPE_RE = new RegExp(`<(?=/?(?:${OBSERVER_EVENT_TAGS.join("|")})(?:[>\\s/]|$))`, "gi");
9
9
  export function escapeObserverTags(text) {
@@ -224,9 +224,6 @@ export class ObserverResumeStateError extends Error {
224
224
  this.name = OBSERVER_RESUME_STATE_ERROR_NAME;
225
225
  }
226
226
  }
227
- function errorName(err) {
228
- return err instanceof Error ? err.name : undefined;
229
- }
230
227
  export const OBSERVER_FRESH_START_NOTE = "[Note: your previous observation context was lost; this is a fresh start mid-task.]";
231
228
  export class ObserverPairing {
232
229
  state = "armed";
@@ -300,7 +297,7 @@ export class ObserverPairing {
300
297
  await this.deliverBatch(digest);
301
298
  }
302
299
  catch (err) {
303
- if (errorName(err) === OBSERVER_STOPPED_BY_USER_ERROR_NAME) {
300
+ if (thrownName(err) === OBSERVER_STOPPED_BY_USER_ERROR_NAME) {
304
301
  this.state = "stopped";
305
302
  this.buffer = [];
306
303
  this.safeOnError(err);
@@ -330,7 +327,7 @@ export class ObserverPairing {
330
327
  await this.spawner.deliver({ digest });
331
328
  }
332
329
  catch (err) {
333
- if (errorName(err) !== OBSERVER_RESUME_STATE_ERROR_NAME)
330
+ if (thrownName(err) !== OBSERVER_RESUME_STATE_ERROR_NAME)
334
331
  throw err;
335
332
  const restart = this.spawner.restartFresh?.bind(this.spawner);
336
333
  if (restart === undefined)
@@ -341,8 +338,8 @@ export class ObserverPairing {
341
338
  }
342
339
  catch (restartErr) {
343
340
  this.firstRunDone = false;
344
- throw restartErr instanceof Error
345
- ? new Error(`${restartErr.message} (after resume-state loss: ${thrownText(err)})`, { cause: restartErr })
341
+ throw isErrorLike(restartErr)
342
+ ? new Error(`${thrownText(restartErr)} (after resume-state loss: ${thrownText(err)})`, { cause: restartErr })
346
343
  : restartErr;
347
344
  }
348
345
  }
@@ -67,7 +67,7 @@ import { LAUNCH_RECEIPT_OWN_WORDS_CLAUSE, launchReceiptNoQuoteClause } from "./l
67
67
  import { ObserverDigestTap, ObserverPairing, createObserverReportToolSpec, markObserverTaskId, unmarkObserverTaskId, isObserverTaskId, ObserverResumeStateError, ObserverStoppedByUserError, observerFramingPrompt, observerSlug, resolveObserverDeclaration, } from "./observer.js";
68
68
  import { SubagentStepRecorder } from "./subagent-steps.js";
69
69
  import { toolFace } from "../core/tool-catalog-entries.js";
70
- import { thrownText } from "../core/thrown-value.js";
70
+ import { readMember, thrownText } from "../core/thrown-value.js";
71
71
  const BG_AGENT_RESULT_MAX = 4_000;
72
72
  const BG_AGENT_RESULT_FULL_MAX = 200_000;
73
73
  function resultSettleFields(result) {
@@ -386,7 +386,7 @@ export function classifySubagentError(terminal) {
386
386
  return { errorKind: "logic", retryable: SUBAGENT_RETRYABLE.logic };
387
387
  }
388
388
  function thrownErrorCode(e) {
389
- const code = e !== null && typeof e === "object" ? e.code : undefined;
389
+ const code = readMember(e, "code");
390
390
  return typeof code === "string" ? code : undefined;
391
391
  }
392
392
  export function completedAgentCard(child, extras) {
@@ -1,5 +1,6 @@
1
1
  import { resolveToolSubset } from "./tool-filter.js";
2
2
  import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
3
+ import { classifySubagentError } from "./subagent.js";
3
4
  import { terminalProjection } from "../core/runner/terminal-projection.js";
4
5
  import { sanitizeUntrustedText } from "../core/untrusted-text.js";
5
6
  const DISCUSSION_WRAPPER_TAGS = ["statement", "discussion", "discussion-final", "earlier-discussion-summary", "previous-summary", "topic"];
@@ -101,6 +102,12 @@ export async function runTeamDiscussion(opts) {
101
102
  checkpointGate: p.terminal.gate,
102
103
  };
103
104
  };
105
+ const canDifferOnRetry = (terminal) => {
106
+ if (terminal.kind === "blocked")
107
+ return false;
108
+ const err = classifySubagentError(terminal);
109
+ return !(err?.errorKind === "governance" && err.retryable === false);
110
+ };
104
111
  const runTurn = async (objective, systemPrompt, modelRef, role, tools, mcp) => {
105
112
  let last = "";
106
113
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -134,6 +141,8 @@ export async function runTeamDiscussion(opts) {
134
141
  }
135
142
  const face = terminalProjection(res.terminal);
136
143
  last = `[unavailable: ${face.status}${face.errorCode ? `/${face.errorCode}` : ""}${face.errorMessage ? ` — ${face.errorMessage}` : ""}]`;
144
+ if (!canDifferOnRetry(res.terminal))
145
+ break;
137
146
  }
138
147
  return { text: last || "[unavailable]", ok: false };
139
148
  };
@@ -59,9 +59,12 @@ function errorMessageOf(model, e, aborted) {
59
59
  timestamp: Date.now(),
60
60
  };
61
61
  }
62
+ function errorFrameOf(message) {
63
+ return { type: "error", reason: message.stopReason === "aborted" ? "aborted" : "error", error: message };
64
+ }
62
65
  function terminalOf(message) {
63
- return message.stopReason === "error"
64
- ? { type: "error", reason: "error", error: message }
66
+ return message.stopReason === "error" || message.stopReason === "aborted"
67
+ ? errorFrameOf(message)
65
68
  : { type: "done", reason: message.stopReason, message };
66
69
  }
67
70
  async function forward(stream, sink) {
@@ -100,16 +103,12 @@ export function passthroughStream(brain, model, context, options) {
100
103
  }
101
104
  if (!sawTerminal) {
102
105
  await inner.result();
103
- out.push({
104
- type: "error",
105
- reason: "error",
106
- error: errorMessageOf(model, new Error("brain stream ended without a terminal event"), false),
107
- });
106
+ out.push(errorFrameOf(errorMessageOf(model, new Error("brain stream ended without a terminal event"), false)));
108
107
  }
109
108
  }
110
109
  catch (e) {
111
110
  const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
112
- out.push({ type: "error", reason: "error", error: errorMessageOf(model, e, aborted) });
111
+ out.push(errorFrameOf(errorMessageOf(model, e, aborted)));
113
112
  }
114
113
  })();
115
114
  return out;
@@ -178,7 +177,7 @@ export function createDegradingBrain(opts) {
178
177
  const errMsg = errorMessageOf(lastAttempted, e, aborted);
179
178
  if (degradeInfo)
180
179
  tagDegraded(errMsg, degradeInfo);
181
- out.push({ type: "error", reason: aborted ? "aborted" : "error", error: errMsg });
180
+ out.push(errorFrameOf(errMsg));
182
181
  }
183
182
  })();
184
183
  return out;
@@ -40,16 +40,29 @@
40
40
  import type { Model } from "../internal/llm.js";
41
41
  import type { RouteAdjudication, RouteCredential, RoutePairingConfig, RoutePairingPosture } from "../internal/llm.js";
42
42
  import type { Brain, EngineNotice } from "../core/types.js";
43
- /** Normalize a base URL for pairing comparison, to WIRE equivalence: trim + strip trailing
44
- * slashes, then — for a string that parses as a URL — fold the halves the wire itself treats as
45
- * case-insensitive (scheme and host, RFC 9110) and elide the scheme's default port, while keeping
46
- * the PATH byte-preserved (paths are case-sensitive; folding them would compare two genuinely
47
- * different endpoints equal — a widening of exactly the leak this module exists to stop). Two
48
- * spellings that resolve to the same origin+path on the wire therefore compare equal, and a
49
- * refusal can never be minted over a spelling difference the wire cannot see. A value that does
50
- * not parse as a URL keeps the raw trimmed spelling — the same fail-closed raw comparison as
51
- * before. Non-string input throws — see the module note on garbage. */
52
- export declare function normalizeBaseUrl(url: string | undefined): string;
43
+ /** A base URL's ROUTE KEY — the value the pairing law COMPARES, folded to WIRE equivalence: the
44
+ * halves the wire itself treats as case-insensitive (scheme and host, RFC 9110) fold, the scheme's
45
+ * default port elides, and PATH and QUERY ride byte-preserved (a query selects the endpoint as
46
+ * surely as a path does; folding either would compare two different endpoints equal — the widening
47
+ * this module exists to stop). An unparseable value keeps its raw trimmed spelling: fail-closed raw
48
+ * comparison. Non-string input throws — see the module note on garbage.
49
+ *
50
+ * **Never render this.** Byte-preserving on the query means `?api_key=<secret>` rides it verbatim;
51
+ * {@link displayUrlOf} is the one spelling allowed into text. */
52
+ export declare function routeKeyOf(url: string | undefined): string;
53
+ /** A base URL's DISPLAY spelling — the only rendering of a request root allowed into text a human,
54
+ * a log or a consumer sees. Composed from the PARSE (never edited out of raw bytes): userinfo
55
+ * drops, EVERY query value becomes {@link REDACTED_VALUE}, and a query NAME rides only where it
56
+ * really is a name.
57
+ * - no secret-name list: a list has a blind spot at the name nobody listed.
58
+ * - names ride because dropping the query WHOLE renders two roots differing only in query as one
59
+ * string — exactly the pairs the law refuses, so its refusal would read "targets X … paired
60
+ * with X". {@link queryDifferenceNote} closes the case where the names match too.
61
+ * - a BARE token (`?<token>`) renders as one placeholder with NO name: `URLSearchParams` calls it a
62
+ * name with an empty value, and that reading put the payload itself on screen. "Every value is
63
+ * withheld" protects only what IS a value, so the raw segment decides what rides.
64
+ * Answers `""` for an undeclared root, like its key twin. */
65
+ export declare function displayUrlOf(url: string | undefined): string;
53
66
  /**
54
67
  * The bad-value refusal every base-URL seat owes: **a request root must be an absolute http(s) URL, and
55
68
  * it may not carry the credential itself.**
@@ -60,7 +73,7 @@ export declare function normalizeBaseUrl(url: string | undefined): string;
60
73
  * Stripping userinfo instead of refusing would send an UNAUTHENTICATED request to the host the operator
61
74
  * thought they had authenticated to. Refused where the value ARRIVES — construction for `config.baseUrl`,
62
75
  * request build for `Model.baseUrl`. An undeclared root is not this rule's question; the PAIRING law keeps
63
- * its tolerance for an unparseable spelling ({@link normalizeBaseUrl}). Answers the value it judged.
76
+ * its tolerance for an unparseable spelling ({@link routeKeyOf}). Answers the value it judged.
64
77
  */
65
78
  export declare function assertServableRequestRoot(url: string | undefined, seat: string): string | undefined;
66
79
  /** Remove the userinfo from every authority in free text, WHOLE — the general redactor keeps a short or
@@ -190,7 +203,7 @@ export declare function adjudicateDerivedRoute(args: {
190
203
  }): Promise<RouteAdjudication | undefined>;
191
204
  /**
192
205
  * Whether two resolved models denote the SAME route for pre-flight purposes: identical wire id AND
193
- * identical normalized entry URL. `Model.id` alone is NOT route identity — two catalog entries can
206
+ * identical entry ROUTE KEY. `Model.id` alone is NOT route identity — two catalog entries can
194
207
  * legitimately share a wire id (the same model served from two endpoints), and a same-id guard
195
208
  * would silently skip the pre-flight for exactly the split the pairing law exists to judge.
196
209
  */
@@ -1,6 +1,28 @@
1
1
  import { AUTH_CARRIER_NAMES } from "./request-params.js";
2
2
  import { BrainError } from "./errors.js";
3
- export function normalizeBaseUrl(url) {
3
+ const REDACTED_VALUE = "«redacted»";
4
+ const REDACTED_CREDENTIALS = "[redacted-credentials]";
5
+ function servableParse(raw) {
6
+ let u;
7
+ try {
8
+ u = new URL(raw);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ return u.protocol === "http:" || u.protocol === "https:" ? u : undefined;
14
+ }
15
+ function composableParse(raw) {
16
+ let u;
17
+ try {
18
+ u = new URL(raw);
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ return u.host !== "" || u.pathname.startsWith("/") ? u : undefined;
24
+ }
25
+ export function routeKeyOf(url) {
4
26
  if (url === undefined)
5
27
  return "";
6
28
  if (typeof url !== "string") {
@@ -9,13 +31,53 @@ export function normalizeBaseUrl(url) {
9
31
  const raw = url.trim().replace(/\/+$/, "");
10
32
  if (raw === "")
11
33
  return "";
12
- try {
13
- const u = new URL(raw);
14
- return `${u.protocol}//${u.host}${u.pathname}${u.search}`.replace(/\/+$/, "");
15
- }
16
- catch {
34
+ const u = servableParse(raw);
35
+ if (u === undefined)
17
36
  return raw;
37
+ return `${u.protocol}//${u.host}${u.pathname}${u.search}`.replace(/\/+$/, "");
38
+ }
39
+ function querySegments(search) {
40
+ if (search === "" || search === "?")
41
+ return [];
42
+ return search.replace(/^\?/, "").split("&").map((raw) => {
43
+ const eq = raw.indexOf("=");
44
+ return eq === -1 ? { raw } : { raw, name: raw.slice(0, eq), value: raw.slice(eq + 1) };
45
+ });
46
+ }
47
+ export function displayUrlOf(url) {
48
+ const key = routeKeyOf(url);
49
+ if (key === "")
50
+ return "";
51
+ const u = composableParse(key);
52
+ if (u === undefined) {
53
+ const cut = key.search(/[?#]/);
54
+ const head = cut === -1 ? key : key.slice(0, cut);
55
+ const at = head.lastIndexOf("@");
56
+ const shown = at === -1 ? head : `${REDACTED_VALUE}@${head.slice(at + 1)}`;
57
+ return cut === -1 ? shown : `${shown}${key[cut]}${REDACTED_VALUE}`;
58
+ }
59
+ const segs = querySegments(u.search);
60
+ const query = segs.length === 0 ? "" : `?${segs.map((sg) => (sg.raw === "" ? "" : sg.name === undefined ? REDACTED_VALUE : `${sg.name}=${REDACTED_VALUE}`)).join("&")}`;
61
+ return `${u.protocol}//${u.host}${u.pathname}${query}`.replace(/\/+$/, "");
62
+ }
63
+ function queryDifferenceNote(requestKey, declaredKey) {
64
+ if (displayUrlOf(requestKey) !== displayUrlOf(declaredKey))
65
+ return "";
66
+ const names = new Set();
67
+ const ua = composableParse(requestKey);
68
+ const ub = composableParse(declaredKey);
69
+ if (ua !== undefined && ub !== undefined) {
70
+ const a = querySegments(ua.search);
71
+ const b = querySegments(ub.search);
72
+ const valuesOf = (segs, n) => segs.filter((sg) => sg.name === n).map((sg) => sg.value).join("\u0000");
73
+ for (const n of new Set([...a, ...b].flatMap((sg) => (sg.name === undefined ? [] : [sg.name])))) {
74
+ if (valuesOf(a, n) !== valuesOf(b, n))
75
+ names.add(n);
76
+ }
18
77
  }
78
+ return names.size > 0
79
+ ? ` (same rendering — the query values differ under: ${[...names].sort().join(", ")})`
80
+ : ` (same rendering — the difference is in a part this line withholds or folds)`;
19
81
  }
20
82
  export function assertServableRequestRoot(url, seat) {
21
83
  if (typeof url !== "string" || url.trim() === "")
@@ -23,19 +85,19 @@ export function assertServableRequestRoot(url, seat) {
23
85
  const trimmed = url.trim();
24
86
  const parsed = URL.canParse(trimmed) ? new URL(trimmed) : undefined;
25
87
  if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
26
- const named = parsed === undefined ? "it did not parse as a URL" : `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
88
+ const named = parsed === undefined ? "it did not parse as a URL" : displayUrlOf(trimmed);
27
89
  throw new BrainError("invalid_request", `${seat} is not a servable request root (${named}) — it must be an ABSOLUTE http(s) URL. Another scheme, ` +
28
90
  `or a spelling that is not a URL at all, fails at the transport in a shape indistinguishable from an ` +
29
91
  `outage, so it would be retried for the whole backoff ladder instead of refused here.`);
30
92
  }
31
93
  if (parsed.username === "" && parsed.password === "")
32
94
  return trimmed;
33
- throw new BrainError("invalid_request", `${seat} carries credentials in its authority (${parsed.protocol}//[redacted-credentials]@${parsed.host}${parsed.pathname}) — ` +
95
+ throw new BrainError("invalid_request", `${seat} carries credentials in its authority (${parsed.protocol}//${REDACTED_CREDENTIALS}@${parsed.host}${parsed.pathname}) — ` +
34
96
  `a request cannot be built from a URL with userinfo, so every call through this brain would fail: put the ` +
35
97
  `credential on the apiKey knob or an auth header and leave the URL bare`);
36
98
  }
37
99
  export function stripUrlCredentials(text) {
38
- return text.replace(/([A-Za-z][A-Za-z0-9+.\-]*:\/\/)[^/\s"'@]*@/g, "$1[redacted-credentials]@");
100
+ return text.replace(/([A-Za-z][A-Za-z0-9+.\-]*:\/\/)[^/\s"'@]*@/g, `$1${REDACTED_CREDENTIALS}@`);
39
101
  }
40
102
  export function resolveRequestRoot(modelBaseUrl, configBaseUrl, laneDefault = "") {
41
103
  const entry = assertServableRequestRoot(modelBaseUrl, "Model.baseUrl");
@@ -76,9 +138,12 @@ export function resolveRouteCredential(input) {
76
138
  return { source: "absent" };
77
139
  }
78
140
  export function adjudicateModelRoute(model, credential, config) {
79
- const entry = normalizeBaseUrl(model.baseUrl);
80
- const declared = normalizeBaseUrl(config.baseUrl);
141
+ const entry = routeKeyOf(model.baseUrl);
142
+ const declared = routeKeyOf(config.baseUrl);
81
143
  const request = entry || declared;
144
+ const entryShown = displayUrlOf(entry);
145
+ const declaredShown = displayUrlOf(declared);
146
+ const requestShown = displayUrlOf(request);
82
147
  if (credential.source === "per-model")
83
148
  return { ok: true, posture: "per-model" };
84
149
  if (credential.source === "deployment-config") {
@@ -91,12 +156,13 @@ export function adjudicateModelRoute(model, credential, config) {
91
156
  code: "route.credential_mismatch",
92
157
  detail: {
93
158
  modelId: model.id,
94
- entryBaseUrl: entry,
95
- requestBaseUrl: request,
96
- configBaseUrl: declared,
97
- fixHint: `model "${model.id}" targets ${request} but the deployment credential is paired with ${declared}: ` +
159
+ entryBaseUrl: entryShown,
160
+ requestBaseUrl: requestShown,
161
+ configBaseUrl: declaredShown,
162
+ fixHint: `model "${model.id}" targets ${requestShown} but the deployment credential is paired with ${declaredShown}` +
163
+ `${queryDifferenceNote(request, declared)}: ` +
98
164
  `give the model its own credential (a per-model key via getApiKeyAndHeaders, or an auth header on Model.headers), ` +
99
- `or serve it through a brain whose config.baseUrl is ${request}`,
165
+ `or serve it through a brain whose config.baseUrl is the model's own root`,
100
166
  },
101
167
  };
102
168
  }
@@ -107,12 +173,12 @@ export function adjudicateModelRoute(model, credential, config) {
107
173
  code: "route.credential_missing",
108
174
  detail: {
109
175
  modelId: model.id,
110
- entryBaseUrl: entry,
111
- requestBaseUrl: request,
112
- configBaseUrl: declared,
113
- fixHint: `model "${model.id}" targets ${request} but no credential is configured for that endpoint ` +
114
- `(the deployment credential root is ${declared}): supply a per-model credential ` +
115
- `(getApiKeyAndHeaders, or an auth header on Model.headers), or serve it through a brain constructed for ${request}`,
176
+ entryBaseUrl: entryShown,
177
+ requestBaseUrl: requestShown,
178
+ configBaseUrl: declaredShown,
179
+ fixHint: `model "${model.id}" targets ${requestShown} but no credential is configured for that endpoint ` +
180
+ `(the deployment credential root is ${declaredShown})${queryDifferenceNote(request, declared)}: supply a per-model credential ` +
181
+ `(getApiKeyAndHeaders, or an auth header on Model.headers), or serve it through a brain constructed for that root`,
116
182
  },
117
183
  };
118
184
  }
@@ -192,7 +258,7 @@ export async function adjudicateDerivedRoute(args) {
192
258
  }
193
259
  }
194
260
  export function sameRouteIdentity(a, b) {
195
- return a.id === b.id && normalizeBaseUrl(a.baseUrl) === normalizeBaseUrl(b.baseUrl);
261
+ return a.id === b.id && routeKeyOf(a.baseUrl) === routeKeyOf(b.baseUrl);
196
262
  }
197
263
  export function fallbackToPrimaryNotice(args) {
198
264
  return {
@@ -73,6 +73,15 @@ export const ROUTE_ADJUDICATION_CONFORMANCE_CORPUS = [
73
73
  expect: { ok: false, code: "route.credential_mismatch" },
74
74
  leg: "named",
75
75
  },
76
+ {
77
+ name: "deployment-mismatch-query-differs",
78
+ intent: "two roots differing only in a QUERY parameter are two routes — the deployment credential is not paired with the second",
79
+ model: { id: "m-tenant", baseUrl: "https://gw.example.com/v1?tenant=b-corp" },
80
+ credential: { source: "deployment-config", apiKey: "k-boot" },
81
+ config: { baseUrl: "https://gw.example.com/v1?tenant=a-corp" },
82
+ expect: { ok: false, code: "route.credential_mismatch" },
83
+ leg: "named",
84
+ },
76
85
  {
77
86
  name: "deployment-mismatch-derived-leg",
78
87
  intent: "the same torn pair on a DERIVED model: same verdict; the engine disposes of it as fallback-to-primary + notice",