Skip to content

DO NOT MERGE — merge train: 2732,2740,2739,2741,2738,2735 - #2745

Closed
vybe wants to merge 39 commits into
devfrom
train/20260913-0705
Closed

vybe wants to merge 39 commits into
devfrom
train/20260913-0705

Conversation

@vybe

@vybe vybe commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2732, #2740, #2739, #2741, #2738, #2735. Never merged; members merge individually once green.

Each member is merged --no-ff on its head SHA so a red job bisects to a PR. Two routine doc append-collisions (feature-flows.md, learnings.md) were resolved on the train only — they do not conflict with dev, so nothing was pushed to member branches.

L4XB and others added 30 commits September 12, 2026 10:46
A '#' followed by three or six hex digits is a colour, and it is also
what an issue reference looks like in rendered copy, so '(ent#184)',
'(#526)' and '>#847</a>' were counted as hardcoded colours. Nine of them
sat in the baseline, and since #2605 made the ratchet bite that turned
load-bearing: adding a second reference to a file grew its entry and
failed the build for prose.

No lexical rule separates the two, so the rule is positional: code is
scanned, rendered copy is not. Tags, attribute values and {{ }}
interpolations stay in, the text between them is blanked before
matching, offsets preserved so sample lines still point at the source.
The hex pattern also refuses a '#' glued to a word, which catches the
'ent#206' shape inside a prose attribute.

hardcoded_colors drops 456 to 447 across 8 files, all of it phantom;
raw_nongray, raw_gray and semantic_tokens do not move and no surviving
sample changes line. The baseline is hand-edited rather than
regenerated, so the refrozen provenance block survives.

Fixes #2718
… once (ent#551, ent#576)

In a Workspace voice call `run_task` now answers the model at once with an
accepted task id (t1, t2, ...) and runs the turn as the agent in the bound
thread in the background. The person keeps the floor; when the task lands its
outcome re-enters the live call as a system notice at a natural boundary (model
not mid-turn, person quiet 1.2 s, no tool call pending; held at most 20 s), or
as a failure with its reason. If the model says nothing within 4 s of dispatch
the platform nudges it, so the acknowledgement is structural. At most 3 tasks
run per call; at the cap the tool refuses in words. Ending the call cancels
nothing: background turns are not in `_pending_tool_tasks`, and a task that
lands later writes its rows into the thread, attributed to the call by
`voice_call_id` (typed rows, so the #2694 delta logic is untouched). A `task`
WebSocket frame drives a persistent "N tasks running" badge distinct from the
amber working badge and refetches the canvas when a task lands. The container
(VoIP) path keeps its synchronous 30 s bound as the documented fallback.

One spoken-etiquette block for the whole tool cycle replaces the filler-only
`_TOOL_ETIQUETTE_INSTRUCTION`: announce a wait, not an action (none for a canvas
write); say it once; never read the canvas aloud; one narration per sequence;
report a failure once with its reason; background tasks. Built from the manifest
(ent#535 AC 6) and worded for the session's dispatch shape; the `run_task`
description and the panel instructions agree with it.

Model: the pin stays `models/gemini-3.1-flash-live-preview`, verified against
Google's Live model list as the newest general-purpose Live model; the
`gemini-3.5-*-live` ids are transcribe-only and translate-only.

Fixes abilityai/trinity-enterprise#551
Fixes abilityai/trinity-enterprise#576

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…odel does not invent a result (ent#551)

Found in the first live run on the branch. Every `run_task` failed at once
with "A voice call is on in this chat — end it, then send": #2694's live-call
guard (56fd9e3) landed on dev after ent#535's run-as-the-agent path
(23a2b59) and refused the call's own turns along with the typed turns it was
written for, so a Workspace call could not run a single task. The guard now
lets a turn carrying `voice_call_id` through — only the voice dispatcher writes
it, no request can — and the #2694 delta cursor skips a reply carrying it, so a
task landing mid-call cannot erase the call's earlier spoken rows from the next
typed turn.

Also from the transcript: right after the accepted result the model said
"there are sixty four files in there right now" — an invented count, seconds
before the real one. The acceptance and the etiquette block now say it holds
no result yet and must not guess one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…er transcribe a platform notice (ent#551, ent#576)

From the second live run on the branch:

- The person heard "let me check… / I've started counting… / it's done" back to
  back with no room to speak. The acceptance asked for "one short line about
  what you started" from a model that had already said its filler; it now asks
  only if nothing was said, and says the model holds no result yet. The
  completion notice waits for 2.5 s of quiet from BOTH sides (the model's own
  speech counted for nothing before), so a fast result no longer lands on the
  heels of the acknowledgement.
- The orb badge and the header line say WHAT is running — the task's one-line
  label, or the count then the labels — instead of "1 task running".
- The sidebar's unread badge counted up during the call: every spoken turn and
  task reply landed as an unread agent row in a thread the person was in. The
  conversation now advances the read cursor while the call is on.
- The model read a platform notice aloud verbatim as the call ended and it was
  recorded as the agent's line. Every platform text now opens "never read this
  aloud", and `_record_turn` scrubs an assistant row that echoes one.
- The task's ask row sat above the spoken request that caused it. The
  dispatcher flushes the turn in progress first; the receive loop accumulates
  on the session's partial-turn fields rather than in locals so that flush is
  the one writer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…ing adopted, and the mic worklet loads from self (ent#551)

Found in the ent#551 QA run: every first call started from a brand-new chat
ended itself at exactly 5 s with an empty transcript. `startVoiceCall` creates
the thread and adopts it before the call starts; the shell's route replace then
changes the `sessionId` prop from null to that id a moment later, and the
thread-change watcher — right for browser back and deep links — stopped the
call it had just started. The 5 s was the `saved`-frame timeout inside that
stop, after which the socket was closed. `threadChangeEndsCall` is the rule
now: an agent change or a switch to a DIFFERENT thread ends the call; the
call's own thread arriving does not.

Also from the console: the AudioWorklet processor was inlined into a `blob:`
URL, which `script-src 'self'` (dev and production CSP alike) blocks, so every
call logged a CSP violation and silently fell back to the deprecated
ScriptProcessor. The processor is a static file under `public/`, loaded from
the page's own origin; no CSP loosening.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…task with a SILENT acceptance; shorter lines; no invented canvas (ent#551, ent#576)

Third live run. The model still spoke twice around every task — "Let me count
the files for you." then, on receiving the accepted result, "I'm checking on
that now. Anything else you're curious about while we wait?" — no matter how
the acceptance was worded, because a blocking call's result IS a turn and the
model answers it. The Live API has the shape for this: on a Workspace call
`run_task` is declared NON_BLOCKING and the accepted dispatch is returned with
SILENT scheduling, so it enters context without a turn to answer. Verified on
gemini-3.1-flash-live-preview: one line before the call, nothing after.
Refusals and the container path stay blocking and spoken; both are
getattr-guarded for an SDK without the enums (the unit stub, now extended).

The etiquette caps an acknowledgement at one short sentence and a report at
one or two. And the model said "the canvas shows the breakdown of your files"
and "I've put the weather up there" without one canvas call — the canvas row
was untouched since 2026-09-07. A canvas session is now told never to claim a
canvas it did not draw, and the completion notice states the result is in the
chat only and invites drawing it with show_markdown before speaking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…s; tools are called, never spoken (ent#576)

Fourth live run. The canvas DID update (three show_markdown calls in the audit
log) but the chart showed as raw JSON: the model wrote a Chart.js-shaped fence
(`data.labels` / `datasets`) because the panel prompt said ```chart fences
exist and never gave their shape. It now carries the chart/kpi/table payloads
in the platform prompt's own words, pinned to the renderer's keys and
CHART_TYPES the ent#536 way, with the Chart.js shape named as the wrong one.

Same run: the model said `show_markdown('Canvas update test') updated now`
aloud, and narrated "the task failed because of a syntax error" for a call the
backend never received. The etiquette now says tools are called, never spoken,
and a failure is reported only when it actually came back as a result. The
block's size cap moves 2200 -> 2600 for the three QA-driven rules (measured
2,259), on purpose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…he mic, and the voice model draws the canvas itself (ent#551, ent#576)

The orb's status text and its two buttons were separately absolute-positioned
(4 rem and 1.25 rem up, with 48px buttons), so on a short stage LISTENING sat on
top of the mute and end-call buttons. They are one bottom-anchored column now.

M toggles the mic during a call: a shared rule (`isMuteHotkey` — plain M only,
not in a field, not a key an overlay already claimed, no modifier so ⌘M stays
the window's) dispatched from the same keydown handler as Escape, after the
end-call rule and before the turn-cancel rule. The mute button's title says so.

Fifth live run, on the canvas: asked to update a chart, the model dispatched a
task for the numbers AND cleared the panel, the agent replied "canvas tools
aren't available in this environment", and the model told the person it
"can't generate the live chart" — 90 s over a blank canvas, for a chart it could
have drawn itself. The panel prompt now says: you draw, never ask a task to
update the canvas, a task's "no canvas tools" is about the agent not you, and
never clear before you have what replaces it; the completion notice repeats the
first point where it lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…at is on the canvas, and doing means calling (ent#551, ent#576)

Sixth live run, three findings.

A click on the very agent being talked to, in the left rail, ended the call
with no warning: `openAgentPage` pushed a route with no guard, the conversation
remounted, and its unmount hook stopped the call. Rooms had the same gap, and
the other exits refused silently. Every exit from the stage other than the End
button — the agent row, a thread, a room, New chat, ⌘J — now routes through one
guard that holds the action and asks ("End the call? … Stay on the call / End
call and leave"); confirming ends the call through the conversation's own
`endVoiceCall` and then navigates. End call itself never asks.

The model said "I don't have a current table on the canvas" while the table was
on screen: it had never been told what the person was looking at. A Workspace
call now starts with a text rendering of the canvas (one line per block — kind,
title, slot, a short extract), bounded and best-effort, and a task that changed
the canvas while it ran says so in its notice and what the canvas shows now.

Three requests, three spoken promises ("searching for recent news about OpenAI
now"), zero tool calls. A blocking-vs-non-blocking probe on the live model put
both at 4–5 of 6, so the declaration is not the cause; the prompt is. The first
etiquette rule for a task-capable session is now "Doing means calling": the
model has no files, web or skills of its own, and saying it will do something is
not doing it. The run_task description says the same.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…tal base URL

Three live docs claim the preview fetch inherits `download_url`'s origin and
that the missing `Range` in `main.py`'s CORS `allow_headers` is what bounds it.
Both halves stop being true once `sharePreviewPath` slices the path from
`/api/files/` (#2733), so state today's rule where the next reader will re-derive
the old rationale:

- `requirements/core-agent.md` AC-4: correct the premise; the whole-blob read
  stays because the 256 KB cap is client-side slicing, not because a ranged
  preview is impossible.
- `architecture/workspace.md`: the Files-tab verbs block gains the rule and its
  reason — `connect-src` is a build artefact, the portal base URL is a runtime
  setting, and a portal base URL on another host is a SUPPORTED topology
  (ent#79), not a misconfiguration.
- `feature-flows/workspace-rail.md`: the Preview bullet loses the falsified CORS
  clause; the Testing block names both guards.

Present tense throughout — the issue number is a lookup key, not narration
(architecture.md editorial rule 3).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…ew fetch

Red before the fix. Two of these assertions changed rather than appeared, and
both changes are deliberate:

- `portalFiles.spec.js` "share preview URL" asserted the BUG — it pinned the
  cross-origin url the browser then refused under `connect-src`. Flipped to the
  same-origin path. This is the #1638 class (a defect shipping green because a
  test asserted it), so it is called out rather than quietly rewritten.
- the `sameOriginPath` cross-origin test at the top of the file is NOT touched.
  Its subject is the general helper, whose contract is unchanged; only its title
  is renamed to say so, because sitting beside the new rule it read as a
  contradiction instead of as a second scope.

New `#2733` describe: the cross-origin rewrite asserted as a property
(`new URL(result, base).origin === base`) and not only as a string, the default
install byte-identical, a path-prefixed portal base URL, a port-only origin
difference, a protocol-relative base, the fragment drop, the sig token across
the rewrite, the input left unmutated, and the two negative pins — another route
falls through absolute, and traversal cannot steer the rewrite.

`test_1400_csp_blob_preview.py` gains the contract half of the same bug class,
and asserts no JavaScript syntax: `connect-src` is a frozen SET in both CSP
sources (a literal origin has no wildcard and no placeholder, so a shape check
would pass the one move #2733 rejects), the share-route literal must match the
f-string `client_portal/service.py` builds `download_url` from, and the loader
carries the `@csp-coupled:` marker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…marker to it

Anthropic shipped claude-fable-5-1, the successor to Fable 5 in the same tier at
the same price, and the catalog had not moved — so the model was unselectable in
the operator picker, the admin fleet-default dropdown, and the per-agent
public-channel override (server-validated, so a 422 rather than a quiet absence).
#2086 made drift between those lists impossible; the control it left human is
exactly this: one ModelEntry, one codegen run.

Fable 5 keeps its slot, its id and both flags — only the (latest) marker moves,
since two adjacent rows reading "Most capable — longest tasks (latest)" is the
confusion the marker exists to prevent. It is NOT relabelled Legacy and NOT
repositioned: AC 2 asks that it stop being labelled the latest, and dropping the
marker is exactly that. No default moves — PLATFORM_DEFAULT_MODEL_VALUE and the
`recommended` marker are untouched.

The Workspace composer is deliberately NOT included: ent#403 excluded the whole
Fable tier from that client-facing surface three days before this issue was
filed, and reversing a written product decision is a type-feature, not a P1 bug.
Both Workspace kwargs are omitted, so the entry matches claude-fable-5 exactly.

The parity guard is derived — it goes green on whatever the catalog says — so the
AC is pinned by explicit assertions instead: a per-id end-to-end test including
the 422->200 gate, and a family-generic "at most one (latest) per tier" rule with
the Fable id pinned on top (deliberately brittle; the next Fable must edit it).
test_per_model_flags named 9 of the catalog's 10 entries, so an 11th could have
been added and gone entirely unchecked while green — it now asserts that every
catalog id falls in some flag group.

All three assertions were meta-tested by planting violations with the mirror
regenerated, so the derived halves stayed green and only the new pins could bite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
… and accept the `fable` the same router advertises

GET /api/model's help text still said `opus` means Opus 4.8 and `fable` means
Fable 5 — two generations behind. It now states the durable rule ("each resolves
to the current generation of its family") before the perishable list, so the next
refresh has less to chase and a reader can see which half rots. The resolution is
the Claude Code CLI's, not Trinity's; the wording is sourced from the same
claude-api reference the catalog verifies ids against.

The same file advertised `fable` in `available_models` while the sibling PUT
/api/model 400'd it — `valid_aliases` omitted `fable` and `fable[1m]`, and the
error text listed three aliases where the GET lists four. Naming the generation
`fable` resolves to while still refusing the value would have made that
contradiction more confident, not less, so it rides the same base-image rebuild:
two array elements and one word, restoring an advertised contract rather than
granting a capability.

The integration case extends the existing valid-model list. It exercises the fix
only on an agent whose base image carries it; a stale image 400s to a backend 503,
which that test already skips on, so it cannot go falsely red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
… origin

The Files rail's preview handed `fetch()` the absolute `download_url`
`portal_documents` builds off `get_portal_base_url()`. Where that resolves to a
different host — a dedicated public agent hostname beside the app hostname,
which ent#79 exists to make possible — the browser refused the request against
`connect-src 'self' blob: ws: wss: <two build-time hosts>` and the preview never
loaded. Download was unaffected: an anchor navigation is not governed by
`connect-src`.

Widening `connect-src` is the wrong lever, for two independent reasons. The
origin is a per-deployment runtime SETTING and both CSP sources are static build
artefacts (#1224's ask-trinity host worked precisely because it is a build-time
constant). And even with the header widened the fetch would still face CORS:
`config.py::CORS_ORIGINS` allowlists the agent hostname, not necessarily the app
hostname the portal page runs on — a CSP-only fix would have shipped, passed
review, and still not previewed.

So stop asking cross-origin. `sharePreviewPath` slices the path from
`/api/files/` onward and drops the origin. The route carries no authority — it
is public and the 192-bit `?sig=` token is the sole credential, compared with
`compare_digest` against the stored row rather than signed over the url — and
the portal page's own origin is guaranteed to serve `/api/` (prod nginx proxy,
the Vite dev proxy, `api.js`'s empty `baseURL`; the Workspace could not load
otherwise). `download_url` itself is never mutated, so the anchor-click Download
and the shareable link are unchanged by construction, and a client upload takes
the other branch of `loadBlob` entirely.

The slice is taken from the route, not from the path root, so a portal base URL
carrying a path prefix resolves to the same `/api/files/{id}` here rather than a
path this origin never serves — and `lastIndexOf`, because the server appends
the route last. Every output is therefore either unchanged or a path under
`/api/files/`: the rewrite cannot be steered at another route, and a url with no
`/api/files/` in it falls through untouched rather than having a local path
invented for it.

`sameOriginPath`'s body is untouched — the general helper's contract is honest —
but its docblock loses "that is the deployment's problem" for the regression
note AC 7 asks for. `PortalFilePreview.vue` and `portal_documents` get the same
correction as comments only, the latter so the next consumer of `download_url`
meets the rule at the producer.

PDF preview stays out of scope: `previewKind` returns 'none' for
`application/pdf` and always has, so a PDF row never reaches this code path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…Workspace deferral

Both preset tables gain the new id and drop Fable 5's `(latest)` marker; the
tier paragraph says plainly that Fable 5 is still current-generation and still
served, so nobody later reads the missing marker as a retirement. The alias line
and the Error Handling row can now honestly say `fable`, because the PUT accepts
it as of this change.

The Workspace deferral is written down rather than left silent: ent#403 excluded
the Fable tier from the client-facing composer on a stated rationale, and a
reader of this flow should find that decision recorded as revisited, not absent.

workspace-model-choice.md's positional-dataclass warning said "all ten entries"
— false at eleven, and it is the warning protecting the single most dangerous
edit in this file. Its twin in model_catalog.py's docstring was fixed with the
catalog change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
Wrapping only — no claim changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…odel

Four customer-facing files still led with Fable 5 as the flagship. Fixing the
internal memory docs while the pages a customer actually reads assert the
opposite would be a half-fix of a ticket whose whole thesis is "labels behind
the current lineup".

Each edit also says Fable 5 is still offered and still supported, so a reader who
has it pinned on an agent or a schedule does not read the change as a retirement.

The FAQ heading is deliberately unchanged — faq/README.md links its slug, and the
question is still valid — so only the body moved. whats-new/v0.8.5.md is left
alone: a release note was true when written, the same rule that keeps
docs/archive/ out of scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…ms; a promise without a tool call is nudged (ent#551)

Seventh live run.

Two tasks dispatched together: the second failed at once with "This
conversation is already handling a message" — the portal admits ONE turn per
thread. Background turns now take a per-call lock: the cap still bounds what is
in flight, but each task waits for the thread and then runs. The `task` frame
carries a `status` (queued | running) and a `running` event marks the moment a
queued task takes its turn; the orb shows one pill per task — the label, and
"· queued" while another holds the thread — never one bunched line.

"creating that bar chart now", "looking into OpenAI's marketing strategy now",
"let me start that search now": three promises, no tool call, until the person
asked "are you sure you ran the task?". The prompt rule did not hold, so the
platform watches: an assistant turn that reads as a promise to act, with no
tool call in the turn or within 3 s after it, gets a notice naming what was
said, stating that nothing has started, and telling the model to call the tool
now or say plainly that it has not. Rate-limited to one nudge per 15 s; a call
that lands just before the promise (the filler-then-call order) counts as kept.

The run_task description also says it is never for the canvas — the model kept
asking the agent (which has no canvas tools) to draw a chart it could draw
itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
The rail source guard asserted only the positive — that a `sharePreviewPath`
call reaches `fetch`. That still passes with a second, unrewritten
`fetch(row.item.download_url)` added beside it, which is the shape #2733 comes
back in: one rewritten caller and one that never was. Neither the behaviour
tests (they exercise the pure function, not its call sites) nor the
route-literal contract guard (it compares two constants) can see that.

Proved to bite by injecting exactly that line into `PortalRailFiles.vue` — the
guard fails; reverted, 64 pass. Green on the real source, which holds exactly
one `fetch(`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…bscription

SUB-003 only ever moves an agent that ALREADY has a subscription (its
precondition 2), and registering a subscription assigned nobody. So on an
instance with no ANTHROPIC_API_KEY every pre-existing agent sat in api_key
mode with nothing behind it. Neither feature is wrong; nothing covered the gap.

`subscription_service.adopt_for_credentialless_agents` is that cover: one
predicate, one loop, two exclusions. Phase A decides and persists inside the
request (every blocking call in asyncio.to_thread), so the panel's immediate
`GET /api/subscriptions` refetch is already correct; Phase B applies to
containers in the background under the #799 per-agent lock, re-verifying the
assignment before each restart because Phase A itself creates SUB-003
eligibility.

Three things are load-bearing and easy to undo by accident:

  - Condition 1 resolves through get_anthropic_api_key() — encrypted row,
    legacy row, then os.getenv. Never has_secret_setting(), which is DB-only
    and presence-only and would adopt a whole fleet off a working env key.
  - The runtime gate is LABEL-STRICT. `agent_container_runtimes` defaults a
    missing label to "claude-code" and `is_claude_runtime(None)` is True, so
    the new `agent_container_runtime_labels` sibling preserves absence: a
    container with no runtime label (trinity-system has none) is not evidence
    of a Claude runtime. An unreadable Docker adopts nobody — the opposite
    resolution to ent#403's documented fail-open, because that call site
    decides a UI affordance and this one writes a credential assignment.
  - Phase B skips the system agent (_restart_agent stops first, so #1816's
    "never recreate a running trinity-system" guard is bypassed by
    construction), and ephemeral ghosts are skipped by the sweep entirely —
    the AUTH recreate predicate carries no ghost exemption, so restarting an
    adopted ghost destroys its workspace mid-budget.

Auth-MODE change, so the apply is _restart_agent; _hot_reload_subscription_token
keeps its producer invariant. SUB-003's preconditions are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…t bug report

The entry the plan reserved for the review stage (.plan/issue-2733.md §11.1).
Two classes met in #2733 and neither is covered by the existing 2026-07-01 CSP
entry, which is about a missing SOURCE (`blob:`) rather than a source that can
never be added: a docblock calling a supported deployment topology "the
deployment's problem", and the #1638 shape — a test asserting the defect as the
requirement — arriving through a frontend door.

Records what the guard has to be, since this review proved the weaker forms:
freeze the connect-src SET rather than shape-check it (a literal host has no
wildcard and no placeholder, and keeps the in-sync test green), pin the backend
route literal across the language boundary, and note that no unit test over a
pure URL helper can see a CSP refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…less fleet

Trigger A1. The hook sits beside the #1089 key-rollover fan-out and copies its
swallow-everything contract: the sweep must never fail a registration whose
credential is already stored. Only Phase A is awaited, so the Settings panel's
refetch on the next line already lists the adopted agents.

The body parameter is renamed `request` -> `payload` because the handler now
also takes the injected Request. Two things called `request` in one handler is
how a log line inside an `except` raises AttributeError, which would escape to
the outer handler and return 500 with the credential stored. All six body-param
sites move, including the two inside logger calls, and the three positional
calls in the existing hot-reload suite are updated.

Also adds the two manual-assignment audit rows. AC5 asks for adoption to be
recorded "alongside manual assignment", which is only literally true if the
manual move is recorded too — #2421 found this router carried no
platform_audit_service call at all, so "how did agent X get onto this
subscription?" answered with a false negative for every manual move. This takes
`subscription_assign` / `subscription_clear` only; #2421's register / delete /
settings actions stay unclaimed. Subscription id and name in details, never a
token (Invariant #12).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…ss fleet

Trigger A2. The canonical migration off a metered key is *register the
subscription, then delete the key*. In that order Trigger A1 does nothing —
the key still resolves, so the sweep short-circuits — and the deletion then
strands the whole fleet in exactly the reported state with no trigger left.

Both reachable clear paths are hooked. The dedicated
DELETE /api/settings/api-keys/anthropic is the one the UI uses; the generic
DELETE /api/settings/{key} reaches the same rows through db.delete_setting,
which — unlike db.set_setting — carries no ent#435 sink guard, so it is a real
second door rather than a theoretical one. Both are gated on the route's
existing `deleted` truthiness, so a no-op DELETE stays inert, and both are
already assert_admin-gated, so the hook inherits an identical human-only
boundary and adds no reachable principal.

The hook deliberately sits on the routes, not on clear_secret_setting: that
leaf also serves github_pat and the Slack keys. A third clear path exists at
set_secret_setting's blank-write branch and is unreachable for this key —
update_anthropic_key 400s anything not starting `sk-ant-`, and the generic PUT
is refused by ent#435's sink guard.

Noted, not fixed here: db.delete_setting has no delete-side twin of that sink
guard. Closing it would change DELETE of any credential-shaped key from 200 to
422 — ent#435's scope, not this bug's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…router boundary

Every test executes the path against a real schema (db_harness) rather than
asserting on source text (#2659). The only stubs are the two edges a unit test
cannot have: the batch Docker runtime read and the container restart.

The ones that earn their keep are the negatives. A working instance key adopts
nobody and never even asks Docker; an agent already on a subscription and one
opted out of the platform key are untouched; a codex agent, a container-less
agent and a container with no runtime label are each skipped for a different
reason, and an unreadable Docker adopts nobody at all. T16 drives
register-then-delete end to end at both routers — the ordering the key-deletion
trigger exists for — and T17 deletes the settings row while ANTHROPIC_API_KEY
is still in the environment and asserts zero adoptions, which is the test that
fails if condition 1 is ever "simplified" to has_secret_setting().

T11b/T11c pin the two destructive exclusions, T11d pins the under-lock
re-verify, T12 drives the REAL headroom ranker rather than asserting a mock's
return value, and T13 pins the already-shipped #74 create path so that half of
the decision survives by behaviour rather than by luck.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
… their guards

Requirements (security.md): 20.3's workflow gains the automatic step; 20.3a is
retitled to cover all three triggers and now carries the five-condition
predicate, the two never-move guards, the phase split, the two Phase-B
exclusions, the container-less known bound, the fail-closed Docker rule and the
owner-blind decision; 20.5a's "Cut: Tier 4 bulk auto-assign" is amended rather
than silently contradicted — the credential-less subset is delivered, bulk
migration of agents with a working credential stays cut; SEC-001's CREDENTIALS
category lists the four new event actions.

Feature flows: subscription-management.md owns the delta (the #74 section
becomes the three-trigger section, Flow 1's overview / sequence diagram /
endpoint block show the awaited decide phase and the backgrounded apply, both
POST entry-point rows note the sweep, revision history row added).
platform-settings.md gains the two Anthropic-key DELETE rows and a downstream
pointer, because that flow owns Settings -> API Keys. subscription-auto-switch.md
gets one cross-reference under "Not covered" so nothing there contradicts it —
SUB-003 stays failure-driven and its precondition 2 is untouched.

Architecture: backend.md's subscriptions / settings / subscription_service /
subscription_auto_switch bullets; agent-lifecycle.md's #1089 section gains the
fourth producer, the one that deliberately takes the recreate branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…ither A2 route

The sweep GRANTS a credential, so it stays human-admin-only by inheriting each
delete route's existing assert_admin rather than adding a gate of its own.
Asserted on BOTH routes, because "a guard applied to one of two sibling
codepaths" is the repo's most recurrent escape class and this change has
exactly that shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…read

The sparse-`attrs["Labels"]` trap this flow documents now has two call sites
with opposite correct answers: the model control fails OPEN to "claude-code",
and #2572's credential-less subscription sweep must fail CLOSED because a
missing label is not evidence of a Claude runtime (trinity-system carries
none). Recorded here so a future change to the sparse shape moves both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…able 5 1"

`_format_model_name` strips an 8-DIGIT date suffix, then prefix-matches a small
mapping, then falls back to title-case-with-hyphens-as-spaces. Every catalog
addition before this one degraded *cleanly* through that fallback
(`claude-opus-5` -> "Claude Opus 5"). `claude-fable-5-1` is the first that
degrades *wrongly*: `-1` is not a date, no mapping prefix hits, and the per-model
cost and token breakdowns rendered the literal "Claude Fable 5 1".

#2086 FR-7 already declared this prettifier a known-deferred follow-up, and the
id was reachable as free text before this change. What moves it is that making
the model selectable in the picker turns a theoretical mangling into a likely
one — so the exact entry ships with the thing that causes it.

One mapping line. `claude-fable-5` is deliberately left unmapped: its fallback is
already correct, and since the mapping is scanned with `startswith` and first
match wins, an entry for it would match `claude-fable-5-1` first and silently
relabel the newer model. Nothing in the function is restructured.

The JS twin `stores/observability.js::formatModelName` is NOT changed, and that
is a decision rather than an omission. It returns a bare family name with no
version ("Claude Opus"), and `costBreakdown()` keeps no `model_id` beside it — so
a one-line `includes('fable')` branch would collapse `claude-fable-5` and
`claude-fable-5-1` into two indistinguishable "Claude Fable" rows on a per-model
breakdown, where today both show their distinct raw ids. It would also not make
the two prettifiers agree ("Claude Fable" vs "Claude Fable 5.1"); agreement needs
version-aware structure on the JS side, which stays with FR-7.

The test executes the function rather than restating the mapping, and its second
half is derived from MODEL_CATALOG: no selectable model may render a version
split across a space — the observable signature of this bug class — so the next
point-release id is covered without editing the test. Both halves were watched to
fail with the mapping removed, then restored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
… request twice is one task, and Escape cools down after a call (ent#551)

Eighth live run. The promise watch did its job once (a promise with no call
became a real dispatch) and then misfired: with two tasks in flight the model
said "both … are running now", the watch saw a promise-shaped line with no
fresh call, nudged, and the model — told "nothing has started" — said "I
misspoke, they weren't running before" and started a duplicate of t1 with the
identical prompt. Three changes: no nudge while any task is in flight (the pill
already tells the person the truth); status lines ("already", "still working",
"are running") are not promises; the nudge states only what the platform knows
(no call arrived), conditions the action on "not already running", and forbids
commenting on the notice. And a `run_task` whose prompt equals a running task's
is refused as a duplicate — one task, its result once.

Also in both calls, a running task was cancelled ~3 s after the call ended,
with "You stopped this message before the agent finished." The thread had just
reattached to that task's turn, where Escape means "stop the turn", and the key
that ended the call landing again cancels it. Escape is not a cancel for 5 s
after a call ends (the Stop button stays live — a click is deliberate). Because
access logging is off and nothing named the requester, `terminate_execution`
now logs who asked on every arm and the portal route logs the client's email.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
The sweep-summary row exists so that "adopted 0 of 40 because Docker was
unreadable" is distinguishable in `audit_log` from "nothing to do" — that is
its stated rationale in the plan and in the shipped test's own docstring. But
the unreadable-Docker arm returned before writing one, so the single case the
row was designed for left the trail saying nothing happened, with an ERROR log
as the operator's only signal and `agent_count` staying 0 either way.

Write the summary row on that abort, carrying a `skipped.docker_unreadable`
count so the reason is in the record rather than only in the log. The other
early returns stay silent on purpose: "no credential-less agents" and "no
assignable subscription" are genuinely nothing-to-do, not a failed decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
webmixgamer and others added 9 commits September 12, 2026 14:43
…es, the canary mapping is guarded, and journey families are runnable (#2337)

The journey validator resolved `invariants:` ids from the catalog's one markdown
table (the 12-row "Recommended starting subset"), so 11 of the 71 defined
invariants were citable and citing P-02 — a real §7 entry, J10's own permission
promise — failed as "coverage theatre". One stdlib parser
(tests/unit/_invariant_catalog.py) now resolves every `**X-NN** … *(Tier`
definition; a table is never a definition.

Catalog E-06 (the unimplemented #129 orphan check) shared its id with the
registry's E-06 ("no overdue next_run_at", #1472). The #129 entry is re-homed to
E-09 — AC4 relaxed for this one id by operator ruling — so the id means the same
thing in the catalog and in canary/invariants/e06_no_overdue_next_run.py. A
Canary mapping table (module → registry id → catalog id) documents live vs
journey-only, and tests/unit/test_2337_invariant_namespace.py asserts INVARIANTS
== every module's INVARIANT_ID == the table, every catalog id resolves, and no
row's two ids differ. Exact per-family pins; `Signal:` mandatory on new
families with a shrink-only allowlist for the 60 legacy entries without the
line; synthetic-markdown self-tests prove a bold mention, a dagger row and a
table row are not definitions. Mutation-verified: deleting a cited entry,
unregistering E-03, drifting a mapping row and adding a Signal-less entry each
turn exactly the intended tests red.

New families scoped to runnable predicates: SK- (skills), RD- (repo-bound
deployment), PLG- (plugins, file/HTTP), IA- (one-hop permission edge, fan-out
batch bound, dead callee fails fast). Inter-agent recursion depth has no product
guard to predicate on and is recorded in Gaps. L-03's documented cascade list is
re-synced to snapshot.ORPHAN_SCAN_TABLES. J01/J06/J07/J08/J09/J10 records gain
ids; JOURNEYS.md regenerated; the two docs that stated the collision as current
(requirements/infrastructure.md, a canary_alerts.py comment) updated. CSO diff
report: clean in scope; one out-of-scope finding routed privately (ent#614).

Fixes #2337

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tasks were the operator's own Stop (ent#551)

The two tasks that failed 3 s after End in the eighth QA run were cancelled
on purpose by the person testing, not by a stray second Escape. A guard with
no defect behind it is a rule nobody can explain later, so it goes. The
terminate-caller logging stays: it is what would have answered this in one
line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
…ith its voice_call_id argument (ent#551)

`_persist_user_turn` gained `voice_call_id=` for the call's own background
turns; the ent#473 source guard located the call by its old exact text and
raised instead of asserting order. Same three-point ordering, new spelling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApbFsEqLtAWVdedXAUqV9p
# Conflicts:
#	docs/memory/feature-flows.md
# Conflicts:
#	docs/memory/learnings.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants