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-flagsisget_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.
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.
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 theget_room_principalfallback, ent#362) and/api/enterprise/room-budget-defaults(admin-only operator defaults, ent#387). The second is deliberately NOT under/api/rooms— a/budget-defaultspath 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 ordinaryexecute_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 toMAX_AGENT_REPLY_CHARS(100,000) by_fit_replyinstead: 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_costjoins messages to executions andcount_budget_messagescounts rows, so a reply split across rows would charge the turn once per part and spend the message budget doing it.post_messageis split at the append: everything a landed message sets in motion (budget close, roster re-check, wake cap, the wakes) runs in_after_landingunder 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.CancelledErrorstill propagates through all of it. The transcript a wake shows its agent is bounded byROOM_TRANSCRIPT_BUDGET_CHARS(240,000 — what a cold wake of 30 capped messages could already receive):_fit_transcriptkeeps 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); andGET /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. Theenterprise_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 indb/schema.py, versioned on the OSS two-track runner (db/migrations.py::shared_sessions_tables_to_oss+ Alembic0044_shared_sessions_oss), bothCREATE TABLE IF NOT EXISTSso adoption is a no-op on an install that already has them. The enterprise Alembic0011_shared_sessionsstays on its own line — deleting it would break that chain — and is idempotent. The revision chains off0038_portal_chat_state,main's head — this landed as a hotfix ontomain, and 0039-0043 exist only ondev, so pointing atdev'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, wherecheck_alembic_headsfails loudly until analembic mergerevision (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 withdev's0039_operator_queue_addressed_toafter the back-merge (ids are strings, but the numeric prefix is the graph's only human ordering cue);test_ent443_rooms_oss_core.pypins both the parent and prefix-uniqueness. - Agent-identity columns are POLYMORPHIC and registered kind-scoped (ent#443):
participants.identityandmessages.sender_identityhold an agent name, a platform user id, or a workspace client's verified email depending on the siblingkind/sender_kind. Both are inAGENT_REFSwith anextra_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 (identityis too generic to add to_AGENT_ID_COLUMNS), sotests/unit/test_ent443_rooms_oss_core.pypins them explicitly andtest_agent_cleanup_parity.pycarries a documented_POLYMORPHIC_AGENT_COLUMNSset for the backward direction. - Why OSS. It was the entitled
shared_sessionsmodule, 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_availablestays 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/:roomIdon it, so deleting the field would make all of them readundefinedand hide the feature this move exposes.- Transition ordering (load-bearing): the OSS routers are included in
main.pybeforeregister_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 bytest_ent443_rooms_oss_core.py.
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.
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.
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.
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.
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.
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'.
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.
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).
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.
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.jsholds report state as a singleton keyed to one agent (loadAgentReports→resetAgentReportsbumps_reportsGenerationand 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
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_platformOR invited, viaservices/portal_capabilities.py), so an uninvited outside client is never told it exists; the store raisesprojectsAvailableonly on a strict=== true(#2128). - Routes —
/workspace/projectsand/workspace/projects/:projectIdrendercomponents/portal/projects/PortalProjects.vuein thePortal.vuestage, placed BEFORE the conversation branch (activeAgentdefaults 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, doorAUDIENCEpluscapability: 'projects'.tabPassesDoorchecks a declared capability fail-closed againstsession.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) —
OverflowTabsover Overview / Tasks (ProjectTasks.vue) / Log (ProjectLog.vue) / Files & reports (ProjectMaterial.vue, reusingReportRenderer). 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.vueimports a folder project once. - Project page Overview (v3 hub + #3694 + ent#896) — ONE layout owned by
ProjectPage.vue: alg:grid-cols-3 lg:items-startwrapper 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-startkeeps #3574 (an opened ask grows only its own column). The assignment is deliberate and pinned byworkspaceProjectsV4.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 formerProjectHub.vuegrid is gone; each hub card is its own component with the sameprojectprop. Continue (ent#896) isprojectsUtils.continueChoiceoverworkOnItOptions: 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 aBaseCarddirectly below Continue, so it never takes first focus. Steward card: the read'ssteward—{kind, ref}plus, for an agent steward on a backend that carries them,last_sweep_atandschedule {state ∈ none | scheduled | hidden | unknown, next_run_at}(worded bystewardScheduleLine; a backend without the fields omits the lines rather than guessing); health from the roll-up; a person steward's waiting lines areattentionLinesfiltered to verify/stuck; "Open I steward" emitsopen-stewarding, whichPortalProjects.vueturns into the list'sinitialFilter = 'steward'. Markdown (#3692): the goal, task objectives and task/project log bodies mountPortalMarkdown(the one sanitizedv-html), neverwhitespace-pre-linetext. Needs you isProjectNeedsYou.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 pureprojectsUtils.projectNeedsYouRows, pending inaskUrgencyCompareorder), with Answer mountingPortalAsksnarrowed byaskIdsin place (the one answer surface, unchanged) and Open in Inbox going toaskInboxRoute(?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'stabindex=-1, then the Answer button back). The "Open in Inbox" link class isportalUtils.INBOX_LINK_CLASS, shared withPortalAsksWaitingLine. 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(kindthread|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 ownsend(), 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.jsonportalHttp+ 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 throughask_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.