Conversation
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
…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
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.