Skip to content

Latest commit

 

History

History
1617 lines (1510 loc) · 129 KB

workspace.md

File metadata and controls

1617 lines (1510 loc) · 129 KB

Trinity Architecture — Workspace / client portal and rooms

Part of the Trinity architecture set. Core map, invariants and topology: architecture.md. This file is not auto-loaded.

Owns: src/backend/client_portal/**, src/frontend/src/views/Portal.vue, src/frontend/src/stores/clientPortal.js, src/frontend/src/components/portal/**

Read this before changing the paths above: The roster payload is the only capability channel a portal principal has. GET /api/settings/feature-flags is get_current_user-gated and its store returns an empty list for every external client, so a UI gate written against it is dead for exactly the audience it targets (#2128). Per-agent fields on that payload also fail in deliberately opposite directions and must not be normalised to match.

Write path: changes to this area land here, not in the core (core editorial rule 4). Keep the core's map row in step if the owned paths change.


Workspace / Client Portal (epic ent#78; OSS core since ent#356)

The client-facing surface, mounted in every build (src/backend/client_portal/, prefix /api/enterprise/client-portal, Vue at /workspace). A caller is either an external client — a verified email with no users row, signing in with a 6-digit code — or a signed-in platform user, who reaches the same surface in one click because their platform session is the workspace session (ent#357). Both resolve through client_portal/portal_auth.py::get_portal_principal, which returns (email, is_platform, is_person); the roster is every agent shared with that email, plus — for a platform session only — the agents they own. is_person (trinity-enterprise#611) is False only for a platform principal that is not a person: a system-scoped key reads here but cannot answer an ask. The field defaults to False, and the principal also carries self_approves + credential (#3404): a portal session token says how it was minted (minted_by: otp | delegate), and only a platform person or an otp-minted session may self-approve a gated skill — see feature-flows/skill-gate.md.

The user rung is Workspace-only (trinity-enterprise#837). A platform account whose role is user is a member who works with agents here and nowhere else: get_current_user refuses it on every operator route (403 workspace_only, architecture/security.md §1). The Workspace doors — get_portal_principal, shared_sessions/router.py::get_room_principal, the Workspace voice start — resolve the credential through resolve_platform_user_unfloored, so such a member reaches this surface exactly as before, roster included (shared agents plus any they own). The handful of operator-side routes the Workspace calls (session identity, sign-out, the /ws ticket, preferences, loops, the voice canvas, an authorized canvas link, the public chat page's history) carry @workspace_route and admit a browser session only; the census in tests/unit/test_837_workspace_route_census.py fails when a Workspace file calls an operator route that is neither a door nor marked. In the SPA, /login lands a user in /workspace and the router guard sends one on an operator URL there (/agents/:name/* → /workspace?agent=:name, utils/workspaceOnly.js). Sharing an agent writes no login whitelist row: the person signs in here with the code. Inside the Workspace such a member keeps the team view: signed in with the platform login, get_portal_principal resolves them as a platform account (is_platform=True), so they read every canvas audience (agent_page.canvas_audience_for), the agent's whole activity, loop runs included (agent_page._client_scope), voice and model choice — the narrower client view is for people without an account (decided at #837; nothing new is exposed, a user saw all of it on Agent Detail before). Their /ws socket carries only the kinds the Workspace reads, each cut to the fields it reads (WORKSPACE_WS_EVENT_TYPES / _FIELDS, architecture/integrations.md). An agent whose owner is Workspace-only is refused at the rooms door.

Sign-out ends whichever credential is live — never a derivation of it (#2258). The implicit entry above runs the other way too: isPlatformSession = !portalToken && isAuthenticated, so clearing only the portal token is what activates the platform fallback, and the Workspace's "Sign out" used to re-enter as the operator on refresh (or re-authenticate a client as the co-resident operator). stores/clientPortal.js:: signOutEverywhere() ends the platform session (authStore.logout()) first, then clears portal state, and routes by principal — operator → /login, client → the OTP form (a user signing back in at /login lands in /workspace, ent#837). A persisted suppression flag was rejected on evidence: the JWT is an axios default header and per-request headers merge over defaults, so a flag hides the disclosure while every portal request still carries the operator's credential. auth.logout() clears local state before the network revoke, because the global 401 interceptors and the /login → / router guard both key on it. endSession({expired}) deliberately does not end a platform session (expiry is not a user act). Residuals stated in workspace-session-signout.md: a client session that expires on a browser which later gained a platform login, and the portal token's server-side validity post-sign-out (no self-service revoke; ent#281's primitive is per-email).

One platform credential, one 401 verdict, one handler (#2791). The paragraph above describes which session a tab is in; this is the layer under it. The platform JWT used to live in two places that could disagree — the in-memory axios.defaults.headers.common['Authorization'] copy and localStorage['token'] re-read per request — with no storage listener anywhere under src/frontend/src and three separate 401 handlers. Because the Workspace opens in its own tab (ent#456) and polls every 20s, a stale Workspace tab could log a freshly re-established platform session out within seconds: its poll went out on the OLD token, 401'd, and the handler called authStore.logout(), deleting the NEW session's token. utils/platformSession.js is now the only reader (readStoredToken), the only verdict (sessionLostVerdict → ignore | stale | logout | logout-in-place, where stale means the credential that failed has already been replaced, so adopt the current session rather than destroy it, and logout-in-place — #3406, the public token pages /chat/:token and /canvas/s/:token — ends a dead platform session without leaving a page that shows its own sign-in; on a chat link only a 401 carrying WWW-Authenticate: Bearer counts as the platform's, since the operator's JWT rides the link's own requests) and the only handler registry; main.js installs a global axios request interceptor so every bare-axios caller derives the header per request, and a storage listener so a login or logout in one tab reaches every other. The axios.defaults copy is written nowhere — a tree-wide source guard says so, because the first cut of this fix guarded auth.js alone while App.vue still wrote it on every boot, and axios merges that default into the request before the interceptor chain runs, so the per-request rebuild was inert for the life of the tab (the merge-train review's C1); the two sync actions and logout() delete it as a belt. The reaction itself (reactToPlatformUnauthorized), the storage listener (reactToStorageEvent) and the request rebuild (applyRequestCredential) are functions of their collaborators, executed by unit tests with fakes — main.js is wiring only. The logout revoke carries its token explicitly, because #2258's clear-before-revoke ordering means storage is already empty by then. The Workspace veto in the verdict reads the per-tab portal token from the store, not shared localStorage, so a client signing in in another tab cannot strand an operator's Workspace tab on an expired JWT. Full model and the verdict table: workspace-session-signout.md.

Membership is a DB fact; container state is a projection onto the card (#2196). The roster is built from agent_ownership / agent_sharing and is never filtered by whether a container exists. A live ownership row with no container is a routine state (#1747: identity lives in the row, and #834 Phase 1c recovery, a docker system prune or a crash mid-create all reach it), so hiding those rows would make "not shared with me" indistinguishable from "shared but containerless" on the one surface a client has. Filtering is also the dangerous direction at scale: every Docker read in the platform collapses no container and Docker could not be asked into one falsy value (list_all_agents_fast returns [] on any fault), so one daemon restart or one DOCKER_GID change would tell every paying customer they have no agents. Instead each card carries availability (ready/stopped/unavailable/unknown), resolved once per roster load in get_roster — not in _roster_rows, which stays pure SQL so #2198's batch-sessions gate does not inherit a Docker read, and not inside _agent_briefing (which #2163 did: defer + bound; cache deferred). Invariant #11 is untouched: every Docker read still happens inside docker_service.py.

The sidebar's thread list is one viewer-scoped call (#2198). GET /api/enterprise/client-portal/sessions returns every thread the caller has across every agent on their roster. It replaced a literal N+1: the sidebar renders a merged, cross-agent, recency-sorted list, so it asked the per-agent route once per rostered agent — from six refreshThreads() call sites including every thread open and every completed turn — and each of those re-resolved the roster before reading the session table. The batch's tenant scope IS the roster: agent_name IN (…) populated from service.roster_agent_names(email, include_owned), the same set agent_on_roster enforces, extracted so the two cannot drift (filtering on client_email alone would re-surface threads for an un-shared agent). No agent parameter, so it is strictly less enumerable than the route it replaces (Invariant #8); no schema change and no new index (the existing (agent_name, client_email, last_message_at) gives it the same plan each per-agent query already got); rate-limited per viewer, since it is no longer even incidentally throttled by a browser connection cap. The one real trade is failure granularity — one unreadable agent used to degrade alone, and the read is now all-or-nothing — which is why the store returns its last good list on failure rather than blanking, and refreshThreads catches both halves: an uncaught rejection there aborts bootstrap() before resolveAgentQuery() and breaks Workspace deep-link landing. Shipping it also closed the hole it would have amplified: get_portal_principal's platform branch now runs reject_agent_principal, so an agent-scoped MCP key — which resolves to its owner carrying the owner's role — can no longer traverse the Workspace as is_platform=True (it could previously read the owner's threads with agents the calling agent holds no agent_permissions edge to, and this route would have made that one call). User-scoped keys, scope='system' and portal session tokens are unaffected; there is no legitimate agent caller of this surface (no MCP tool targets it, no agent image calls it). Since trinity-enterprise#611 scope='system' keeps that read breadth but is not a person: PortalPrincipal.is_person is False and the ask answer refuses it (403 person_required), as the operator respond route does.

It was an entitled module and returned 404 in community builds; ent#356 moved it into OSS core (adoption: this is the main surface a non-operator uses to work with agents). The /api/enterprise/client-portal prefix and the enterprise_portal_sessions / enterprise_portal_messages / enterprise_client_blocks table names are retained history, not licensing — ent#83 shipped that prefix as the documented integration surface for API-only clients, and renaming the tables would force a data migration on every existing install. Tables are versioned on the OSS two-track runner (client_portal_tables_to_oss + Alembic 0036_client_portal_oss), both CREATE TABLE IF NOT EXISTS so adopting them is a no-op where they already exist, and both agent_name columns are registered in AGENT_REFS (CASCADE) — which the enterprise track never was, so an agent rename used to strand a client's portal history.

A turn may ask for a NEW thread (ent#451). PortalChatRequest carries new_thread on both POST .../chat (the ent#83-documented headless surface) and POST .../chat/stream; it defaults False, so no existing caller changes behaviour. It exists because an absent session_id meant two different things — "I don't know which thread" and "I want a fresh one" — and _resolve_session_id resolved it as the first, which is why the Workspace's New chat kept landing in the existing conversation. An explicit session_id WINS over the flag (the id is a fact, the flag an intent, and abandoning a named thread would strand a turn meant for a conversation the caller could see), and the ownership check runs first either way, so the flag is never a route past it. ensure_thread_for_ask deliberately does not set it — ent#429's rule reuses the latest thread so asks do not accumulate beside the conversation. OSS-core by decision (ent#451): deliberately ungated — no requires_entitlement, logic stays in the OSS tree. Recorded explicitly because CLAUDE.md's default for an enterprise-tracker feature is gated unless ruled otherwise, so the ruling must never be inferred later from the mere fact that it merged; it inherits ent#356's move of the whole client-portal surface into OSS core.

The agent's chats are tabs, and a title has three hands (ent#451 remaining slice, ent#473, #2579). Above the thread, PortalChatTabs.vue renders this user's threads with the active agent as OverflowTabs (dense, fixed-width, counted moreLabel → "N more"), most recent first — a slice of the sidebar's list, never a second fetch; a room is not an agent's tab. New chat is in the conversation header with ⌘J / Ctrl+J, armed on window at mount above bootstrap()'s await and resolving the agent in front of the person (page or conversation; a room or the root opens the picker).

#2579 fixed four defects in that strip, and one of them is a recorded reversal. The 2026-09-06 ruling ("a new chat exists — tab and sidebar row — once its first message is sent") stays true for the thread; the strip now draws an unsaved active chat as a provisional tab labelled New chat with thread: null, inserted directly after Main (the slot the real row takes, so adoption causes no jump) and refusing to emit a select. It is keyed only off explicit intent — Portal.vue::startingNewChat, ORed with PortalConversation's bornHere — never off "the active id is not in the list", or a cold deep link to a thread the batch has not listed yet would wear the label. bornHere exists because the shell clears startingNewChat on session-adopted, before the refreshed list arrives; it is raised in the ONE adoptSession() seam all three adoption sites go through (streaming, the sync /chat fallback, the voice path's createSession — setting it at one drops the tab exactly when streaming is unavailable) and spent when the row lands. New chat focuses the composer from onMounted's else-branch, because the press bumps convGen and remounts the conversation, so focus set before it is thrown away. Tabs are a fixed width (OverflowTabs fixedWidth, FIXED_TAB_WIDTH = 'w-40', Main included) with clamped labels and the full title on title=; every width class is gated on the prop, and shrink-0 + the visible nav's overflow-hidden are load-bearing (inlineCount starts at +Infinity, so a truncating label would let flex squeeze the pre-measure row while the max-content mirror still reports 160 — the parity class the pinned-glyph comment is about). Geometry is proven in e2e/workspace-chat-tabs.spec.js; the node-env unit specs can only pin the class strings.

Main is ensured by the shell, through the read that already mints it (#2579). The batch's no-mint ruling is untouched — Portal.vue::ensureMainListed(name) calls the per-agent list_sessions (which calls ensure_main_session) once per agent per session when the on-screen list carries no Main for that agent, then re-reads the batch. It is a GET that inserts. Map-deduplicated in flight, capped at two attempts, and both maps cleared in onSignOut — that handler resets state in place (the OTP form is a branch of the same component), so client B would otherwise inherit client A's resolved promises. The cap is load-bearing rather than tidy: fetchAllSessions never rejects (it flags sessionsFailed and returns the last good list), so a resolved entry over a still-missing Main would make the miss permanent for the session. landOnAgent's repair branch had in fact never run — it destructured { sessions } off an array.

A title is written by three hands — the derived fallback, the ent#186 generator, and a person — and enterprise_portal_sessions.title_source (NULL · generated · user; SQLite portal_session_title_source + Alembic 0052, no backfill) records which. The generated write is guarded in the UPDATE itself (set_portal_session_title → title_source != 'user', returning whether it landed): generation runs off the reply path, so a rename typed inside the first turn's window races the model's guess and a read-then-write in the caller would leave exactly that window open. _title_plan(row, history) earns a turn first (empty title), retry (the exchange after the opener, message_count <= 2, when the hand is still NULL or the opener is_greeting) or nothing — exactly one more, never a person's title. The validator is one leaf, services/chat_title.py, imported by BOTH client_portal and shared_sessions so a thread and a room refuse the same titles with the same named 400 (invalid_title + reason + a sentence with an example); the thread UPDATE is (agent, client)-scoped (uniform 404), the room rename is membership-then-person (a member agent talks, it does not rename — 403 not_a_person, the ent#220 line) and broadcasts a thin room_renamed trigger with the id only (#918). Generator health is an in-process record (title_generation_health()) that WARNS once on the transition into no_credential or failing (3 consecutive), stays quiet in it, re-arms on recovery, and rides GET /api/settings/portal-session-policy → title_generation for the Workspace sessions panel's notice — the operator side of a path that is otherwise fail-soft by design.

#2579 — the generator runs concurrently with the turn, and the client keeps a belt. The spawn was fired as the turn returned, so the client's own turn-done refresh always lost the race and read the derived fallback; the generated title arrived only on some later refresh, which is why a chat wore its first message as its name. The ordering is now _persist_user_turn → _spawn_title_generation(..., reply="") → the turn, and both halves matter: before the turn so the refresh does not lose the race, after the persist because the derived fallback must exist first (the generated write is guarded against a person's rename, not against an empty row). _title_plan does not move — it was already decided pre-turn. With no reply, _generate_thread_title picks _TITLE_PROMPT_OPENER, a separate constant rather than the two-block prompt formatted with an empty <assistant_reply> (an empty block in a prompt that names it invites the model to describe the emptiness); it carries the same never-follow-instructions hardening. Two deliberate consequences: the title comes from the opening message alone (the retry attempt is the disambiguator that remains) and a failed turn still titles its thread, consistent with _persist_user_turn's own ruling. Client-side, the shell re-reads the list on TITLE_SETTLE_DELAYS_MS after a turn on a thread inside the two-attempt window (titleSettling, post-turn message_count 2..4, Main included) and stops as soon as the title differs. That schedule is a best-effort window and deliberately not a mirror of PORTAL_TITLE_TIMEOUT_SECONDS (operator-tunable — the #2133 class), so exhausting it asks the health record rather than deciding; the cycle aborts on store.sessionsFailed, stops without a verdict on a vanished row, and is cleared from three sites (the next turn-done, onBeforeUnmount, and watch(convKey) — the only one that fires on a thread switch). The notice rides PortalConversation's #notice slot for platform admins only (shouldFetchTitleHealth), through clientPortal.js::fetchTitleGenerationHealth on portalHttp — never @/api, whose 401 handler hard-navigates to /login under /workspace and would bounce an operator out of a conversation over a background diagnostic. Known blind spot, accepted: _title_health is a module global and prod runs --workers 2, so a probe can land on a worker that ran no generation and answer unknown, which shows no notice. Honest under-reporting; making it cross-worker means new Redis-shared state for a diagnostic. PortalEditableTitle.vue is the one editor for the row, the 1:1 header and the room header (its clicks and keys stop inside it, or a rename would open the chat it is renaming). OSS-core by decision, deliberately ungated — the ent#356/ent#451 ruling for the whole surface, recorded here so it is never inferred from the merge. See workspace-chat-tabs-and-titles.md.

New-chat briefing hints (ent#138 / ent#380): each agent has a briefing — description + capability hint cards playbooks[]{title,description,starter_prompt} — resolved best-effort by service.py::_agent_briefing from the agent's /api/template/info + /api/skills. ent#138 shipped it ON the roster payload so the empty-chat screen rendered with zero extra fetches; since #2163 it is hydrated by GET /briefings after the roster lands (below), because that fan-out was the Workspace's latency floor. The hint set is a ladder: the operator's exposed playbooks (connector allow-list ∩ user_invocable — the same policy the MCP connector advertises) win outright; an agent exposing none falls back to its template-declared use_cases ("What You Can Ask"), sanitized and capped (6 × 200 chars). Clicking a hint pre-fills the composer, never auto-sends (PortalBriefing.vue → prefill). The future curated exposable-skills config (ent#178) slots into this same seam. A chat holds one agent — or, where the capability below is present, several — and the picker starts a new chat either way, so hints scope to the active agent by construction. ent#380 also fixed the briefing's metadata read — #138 called a nonexistent agent /info route, so descriptions were silently always None.

Briefing hint grid is bounded (#2101): with no connector allow-list configured every user_invocable skill becomes a "Things you can ask" card, so the hint set is belted server-side (_agent_briefing ships ≤24 — one final slice at the return so it binds whichever tier populated the list) and folded client-side (PortalBriefing.vue renders 6 described-cards-first via portalUtils.planHintDisplay, the rest behind a counted in-place "Show all N" toggle — deliberately no nested scroll region: the chat pane stays the single scroll axis, and the toggle counts the shipped list, never claiming the agent's full skill set). Hint curation stays the connector allow-list (ent#178 later).

Composer typeahead — / playbooks, @ agents (ent#392). The same briefing payload now also feeds a composer typeahead, which is what makes it reachable after turn 1 (the hint cards render on the empty-chat screen only). / lists the active agent's playbooks[] and splices the starter_prompt in without sending — the ent#138 prefill contract; @ lists reachable agents and inserts a token mentionedAgents() resolves (ent#361). All decidable logic is pure and exported from components/portal/portalUtils.js (vitest runs environment: 'node' with no component-mount harness, so a decision inside a component is one no test can reach); the components are dispatchers over it and PortalTypeahead.vue is presentational. Three properties are load-bearing rather than stylistic: the trigger rule is stricter than the parser (MENTION_RE is unanchored, so user@example.com parses as @example — the popup must never open on something the parser would not see); un-mentionable slugs are excluded, the predicate derived by asking mentionedAgents rather than copying the grammar, because sanitize_agent_name keeps . and caps nothing while the grammar allows neither, so listing data.scout would manufacture the silent degrade-to-plain-text this feature exists to close; and a plain Enter never accepts without an explicit selection, since an accidental accept destroys typed work while an accidental send is what the user was reaching for. @ is hidden — popup and placeholder — without the rooms capability, which it reads from the roster payload per the rule below. The room composer gets @ scoped to its agent participants — established by observing the running server rather than reading the private rooms engine (POST /api/rooms/{id}/messages answered woke: ["<participant>"] for a participant and woke: [] for a non-participant), so the list contains only names a pick is known to wake; whether a non-participant mention still joins someone by the ent#361 engine-side path (§5.12) is deliberately not claimed either way, and recruiting stays with the explicit "+ Add agent" control. /-in-room is deferred — a room has no active-agent subject. No backend change, no new endpoint, no migration. OSS-core by decision (ent#392): deliberately ungated — no requires_entitlement, logic stays in the OSS tree. Recorded explicitly because CLAUDE.md's default for an enterprise-tracker feature is gated unless ruled otherwise, so the ruling must never be inferred later from the mere fact that it merged. See workspace-composer-typeahead.md.

Voice mode — the orb takes the conversation (ent#534; supersedes ent#440). The Workspace's voice is the platform's real-time voice session (routers/voice.py + services/gemini_voice.py, the platform orb VoiceOverlay + useVoiceSession, reused not forked — and since #2559 the Workspace is its only consumer), run as a modal call inside the conversation: the orb covers the thread, the header controls / tabs / composer are visible but inert, the shell swaps the rail for the agent's canvas (PortalVoiceCanvas, 40/60), and End / Escape returns to the chat. It is bound to the Workspace thread: POST /api/enterprise/client-portal/agents/{name}/voice/start under get_portal_principal (platform principals only; off-roster, a foreign thread and a portal token are ONE uniform 404; _require_roster → rate limit → 503-with-reason) calls client_portal/voice.py::start_workspace_voice, which builds the context from the thread's recent rows and creates the session with portal_session_id + client_email, canvas_audience="operator" and max_duration=WORKSPACE_VOICE_MAX_DURATION (1800 s). The transcript is written turn by turn on the worker holding the live socket (persist_voice_turn, called from the bridge's on_turn) as enterprise_portal_messages rows carrying source='voice' + voice_call_id; the call closes with one system label row and touch_portal_session; a call with no turns writes nothing; /stop never writes for this surface. Save-at-end was rejected by both independent plan reviews: under two uvicorn workers a /stop landing off-worker reconstructs an EMPTY session from Redis and would write a phantom call, and a restart mid-call would lose it all. The chat folds one call's rows into a collapsed "Voice call · N min" block keyed on the call id, never an opener row (portalVoiceMode.js::groupVoiceBlocks) — a long call is ~180 rows, and since trinity#2694 the history window is counted in typed turns (the newest 100, the spoken rows of the calls among them riding along, under a row ceiling that reports truncated), so a call can no longer push the typed turns before it off the screen; the reply poll reads ?limit=N (row semantics) and finds the reply by identity, never by count. The agent's next typed turn is told what was said: a resumed turn is prefixed with the platform-written rows since its last typed reply (get_platform_rows_since_last_reply → _format_voice_delta), the cold replay carries the same rows in the same form under one 24k spoken-char budget, and the two are mutually exclusive — a call is refused (409) while a typed reply is in flight and a typed turn is refused (409) while a call is on — see workspace-voice-conversation.md → One timeline. The session must outlive the provider connection: every session asks for context-window compression + session resumption and connect_and_stream reconnects on go_away; the cap speaks a wrap-up at T-30 s and ends with a reason the status and saved frames carry, so the surface reloads the thread only after the rows exist. The roster carries realtime_voice {available, reason} (platform principals only, fail-closed, named for the capability — ent#354 may add a second provider behind the same field; the per-agent voice_available still means "has a TTS voice"). A platform principal reads every canvas audience in the Workspace (agent_page.canvas_audience_for; they already can on Agent Detail), a portal-token client stays roster. The ent#440 hands-free STT→turn→TTS loop is retired by the same ruling (one voice entry point); hold-to-dictate (#2212) and spoken replies (#2157) stay as composer affordances. OSS-core by the standing ruling, deliberately ungated. See workspace-voice-conversation.md.

Thread readability, copy, new-tab entry, agent search (#2515 / ent#456 / ent#402). components/portal/PortalMarkdown.vue is the single home of the rendered agent body — the one v-html, the one .prose-portal stylesheet, the one delegated code-copy handler — and PortalAgentBubble.vue is the chat chrome around it, mounted by both transcripts. Before this the stylesheet was applied in two SFCs and defined in both, kept "byte-identical so the two cannot drift", which is the shape a thing takes when it wants to be one thing; a future surface rendering agent markdown outside a bubble (the ent#486 Files tab) mounts PortalMarkdown and inherits render, style and copy as a unit instead of re-copying two of the three. renderMarkdownWithCodeBlocks is a SECOND export, never a marked renderer override — renderMarkdown has twelve consumers and a global override would sprout a Workspace copy control on dashboards, queue cards and reports; its body is byte-identical. Order is the security of it: marked → stripCodeBlockMarkers → decorateCodeBlocks → DOMPurify.sanitize. The markers are stripped from the INPUT first because marked passes raw HTML through and DOMPurify keeps data-*, so an agent could otherwise ship a forged wrapper whose Copy resolves to a hidden <pre> (pastejacking); decoration runs BEFORE sanitization so every byte reaching v-html has passed the one policy (H-005 stays literally true); and the decorator matches only the shapes marked actually emits — the BARE <pre><code opener carrying nothing but an optional class, over a body with no literal <. marked escapes fence contents, so a < proves raw-HTML passthrough (a block that could nest a display:none element the copy would silently pick up), and an attribute marked never writes proves the same thing on the opener: DOMPurify keeps hidden and style, so a raw <pre><code hidden> would otherwise get a real Copy button over a block that renders empty. The one non-constant byte injected is the charset-validated language label; the scanner is a linear indexOf walk (the lazy regex it replaced was quadratic on adversarial input, on the render path). utils/markedConfig.js is the ONE marked configuration and exists so a spec can exercise the configured parser — markdown.js cannot be imported in a DOM-less node process (DOMPurify's stub has no addHook), so without the split a future highlighter could change fence output while the spec stayed green and every Copy button vanished. utils/clipboard.js::copyText returns a result, never throws, never logs the copied text (it may be the credential the operator just asked for), and falls back to execCommand on an insecure origin — plain http on a LAN or Tailscale address is a first-class Trinity topology — so the controls say "Copy unavailable / blocked / failed" only when copying genuinely cannot happen; its pre-existing sibling copyToClipboard (four settings-panel callers) is left byte-identical and converging them is a follow-up. Blocks WRAP (pre-wrap + overflow-wrap: anywhere, no overflow-x), so a bubble never widens its column; the copy reads textContent, so wrapping is display-only and ASCII-table alignment is the accepted cost. Both console entry links are _blank + rel="noopener" (Vue Router's guardEvent declines to intercept those and modified clicks, so there is no window.open) while the ?tab=session redirect stays deliberately same-tab — it rewrites a navigation in flight rather than starting one. Sidebar search reuses filterAgentCandidates with requireMentionable: false (a row is not a mention, so data.scout stays findable) and bounds results through visibleAgentRows, so an ask-bearing match is never collapsed out of its own result; both empty lines are per-section, and the roster skeleton outranks them while the roster is still loading. OSS-core by decision (ent#456 / ent#402): deliberately ungated — no requires_entitlement, logic stays in the OSS tree. Recorded explicitly because CLAUDE.md's default for an enterprise-tracker feature is gated unless ruled otherwise, so the ruling must never be inferred later from the mere fact that it merged. See workspace-thread-code-blocks.md.

The briefing is off the roster's critical path (#2163). get_roster used to fan _agent_briefing across every card and await asyncio.gather(...), which waits for ALL — so the Workspace's first paint was bounded by the SLOWEST agent in the fleet, for every user, on every sign-in, regardless of fleet size. "Best-effort and parallel" bounded the blast radius (a failing agent left defaults) but never the LATENCY. The roster now awaits no agent HTTP at all, and GET /api/enterprise/client-portal/briefings hydrates the briefings after it — viewer-scoped like /sessions, with an optional ?agents= filter whose names are only ever tested for SET MEMBERSHIP against the roster (the string that reaches agent-{name}:8000 is always a DB row value, so a crafted name cannot steer the target, and an unknown one is dropped rather than answered). Two forms, two per-viewer limiter keys: the unfiltered batch is far tighter, because one call to it costs one bounded agent request per rostered agent. It makes no Docker read — _agent_briefing attempts unknown by design (it reaches the agent by DNS, so container state says nothing about whether it answers HTTP), so a stopped container refuses the connect, no leg of the briefing gets an answer, and it lands unavailable — the same verdict a skip would give, one fleet Docker call cheaper. Every remaining briefing — the batch and the agent page's single one — runs under ONE bound: _BRIEFING_HTTP_TIMEOUT_SECONDS (2.0, httpx, PER PHASE) inside _BRIEFING_BUDGET_SECONDS (3.0, wall clock, _bounded_briefing); the literal 5.0 it replaces was never a ceiling, because the function makes two sequential GETs. The result rides the card as briefing_state — pending | ready | unavailable, all three SERVER-owned, defaulting to "ready" so an older payload reads as resolved-inline. A bound trip must never pass for an agent that genuinely has no hints, and a headless ent#83 client must not have to reinvent the third value from empty fields; ready means THE AGENT ANSWERED inside the budget, not "returned data". The verdict follows reachability, never which door the failure exited by — the first cut got that wrong and measurement caught it: _agent_briefing swallows HTTP failures in a try/except per GET leg AND an outer one, so a wedged agent (httpx ReadTimeout) and a missing container (ConnectError) both returned an ordinary empty briefing INSIDE the budget and read ready, i.e. exactly the hint-less card this field exists to prevent — and since the client retries only unavailable, it never asked again that session. Only the tarpit shape, which trips the wall clock, was correct. So reachability is now reported separately from content: every exit of _agent_briefing that got no answer out of the agent returns the _UNREACHED sentinel, which _bounded_briefing reads by IDENTITY (it compares EQUAL to an empty briefing an agent legitimately produced, and that one must stay ready). One leg answering is enough — the client renders unavailable INSTEAD of the fields, so a half-answered briefing must not throw away the description it did get. It is a data-state marker, not a capability — the #2128 rule below is untouched. API consumers that want the briefing make the second call. On the client, the three loading zones (stage, conversation body, briefing) each key on their own "no data yet" and — since #2540 — render a skeleton placeholder (components/portal/PortalSkeleton.vue), never the scanline beam, which is the CHART-loading motion (design-system principle 12 as amended 2026-09-06; ScanlineReveal keeps the additive content-class prop #2163 gave it, for its chart consumers). The gates read stage.state / !historyLoaded / zone.state, never a bare <x>.loading path, because that spelling is what the #1927 ratchet counts as a bare loading gate. See workspace-roster-briefing.md.

The conversation rail (trinity-enterprise#474, slice 1 of #472). A collapsible third column — a sibling of <main> in Portal.vue, so the conversation and the room render into the same rail and its state (a setup ref, persisted under localStorage['trinity-workspace-rail']) rides no remount: a chat switch remounts the conversation, never the rail. 48px collapsed / 384px open, collapsed by default, hidden on the agent page and on every stage that holds no conversation — visibility is keyed on the route and the stage VERDICT (railVisibleFor), never on data still arriving, so a room whose participants have not landed keeps its rail. components/portal/portalRail.js is the tab contract: a tab declares door (platform / audience / agent), its participant scope, its empty state and its signal shape, and visibleTabs is the ONE gate for render AND mount — PortalRail never reads the registry, mounts a body for exactly the active tab, and renders no chrome at all for an empty list, so a tab whose door the session fails is never asked for its data (an external client sees no rail until an audience tab docks). The collapsed strip carries two signal shapes in one hue — live (ringed, motion-safe:animate-pulse) and updated (plain) — DERIVED on every render from the conversation's in-flight turn or the room's server-reported working list, and reset on every chat switch, so it structurally cannot stick. A room groups every tab by participant over portalLoopUtils.byAgent, absence visible. Below sm the column is a strip above the composer (PortalRailStrip, through the two components' #rail-strip slot) that opens the same component as a bottom sheet. OverflowTabs gained an optional per-tab signal (measured in its mirror row) for the label dot. The first docked tab is Work, empty by the operator's split — #457's Activity docks into its #tab-work slot; loops / canvas / files re-home in #472's second child; #492 lands the grid variables the rail's widths then follow. See workspace-rail.md.

Work — the live card and the rail's first tab (trinity-enterprise#525, the visual half of ent#457). client_portal/work/ is the read (GET …/client-portal/work?agents=&chat_id=, platform door only — a portal token gets a uniform 404 before any read, ent#78's auth-path invariant restated by the 2026-09-06 ruling; the frontend's visibleTabs gate is UX, this line is containment). It projects the executions ledger for the person who asked: Now (in-flight rows), Earlier (terminal rows inside a 30-day window, bounded at 30 with the window total counted server-side) and, given the open thread, that chat's delegated children — found by source_channel_chat_id, never by agent, because a child's agent_name is the delegate (ent#265 D0 / #2386 copy the chat binding onto the child row; idx_executions_status drives the in-flight selector, so no migration). The DB queries are the fleet dashboard's (get_fleet_executions / get_fleet_execution_stats, which gained source_channel, source_channel_chat_id, loop_id) under the portal roster — never the operator fleet ACL, which resolves through list_all_agents_fast() and answers [] on a Docker fault (the #2196 class). Every name on the payload is roster-masked (a child on an agent the caller cannot see is a step, unnamed — the ent#467 disclosure class); title/error are sanitized and bounded; only a portal stamp becomes a chat_id. Honesty rules: a RUNNING row past 1.5× the agent's turn bound (floor 30 min) is stale — not live, no clock, no signal, no poll — because the 120-minute sweep leaves ghost rows after a restart; can_stop mirrors exactly what the terminate route accepts; steps are three-state (reported / none = no sentence (#3001 removed "doesn't report steps") / unknown = stopped, unreachable, unreadable, or two runs on one agent so no instance can be attributed) — a stopped agent must never be described as one that does not report. The #919 read (work/pipeline_state.py) mirrors pipelines.ts's hardening: id grammar before any path, size from the listing before the download, a streamed byte budget, safe_yaml, no retries, 2 s per call in a 3 s wall budget, a 10 s per-agent cache. Frontend: stores/portalWork.js is fed by usePortalRailFeeds (the ONE owner), polls 12 s only while something is live, refreshes on agent_activity (started AND terminal) and loop events for a participant (debounced 2 s); the Work signal is one merged set — the feed's live rows plus the conversation's in-flight emit joined by execution id (workSignalFromItems), never two signals summed. PortalWorkCard is the one card for the chat (under the message; the stream's last line is the current step while live; the terminal card renders FROM the durable #2320 verdict, so it survives a reload; Ask about it is a prefill, never a send — the ruled lesser control) and for PortalWork, the tab body (Now · Earlier; since the ent#610 2026-09-30 ruling Work draws no ask — one PortalAsksWaitingLine ("N ask(s) waiting on you · Open in Inbox", counted from openAsks, to asksHomeRoute(agent)), which Info also mounts for every principal; the ask row itself renders in the Inbox, in its raising chat's thread as a tile when raised_in_turn (placed by server time; the chat also reads its own chat-turn asks, GET …/asks?chat_id=, with no ended window, merged under the store's fresher rows), and in a project's Needs you — since #2915 the projection (WorkspaceAsk) carries a coarse sync ∈ {confirmed, changed, closed, unconfirmed} + aging (never the reason or a poller timestamp), rendered by the shared queueSyncBadge rule (in the Workspace through workspaceAskBadge, which drops the operator-only "Unconfirmed" since ent#610 PR A2), and an answer to a changed/closed ask is refused with AskError(409, "item_diverged") until acknowledge_divergence rides the resend; since trinity-enterprise#611 the list also carries asks that ENDED in the last 7 days (include_ended, read past the operator's Clear All) with a coarse ended_by ∈ {you, operator, timeout} + ended_at and never an email or the cancel reason — since trinity-enterprise#816 (the 2026-10-07 ruling) an ask to a role several people fill is ONE row delivered to each of them: "addressed to the viewer" is addressed_to_email = viewer OR viewer ∈ resolved_to, in SQL for the list and its count (db/operator_queue._resolved_to_contains) and in _is_addressed_to for every per-ask door (answer, dismiss, discuss, context — a non-addressee keeps the uniform 404); the projection carries shared, the first answer wins (a later one is already_resolved), the others read ended_by = someone_else + answered_by (that co-addressee's name, else address; never an operator's), and Dismiss/Discuss are refused 422 shared_ask and hidden in PortalAsks until they are per person; a shared ask has no single addressee, so it attaches to no chat and lives in the Inbox only — pending-only keeps a project's Needs you to what is still open; a chat's ended chat-turn asks are its own read, not this window — and the answer is person-only; since PR B the projection also names proposal, the exact action an approval asks the person to approve (rendered read-only by QueueProposal), while context stays off; since trinity-enterprise#610 PR A2 (§3g L7) a selected ask's context is its own lazy read, GET …/asks/{id}/context → asks/service.get_ask_context (the run only after the agent / live-window / audience checks on the agent-written execution_id; an origin excerpt only from a chat TURN's thread (triggered_by = public) verified as the viewer's, else the ask's own chat only for an in-turn ask — a background ask has no chat origin; no cost or execution_id — stricter than Work's projection by decision), rendered by PortalAskContext below the pane's card; rooms grouped by participant, absence visible). OSS-core by decision (ent#525): deliberately ungated. See workspace-work.md.

The roster payload is the portal capability channel (#2128). A portal principal cannot read GET /api/settings/feature-flags — that endpoint is get_current_user-gated and the frontend store behind it returns [] for any caller without a platform JWT, i.e. for every external client, including on an instance where the capability is present. So any UI gate on this surface takes its signal from PortalRoster, which get_portal_principal already serves to both principal kinds and Portal.vue::bootstrap() already awaits first — one field, no new route, no new auth surface, no extra round-trip (voice_available is the per-agent precedent). Reach for this before adding a second channel: the platform entitlement store is structurally unavailable here. multi_agent_chat_available is the first such field — resolved once per roster load from the entitlement registry, fail-closed (an unreadable registry reports the capability absent, because promising an affordance that cannot work is the bug it fixes), and named for the capability rather than the module or the edition, since this payload is served to an operator's customer. When it is false the picker is single-select, all five room store actions refuse before issuing a request, and /workspace/r/:roomId renders an honest refusal instead of mounting the room; a 404/403 from any of the five self-heals the flag mid-session, so a capability that lapses between roster load and confirm — or while a room is open, which nothing else converges, since the sidebar refresh is event-driven — is observed by the next room call rather than dead-ending. The status alone is not the signal: a serving module authors its own refusals as a structured detail: {code, …} (you cannot reach that agent → 403, you are not in that room → uniform 404), while absence is a plain string — the framework's own "Not Found" for an unmounted route, the entitlement gate's sentence for mounted-but-unlicensed. Only the string form lowers the flag; a coded refusal is passed through so the server's own words reach the user, because reading one denied request as absence would turn it into a session-long false claim about the operator's build. The frontend gate is UX, not containment — a portal token legitimately reaches the room endpoints where they exist, and the real boundary is the serving module's own roster-scoped access plus membership-scoped uniform 404s. Room data is untouched by the flag and reappears intact if the capability returns.

availability is the second per-agent field on this channel, and it fails in the OPPOSITE direction (#2196). voice_available and multi_agent_chat_available default False (fail-closed) because their bug is promising an affordance that cannot work. availability defaults "unknown" (fail-open) because its bug is the mirror image: denying a working agent, and — since a Docker fault would mark every card at once — emptying a paying customer's roster over an infrastructure fault. Same payload channel, opposite default, for a stated reason; the asymmetry is deliberate and is written into PortalAgentCard itself so it is not later "tidied" into consistency. It is resolved from the tri-state pair docker_service.agent_container_states() (batch, one sparse containers.list() per roster load) / agent_container_state(name) (single, for the agent page and each turn — routing one agent through the batch would make it pay a fleet-scale read, against #2160). Those two exist because no pre-existing Docker helper can distinguish no container from Docker unreadable: both return a falsy value, which is the single fact this design turns on. Both are awaited through a docker_utils executor wrapper (that module's mandatory async contract), and list_all_agents_fast's []-on-fault contract is deliberately left unchanged — ~60 stub sites depend on it. The portal seams _availability_map / _agent_availability are isinstance/enum-guarded (the a2a_outbound precedent, here failing in the safe direction) so a sys.modules MagicMock stub degrades to unknown rather than silently inverting the default inside the suite meant to prove it; _availability_map also narrows its result to the requested names, because the underlying call sees every agent container on the host.

model_options + model_default — the composer's model choice (ent#403). The curated option list rides the roster, not the card: it is identical for every agent (the realtime_voice / multi_agent_chat_available precedent), so putting it per-card would ship N copies of it on exactly the path #2159/#2163 exist to keep small. Only the resolved default varies per agent, so only that is a card field — which also means get_agent_card needs no payload change. Both fail closed: model_default is None renders no control, and that is the value for every non-platform principal and for a non-Claude runtime (the platform passes no --model to the Codex runtime at all, so a Claude-model list there promises something and changes nothing). Three things are resolved once per roster load beside tts_ready and the default voice — the option list, the platform default, and its label — and threaded into _row_to_card as model_context; is_platform, runtime and model_context are keyword-only with no default, because a default would let the agent-page call site keep compiling while silently serving the wrong card. The runtime comes from docker_service.agent_container_runtimes(), a second sparse containers.list() — O(1) in fleet size, not the N+1 #2160 forbids, and a separate leaf rather than a widening of agent_container_states() so #2196's guard suite keeps pinning what it pins. Read sequentially, not with asyncio.gather: #2163's guard pins that get_roster contains no fan-out at all, and that blanket shape is the point — two fixed O(1) reads are not the N-agent fan-out it closed, but loosening a guard to admit one's own change is how the property stops being true. The trade is ~50-200ms once per roster load. Note the sparse trap in its other form: under sparse=True docker-py's .labels raises (it reads attrs["Config"]["Labels"], which only a full inspect populates), so the runtime is read from attrs["Labels"] — the key the /containers/json summary actually carries. An unreadable runtime falls back to claude-code, matching get_agent_runtime's own documented posture and availability's fail-open direction on this same payload.

The turn's model is resolved once, at a specific line. resolve_turn_model is the ONE ladder for both portal turn routes — requested → the agent's #894 public_channel_model → the platform default as a concrete id — and it takes no principal, which is what makes "the streaming route and the synchronous ent#83 route cannot disagree" true by construction. It runs immediately after the availability gate, before anything is created, because schedule_executions.model_used is written ONLY at row creation and both portal paths pre-create the row: resolving where the value is used would stamp the pre-created row None and half-fix the requirement on exactly the path #2426 already burned. The last rung is a concrete id and not None for that same reason (review, 2026-09-08): execute_task resolves the platform default at :1044 but stamps it inside if not execution_id:, so on a portal turn the resolution happens and the stamp does not — None recorded NULL for the default state of every agent, which is most Workspace turns. It reads settings_service.get_platform_default_model(), the same function execute_task calls, so the two hold one opinion and execute_task's lookup becomes a no-op; None still reaches the row only when that read itself fails. A caller passing execution_id must pass the resolved_model it stamped — resolved_model or resolve(...) would let the row and the turn disagree, so that path raises. The requested value cannot simply be re-laundered through the composer's allow-list either: an inherited public_channel_model may legitimately sit outside the curated set. The router owns normalise-then-authorise-then-allow-list (blank → None before validation, or the control's own "" default 422s every default turn), and the closed WORKSPACE_MODELS set is the security control, since the value reaches the agent as a --model argv element.

Multi-Agent Rooms (ent#169; OSS core since ent#443)

src/backend/shared_sessions/ — the substrate behind a Workspace chat that holds more than one agent. One idea: a room is a shared persistent RECORD, never a shared CONTEXT. Each agent keeps its own isolated Claude session and, before it speaks, is handed only the transcript it has not seen (participants.last_read_seq); that is why a room does not cost N× tokens and why no LLM has to decide who talks next — turn-taking is mechanical: you are woken iff you were @mentioned.

  • Two routers, mounted unconditionally in main.py: /api/rooms (membership-scoped; any authenticated principal, and a Workspace client via the get_room_principal fallback, ent#362) and /api/enterprise/room-budget-defaults (admin-only operator defaults, ent#387). The second is deliberately NOT under /api/rooms — a /budget-defaults path there would sit beside /{room_id}, one ordering slip from being read as a room id (Invariant #4) on the one surface whose reader must never be a client.
  • Turn engine (service.py::post_message → _wake_agent): mentions resolve against participants; each woken agent runs an ordinary execute_task(triggered_by="room"), so slots, the circuit breaker, cost and observability come for free, and its reply is auto-posted back. An agent never re-wakes itself; only a human mention recruits a non-participant (an agent that could pull agents in is a spend amplifier and a prompt-injection lever). Chain depth, a per-participant wake cap, and the ent#220 cancellation shield bound the cascade; the ent#218 rule keeps an in-flight reply that was already billed from being discarded by a budget trip.
  • A reply is never refused for its size, and a landed message never fails its post (#3210). MAX_CONTENT_CHARS (8,000) is an inbound rule — people, Workspace clients, and agents calling the API. The engine's own post of a turn's reply is fitted to MAX_AGENT_REPLY_CHARS (100,000) by _fit_reply instead: past the bound it keeps head and tail around a marker, and there is no size at which it raises. It stays one row per turn — db.room_cost joins messages to executions and count_budget_messages counts rows, so a reply split across rows would charge the turn once per part and spend the message budget doing it. post_message is split at the append: everything a landed message sets in motion (budget close, roster re-check, wake cap, the wakes) runs in _after_landing under one guard, and each wake is caught per target, so a failure three agents down a mention chain costs that agent its turn and nothing above it — not the upstream agents' cursor advances, not the human's HTTP status or idempotency claim. A reply that genuinely did not land (and one that arrives after the room closed) leaves a system line and a warning carrying the execution id; handler lines go through the non-raising _post_system_safe. CancelledError still propagates through all of it. The transcript a wake shows its agent is bounded by ROOM_TRANSCRIPT_BUDGET_CHARS (240,000 — what a cold wake of 30 capped messages could already receive): _fit_transcript keeps whole messages from the newest backwards, always keeps the newest and the latest one that mentions the agent, and replaces each omitted run with one line. The read cursor still advances over the full delta. Two consequences to know: every agent woken after a long reply re-reads it, so long replies raise a room's spend per message (the turn prompt suggests sharing a file plus a summary for a long deliverable); and GET /api/rooms/{id} is unpaginated, so a room's first load grows with its message sizes.
  • Three tables — enterprise_rooms, enterprise_room_participants, enterprise_room_messages. The enterprise_ prefix is retained history, not a licensing claim (the ent#356 portal precedent): every entitled install already holds live transcripts under those names, so renaming them would be exactly the data migration the move forbids. DDL in db/schema.py, versioned on the OSS two-track runner (db/migrations.py::shared_sessions_tables_to_oss + Alembic 0044_shared_sessions_oss), both CREATE TABLE IF NOT EXISTS so adoption is a no-op on an install that already has them. The enterprise Alembic 0011_shared_sessions stays on its own line — deleting it would break that chain — and is idempotent. The revision chains off 0038_portal_chat_state, main's head — this landed as a hotfix onto main, and 0039-0043 exist only on dev, so pointing at dev's head would name an absent revision and fail boot on the line it ships to. That forks the two lines at 0038 by construction; the fork surfaces as two heads at the main→dev back-merge, where check_alembic_heads fails loudly until an alembic merge revision (down_revision = ("0043_subscription_headroom_history", "0044_shared_sessions_oss")) collapses it — the fix is that merge revision, never renumbering a revision already applied wherever the hotfix went. The file is numbered 0044 rather than 0039 so its prefix does not collide with dev's 0039_operator_queue_addressed_to after the back-merge (ids are strings, but the numeric prefix is the graph's only human ordering cue); test_ent443_rooms_oss_core.py pins both the parent and prefix-uniqueness.
  • Agent-identity columns are POLYMORPHIC and registered kind-scoped (ent#443): participants.identity and messages.sender_identity hold an agent name, a platform user id, or a workspace client's verified email depending on the sibling kind / sender_kind. Both are in AGENT_REFS with an extra_filter (kind = 'agent'), so rename re-keys and purge cascades only the agent rows; an unscoped ref would rewrite — and on purge delete — a human participant whose id or email happened to equal the agent's name. The forward parity regex cannot see either column (identity is too generic to add to _AGENT_ID_COLUMNS), so tests/unit/test_ent443_rooms_oss_core.py pins them explicitly and test_agent_cleanup_parity.py carries a documented _POLYMORPHIC_AGENT_COLUMNS set for the backward direction.
  • Why OSS. It was the entitled shared_sessions module, 404ing in community builds — while the frontend that drives it (components/rooms/, stores/rooms.js, the ent#392 composer typeahead) and the MCP tools (src/mcp-server/src/tools/rooms.ts) shipped in every build and self-disabled. Three of four surfaces were already public, so gating only the backend left an OSS install rendering an affordance it then refused. Workspace itself moved for the same adoption reason (ent#356), and rooms are the half that makes it the place people work with agents rather than a second 1:1 chat.
  • PortalRoster.multi_agent_chat_available stays on the payload and is now unconditionally true. It is the portal's ONLY capability channel (#2128) — a portal principal cannot read /api/settings/feature-flags — and the shipped bundle gates the picker, five room store actions and /workspace/r/:roomId on it, so deleting the field would make all of them read undefined and hide the feature this move exposes.
  • Transition ordering (load-bearing): the OSS routers are included in main.py before register_enterprise(app), so on an install whose submodule has not yet been bumped both routers mount and the ungated OSS one wins the match order. Pinned by test_ent443_rooms_oss_core.py.

What it remembers about you — seat memory, visible and undoable (ent#637)

A scheduled run addressed to a person (ent#498's deliver_to_workspace_email) now runs AS that seat: the internal dispatch composes the seat's MEM-001 memory block into execute_task(system_prompt=…), and write_user_memory accepts the run because services/schedule_seat_memory.seat_for_execution reads the seat off the row's stamp (triggered_by='schedule' + source_channel='portal' → source_channel_client) — the agent never names the user. The details panel's PortalAgentMemory.vue reads GET /agents/{name}/memory (the viewer's own memory, keyed on the principal, plus the public_user_memory_writes history with schedule names through the page's bounded map) and POST …/memory/writes/{id}/undo (latest-first; 409 not_latest / already_undone named, unknown id the uniform 404). Requirement §10.19 of scheduling.md; flow in schedule-workspace-delivery.md.

The role card — a projection of the agent's files, and the owner's readiness stamp (ent#527, #663)

client_portal/role_card.py builds the Info rail's Role card from the agent's own container on every read (template x-role + x-canon.clone_path, <canon>/roles/<id>.yaml) — never cached, never a second store; author-controlled ids/paths are validated before any read and every failure is named. The objectives are not the card's to compute (ent#676): it calls the one objective ↔ metric join (objective_join_service.read_objective_join, handed the template and client it already holds) and serves a field-picking projection — a finding crosses as its code, never the operator's sentence, an objective file path or owner: role:<id>, and a field the join grows later reaches no client until it is added to the picker and the model. The card's objective read draws on the per-agent budget the operator's /objectives route draws on (services/objectives_read_budget.py), behind a per-viewer cap, and never refuses the card: a spent budget leaves the objectives out (objectives_error) so the role, the readiness stamp and the owner's flip still answer. build_role_card takes the check as a required admit_objectives callable and calls it only when the objectives are about to be read, so a card that stops earlier (no role, a stopped agent, a broken role file) spends nothing; a new caller that forgot it would reach the container fan-out unbounded. Readiness is the one platform fact: agent_role_readiness is the agent OWNER's stamp (POST …/role/readiness, owner check via get_owned_roster, agent never), and a template that claims ready without a stamp is shown as calibrating. Requirement §5.36 of core-agent.md; flow in workspace-role-card.md.

The stamp gates the proactive brief (ent#689). The scheduler's readiness gate (_apply_readiness_gate, just ahead of the #454 pre-check and sharing _record_gate_skip) asks GET /api/internal/agents/{name}/brief-readiness (services/role_readiness_gate.py) before a cron fire of a seat-delivery schedule, and records a skipped execution with the reason on fire: false. Stamp ready fires; any other stamp holds; no stamp holds only a companion (the template is read — bounded 3 s — solely for x-role presence, never its status); every ambiguity fails open and is logged. The rollout is a one-time data seed on both tracks (role_readiness_rollout_seed / Alembic 0074_role_readiness_rollout_seed): ready, changed_by = rollout:ent#689, insert-if-absent, for every live agent with autonomy on and an enabled seat brief at deploy. effective_readiness reports that stamp as source: "rollout" with no person; the card's brief_held (platform viewers only; false when autonomy is off) says a seat brief is paused.

The seat decision record — why things were approved, deferred or killed (ent#638, R25)

client_portal/seat_decisions.py + services/seat_decision_service.py give a seat (agent × person, the ent#637 scope) a lintable record of its judgment: outcome, what was decided, the alternatives that were live (none = a note, refused), the criterion that discriminated, who decided (role + person), review_by (expiry is computed on read, never written), what would reverse it, prose only in notes. Correction supersedes (a new row; the old stays as history), close / reverse are status flips, reconfirm moves review_by. A direction decision (R C3) is kept as routed with the canon hint — never a seat decision, never dropped. Readers: own seat always; the agent OWNER (role_card._is_owner, never an assignment kind) every seat, writable; nobody else — an assignment kind (kinds_for) answers the role card's relationship line and never widens this read (#3660; per-role reads are #3381); an external principal is never an owner. Writes: own seat, or the owner on a named seat. The companion's half (routers/seat_decisions.py, MCP record_decision / list_seat_decisions) resolves the seat from execution_id and returns no email. The seat's active decisions ride the shared memory block into every turn (platform_prompt_service.format_user_memory_block), which is what makes a criterion reusable and cites — the health metric — non-zero. PortalAgentDecisions.vue in Agent details. Requirement §5.37 of core-agent.md; flow in workspace-seat-decisions.md.

Suggestions — what you can do with this agent, and what is waiting (ent#465)

client_portal/suggestions/ (router → service → db, the work/ shape) answers GET /api/enterprise/client-portal/agents/{name}/suggestions for ONE viewer and ONE agent. service.build is pure over an injected now: signals in (my pending asks, my seat's decisions past review_by, the agent's schedules + one windowed query of recent runs, overdue reminders, my last Workspace message, the playbooks I have started, the exposed playbooks from the ONE briefing path), ranked (Suggestion, fingerprint) pairs out. Door, per class: the router 404s a portal token before any read (ent#78 auth-path invariant); configure classes reach only owner or admin — PortalPrincipal.is_admin exists for this ONE decision (role admin AND scope in ADMIN_GATE_SCOPES); everywhere else a non-owner admin stays a viewer (ent#358). Fingerprint = the identity of the state, never a count (the first failure of a streak, the set of held schedules), so a dismissal outlives more of the same and ends when the state changes. The feedback write (POST …/suggestions/feedback, key in the body) recomputes and 404s a key not currently emitted — writes are bounded to real items and the fingerprint is the server's. workspace_suggestion_feedback is named generically with a surface column so the post-action next-step tier (design-system p27) shares one dismissal model; CASCADE on the agent. The briefing is fetched only when the agent can answer (#2196 availability first) and cached 60 s. Frontend: PortalSuggestions.vue in the Info tab (full) and on the empty chat (compact, top 3, no chrome when empty); the shell loads the slice for the active 1:1 agent so Info's rail dot (updated + a note, "2 suggestions") lights mid-conversation. Requirement §5.39 of core-agent.md; flow in workspace-suggestions.md.

The Inbox — what needs you and what came back (ent#610)

OSS-core by decision (ent#610): deliberately ungated. The Workspace is OSS core (ent#356), and the Inbox is four windows over rows the Workspace already owns — the ask table (Action), the portal messages and addressed deliverables (Unread, All), and the #557 read cursor. It adds no table, no store and no router. Requirement §5.40 of core-agent.md; flow in feature-flows/workspace-inbox.md.

Unread is the #557 cursor, generalised to deliverables. client_portal/db.py holds ONE fragment, _UNREAD_ARRIVALS, a UNION ALL of two arms that share the cursor-or-baseline predicate: (i) assistant messages in the viewer's own chats (today's rule), and (ii) agent_reports with addressed_to_email = :email stamped to a session the viewer owns (JOIN enterprise_portal_sessions s ON s.client_email = :email). The audience predicate is plain equality with a Python-lowercased bind, never lower(column): addressees are normalised at the boundary (#2955) and idx_agent_reports_audience serves only the bare column. count_unread_by_session is the only unread function and groups that fragment; unread_arrivals_with_latest reads the same fragment with window functions (COUNT(*) OVER, ROW_NUMBER() OVER … ORDER BY at DESC, id DESC) for the count, the latest arrival and the earliest unread message. The two cannot drift because they are one fragment. A viewer with no baseline row counts nothing, deliverables included (the inherited #557 property).

Previews ride GET /chat-state?previews=true. client_portal/chat_previews.py (not service.py, the #1 code-health hotspot, #2556) runs that ONE statement and hands its counts to service.get_chat_state(email, unread=...), so the emitted unread values and the previews come from the same statement and instant; the cursorless-thread and row-cap logic is reused unchanged. Each thread with unread > 0 gains latest {kind, id, at, excerpt, outcome} and first_unread_message_id, only for sessions whose agent is on roster_agent_names(email, include_owned=is_platform), bounded to the 100 most recent. The excerpt is credential-sanitised (utils.credential_sanitizer), then markdown-stripped and ≤160 chars; a deliverable's excerpt is its stripped title. The route sets response_model_exclude_none, so an entry with no preview simply omits the new keys and previews=false is byte-identical to the ent#359 shape. A roster read that fails raises (the request fails loud) rather than silently dropping every preview. No cost — PortalChatArrival has no such field. outcome comes from the platform-written source = "completion:done" | "completion:failed" marker that channel_completion_report now stamps on its portal message; any other value (including every historic NULL row and an agent reply that merely begins "Finished") gives null. Every frontend reader of source compares it to 'voice' only. The marker is not inert on the agent's side: a non-NULL source takes the row out of db._TYPED, so it is never the resumed-turn cursor, it is replayed into the next resumed turn, and it rides the cold history window without taking a typed slot — both told as [Background task report: …] (deliberate: the live session never saw the background run; #3054 review, pinned in test_ent610_inbox.py).

An addressed report always has a chat. See observability.md → Agent Reports: an addressed report with no in-flight chat of the addressee, published by the agent itself (a human sharer's publish places no card at all), is stamped to the addressee's Main and that Main is touched (added=0). That is what makes the deliverable arm count it, gives the card an inline home and an anchor, and keeps the Main visible to sidebarThreads (which hides is_main && !last_message_at).

The frontend. Route /workspace/inbox (WorkspaceInbox) renders in the same Portal.vue shell, as a stage branch isInboxRoute && stage.state === 'ready' after the room branches and before the conversation, so a roster error or an empty roster falls through to the existing bare-stage copy. bootstrap() captures the route before its first await and, on bare /workspace with no stage key, awaits a router.replace('/workspace/inbox') inside the try — the stage never resolves on bare /workspace first, so no conversation flashes. WORKSPACE_INBOX is a new constant; WORKSPACE_ROOT stays the escape and sign-out target. The selection lives in the URL (?tab=&item=, read as the inboxSelection computed) and is never written to activeAgentName (writing that fires ensureMainListed, which mints a Main); activeAgent reads the selection on the Inbox route, so the rail follows the selected item and railColumnReservedFor is reused unchanged. Unread and All read sidebarThreads — the same projection the sidebar sums — so "came back" equals the sidebar total by construction; threadsLoaded (latched on the first successful sessions read) and store.sessionsFailed gate the empty copy, the D7 twin of asksLoaded/asksFailed. Split vs stacked is the Inbox's CONTAINER width, not the viewport (§3g A4: portalInbox.inboxLayout over composables/useContainerWidth.js, split ≥ 720 with 16px hysteresis; Portal.vue::inboxRailAllowance = portalInbox.inboxRailAllowance counts the rail width the column has NOT grown into yet — the whole target before it exists, then target − its measured width while it enters from 0 — so neither a preview that brings the rail in nor the rail's enter animation can flip the layout; an OPEN rail on the Inbox with nothing selected keeps its column as PortalRailPlaceholder with the collapse control, since with no agent the rail has no tabs and would otherwise vanish, uncloseable). When split, the tab's first row is a local preview (so the rail column does not pop in): never in the URL, never a read, told to the shell as update:preview so inboxSelection = ?item= || preview scopes the rail (§3g S5, T2). A chat is read only when the reader opened it (a click, or the initial ?item=) AND the pane has emitted rendered — history, the strict deliverables list and every payload on screen; the shell's markRead arrives as a function prop because the pane shows its false verdict. On phone nothing is previewed. The pinned sidebar row is its own component, PortalInboxRow.vue. The pane's Open canvas (§3g C10) reads the rail's own canvas feed — portalInbox.inboxCanvasCount({tabs, canvases, agent}) over stores/portalRailFeeds.canvases for the selected item's agent, which the rail loads anyway — and shows only when that agent has a canvas and the Canvas tab is one this session has, on chats only (#3714); it emits open-canvas → openRailOn('canvas'). An ask's header instead offers Open in chat via portalInbox.askOriginThread → askThreadLink (raised in a turn → its chat_id; a background ask → none), emitted as open-thread like the card's own link — so a chat the sidebar has not loaded still opens (#3140).

Reply to one message (sign-off). Each agent message in the Inbox pane carries an arrow (not Copy): it opens the chat anchored at that message (?anchor=m:<id>, landed by useConversationAnchor — scrollWithin moves only the thread's scrollTop, never scrollIntoView, which also scrolls the h-screen overflow-hidden shell; the landing is the anchor-glow animation in style.css) and hands the shell {sessionId, messageId, excerpt} as replyTarget, which the conversation shows as PortalReplyChip on top of the composer for that chat only. The turn carries only the id — PortalChatRequest.reply_to_message_id on both /chat and /chat/stream. The router resolves it with service.reply_context() BEFORE anything is written: the row (db.get_portal_message, now with content) must be this caller's, this agent's and this thread's, else one uniform 422 (no existence oracle; loud, because a dropped reply is context the person believes they gave). The quote is built server-side, capped at REPLY_QUOTE_MAX_CHARS, and rides reply_prefix directly before the client's text on BOTH the resumed and the cold message; the stored user row stays what was typed. Pinned by tests/unit/test_ent610_reply_to_message.py through the real prompt composition. The 422 has a way out: the sent message's chip is removable while that message is failed, and Retry then sends it as an ordinary turn (dropReply; mounted in portalReplyRefused.mount.spec.js). A delivered message's chip stays fixed.

The second door (ent#738). Reply is also the agent bubble's own action in the 1:1 chat, PortalAgentBubble replyLabel, in the row Copy · Reply · thumbs. The Inbox pane's arrow is that same action. It renders on persisted rows only (item.message.id && currentSessionId). The conversation emits reply and the shell (setReplyTarget) sets the same replyTarget, so the chip, the send and the 422 path above are unchanged. The payload names currentSessionId, never the sessionId prop: on a URL with no :sessionId the conversation resolves the chat itself. For the same reason the shell clears a target on watch(convKey) as well as on a route sessionId change. Otherwise a New chat or an agent switch on such a URL left the target hidden, and it came back with that chat. The Inbox path keeps its target through its own navigation because the /c/:id handler sets pendingSession in the tick it remounts. Esc in the composer drops the chip through resolveComposerKey's drop-reply, keyed on the chip ON SCREEN and on !defaultPrevented. Its preventDefault is what makes the turn-cancel listener yield. So it is innermost first: typeahead, then chip, then turn. Escape with focus on the chip's own × is claimed by the chip itself (PortalReplyChip @keydown.esc, removable only), for the same reason. An @mention escalation to a room carries the text, not the reply (unchanged). The relation is not persisted on the stored user row, so a reload shows no quote on a sent reply (ent#746). Pinned by portalInChatReply.mount.spec.js and portalInboxShell.mount.spec.js.

Person tags — a pointer, not a seat (trinity-enterprise#631)

OSS core, ungated — the same edition as rooms (ent#443) and the Inbox (ent#610). A person in a 1:1 chat or a room @s a colleague and the colleague is told. Requirement §5.42 of core-agent.md; flow in feature-flows/workspace-person-tag.md.

One row on the existing ledger, outside the ask lifecycle. A tag is ONE operator_queue row (db/queue_mentions.py) — type mention, addressed_to_email = the tagged person, platform-written context.mention = {conversation {kind, id, label}, message_id, seq, tagged_by {username, label}, person {label}}, raised_by = 'person', channel = 'mention'. Its status is delivered → read (acknowledged_at is the read time — unused on a platform-minted row, since no agent acknowledges one) and never pending: every depth cap, expiry sweep, delivery flag, ending observer and operator badge reads pending, so starting outside it keeps all of that blind to the row by construction. The id is mention-<h(kind:conversation)>-<h(message)>-<h(email)>: deterministic, so the (agent_name, request_id) conflict makes a repeat a no-op (same person twice, a retried turn, a re-delivery), and one conversation's tags share a prefix the tagger's marks read in one query (served by idx_operator_queue_type). agent_name is the conversation's agent — a room's first live agent participant, by join order — which is what scopes the row's lifecycle (an agent rename cascades it; an agent delete removes it). mention- is in _RESERVED_ID_PREFIXES (an agent's queue file cannot forge or pre-suppress one, and is_platform_minted keeps it out of the agent's file and answer wake) and in the about-a-person subset (a machine key never reads one). A row is a tag only when it carries BOTH the reserved mention- id and type='mention' (queue_mentions.is_tag_clause / is_person_tag): type is agent-authored (the queue file's ingest only bounds its length), so keying on the type alone let an agent forge "Alice mentioned you" into a roster member's Inbox and hide its own ask from the operator by naming it mention. The operator door (_list_conditions, get_stats, and GET/respond/cancel on /api/operator-queue/{id}, each a 404) leaves tags out unconditionally — type is a query parameter of the list route, so an exception for "a caller who names the type" was a door any sharee could open with ?type=mention. Listed there, a tag would tell anyone with access to the agent who tagged whom.

The wake is untouched. shared_sessions.service.resolve_mentions still returns the agents to wake from the TEXT, and the message's stored mentions stay agent-only. Tags are not parsed from text: the composer sends tags: [email] beside it (picked from the @ typeahead; a name the writer deleted is not tagged — portalMentions.tagsInText). A name equal to an agent participant still wakes that agent, exactly as before.

Who, decided once in services/person_mention_service.py. tagger_for resolves the tagger from the auth context and refuses an agent key (agents_cannot_tag — checked before is_person_principal, since an agent key resolves to its owner), a non-person platform key, and an external Workspace client (tagging_unavailable: the colleagues it would list are the organisation's, #78). taggable_people / resolve_tags share ONE predicate (db.users._taggable_conds): an account with an email, not suspended, that is an admin, an owner, or a sharee of one of the conversation's agents. The picker requires a query, caps at 8 and is rate-limited (room_people:*, portal_people:*, 120/min) — the ent#450 shape, never a directory. The send-time check refuses before ANY write (in a room, before the newcomer join): one unknown_person for "no account" and "no access", so it is no oracle; cannot_tag_yourself; too_many_tags past 10 distinct.

Delivery. Rooms: post_message(tags=) validates, appends, then _deliver_tags writes the rows — inside the #3210 "the message has landed" discipline: a failed write is reported as failed on the tagger's marks, never as a failed post. 1:1 chat: the router resolves PortalChatRequest.tags before the turn (service.resolve_chat_tags, refusals as ChatTagError → 403/422 with {code, message, name}), threads person_tags through start_portal_turn → portal_chat, and delivers once _persist_user_turn returns the row that stands for the message (the retry-reuse row on a retry — still one item).

The tagged person's door (client_portal/mentions/router.py, portal principal): GET /mentions (theirs, newest first), GET /mentions/{id}, POST /mentions/{id}/read (person only). Addressee match in the service; not-yours and not-there are one 404. Opening one is open_for_reader: for a room, _room_access — the room's own membership rule, per door: a live workspace_user participant (email) on either door; a live user participant (username) or an admin on the platform door only (Reader.is_platform; a Workspace session for a platform member's or an admin's address — a portal_delegate-minted one included — is a 404 on that room through _require_membership, so it is not shown it here either, #78) — decides can_see. Only then does any content cross: the tagged message and its neighbours (seq −3…+2, each ≤4,000 chars), sender labels, never execution_id or cost. Otherwise can_see = false, conversation.id = null, no content (not even the tagger's own words), and can_let_you_in = the room's human moderators. A 1:1 chat is always its owner's alone: can_let_you_in = [tagger].

The tagger's view. Rooms: get_room stamps tags on the caller's own messages and returns own_tags (message id → [{label, state, read_at}]) for the whole room, filtered to tagged_by.username = the caller, so the 3 s incremental poll (which re-reads only new messages) still sees an older tag turn read. 1:1: history rows carry tags on the reader's own messages (a thread is only ever its owner's). PortalTagMarks.vue draws them — envelope "in their Inbox", double check "read", cross "not delivered" — icon and word.

The Inbox. portalInbox.js gains a third item type, mention:<id>: in Unread while state = unread and in All always, counted in inboxCounts().came (one per unread tag), never in Action. The store reads GET /mentions on the asks tick (fetchMentions; absence is 404/403, a failure keeps the last good list). The pane renders PortalMentionCard.vue, which reads the item, says rendered, and the container's read waits for that (the chat rule — a previewed card is reloaded on an explicit open). The read is store.markMentionRead; the row stays in place drawn read (ghostOf). Tags merge into Unread/All as their read lands; the tabs' honest state stays the chats'.

Agents at the centre — Main, Reset, and the one page (ent#523, ent#524)

Clicking an agent opens the conversation you were last in. /workspace/a/:agentName keeps its URL and resolves to a chat; PortalAgentPage.vue is gone, dismantled into an always-visible band and an on-demand details panel. ent#360's reasoning is not reverted — an agent still has a home with its history, what it can do, and a place to ask you something — it is simply no longer a STOP on the way to the conversation.

Main. Every (user, agent) pair has one pinned Main chat: the place the agent reaches you when no conversation named itself. enterprise_portal_sessions.is_main marks it and archived_at marks the one Reset retired (both tracks: SQLite portal_session_main_chat + Alembic 0055, no backfill). Uniqueness is the partial unique index idx_portal_sessions_main (WHERE is_main = 1), not a check-then-insert: ensure_main_session is reachable from two request paths and runs in every uvicorn worker, and the loser of the race catches IntegrityError and adopts the winner's row. The predicate is load-bearing rather than an optimisation — an archived row keeps its (agent_name, client_email) pair forever, so an unconditional unique index would refuse the second Reset. Main is minted lazily by list_sessions (opening an agent, which is what renders the pinned tab) and by _resolve_session_id, and deliberately not by the cross-agent batch (#2198), which runs on every sidebar refresh and would write a row per agent the person has never opened.

The landing rule is one edit. _resolve_session_id(agent, email, None) resolves to Main rather than the most recent thread, which is the whole of the rule for an agent-initiated message, an ask raised outside a chat (ent#364/#429) and a scheduled brief (ent#498) — all three already funnel through it via ensure_thread_for_ask. An explicit session id still wins. A fourth case lands in Main at WRITE time, not through that function: an addressed report the agent publishes with no in-flight chat of its addressee (ent#610, services/report_service.resolve_report_session).

An ask raised during a chat turn is the exception (ent#734). On the native path the raise carries the platform's execution id (#2392); operator_queue_service._workspace_attachment asks client_portal.service.chat_for_execution first, which answers with the turn's own chat when the execution row is a still-running Workspace chat turn (triggered_by="public", source_channel="portal") of the same agent and addressee and the session belongs to that pair (a reset, archived Main included — it stays resumable) — the link the portal creation sites already stamp (ent#457), so no column. Anything else, including a schedule that DELIVERS into Main (portal stamp, schedule trigger) and every file-ingested ask, falls through to Main; a lookup failure falls through with a warning. Because Main is then both a chat-turn ask's home and a background ask's reply target, the row also carries context.workspace_raised_in_turn (platform-written, only on a turn match), projected as WorkspaceAsk.raised_in_turn — the fact a chat strip needs to draw a tile (trinity#3101 is the consumer; no frontend reads it yet).

Reset needs no second reset primitive. POST …/sessions/main/reset archives the current Main and mints a fresh one in ONE transaction (clear the flag before the insert, or the index refuses it), then writes one role = 'system' line in the new Main naming the archive. "Starts cold" is a property of the new row — it carries no cached_claude_session_id and session_turn_service resumes only on a cached id — so routers/sessions.py::reset_session_memory is untouched and uncalled: clearing a cache and keeping the thread is a different verb from retiring the thread. The archive keeps its own cached id (still resumable, still in session_cleanup_service's keep-set) and its own title; only an untitled one is named, and dated, because these accumulate in one list. Refused with a named 409 while a turn is in flight (turn_in_flight) or when a concurrent Reset won (reset_raced); resetting an untouched Main is a no-op reported as archived_session_id: null. No confirmation dialog (operator, 2026-09-06) — nothing is lost, and ConfirmDialog is not a caller here.

One page. PortalAgentBand.vue carries the stats strip and the Activity chart under the header, always visible (operator, 2026-09-06), and is the only surface on this page entitled to the scanline (#2540 — it is the chart-loading motion). PortalAgentDetails.vue carries chats, what it can do, and reports. It opened into the rail's place as a SIBLING of the rail, never a rail tab (ruled 2026-09-05); ent#547 reversed that on 2026-09-07 and it is the rail's Info tab — see "The compact header" below for the door, the room restriction and why the 2026-09-05 objections are answered rather than dropped. Both read one payload through composables/usePortalAgentPage.js. Canvas, Files and recent work are not duplicated here — they have been rail tabs since ent#475/#525. portalUtils.js::agentLanding is the one rule for which chat you land in, and the ?agent= deep link's resolveAgentLanding defers to it so the two entry points cannot disagree. Since ent#784 that rule replaced ent#523's most-recently-active landingThread with a precedence, settled by the operator's 2026-10-05 ruling: a link naming a CHAT wins (and never reaches the rule); then a validated lastOpenSessionId — the ent#621 seam, checked against the principal's own thread list; then the agent's chat holding an unsent draft, newest updatedAt first, where a new:<agent> winner lands on a new chat (that key IS where the words are) and a thread winner opens that thread; then the agent's existing empty chat, reused (agentEmptyChat — an unarchived, non-room thread of this agent with no message sent: no last_message_at AND message_count 0 or absent, since the two fields come from different reads; Main first, then newest created_at, so list order never decides); then a new, empty chat. The write-side twin of that arm is ensureMainListed, a GET that INSERTS: it now also returns early when the agent already has an empty chat, so visiting an agent can never add a SECOND empty row ("at most one empty chat per agent at any time"). An agent whose chats are all used still gets its Main on the visit, which is the case #2579 was about. The drafts arm reads portalDrafts.js::isDraftedThread — the SAME predicate agentsWithDrafts lights the sidebar's agent-row mark from, shared rather than copied so "the mark means click here to continue" cannot drift (a room and an archived thread are excluded on both sides). The drafts map is passed IN by each door (landOnAgent, resolveAgentLanding), never read inside the rule, so it stays pure; ?new=1 outranks the whole precedence. Nothing in it mints a row: the drafted-new: and fresh arms are both sessionId: null, and the empty arm opens a row that already exists. landOnAgent is therefore synchronous, keeps /workspace/a/:name, and guards the call before writing activeAgentName; the composer's mount focus is gated by a focusOnMount mode (always for a gesture, fine-pointer for a landing) so an unasked-for landing cannot raise a soft keyboard. Because that URL is now where a new chat RESTS, three things follow from it. It carries the rail like every other chat: the rail rules read it as a rail-free page only until the landing has put the named agent on stage (unlandedAgentPage), and its column is reserved mid-load like /workspace/c/:id. Clicking the row of the agent whose new chat is already on stage is a no-op in openAgentPage — the push would not re-fire the landing, and clearing startingNewChat there sent the first message without new_thread, i.e. into Main. And a landing that reuses a chat (the empty one, a remembered one) mounts as a thread, which focuses nothing, so the shell hands the caret over under the same pointer rule (focusLandedComposer). Main is named by its role in both the tab strip and the header and is not renameable; an archived chat is NOT a tab and is not in the sidebar's active groups — one rule for a chat closed from its tab × and for Reset's retired Main (ent#841, reversing the ent#523 ruling) — it is listed under the sidebar's Archived group and opening it reopens it (PUT/DELETE …/sessions/{id}/archive; the live Main is a named 409). Local star and close writes are held over any list read issued before they settled (portalUtils.createWriteGuard, #3356), because refreshThreads replaces whole maps and a stale read otherwise undoes the click; an unused Main is filtered from the sidebar only (a projection, not a filter on threads, because the strip must show Main from the first visit).

Files onto the conversation (ent#524). composables/usePortalFileDrop.js is the ONE drop/batch implementation, used by the conversation, the room and the rail's Files tab — the issue forbids a second, and the defect it fixes was exactly that each surface had written its own files?.[0] and reported success while discarding the rest. Both <input type="file"> carry multiple; every file gets its own chip, progress and outcome; a refused file names itself and the limit; a 429 batch says which files landed and when to retry. Uploads run sequentially — twenty parallel requests is the surest way to trip the per-email limiter (ent#287). A room's drop fans out to every participating agent's inbox and the chip names the recipients (operator decision 13). The destination is the caller's upload, so the ent#484/#486 working folder can take it over without the gesture changing. Since #2582 the upload also announces itself to the rail owner: stores/clientPortal.js::uploadDocument — the single funnel all three surfaces already call — adds the agent to a pending SET that usePortalRailFeeds drains into a targeted inbox re-read. A set and not a scalar, because the two real gestures both defeat a scalar: a multi-file batch uploads sequentially without awaiting the re-read (a listing snapshotted before file 2 landed), and a room's fan-out mutates the signal once per agent inside one Vue flush window (only the last survives). The drain coalesces leading AND trailing and is ordered against refresh() by a per-agent inbox epoch the refresh snapshots before its awaits, so a refresh issued before the upload but resolving after it cannot clobber the fresh listing. Per agent and not one shared token, because a room's drop runs three of these concurrently for three different agents and a shared counter lets each invalidate the last.

A sent message keeps what it carried (#3265). The 1:1 send waits for in-flight uploads (settled(), as the room escalation does), snapshots the settled chips onto the message (components/portal/portalMessageAttachments.js::sentAttachments, named by the upload route's stored filename) and clears the composer, then sends the names as PortalChatRequest.attachments on both /chat and /chat/stream. client_portal/service.py::resolve_turn_attachments reads the sender's own inbox once and keeps a successful entry only when that filename is there, with size and type from the listing — a request cannot put a file it never sent on its message. A failed upload is stored with its reason. The JSON lands on the user row (enterprise_portal_messages. attachments, both migration tracks) and get_history returns it on PortalHistoryMessage, so a reload shows it. PortalMessageAttachments.vue renders a fixed-size thumbnail per image, a chip per other file and a failed chip; thumbnails read through the rate-limited upload route (20/min, 100/h), so each is fetched once per tab, only when the message scrolls into view, and falls back to a chip when refused. Room messages (enterprise_room_messages) do not carry attachments yet.

The Files tab's own verbs (#2582 + ent#548). Rows render from ONE flat projection (components/portal/portalFiles.js::flattenFiles) that owns both the render order and the preview index — two orderings would drift and the modal would silently open the wrong file. Download on either list; a preview modal for images and displayable text with next/previous over the previewable subset; and a delete whose affordance depends on the case:

Case Affordance Mechanism
My own upload Delete (real) rm -f -- in the container inbox
Agent-shared, I am a viewer Remove from my list a portal_file_dismissals row; the share is untouched
Agent-shared, I am the owner in a platform session both, "Delete for everyone" offered db.revoke_agent_shared_file (soft; the sweeper reclaims bytes)

The matrix is session-type dependent and the UI copy says so. PortalPrincipal is (email, is_platform, is_person) and carries no role, so include_owned is principal.is_platform at every call site (ent#358). Therefore a non-owner admin is a viewer here — stricter than the platform surface, and correct — and an owner signed in with a magic-link portal token also gets the viewer affordance. portal_owns_agent is the same membership the roster card renders, so the UI and the enforcement cannot disagree: the affordance is simply not offered rather than offered-and-refused. portal_file_dismissals is its own table because user_ui_preferences is FK'd to users.id, which a portal principal has no row in.

What the tab lists (trinity-enterprise#549). A shared file has ONE addressee, decided by the platform from the turn it came from — see Outbound File Sharing, the one home of that rule. The tab lists what is addressed to the viewer, and for the agent's owner (the same portal_owns_agent, so a non-owner admin and an owner on a magic-link token are viewers here too) also the files addressed to nobody: a schedule's, an operator chat's, a row from before the columns. "Nothing shared with you yet." used to sit above other people's files; it is now true. A dismissal is therefore a preference over the viewer's OWN files until onward sharing (ent#633) makes a row several people's again.

Every /api/files/ share URL is previewed same-origin (#2733). portalFiles.js::sharePreviewPath takes the path from that route onward and drops whatever origin get_portal_base_url() put on download_url, because connect-src is a build artefact while the portal base URL is a runtime setting — a portal base URL on a different host is a supported topology (ent#79), and no static header can carry it (CORS would refuse it a second time). The scope word is load-bearing: only that one route is rewritten, and download_url itself stays absolute and shareable for the anchor-click Download.

The Work card's activity line (trinity-enterprise#620)

While an agent works, its card — in the chat and in the rail's Work tab — carries ONE fixed-height row saying what it is doing now. Two feeds, one vocabulary (utils/workActivity.js): the chat's own turn parses the SSE frames' real shape (message.content[].type === 'tool_use'; the earlier handler matched a shape that never occurs, which is why the card was blank), every other run — delegated, scheduled, room — reads the agent's heartbeat, which now carries a bounded executions[] from the agent server's per-execution activity slot. The backend never composes the words: WorkItem.activity {tool, summary, since, age_seconds} is folded onto live, non-stale rows of rostered agents through the title sanitiser and the roster mask, and a Redis-only GET …/work/activity sibling is polled every 2.5 s while a card is live. The card owns the motion (createActivityLineQueue: ≥700 ms per line, a burst collapses, identical lines never re-key, cleared at terminal; slide-up <Transition>, a swap under reduced motion). A person's own send re-pins the transcript once the card mounts, guarded by following (#2624).

Unsent drafts are per person, in the browser, and keyed like a chat (trinity-enterprise#657)

The composer's text outlives the stage that held it. Every switch remounts PortalConversation / PortalRoom, so the text lives in stores/portalDrafts.js (rules in components/portal/portalDrafts.js, binding in composables/useComposerDraft.js) keyed thread:<id> / room:<id> / new:<agent> — the first two are the shell's own chatKey, the scheme enterprise_portal_chat_state already uses, so a later server-side draft would swap the source and not the keys. No server contract exists today: no route, no column, no MCP surface.

The identity is the roster's client_email, and that is a deliberate departure from the column-layout rule one file over. useColumnResize.js::resolveLayoutIdentity reads auth0_user and gives every portal client one shared client bucket, because widths must be resolved synchronously before first paint. Drafts have no such constraint (the stage gates on rosterLoaded, and fetchRoster sets clientEmail first), and a shared bucket that is harmless for a number would let one client on a shared browser read another's words. Stated here so the next per-viewer cache picks one of these two rules on purpose rather than inventing a third.

Persistence is key-granular. The Workspace opens in its own tab (ent#456); persistDraft re-reads the bucket and applies one key, and a storage event re-reads it. A whole-map write would carry tab B's stale snapshot of tab A's keys and could re-persist a draft A had just sent — the one thing the feature must never do. safeStorage() moved to utils/safeStorage.js here (it was copied in views/Portal.vue and composables/useColumnResize.js, and the two copies read different globals: window.localStorage vs the bare one).

Also corrected in passing: the #2259 comment at PortalRoom.vue's onMounted reads as though the composer does not exist before load() resolves. isClosed is false while room is null, so the form IS mounted — what load() gates is whether the room is closed, which is why the draft restore (and the draft clear for a closed room) sits after it.

The compact header — Info as a rail tab, one paperclip, voice at the composer (ent#547, #2580)

Theme switch (trinity-enterprise#625). The header's LAST control, in both the conversation and the room, is a #header-end slot that views/Portal.vue fills with components/portal/PortalThemeSwitch.vue — a trigger showing the RESOLVED theme (icon + "System · dark", icon-only below sm) over a popover holding components/base/ThemeChoice.vue, the one light/dark/system picker the NavBar's user menu now consumes too. It drives useThemeStore and nothing else (no auth/agents/clientPortal import — pinned), so an external client gets it; only the root dark class flips, and the slot carries no :key, so scroll, draft and thread survive a switch. The decidable half is utils/themeSwitch.js. Its spec is the frontend suite's first mounted component test: vitest.config.js registers @vitejs/plugin-vue, and a spec opts into jsdom per file (// @vitest-environment jsdom); the default environment stays node.

The band is compact, and the three controls that were not about the conversation have left the header. Ruled by the operator on 2026-09-07 after testing dev.

What sets the band's height is the stats strip, not the chart. This is the fact to keep: once the legend is gone, a stat block (39px: an 18px figure over an 11px caption) plus the band's 8px padding is a hard floor of 56px, and the chart column is budgeted at exactly one stat block — title 10 + mb-1.5 6 + a 23px row. Inside that budget the chart is free; over it, every pixel is one the band grows. Measured in Chromium at 1440px: 99px → 56px, against the issue's ≤60% target, and pinned by e2e/workspace-compact-header.spec.js because vitest runs environment: 'node' and cannot measure anything. The real height was the legend, not the bars — legend="side" lays out flex-col, so it grew ~13px per bucket (~29px at one, ~133px at nine) and a busy agent's band ran to ~153px. StackedBarChart gains legend="none" and :axis="false"; the legend !== 'side' guard became legend === 'below', or the new value would have rendered the very legend it removes. Within the 39px the chart keeps its title OR its x-axis labels, not both; the title stays (board A3: "without it the bars read as another statistic") and the axis goes, since the tooltip carries each bar's full date.

Info is a rail tab, with a door that is the whole design. RAIL_DOORS.SOLO_AGENT — exactly ONE participant, never "at least one". Two properties are load-bearing:

  • Not PLATFORM. The header button it replaces carried no gate, so it rendered for external portal clients; a platform door would have silently removed a panel they have today — the #2128 class, on the surface this file already warns about.
  • Not AGENT (> 0), because in a room that renders one arbitrary participant's panel under a strip promising the whole conversation. Info does not group by agent in a room, which is a recorded deviation from ent#547's AC: stores/clientPortal.js holds report state as a singleton keyed to one agent (loadAgentReports → resetAgentReports bumps _reportsGeneration and invalidates siblings' in-flight requests), so N mounted panels leave N−1 in a permanent loading skeleton. Grouping is unblocked by keying that store per agent — a store change, not a rail change.

Info declares empty: null — an agent always has a name, a health state and a chat list, so there is no empty state to teach. It shipped as the registry's first static tab (signal: RAIL_SIGNAL_NONE, ent#547); ent#465 amended that: Info now carries RAIL_SIGNAL_UPDATED, and the shell writes an info entry — with a note, "N suggestions", that the tooltip and mobile strip read instead of "updated" — while suggestions are waiting in it. A signalling tab without an empty state is therefore a legal registry shape; RAIL_SIGNAL_NONE stays for a tab with neither. It is also absent from feedsFor by design — its body owns its own two reads, the one docked tab not fed by the shell. Both <PortalRail> mounts (the column and the mobile sheet) receive #tab-info; one alone leaves the phone on the generic empty state. Mobile is a gain: the old panel was hidden sm:flex, so the header button did nothing visible there at all.

The composer owns attach and voice. One paperclip (the header's opened the Files tab with the attach glyph); the controls are voice-call · attach · dictate · model · send, every button a 44px box (#2259) and the model picker the same height (#2662).

The composer is ONE shell, stacked (#2662). The field is the first row and takes the shell's full width; the controls are a second row inside the same box, with the model picker right-aligned beside Send. The border, fill and focus ring live on the shell, not the textarea, which goes transparent and borderless — that is what makes the controls read as inside the field. The ring is scoped has-[textarea:focus], deliberately not focus-within: the latter lit the whole shell when an icon button was merely tabbed onto, and drew a second ring concentric with the picker's own. A textarea that regains rounded-2xl or a background nests a second box inside the first.

Moving the chrome off the textarea makes the visible box larger than the field, so a click on the shell's padding band or on the control row's ground lands on <body> — before the shell existed the rounded box was the textarea. focusComposerFromShell puts the caret back, and is guarded twice: it returns for a live call, and for any event that already reached a control (SHELL_INTERACTIVE). The second guard is the model picker's — the typeahead commits on mousedown with the default prevented, so the click that follows would otherwise drag focus straight back out of the row the user just chose.

The shape is load-bearing, not cosmetic. While the buttons shared one row with a growing field, every 44px box came out of the field's width — 143px of a 351px form at 375px, a placeholder wrapped over four lines — and it is what left 34px when ent#403 tried to add the model picker there, hence that control's original own-row placement above the composer and hence #2662. Stacked, the field is ~333px at 375px and the picker is the element that yields, truncating itself while Send stays 44px.

The call toggle sits OUTSIDE the composer's inert region — the form goes pointer-events-none for the call's duration, so the button that ENDS the call would have rendered pressed and refused the click. Stacking made this two inert regions, not one: the field's wrapper and the control row's wrapper each carry the pair, because "everything except the toggle" is now two boxes on two rows and one without the other leaves half the composer live during a call. Each generates a real box — the field's wrapper is a block (composerWrap, deliberately not a flex container: the block w-full textarea inside it is what fixes #2259's line box, and making the wrapper flex would turn that textarea into a flex item and undo it), the control row's is a flex row. What neither may become is display: contents, which generates no box and would have silently dropped the dimming while pointer-events (which inherits) still applied.

The shell is the parent of both regions and cannot become a third: the live toggle is inside it, and opacity on a parent is not something a child can undo. So it sheds its border and fill for the call's duration instead of dimming — the composer recedes to the page ground and the one live control stays at full contrast. Both arms are bound, with no chrome colour left in the static class: two Tailwind utilities of the same shape do not agree on which of an equal-specificity pair wins (the sheet emits .border-transparent after .border-gray-300, but .bg-transparent before .bg-white), and every dark: variant beats both — so the static-plus-resting spelling renders a borderless light composer that is invisible to anyone checking in dark.

The same trap caught the ghost select twice more, one level down: border-transparent in FIELD_GHOST_CLASS's base string beat the error arm's border-status-danger-500 (emitted later, equal specificity), and the unvariated disabled:hover:bg-transparent only TIES dark:hover:bg-gray-750 (:hover:is(.dark *), emitted later), so a disabled picker still lit up under the cursor during a call — in dark only. Both are fixed in fieldClasses.js with the colour on the arms and the reset in both theme arms. The rule for any future field recipe is in design-system.md → BaseSelect → Ordering rule.

Both composers carry the shape. PortalConversation.vue and PortalRoom.vue are the same markup in two files (the #2211 lesson), so a shell landing in one leaves the room visibly diverged from the chat beside it; portalComposerAlignment.spec.js runs every structural assertion over both. The room's control row holds Send alone — the model picker is per-agent and a room has several.

The band's remount is fixed at its source, not by moving it. PortalConversation is keyed on convKey, whose convGen half bumps on every thread switch, and the band renders through that component's #band slot — so slot content inside a keyed subtree was torn down per switch, refetching numbers that had not moved. The band stays in the slot, because the operator ruled a band under the header and the header lives inside the conversation; hoisting it to a sibling renders an agent's numbers above its name (built, looked at, reverted). Instead usePortalAgentPage seeds from its cache during setup rather than in onMounted (which runs after the first paint, which is exactly why a warm remount still flashed its skeleton and scanline) and skips the refetch inside PAGE_FRESH_MS. The decision is the exported pure shouldRefetchPage, which fails stale on an unusable timestamp — a wasted request beats a band that never updates again. Verified live: 6 same-agent tab switches → 0 /page requests and 0 placeholder frames, with a control proving the probe could see a request at all. Making it literal means lifting the header out of PortalConversation; that is the follow-up, and the conversation root's h-full must become flex-1 min-h-0 in the same change or the composer is pushed out of an overflow-hidden shell.

A reply is rateable as it lands. awaitPersistedReply polls history — that is how it knows the turn ended — and was returning only the row's content and cost, discarding the id and my_rating that were already in hand; both live-turn paths then pushed an id-less message and the rating control correctly hid itself until a reload. One shared mapper (portalUtils.js::assistantRow / replyFromHistory) now feeds all three construction sites. The synchronous fallback genuinely had no id — portal_chat minted one inline and threw it away — so it returns message_id, declared on PortalChatResponse because the response model strips undeclared keys in silence. The v-if="item.message.id" gate stays: carry the identifier with the flag and let the consumer still refuse an empty one.

A self-approved reply says so (trinity-enterprise#754). get_history (both the window and the reply poll's limit read) calls skill_gate_map_service.annotate_self_approved_turns, which marks each agent reply whose execution_id has an ent#752 self_approved record with gate_self_approved / gate_self_approved_by_viewer (the portal session's email, casefolded, against the record's requester key). Both are declared on PortalHistoryMessage (an undeclared key is stripped by response_model); no email is added. assistantRow / replyFromHistory carry them, so a just-landed reply shows ExecutionGateMarker under the bubble without a reload. Rows from before #3166 (NULL execution_id) read false. The synchronous fallback (POST .../chat) answers the same pair from _persist_reply, declared on PortalChatResponse; the client reads either spelling through portalUtils.turnGateFlags. The marker is for people only: the history route passes principal.is_person and the chat path gate_is_person, so a system key on the platform session reads neither flag (the executions rule).

A reply names its turn (#3166). enterprise_portal_messages.execution_id holds the execution that wrote the row: the dispatched id the client watches (never a cold retry's second row), on the reply, on the skill-approval notice and on the user row (stamped after the fact on the synchronous path, and moved to the retrying turn when a retry reuses the failed turn's row). Once history rows carry the key, replyFromHistory accepts only the row with the caller's own execution id. A row with NULL (a completion report) is never this turn's reply; rows without the key (an older backend) keep the identity/count rule. On load, pairRepliesWithQuestions places each reply under its own question, except across a voice call's rows or when the question is outside the window. A second message on a thread queues behind the first: portal_chat runs the turn with wait_for_lock=True, so the resume lock is waited for up to its TTL instead of LOCK_WAIT_TOTAL_SECONDS (30s, still the rule for the Session surface), and portal_wait_budget_seconds adds that TTL to the marker and the client's wait budget. A first turn over 30s no longer turns the second into a 429. Every portal_chat caller waits this way: the synchronous POST .../chat and the voice path hold their request open for up to the lock TTL (about agent timeout + 30s) where they held 30s. The queued turn's row is already running, so the wait is registered with track_inflight_dispatch (the watchdog's proof of life; without it a turn queued behind a first turn over 60s is orphaned), started_at is re-anchored once the lock is held (the #2433 rule, so the wait does not spend the run's timeout), and a cancel that lands during the wait finalizes the row without dispatching.

Flow: workspace-agents-at-the-centre.md · Requirements: requirements/core-agent.md §5.23, §5.30

Projects — the Workspace surface (ent#661)

A project links the chats and rooms where a piece of work happens. The record, its rules and its agent-facing reads live in a private module; what ships here is the edition-agnostic surface it plugs into:

  • Capability — PortalRoster.projects_available (_projects_available): entitled AND (is_platform OR invited, via services/portal_capabilities.py), so an uninvited outside client is never told it exists; the store raises projectsAvailable only on a strict === true (#2128).
  • Routes — /workspace/projects and /workspace/projects/:projectId render components/portal/projects/PortalProjects.vue in the Portal.vue stage, placed BEFORE the conversation branch (activeAgent defaults to the first roster entry, so a fall-through would open that agent's chat under a projects URL). Both are rail-free pages (railFreePage).
  • Rail tab — projects, door AUDIENCE plus capability: 'projects'. tabPassesDoor checks a declared capability fail-closed against session.capabilities, so a session without it — including a platform session on a build without the module — sees exactly the tabs it saw before.
  • Project page (v2) — OverflowTabs over Overview / Tasks (ProjectTasks.vue) / Log (ProjectLog.vue) / Files & reports (ProjectMaterial.vue, reusing ReportRenderer). An invited guest gets one narrow view (the server omits the log, tasks, decisions and members); the controls it cannot use are absent, not disabled. ProjectImportModal.vue imports a folder project once.
  • Project page Overview (v3 hub + #3694 + ent#896) — ONE layout owned by ProjectPage.vue: a lg:grid-cols-3 lg:items-start wrapper holding a main stack (lg:col-span-2) and a side stack, cards stacking within their column so no row-shaped gap opens beside a tall neighbour and the narrow column never switches sides; items-start keeps #3574 (an opened ask grows only its own column). The assignment is deliberate and pinned by workspaceProjectsV4.mount.spec.js — main: Continue · Where it stands (ProjectRollupCard) · Work on it · Metrics (ProjectMetricsCard) · My chats; side: Steward (ProjectStewardCard) · Health (ProjectHealthCard) · Needs you (ProjectNeedsYouCard) · Members · Agents & rooms. The former ProjectHub.vue grid is gone; each hub card is its own component with the same project prop. Continue (ent#896) is projectsUtils.continueChoice over workOnItOptions: my newest linked chat across agents (reopen), else the first agent I can start with (new), the other actionable agents one click away; it is the first focusable control after the tabs. One dismissable "how it moves" line (HOW_IT_MOVES_KEY, per browser) sits as a BaseCard directly below Continue, so it never takes first focus. Steward card: the read's steward — {kind, ref} plus, for an agent steward on a backend that carries them, last_sweep_at and schedule {state ∈ none | scheduled | hidden | unknown, next_run_at} (worded by stewardScheduleLine; a backend without the fields omits the lines rather than guessing); health from the roll-up; a person steward's waiting lines are attentionLines filtered to verify/stuck; "Open I steward" emits open-stewarding, which PortalProjects.vue turns into the list's initialFilter = 'steward'. Markdown (#3692): the goal, task objectives and task/project log bodies mount PortalMarkdown (the one sanitized v-html), never whitespace-pre-line text. Needs you is ProjectNeedsYou.vue: the Overview is a list, not a second Inbox pane — a pending ask is the Inbox's own list row (agent, when, one-line inline-markdown title; rows from the pure projectsUtils.projectNeedsYouRows, pending in askUrgencyCompare order), with Answer mounting PortalAsks narrowed by askIds in place (the one answer surface, unchanged) and Open in Inbox going to askInboxRoute (?tab=action&from=<agent>&item=ask:<id>). An opened row stays after its ask ends until collapsed (PortalAsks draws the ending and the ent#468 confirmation); a row inside its ent#748 Undo window is forced open, since Undo lives on the card. Gates: read failed → loading (ids but no list yet — never "nothing waiting" before the read) → nothing waiting (on the ROWS, not the ids) → rows. Focus follows the toggle (the opened card's tabindex=-1, then the Answer button back). The "Open in Inbox" link class is portalUtils.INBOX_LINK_CLASS, shared with PortalAsksWaitingLine. Tasks (ent#896): the "Awaiting verification" group states the rule and each row carries Confirm (updateTask → done) for contributors on a live project.
  • Chat and room header — ProjectChatControls.vue (kind thread|room): the project badge + Detach, or one "Project" button (add to a project, or — for a 1:1 chat — make one from it). Wrap up in a linked chat sends the agent a fixed instruction through the conversation's own send(), so the request is visible and the log entries are the agent's. A guest gets the badge and Detach only. Never in Main, never before the chat exists.
  • Store — stores/projects.js on portalHttp + the portal auth header; loading = no verdict yet, a failed refresh keeps the rows (ent#253).
  • Turn line — through services/turn_context.py; the owner-consent ask through ask_service.raise_ask(addressee=...) and an ending observer, so it renders in the existing asks UI with no new component.

The Tandem layer — a brief lands in Main, a room says who is reading, a complaint reaches the operator (ent#498, ent#363, ent#499)

Three small features that only make sense once Main exists (ent#523).

A schedule can name one Workspace user (agent_schedules.deliver_to_workspace_email, nullable, both tracks, Alembic 0056). Almost all of the delivery already existed: report_completion is trigger-agnostic and schedule is deliberately NOT in INLINE_CHANNEL_TRIGGERS, so the only missing fact was that a scheduled execution row never carried source_channel='portal'. This is therefore a stamp and nothing else — no new delivery path, no second applier, no terminal writer changed. The scheduler carries the ADDRESS only: it is a separate process that cannot import the portal package, and it always sends execution_id, so execute_task's channel-persisting branch can never run for a cron fire and channel columns passed as kwargs would be silently inert (#2426). execute_task_internal resolves Main via ensure_main_session and stamps the pre-created row BEFORE dispatch, through db.stamp_execution_channel_context — the first UPDATE of those columns (every other writer sets them at INSERT), guarded on source_channel IS NULL so it only ever ADDS a destination and can never repoint an inbound channel turn whose adapter is waiting on that reply. Access is checked with agent_on_roster(..., include_owned=True), the Workspace's own roster, NOT email_has_agent_access: that admits any admin, and an admin who neither owns the agent nor is shared it cannot open the thread, so a brief delivered there would be invisible. Every refusal — blocked client, unreachable address, unreadable roster (fail closed), unavailable session, already-stamped row — fails the pre-created row with a named reason and releases the idempotency claim, because running the turn anyway spends the tokens and puts the answer where nobody can read it. C9 is honoured by a bounded wait on the ent#286 in-flight marker inside the portal delivery leg, which then writes regardless: the report is never dropped, only deferred, and the narrowing applies to every portal report rather than only scheduled ones. See schedule-workspace-delivery.md.

A room tells its agents when a CLIENT is reading. Full transcript visibility is the deliberate choice for Workspace rooms, and it is only safe while the agents know they are watched. shared_sessions.service.room_is_user_facing is the pure rule, derived from MEMBERSHIP — nothing a participant writes reaches it. FLEET_INTERNAL_PARTICIPANT_KINDS holds agent, system AND user: the platform user is the operator, and an ops room is not client-facing. Getting that wrong is not cosmetic — create_room always seats its creator and the only removal path is kind="agent", so counting user makes every room client-facing and the quiet branch unreachable. The set is otherwise the complement of "reader" because a kind added later (ent#171's external A2A sender) is likelier to be a person than a machine, and an allow-list of human kinds would silently classify it as fleet-internal. platform_prompt_service.build_user_facing_room_prompt takes no arguments and names no participant: it is composed into a prompt handed to EVERY woken agent, so an address would be disclosed sideways to agents that person never addressed. Derived per wake rather than threaded from post_message (which does hold the list), because _wake_agent calls post_message back and a threaded value could go stale when a reply recruits a human. An unreadable roster assumes a person IS reading — the inverse of the usual capability default, because the mistakes are not symmetrical.

A negative rating reaches the operator. ent#366's redaction stands — the rated agent reads the score and never the words — so the operator's copy goes straight to operator_queue, routed through create_bounded_alert (the volume is driven by a client clicking, which is the agent-influenceable side of the #1677 classification) with its own registered type and a reserved id prefix, one item per person per target. It fires on EVERY thumbs-down, not only commented ones: "this was not useful" is the report and the words are the elaboration. The queue's own reads keep the redaction: since trinity-enterprise#715 they never return the item to a machine key (operator_queue_service.is_about_a_person), which the rated agent's key could otherwise list; the ent#308 inbox-collision alert is withheld the same way. Prerequisite, and a live bug: operator_queue.type is free TEXT and both queue cards hardcoded an approval → question → alert chain that rendered no control for anything else, so skill_not_found items have never been closeable and five of them would jam a budgeted type's pending cap forever. Both cards now consume utils/operatorQueue.js::queueResponseKind and its unknown-type default moves from question to acknowledge.