Skip to content

DO NOT MERGE — merge train: 2607,2609,2621,2596,2615,2606 - #2639

Closed
vybe wants to merge 67 commits into
devfrom
train/20260909-0822
Closed

vybe wants to merge 67 commits into
devfrom
train/20260909-0822

Conversation

@vybe

@vybe vybe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2607, #2609, #2621, #2596, #2615, #2606. Never merged; members merge individually once green. Assembled by /merge-train on 2026-09-09 from each member's head SHA (--no-ff, one merge per member).

AndriiPasternak31 and others added 30 commits September 7, 2026 20:26
…e rename sweep

The edition-agnostic half of role assignments (trinity-enterprise#500). Inert on
its own: no provider is registered in a core build, so `resolve_assignment`
returns None, the three new `ExecutionContext` fields stay None, and the prompt
block renders byte-identically to today.

* `services/assignment_provider.py` — the seam, in the `mfa_gate` / `a2a_gate`
  shape (Protocol + register/get/clear + a resolve function). The SEAM owns the
  failure handling, not the provider: `compose_system_prompt` has no exception
  handler and all three of its callers lose the execution-context block if this
  raises (one loses the platform prompt entirely). A malformed non-raising
  answer is validated separately, because try/except cannot see a `str` where a
  list was promised — it would iterate into single characters and render the
  wrong prompt without raising.
* `platform_prompt_service.py` — `primary_user_display` / `role_id` /
  `stakeholders` / `proactive_consent`, auto-filled as ONE provider call beside
  collaborators and platform_url. `needs_assignment` joins the `replace` guard,
  not just the value: a caller that pre-filled both existing fields would
  otherwise skip the block and the new fields would silently never render. The
  primary line requires a display NAME — never an email, because this block
  reaches anonymous public-link and paid turns.
* `db/agent_cleanup.py` — `cascade_rename` now sweeps `EXTRA_AGENT_REFS`, which
  its own docstring already promised it did. A registered private table kept the
  OLD agent name across a rename, so the agent lost its rows and a later agent
  taking the freed name inherited them — the recycled-name cross-wire
  `delete_reports_to_refs` exists to prevent, one function below.
* `operator_queue_service.py` — `ROLE_DRIFT_ALERT_PREFIX`, reserved so an agent
  cannot pre-create and ON CONFLICT-silence the alert about its own role file,
  which lives in its own writable workspace.
* `enterprise-docs-guard.yml` — the new seam file joins `SEAM_FILES` and both
  path filters, so a leak in its docstring fails the build instead of shipping
  green (#1461's lesson).

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third surface for role assignments (Invariant #13, trinity-enterprise#500):
tool module + client method + server registration, plus the tests.

Read-only, and that is structural rather than a v1 scope cut: creating an
assignment is a GRANT, and the backend refuses every non-interactive principal
on the write path, so an MCP write tool would be one that can only ever fail.

Narrower than credential_vault.ts on purpose. That module branches on the detail
SHAPE because its backend raises coded refusals; this route raises none — its
failures are the entitlement 403 (plain-string detail) and a UNIFORM 404 that
covers "route absent" and "no such agent / no access" alike. The 404 is uniform
for enumeration safety, so the tool merges those cases in its message instead of
claiming a distinction it does not have.

Registered in the operatorOnly group, whose allow-list includes `agent` — which
is exactly why the backend self-scopes an agent principal to its own roster.
Advertisement is not authorization, and an agent-scoped key resolves to its
owner carrying the owner's role.

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cross-wire

Four public files, and each one exists for a failure that ships GREEN without it.

* `test_ent500_assignment_provider.py` — no provider / a raising provider / a
  malformed non-raising answer all degrade to None. The third is the one
  try/except cannot give you: a `str` where a list was promised iterates into
  single characters and renders the WRONG prompt without raising, which is
  strictly worse than a missing line because nothing looks broken. Also asserts
  the seam file is in enterprise-docs-guard's hardcoded SEAM_FILES and in BOTH
  path filters — an omission there is invisible forever (#1461).
* `test_ent500_execution_context_fields.py` — rendering, bounds, and the two
  wiring properties that would otherwise fail silently: the provider is called
  exactly ONCE per compose, and `needs_assignment` is part of the `replace`
  guard, so a caller that pre-filled collaborators AND platform_url still gets
  the assignment lines. Plus: a raising provider still yields the full block,
  platform prompt included.
* `test_ent500_public_turn_no_pii.py` — an outside turn renders no assignment
  line, an inside turn still does, an unrecognised label is suppressed by
  default, and the answer contract has no email-shaped key at all. Pins the fact
  the suppression rests on: a Workspace/portal turn is labelled `public`, so
  there is no third label to remember — asserted over the source, so a new
  outside surface under its own label fails here instead of disclosing.
* `test_ent500_cascade_rename.py` — verified to FAIL on the pre-fix
  `cascade_rename` (3 of 5 red), using a table deliberately absent from the OSS
  MetaData, because a test against an OSS table passes on the broken code.

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rule #1's requirements-first pass, plus the architecture and flow updates. The
public tree describes the generic open-core seam only — the record itself, its
schema and the module that owns it stay in the private repo.

* `requirements/infrastructure.md` §12.6.2 — the four execution-context fields
  live beside §12.6.1, which is where execution-context injection is actually
  documented (the plan named core-agent.md; that is not its home).
* `architecture/backend.md` — the seam entry beside the other open-core seams,
  and a paragraph on the agent-cascade registry stating why BOTH lists are swept
  by BOTH paths, with the recycled-name inheritance that made it matter.
* `architecture/mcp-server.md` — the tool count RECOUNTED (129 across 33), not
  incremented: it read 121/30 and the table was already missing `canvas.ts` and
  `credential_vault.ts`, so those rows land too and the count is true again.
* `feature-flows/role-assignments.md` — the new flow.
* `feature-flows/execution-context-injection.md` — the two new lines in the
  block, the auto-resolved list, the `replace`-guard contract, the per-field
  bounds and the audience gate, and the tests.
* `feature-flows.md` — Recent Updates row + a Collaboration & Permissions row.

Deliberately NOT added: a route row under api-endpoints.md's Enterprise Modules
section. That section ends with the standing rule that the module catalog is not
documented publicly, and the credential-vault routes are absent for the same
reason. The seam and the OSS tool are public; the module is not.

enterprise-docs-guard re-run locally over docs/ + CLAUDE.md + all six seam
files: clean.

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ect the claim

Two things, and the second matters more than the first.

**The correction.** The previous commit here said `cascade_rename`'s missing
`EXTRA_AGENT_REFS` sweep meant a registered private table kept the OLD agent name
after a rename, so the renamed agent lost its rows and the next agent to take
that name inherited them. That was overstated. Reading every caller —
`db/agent_settings/metadata.py::rename_agent` is the only production one — shows
it carried its OWN `EXTRA_AGENT_REFS` loop (ent#46) immediately after the
`cascade_rename` call. The rename endpoint was correct the whole time. Corrected
in the docstring, `architecture/backend.md`, `role-assignments.md`,
`agent-rename.md` and the Recent Updates row.

**What was actually wrong, and the fix.** The behaviour lived in the CALLER while
`cascade_rename` — the shared function whose contract advertised it — did not do
it. That is exactly what made it survivable and exactly what made it worth
fixing: a cross-repo module author reads `register_agent_owned_table`'s
docstring, not one caller's body, and a second caller of `cascade_rename` would
have silently dropped the sweep. The #1819 shape one level down — two places
answering one question.

So this is a CONSOLIDATION, not a bug fix: the sweep now lives in
`cascade_rename` (previous commit) and the duplicate loop in `rename_agent` is
deleted here. Leaving both would have been a third copy, and a redundant pass is
how the two lists drifted apart in the first place.

Tested at both levels, because moving a loop is otherwise an unverified refactor
of the one path that mattered: `cascade_rename` re-keys a registered table; the
production `rename_agent` path still re-keys after the move (verified RED with
the shared sweep removed — 4 of 7 fail, including the caller-level test); and a
structural guard forbids `rename_agent` regrowing a loop of its own.

Found by the doc pass, not by a test — which is its own small argument for
writing the flow doc rather than only the code.

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The provider's allow-list dropped `mcp`, `agent` and `room` (each has a reader
who may be outside the organisation) and gained `manual` / `retry` while losing
the dead `task`. The public contract test's stub mirrors that rule, so it should
say the same thing.

Adds a case for the three transitive/ambiguous labels specifically, because the
source scan beside it CANNOT catch them: no outside-facing router emits `mcp`,
`agent` or `room`, so asserting that `public.py` / `paid.py` /
`client_portal/service.py` use only the suppressed set says nothing about them.
Two different gaps, two different checks.

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…der owns "never an email"

The public flow doc claimed an address "cannot ride in even by a provider's
mistake" because the answer contract has no email-shaped key. The provider found
out otherwise: a display name resolved from an account whose only name-shaped
column IS its address is a `str`, and it passes every check the seam makes.

The seam validates types, not contents, so the property has to hold where the
value is resolved. Both the doc and the seam docstring now say which side owns
it. (The provider-side fix is in the private module.)

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6P6bjT1RRiHosQCAEgJE7
… predicates, and value-vs-field-name contracts

Refs trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6P6bjT1RRiHosQCAEgJE7
…2559)

Requirements first, per Rule of Engagement #1.

VOICE-004 is rewritten: the orb has one consumer (the Workspace conversation);
Agent Detail offers a door, not a surface — an ungated `Talk` button in
`AgentHeader` that navigates to `/workspace?agent=<name>&voice=1`. Ungated
because the door's gate and the destination's gate are the same two booleans
(`VOICE_ENABLED` and a provider key), so gating the door buys nothing and costs
a cold-load pop-in; the Workspace already says in words why a call cannot start.

VOICE-003 keeps its write path but records that it has no first-party caller
after this change, and that historic `chat_messages` rows keep their badge.
VOICE-001/VOICE-006 gain a scoping clause naming the OSS start route against the
Workspace's own. VOICE-008 is marked retired (#2484). VOICE-010 gains the
`?voice=1` entry contract — an intent armed IN THE APP, never by the URL alone.

public-access.md §48.3 gains FR-1b (the same contract, from the destination's
side) and FR-7 stops calling the orb "the Agent Detail overlay".
core-agent.md's Escape ownership list drops the overlay from Agent Detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…#2559)

frontend.md (the map row that owns `src/frontend/src/**`) gains the paragraph:
VoiceOverlay + useVoiceSession are mounted only by PortalConversation; Agent
Detail's affordance is an ungated Talk button pushing `?voice=1`; and the
one-shot rule — armed in the app, stripped once in `bootstrap()`'s finally —
lives as a pure function in portalVoiceMode.js, with the reason a browser
activation heuristic cannot replace it.

workspace.md stops calling the orb "the Agent Detail orb". observability.md's
ent#438 knock-on re-points at the caller that actually passes `workspace_mode`
now (`client_portal/voice.py::start_workspace_voice`), which is what keeps the
Canvas tab drawing for calls started through the door. api-endpoints.md records
that `/voice/start|stop|status` have no first-party frontend caller after this
change, that they are kept and test-pinned, and which voice routes both start
paths still share.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…#2559)

voice-chat.md loses two architecture diagrams that described surfaces which no
longer exist — the Agent Detail chat-tab overlay (retired here) and the
standalone `/agents/:name/workspace` page (retired by #2484 and still documented
three releases later) — replaced by one "Front doors" section. The User Flow is
rewritten around the door and the Workspace's own Call control, the ending
section says where the transcript actually lands, and the feature-flag section
explains why the door is UNGATED: `voice_available`, the roster's
`realtime_voice` and the retired `/voice/status` probe are three spellings of
the same two booleans, so a gate on the door buys nothing and costs a cold-load
pop-in plus a sticky-false hide.

workspace-voice-conversation.md gains the `?voice=1` entry contract beside the
entry control. agent-canvas.md re-points the `workspace_mode` caller at the
Workspace call, which is what keeps the Canvas tab drawing for calls started
through the door. chat-turn-cancellation.md shrinks ChatPanel's overlay list.
feature-flows.md gets the index row and the dated changelog entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…is gone (#2559)

Every user-facing instruction told people to click a microphone beside the chat
input, which after this change is not there. `advanced/voice-chat.md` leads with
the two ways in (Talk on the agent's page, the Voice control in the Workspace),
says plainly that the agent's Chat tab is text only and that older voice
transcripts stay where they are, and explains why Talk is never hidden — hiding
it would make a working feature vanish whenever the flag has not loaded or its
fetch failed, indistinguishable from "this instance has no voice". The API table
marks `/voice/start|stop|status` as retained for API clients but no longer
called by Trinity's own UI.

The two FAQ answers are re-pointed, "Why don't I see the microphone button"
becomes "I clicked Talk and the call didn't start", and "What is Workspace
Mode?" becomes "What happened to Workspace Mode?" — the page it described was
retired by #2484 and the answer still promised it. faq/README.md's index rows
follow both renames. sharing-and-access/workspace.md inverts its stale
"not to be confused with" note: the Workspace is where voice lives now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
ChatPanel loses the `VoiceOverlay` mount, the `useVoiceSession` wiring, the
per-agent `GET /voice/status` probe and its two call sites, the overlay's claim
on Escape, and the unmount `voice.stop()`. ChatInput loses the composer mic
button, its `voiceAvailable`/`voiceActive` props and the `voice` emit —
`PublicChat.vue`, the other ChatInput consumer, passes none of them.

`source: msg.source || 'text'` stays in `loadSession`, and `ChatBubble.vue` is
deliberately untouched: historic voice rows in these chats keep rendering with
their badge. Only the ability to create new ones here goes.

`useVoiceSession` loses `start()` — it hardcoded the OSS route and ChatPanel was
its only caller — AND its entry in the returned object. That second half is the
whole edit: `start, startWith, stop, …` is shorthand, so deleting the function
alone would throw `ReferenceError: start is not defined` at setup in EVERY
`useVoiceSession(...)` call, including `PortalConversation.vue` — the Workspace
conversation this change hands off to. It would have shipped green: src/frontend
has no lint script and no eslint config, Rollup does not error on an unresolved
shorthand, and no vitest spec imports the module (they read it as text). Pinned
in the guard spec that follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…2559)

`AgentHeader` gains a Talk button beside Workspace: `armVoiceAutoStart()` then
`router.push('/workspace?agent=<name>&voice=1')`. Same tab on purpose — the
navigation is same-document, which is what carries the module-scoped one-shot to
the Workspace (a new tab is a fresh document, where it is false by construction)
and what keeps the AudioContext resumable from the click that started the call.

Ungated, with the reason in the template: the flag this page could gate on is
the same boolean the Workspace itself checks, so a gate buys nothing and costs a
cold-load pop-in plus a sticky-false hide on a failed flags fetch. The knock-on
is a deletion, not an invention — `AgentHeader`'s `voiceAvailable` prop has been
dead since ent#438, so it goes rather than being handed an artificial consumer,
and `AgentDetail` stops passing it.

`portalVoiceMode.js` gains the rule as pure functions: `VOICE_QUERY_KEY`,
`voiceQueryRequested`, the armed flag (`armVoiceAutoStart` / `disarmVoiceAutoStart`
/ `voiceAutoStartArmed`) and `voiceAutoStart`, which requires armed AND landed
AND a platform principal. `navigator.userActivation` is deliberately not used:
it is an audio-playback heuristic, not a provenance check, and `Portal.vue`
skips `bootstrap()` while signed out — so a pasted link survives unconsumed
until exactly the sign-in click that would satisfy it. sessionStorage was
rejected for surviving a reload; the module `let` dies with the document, which
is the semantics wanted, for free. `voice` joins `STAGE_QUERY_KEYS` so a
sign-out never carries the key into the next session.

`sessions.js::voiceAvailable` now has no reader in src/; kept with a comment
naming the follow-up, since the backend key still derives `workspace_available`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…ry exit (#2559)

`Portal.vue` consumes the Talk door's intent. `resolveAgentQuery()` only RECORDS
it — at the top, before its own trailing `router.replace`, which is async and
rewrites `route.query` when it lands. `bootstrap()` owns everything else:

  - `pendingVoiceStart = false` at the top. The function is try/finally with no
    `catch`, so a throw runs the finally, propagates, and skips the consume — a
    stale `true` would make the NEXT bootstrap (continueAsOperator, onVerify)
    start a billed call nobody asked for, with the URL already stripped so
    nothing on screen explains it. Latent today, since fetchRoster and
    refreshThreads carry `.catch` — but `decorate()` and `seedAgentRecency()` do
    not.
  - the query key is read BEFORE the first await, and stripped ONCE in the
    `finally`, keyed on the key's PRESENCE rather than its value. Three
    per-branch strip sites would have missed the `/workspace/c/:sid` early
    return, the no-`?agent=` fall-through and a throw; keying on the value would
    leave `?voice=0` resident, which now also matters because `voice` is a
    `STAGE_QUERY_KEYS` member and a residual key makes `shouldEscapeStage`
    navigate spuriously.
  - the hand-off runs after the try/finally, one `nextTick` after the ref is
    assigned in the same patch as `bootstrapResolved`.

`PortalConversation` exposes `startVoiceCall` alongside `focusComposer`, and
`Portal.vue`'s `ref="conversationRef"` is that `defineExpose`'s first consumer —
nothing used `focusComposer`, so neither token is dead code now. Its header
comment stops calling the orb "Agent Detail's".

`npm run build` passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
New `agentDetailTalkDoor.spec.js` — the removal side, as source guards (vitest
runs environment:'node' with no mount harness). What must stay gone (the orb
mount, the session, the `/voice/status` probe, the mic and its props/emit), what
must NOT have gone with it (`source: msg.source || 'text'` and ChatBubble's
badge, so historic voice rows still render; PortalConversation's own mount of
the orb), and the door's properties: a stable test id, no `v-if`, no
`target="_blank"` — scoped to the Talk block, since the Git remote link in the
same header legitimately carries one — and no `voiceAvailable` prop.

The load-bearing case is `start,` absent from the composable's return object.
Nothing else in the suite catches that: it throws only at component setup, and
no spec imports the module.

`portalVoiceMode.spec.js` gains the rule tests — strict `'1'` parsing including
the repeated-key array, and `voiceAutoStart` refusing an otherwise perfect
request that was not armed, which is the regression test for the signed-out
pasted-link path — plus source pins for the hand-off: arm-before-push, record
without stripping, reset before the try, key read before the first await, one
strip in the finally, and no `navigator.userActivation` in any code path. Its
"the call is the Agent Detail orb" describe block is renamed; the claim is false
now even though its assertions still hold.

Two traps, both found by the tests failing on arrival rather than passing
vacuously:

  - The shared `stripComments` helper destroys `ChatInput.vue` — it contains
    `accept="image/*"`, whose `/*` opens a block comment that swallows 74% of
    the file including the `defineEmits` line. Masked, with the reason; and
    every stripped source in the new spec is now checked against a sentinel, so
    a future stripping change fails loudly instead of turning the absence
    assertions vacuous.
  - My first falsification harness shifted its args and re-read `$3`/`$4`, so
    all five mutations silently no-oped and every pin "passed". Redone: 12 of 12
    mutations now fail the right test, including reinstating `start,`.

`test_ent438_agent_canvas.py` is re-pointed rather than deleted — the property
(some live caller passes `workspace_mode`) is unchanged, only the caller moved
to `client_portal/voice.py::start_workspace_voice`. Its ChatPanel arm is
inverted, not dropped, so the retired front door cannot quietly return.

Evidence: `npm run test:unit` 102 files / 2276 tests passed;
pytest test_ent438_agent_canvas + test_voice_auth + test_1076_voice_model_config
+ test_voice_tools + test_ent534_workspace_voice + test_ent279_scrub_parity
= 54 + 131 passed (the "nothing on the backend was deleted" proof).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…ne (#2559)

`.github/workflows/frontend-e2e.yml` runs `npm run test:e2e:smoke`
(`--grep @smoke`), so an `@interactive` case runs once on a laptop and never
again — and a source-grep spec cannot catch a sibling PR breaking `defineExpose`
on rebase. Five `@smoke` cases therefore carry the wiring: the click navigates,
the start request is ISSUED, the URL loses `voice=`, `/voice/status` is never
asked, Talk renders with the platform flag patched off (the ungated ruling), a
pasted `?voice=1` starts nothing, `?voice=0` and `/workspace/c/:sid?voice=1`
both leave a clean URL, and the composer has no mic.

Two traps closed by construction:

  - The roster is patched to `realtime_voice.available: true`. Without it the
    local stack (empty GEMINI_API_KEY) reports unavailable and `startVoiceCall`
    bails BEFORE `voice.startWith`, so "no start POST" would pass for a reason
    unrelated to the rule under test — a false green. The `@interactive` case
    additionally asserts the surfaced text is neither REASON_DISABLED nor
    REASON_NO_KEY.
  - The hand-off is `conversationRef.value?.startVoiceCall?.()`, which silently
    consumes the intent when the ref is null. So the test asserts the call was
    MADE, never merely that nothing threw.

Fixture handling follows the #2199 contract rather than convenience: default
`testfix`, the shared authenticated `agentExists` probe, definitive-404 skips
only. Defaulting to `trinity-system` is the exact anti-pattern that helper's
docstring names. The spec's header states the further need the probe does not
cover — the agent must be on the operator's Workspace roster.

`route.fetch()` has no precedent in this suite and the spec says so rather than
claiming one; `route.fulfill()` with a hand-built body is the house pattern.

`playwright --list` confirms 5 `@smoke` + 2 `@interactive`, and that `--grep
@smoke` selects exactly the five. README fixture table updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…hat tab (#2559)

/sync-feature-flows sweep over every flow doc touching the changed surfaces.
Seven candidates beyond the five already updated; five needed nothing and two did:

`authenticated-chat-tab.md` owns the Chat tab, which just lost voice entirely,
and said nothing about it either way. It now states the tab is text only, names
the door, and records that historic voice rows keep their badge. Its ChatInput
inventory gains the current emit list — and a note that the line counts and
control lists in that section predate several changes, since correcting only the
line my PR touched would imply the rest is current.

`session-tab.md` listed the voice mic as deferred pending "a backend extension
to the turn endpoint — voice writes to the wrong DB tables today". #2559 settles
that rather than leaving it pending: there is one front door, no chat surface
has a composer mic, and the answer is the Talk door.

Checked and deliberately not changed: `brain-orb.md` (its `voiceAvailable` is
`brain_orb_voice_available` on a different surface, untouched);
`workspace-agent-page.md` (its `shouldEscapeStage` prose is about route SHAPE and
never enumerates `STAGE_QUERY_KEYS`, so adding `voice` does not falsify it);
`public-agent-links.md` and `web-chat-file-upload.md` (PublicChat passed no voice
props); `playbook-autocomplete.md` (its abridged `<ChatInput>` snippet omitted
the voice props already, and my change makes its `:disabled="loading"` line
accurate again).

Index is 526 lines — over the skill's 400-line guidance, but it was 525 before
this PR: one index row and one changelog row. Condensing it is not this PR's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…flow

/validate-pr's feature-flow format check wants a status indicator on the
Testing section — the one thing a reader cannot reconstruct from the file
list. States what was actually run (71 assertions across three
pytest-randomly seeds, 326 mcp-server tests, the 14456-test unit island)
and, separately, that the cascade_rename suite was verified RED against
the pre-fix code: "there is a test" and "the test would have caught it"
are different claims and only the second is worth writing down.

Refs Abilityai/trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6P6bjT1RRiHosQCAEgJE7
9e77626 carried a `src/backend/enterprise` gitlink bump along with its
code change. It is invisible in a normal `git diff` because this clone
sets `ignore = all` on the submodule, which is exactly how it got past
the working-tree check.

It must not be here. The gitlink bump is its own PR, opened only after
the private one merges, and it is the moment the feature actually goes
live — not this PR, which is inert by construction. Worse, the pinned
commit is local-only: it is on no remote branch, so any clone that did
initialize the submodule would fail to resolve it.

Restored to the commit `dev` pins. Net diff for the submodule is now
empty; the working-tree submodule is left where it was.

Refs Abilityai/trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6P6bjT1RRiHosQCAEgJE7
`resolveAgentQuery()` ends with `router.replace('/workspace/c/<sid>')` — a bare
path, so it drops the WHOLE query, `voice` included. `bootstrap()`'s `finally`
then started a SECOND replace in the same tick, because vue-router updates
`route` asynchronously and the strip could not see the first one in flight.
Two navigations in one tick do not compose: vue-router cancels the earlier one
(NAVIGATION_CANCELLED), so the strip won and the Talk door landed on
`/workspace?agent=X` instead of the thread URL — the code comment claiming the
two "compose rather than race" was exactly backwards.

Recorded with a flag rather than detected, since `route` cannot answer the
question in the same tick: `resolveAgentQuery()` sets `landingReplaced` where it
replaces, and the `finally` strips only when nothing already did. Every exit
still leaves a clean URL — the deep-link early return, the no-`?agent=`
fall-through, `?voice=0` and a throw all keep the one strip site.

Measured against a real `createRouter`, before → after:
  door, agent has a thread   /workspace?agent=acme  → /workspace/c/sid-123
  door, no thread yet        /workspace?agent=acme  → /workspace?agent=acme
  ?voice=0 + thread          /workspace?agent=acme  → /workspace/c/sid-9
  deep link /c/:sid          /workspace/c/sid-7     → /workspace/c/sid-7

The guard spec pins the new shape and is red without the fix (negative control
run: 1 failed / 56 passed, then 57 passed restored).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…2559)

No findings at the gate. The one new security-relevant surface is `?voice=1`,
a URL that would open a microphone and mint a billed realtime session; the
in-app armed one-shot that guards it was traced end to end, and the module-
instance assumption it rests on was verified against a real `npm run build`
(AgentDetail and Portal are separate lazy chunks — a duplicated module would
give each its own flag and break the door in prod while working in dev).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
… not compose (#2559)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
The tier was inert. A file-level #2199 existence probe skipped all five cases,
and frontend-e2e.yml pins the e2e baseline at zero user agents on purpose, so
the fixture it waited for is one CI will never have. Playwright reports that as
`5 skipped` inside a green run, so nothing said the coverage did not exist —
including the spec's own claim to be "the only thing that would catch a sibling
PR breaking defineExpose on rebase".

Behind it sat a second blocker: CI bootstraps the admin from ADMIN_PASSWORD and
flips setup_completed directly, so that user has no email, and portal_auth.py
403s a platform principal without one. GET /my-agents cannot answer, the roster
is empty, and the door lands on "You don't have access" rather than the call.

So the @smoke tier is now hermetic on a synthetic `e2e-talk-door`, following
workspace-code-blocks.spec.js: a fulfilled roster, the shell's reads, and — the
one that actually hid the failure — the thread-creating POST, because
startVoiceCall opens a thread before the first word and its failure branch
returns without ever reaching voice.startWith. The probe moves inside the
@Interactive describe, which is the rule workspace-absorbs-session.spec.js:30-32
already states. @Interactive keeps the real agent, patches only realtime_voice
on the live roster, and no longer stubs the start: stubbing it would make "the
orb came up" true with no call behind it.

Verified against a live stack on this branch, under the CI condition (no fixture
agent): 6 passed, and the whole @smoke suite is 76 passed / 0 failed. Falsified
by mutation — dropping armVoiceAutoStart(), dropping the
conversationRef.value?.startVoiceCall?.() hand-off, and removing startVoiceCall
from defineExpose each turn it red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wz4MR9mmFHHUPM6DRkiCyH
…does

CI seed 99999 failed `test_the_production_rename_path_still_rekeys_a_registered_table`
with `(1, 0) == (0, 1)` — the probe row never moved — while seeds 12345 and
67890 and every isolated run passed. The rename itself returned True, so the
production path was fine; the test was looking at the wrong registry.

`registry_isolated` resolved the module with `from db import agent_cleanup`,
which reads the `db` package ATTRIBUTE. The production call site does
`from db.agent_cleanup import cascade_rename` inside `rename_agent`, which goes
through `sys.modules` at call time. Those two diverge in a suite that drops the
module from `sys.modules`: the package attribute keeps pointing at the stale
object, so the registration lands on a list nothing reads, the sweep finds an
empty registry, and the row stays put. The test then reports the exact wording
of the regression it was written to catch — an isolation failure wearing a real
bug's face, which is the expensive kind.

Resolved via `importlib.import_module` so both sides agree by construction.

Also asserts the two pieces of process-global state the test does not own —
that the probe table is in the registry the production path actually reads, and
that it is visible on the engine the rename will use — BEFORE the rename. Both
fail the same way at the final assert, so without this a future isolation break
impersonates a rename regression again.

Refs Abilityai/trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6P6bjT1RRiHosQCAEgJE7
#2585)

Both nightlies splice a downloaded diff artifact into a sticky comment authored
by `github-actions[bot]`. The artifact is produced by pytest over the MERGED PR
TREE, so a `@pytest.mark.parametrize` value — arbitrary text chosen by whoever
opened the PR, on a public repo that evaluates fork PRs — reaches the comment
unfenced. The impact is not RCE on the runner; it is that the bot's comment can
be made to say something its author never wrote, and the bot's voice is the only
reason anyone trusts it.

WHERE THE FIX WENT, AND WHY NOT WHERE THE ISSUE SAID. The issue asks for a fence
around the spliced text in each workflow. Reading the producer first changed the
answer twice over:

  * the artifact is STRUCTURED markdown — headings, a per-XML totals table,
    bullet lists — so fencing it would turn the table into monospace text on
    every ordinary nightly, destroying the thing that makes the comment useful;
  * the PR-authored surface is narrower than reported. There is no assertion
    text in this document (`_render_summary` emits ids and counts, never a
    failure message), and the table's other column is `s.path.name`, i.e. the
    `junit-base-pr<N>-<seed>.xml` name the workflow itself templates. Only the
    node id is untrusted.

So the neutralisation is at the node id, where the untrusted input actually
enters, and BOTH nightlies inherit it because both run this one producer —
`integration-nightly.yml:432` needed no separate patch.

`_inline_code` needs two properties and the backtick one alone is not enough:
open with one more backtick than the longest run inside (CommonMark closes a
span on the first run of EQUAL length, so a hard-coded delimiter is escapable by
any input containing one — the rule #2533 applied to fenced blocks, at the
inline level), AND collapse newlines first, because a code span cannot contain a
blank line and an id carrying `\n## ` breaks out of the bullet however the
backticks are counted. Edge backticks are space-padded, which CommonMark strips
back off, and the id is capped at 300 chars so a megabyte-long parametrize value
cannot bloat the comment past GitHub's body limit.

AC item 3, confirmed rather than assumed: `--randomly-seed=${...}` is a command
a human is invited to paste, and the seeds come from the workflow constant
`NIGHTLY_SEEDS: '12345 67890 99999'` through the build matrix — never
PR-derived. Pinned by a test so a change making seeds dynamic fails here.

Tests: the CommonMark property stated directly, the newline property, bounds,
padding, and the control the AC asks for — an ordinary id still renders as a
plain single-backtick span and the document still renders as a TABLE, not a code
block. Mutation-tested, and that found a hole in my own guard: deleting the
neutralising call left every CommonMark test green because they exercise the
helper directly, so the property is now also asserted on the rendered document,
driven end to end from JUnit XML. With the fix reverted, 4 tests fail.

The issue cites `HEADW-011`; that anchor arrives with the unmerged #2533 PR, so
the durable note went to `learnings.md` rather than inventing a requirement id.

Related to #2585
#2579 (PR #2600) landed step 1 of the Workspace sequence and conflicted in two
places.

`PortalConversation.vue::defineExpose` — dev refactored `focusComposer` from an
inline arrow into a named function and gave it a real consumer
(`nextTick(focusComposer)` on a new chat). Resolved to `{ focusComposer,
startVoiceCall }`, keeping dev's named reference and this branch's Talk-door
export. The comment claiming nothing used `focusComposer` was true when written
and is not any more, so it is corrected rather than carried forward.

`tests/unit/workspaceNewChat.spec.js` — #2579's pin asserted the exact
one-token `defineExpose({ focusComposer })`, which any sibling PR exposing a
second thing would fail. Relaxed to membership, which is what the case's own
comment says it cares about; falsified by dropping `focusComposer`, which still
turns it red.

`learnings.md` — an append-only ledger, both sides' entries kept.

Frontend unit suite after the merge: 102 files / 2315 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ent#547 / #2580 (PR #2604) landed the compact Workspace header. The only
conflict was `learnings.md`, an append-only ledger — all five entries kept,
dev's three first. `PortalConversation.vue` auto-merged clean this time.

Frontend unit suite after the merge: 104 files / 2339 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md and the design-system contract both stated raw-palette usage was
ratcheted — per-file counts may only shrink. Nothing ran it. No workflow invoked
the scanner and no spec read the baseline, so the guarantee had been false since
it was written and a contributor reading "baseline not grown" in the PR
self-check was reasonably trusting a machine check that did not exist.

THE GUARD. `tests/unit/rawColorRatchet.spec.js`, modelled on its working sibling
`loadingGateRatchet.spec.js` so it rides `npm run test:unit` — which the
frontend job already runs — and needs no new CI wiring to bite. Three
properties, mutation-tested one at a time:

  1. no file grows past its entry           (added one amber class -> red)
  2. a file with NO entry is held to ZERO
     raw_nongray                            (new file with bg-emerald-500 -> red)
  3. the baseline is exact, so a paydown
     must lower its entry                   (inflated an entry -> red)

(3) is what stops a stale ceiling silently re-permitting regressions back up to
it; (2) is the "new code starts at zero" rule, which was prose only — an absent
entry read as "unmeasured" rather than "must be clean", which is exactly how ten
post-freeze files accumulated raw palette classes unchallenged.

THE SCANNER became importable. It parsed `process.argv` at top level and
`process.exit(2)`d without a path argument, so importing it would have killed
the test run. `scanRawColors()` is now exported and the CLI sits behind an
`import.meta.url` entry-point check. Verified behaviour-identical rather than
assumed: same 196-file set, same totals, zero files differing, and `--json`
output byte-identical against the pre-refactor script on the same tree.

THE DRIFT, dispositioned deliberately and in its own PR as the issue asks.
`views/Portal.vue`'s six `amber-*` classes from #2261 — which the issue names as
the exact thing the ratchet exists to catch — are PAID DOWN to semantic
`status-warning` tokens (6 -> 0), using the house idiom already in
`BindRepoPanel.vue`. The rest is re-frozen, with every increase named inside
`raw-color-baseline.json` itself so the record travels with the file:

  raw_nongray       737 ->  815  (+78; 4 baselined files, 10 post-freeze files)
  raw_gray         6938 -> 8699  (+1761)
  hardcoded_colors  383 ->  456  (+73 — NOT reported in the issue; almost all
                                  FleetGrid.vue 102 -> 156)

19 files had improved below their old entries; recording their real values is
what gives the stale-ceiling check something true to compare against.

Frontend suite: 2255 passed (102 files).

Related to #2605
…t is gone

The panel that sets a room's message, cost and TTL budgets was hidden on every
install. It gated on `isEntitled('shared_sessions')`, correct while rooms were
an enterprise module — but ent#443 moved multi-agent rooms into OSS core:
`main.py` mounts both routers unconditionally, `shared_sessions/router.py`
records that its routes "used to carry requires_entitlement", and nothing
registers that feature id any more. So the predicate was False on every build,
OSS and enterprise alike.

The endpoint behind it was fine the whole time — `budget_router` is
`require_admin` plus `reject_agent_principal` on the write. Only the UI was
unreachable, which is why this went unnoticed: rooms worked, and just their
dial was missing. Found by an operator asking why a room stopped at 59/60
messages and had closed permanently, with no way to raise the default.

Removing the gate changes no authorization. The routes enforce `require_admin`
themselves, and the panel only mounts on Settings' Retention tab, which is
`adminOnly`.

This is the second instance of one class. ent#356 did the same to
`client_portal`, caught in `NavBar.vue`, whose comment even named the mechanism
that would eventually break it. So the fix is not just this panel:

* `retiredEntitlementGates.spec.js` lists the ids that moved to OSS
  (`client_portal`, `shared_sessions`) and fails on any `isEntitled(...)` gate
  naming one. Adding an id to that list is the second half of moving a module
  to OSS. Mutation-tested: restoring the gate fails two of its three cases.
* `shared_sessions.service.FEATURE_ID` is documented as retired rather than
  deleted — the private submodule is not visible from here and may still
  import the name. It is not an entitlement id and must not be gated on; a
  surviving constant is what makes this class read like a live gate to the
  next person.

Docs: `learnings.md` records the class — an OSS move is two edits, and the
failure mode is a control that silently disappears while the capability works.

Related to #2620

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
AndriiPasternak31 and others added 21 commits September 8, 2026 19:44
…bilityai/trinity-enterprise#403)

`agent_container_runtimes` is the seam ent#403 added, and nothing covered it.
Every way it can fail is silent: it returns None, `_runtime_map` and the roster
both fail open by design, and the Claude control reappears on every Codex agent
while the suite stays green. Its own docstring names the trap and no test held
it — under `sparse=True` docker-py's `.labels` RAISES (it reads
`attrs["Config"]["Labels"]`, which only a full inspect populates), so a rewrite
to `.labels` would be swallowed by the leaf's `except` and answer None forever.

Seven cases, the leaf's twin of test_2196's own sparse guard: a REAL docker-py
Container seeded with a real /containers/json summary (a hand-built object with
a `.labels` attribute is the shape sparse does NOT produce), the name-keying
rule against a renamed container's stale `trinity.agent-name` label, tri-state
{} vs None, a label-less legacy container reading claude-code, `_runtime_map`'s
narrow/validate/never-raise guards, zero rows costing zero Docker calls, and the
fail-open living at the call site.

Proved as a guard, not just as a pass: reverting `attrs["Labels"]` to `.labels`
turns 3 of the 7 red.

Also corrects `_runtime_map`'s docstring, which still claimed the read is
"gathered concurrently with the availability one" — it was made sequential when
#2163's blanket no-fan-out guard was upheld rather than widened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ads it (Abilityai/trinity-enterprise#403)

/verify-local's full unit suite went red on
`test_an_unreadable_platform_default_degrades_to_none_never_a_failed_turn`,
which is green in isolation and had only ever been run in a slice. Not a flake
and not the suite's fault: the ladder's last rung is `from services import
settings_service`, which walks the PACKAGE ATTRIBUTE, while this file's header
rule — correct for every other seam here — patches `sys.modules`. The two are
normally one object, so the patch usually works by luck. A sibling unit file
that leaves a stub in `sys.modules` splits them, and the patch then lands on the
object the product code never reads: the ladder returned the REAL platform
default, and the test that exists to prove the degrade path was asserting
against a value it did not control.

`_settings_modules` now patches every object the name can resolve to, and the
test proves the stub is reached before asserting what it degrades to (the
`test_the_seam_is_stubbed_not_ambient` discipline #2196 established).

The same run exposed why it survived: the pinned platform default WAS
`claude-sonnet-4-6`, the catalog's real recommended value, so four assertions
passed whether or not the patch landed. Pinned to `claude-opus-4-6` instead —
not the ambient value, and deliberately not workspace-selectable, which is the
last rung's point.

Proved both directions against a plugin that installs exactly that identity
divergence: the pre-fix file fails on that one node-id, the fixed file is 45/45.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lityai/trinity-enterprise#403)

/validate-pr's feature-flow format gate failed on 8 of its 10 required sections.
Four were genuinely missing and four were present under names of their own.

Added, because they were absent and are worth having: **Entry Points** (the flow
has four doors — the composer, the roster read, the two turn routes, and the
preference write — and they were never named in one place), **Side Effects** (the
write-once row stamp, the preference write, the self-heal's deliberately narrow
trigger, the idempotency scope, and the second Docker read), and **Related
Flows** (#894 upstream, the roster it hangs off, the Workspace siblings that
share this composer and fix the merge order).

Renamed onto the template's names without touching content: `Security` →
`Security Considerations`; `Degradation …` → `Error Handling — degradation …`.
The `Files` wrapper added nothing over its two per-layer tables, so those are
promoted to `Backend Layer` / `Frontend Layer` — the layer inventories the
template asks for, rather than two invented sections.

Also brings Testing up to date: the seven cases for the runtime read added since
the flow was written, and a **⚠️ status** recording exactly how far verification
went — the live sibling stack proved all three ladder rungs stamping real rows
(including the #894 rung reaching an external portal client) and the 403/422
refusals, while a real turn on a chosen model remains unproven because
`ANTHROPIC_API_KEY` is empty locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves two conflicts:

- .claude gitlink: reset to dev's pointer (584a70d). The branch had bumped
  it to ea6dd815, which does not exist on trinity-dev — merging would have
  broken `git submodule update` for every core-team clone while public CI
  stayed green (merge-train ejection, PR #2606). The two test-catalog doc
  commits are pushed to trinity-dev separately; this PR carries no
  submodule pointer bump, per the "never stage the submodule pointers"
  convention now in the sprint/commit skills.
- docs/memory/learnings.md: mechanical — both sides appended entries at the
  tail. Union kept, no entry from either side dropped.
…bilityai/trinity-enterprise#403)

The branch inserted its requirements section as §5.23 right after §5.22, but
§5.23 was already taken by "Workspace — agents at the centre" (ent#523/#524)
— the 5.2x run continues further down the file, after sections 6 and 9, so
"the next heading after 5.22" is not the next free number. Two sections shared
one id, and `feature-flows/workspace-agents-at-the-centre.md` plus
`requirements/scheduling.md` both already pointed at the other one.

Renumbered to §5.32 (dev's highest is §5.31, the Files tab) and moved to the
end of the 5.x run beside §5.30 and §5.31, which is where the two preceding
Workspace slices appended. The one pointer that meant this section —
`client_portal/service.py`'s note on the deliberate #894 behaviour change —
follows it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ost's scrollbar (Abilityai/trinity-enterprise#403)

The `@smoke ... usable typing space at 375px` case failed in CI on all three
attempts with the field at 128px against a `>= 140` floor, and passed locally
at 143px. The 15px is the scrollbar: macOS overlays it and reserves nothing,
the Linux runner reserves it, so the SAME correct layout measures either
number and a floor between them can only be green on one platform. No change
to this feature can move it — the `<form>` is byte-identical to the one on
`dev`, so 128px is the platform's composer geometry at 375px and not something
this PR introduced.

The claim the fix actually makes is that the picker costs the action row
nothing, so it is now asserted that way, from a single render and relative to
the row:

- the picker ends above where the form begins — it is on its own row;
- the field takes all the slack, `form.width - buttons * (44 + 8)`: for B
  buttons the row spends B boxes and, whatever the nesting, B gaps, since the
  field's wrapper is the one item in those rows that is not a button;
- the 44px touch targets and the no-overflow check are unchanged.

Every term is measured in the same render, so both scrollbar regimes agree.
Verified against a live stack: the three width cases plus the rejection case
pass; moving the `BaseSelect` back inside the `<form>` turns all three red
again, so the guard is the fix's and not ambient. `npm run test:e2e:smoke` —
CI's exact selection — is 77 passed / 7 skipped / 0 failed locally, including
the `agent-detail-request-dedupe` case CI reported flaky.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…host's scrollbar

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three conflicts, all "both sides appended at the same anchor":

- `operator_queue_service.py::_RESERVED_ID_PREFIXES` — dev added
  `"gitignore-untracked-"` (#2529) and this branch added
  `ROLE_DRIFT_ALERT_PREFIX` (ent#500). Independent reservations; both kept.
  Dropping either would un-reserve a prefix an agent could then pre-create,
  which is the whole point of the tuple.
- `docs/memory/feature-flows.md` — newest-first, so dev's 09-08/09-07 rows
  precede this branch's 09-07 ent#500 row.
- `docs/memory/learnings.md` — append-at-bottom, so this branch's two 09-07
  entries sit after dev's 09-07 group and before its 09-08 group.

Both submodule gitlinks resolve to dev's pins (`.claude` takes dev's #2631
bump; `src/backend/enterprise` stays at `fd25b47`, unmoved by this PR).

Verified: 138 passed across the ent#500 suites and the operator-queue
reserved-id/caps/quarantine suites that guard the merged tuple.

Refs Abilityai/trinity-enterprise#500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeQL flagged the comment stripper in the retired-entitlement guard
(js/incomplete-multi-character-sanitization). It is not an XSS sink — the
output is only regex-matched against `isEntitled('<id>')` — but the rule
points at a real hole in the guard: one pass over `<!--…-->` can leave a
fresh `<!--` behind (`<!-<!---->->` strips to `<!-->`), so a live gate
written inside a comment-shaped string could survive stripping and read as
a comment the guard was told to ignore.

Loops the three replaces until the source stops changing, and says in the
comment which of the two problems this is about.

Related to #2620

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
The `lint (sys.modules pollution check)` job fails on this branch and passes
on dev: the module-scoped `dpf` fixture assigned `sys.modules[...]` directly
and popped it in a `finally`.

`pytest.MonkeyPatch.context()` is the same mechanism as the `monkeypatch`
fixture at a scope that fixture cannot reach, and it RESTORES rather than
deletes — a bare pop would unbind the name even if something else had
legitimately bound it first.

Related to #2585

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…-diff

# Conflicts:
#	docs/memory/learnings.md
…anel-gate

# Conflicts:
#	docs/memory/learnings.md
… merge (#2609) — mechanical, per the merge-train note on the PR

Three portal files were paid down on dev after this branch forked and PortalRailFiles.vue grew by 3 raw_gray via #2608; the ratchet's own 'baseline is exact' property then failed on the merge ref. Regenerated with the documented command and carried the PR's 'refrozen' block forward (the generator drops it), noting the one absorbed growth.
# Conflicts:
#	docs/memory/learnings.md
# Conflicts:
#	docs/memory/feature-flows.md
#	docs/memory/learnings.md
@vybe

vybe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Train complete: all six members merged individually (#2607 eca1749, #2609 fabcb58, #2621 e5d606b, #2596 5585f15, #2615 3a048cc, #2606 7eaace3). Closing the integration surface.

@vybe vybe closed this Sep 9, 2026
@vybe
vybe deleted the train/20260909-0822 branch September 9, 2026 09:13
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.

3 participants