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

fix: add missing logging_config.py to backend Dockerfile#4
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 Jul 10, 2026
…04 (Phase 4) (#1497)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e1, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:

1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
   — the E-06 work added a second definition next to the pre-existing one,
   so `executescript` raised "table agent_schedules already exists" and
   every fixture-dependent canary test errored. Removed the older, unused
   block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
   and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
   the invariants registry by the same PR). Added E-06 to the expected set
   and asserted it is green on a clean platform.

Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.

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

* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)

B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):

(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
    between the two reads produced a transient count mismatch → spurious
    critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
    `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
    Postgres those are two different databases, so B-01 compared Postgres
    truth to a stale/absent SQLite file (fatal under the Postgres direction +
    SQLite EOL, #1278).

Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.

Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
  `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
  snapshot field. Independent code path (SELECT id/literal 'queued' vs
  COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
  cache/status-filter regression still surfaces (non-tautology, AC #3). No
  cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
  self-heals; a persistent drift survives and fires (AC #2). An engine-read or
  unconfirmable confirm degrades to a B-01 skip — it never compares an engine
  count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
  radius is B-01-only and the sibling #1077 merge stays clean.

The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.

Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.

Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.

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

* docs(feature-flows): add #1450 canary B-01 Recent Updates row

Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.

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

* test(canary): fix duplicate agent_schedules DDL + reconcile E-06 in registry test

The canary_db fixture had two `CREATE TABLE agent_schedules` statements in one
executescript (a #1472 merge artifact), so it raised `table already exists` and
reddened the whole file. Merge them into one definition carrying every column
the canary reads (next_run_at/enabled/deleted_at + agent_name). Add
duration_ms/queued_at/backlog_metadata to schedule_executions and extend
_add_execution for the #1077 E-03/E-04 collector work. Reconcile E-06 (already
registered) into test_run_invariants_all's expected key-set.

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

* feat(canary): terminal-row collector for E-03/G-03 (#1077)

Add _collect_terminal_rows(window_seconds) + Snapshot.terminal_rows. Windowed on
started_at (not completed_at, so E-03 can see NULL-completed_at rows), scoped to
success/failed/cancelled via a local _E03_TERMINAL_STATUSES subset that excludes
skipped (which legitimately has no completed_at/duration_ms). PRAGMA guard skips
the source entirely when completed_at/duration_ms are absent (column-absent !=
value-NULL) rather than false-firing. Window = max per-agent timeout + 300s;
bounded ORDER BY started_at DESC LIMIT 5000 with a logged sampled flag (no
(status,started_at) index over 90-day retention; tripwire, not backfill audit).

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

* feat(canary): register E-03 (completed_at populated) + G-03 (clock sanity) (#1077)

E-03 (A/major): terminal rows must have completed_at NOT NULL. Predicate is
completed_at-only — the catalog's + duration_ms clause false-fires on healthy
queue-terminated rows (cancel/fail/expire set completed_at but never
duration_ms). G-03 (A/minor): started_at <= completed_at with a ~1s cross-worker
clock-skew tolerance, UTC-aware parsing (E-06 _to_utc shape) so a #1474 mixed
naive/Z pair compares without raising. Both are leading-edge tripwires over the
shared terminal-row collector and catch all producers incl. the standalone
scheduler's raw-SQL writers a unit test never exercises.

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

* test(canary): E-03/G-03 synthetic + collector + end-to-end coverage (#1077)

Per-invariant synthetic tests (holds-clean, fires-on-violation), collector tests
(started_at window in/out, NULL-completed_at still collected, skipped-status
excluded, column-absent DDL -> unavailable, LIMIT cap + sampled flag), and
end-to-end collect_snapshot tests through the real collector: the C1
cancelled-from-queue holds-clean guard (completed_at set, duration_ms NULL ->
zero E-03), half-written fires E-03, bad-clock fires G-03, sub-second skew does
not, and the #1474 naive-vs-Z compare-without-raising case.

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

* docs(canary): document E-03/G-03 Phase 4 invariants (#1077)

architecture.md canary table gains E-03/G-03 rows + Phase 4 lookup-key line;
requirements/infrastructure.md §31 gains a Phase 4 bullet (and reconciles the
stale 'Phase 2 deferred' note now that #882/#1472 shipped);
orchestration-invariant-catalog.md annotates E-03/G-03 as shipped with explicit
registry-id mapping, notes the E-03 completed_at-only predicate deviation and
G-03 started_at<=completed_at reduction, and flags the catalog-id vs registry-id
E-06 drift (catalog #129 != registry #1472).

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

* docs(feature-flows): index row for canary Phase 4 E-03/G-03 (#1077)

Canary is an internal invariant harness (no user-facing flow / dedicated flow
doc); follows the #1446 precedent of a Recent Updates row pointing at
architecture.md.

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

* feat(canary): queued-row metadata collector for E-04/G-04 (#1077)

Capture queued_at + backlog_metadata for status='queued' rows in the
existing _collect_executions query, keyed by execution_id in a new
AgentSnapshot.queued_meta map. Both columns are PRAGMA-guarded (added by
BACKLOG-001): when either is absent on an older/minimal DDL the map is
left empty so E-04/G-04 skip those eids (older-image fail-open). Scoped
STRICTLY to queued rows — never terminal — so #1449's deferred
terminal-row backlog_metadata NULL-out cannot make E-04 false-fire.

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

* feat(canary): register E-04 (queued metadata) + G-04 (no creds in metadata) (#1077)

E-04 (Tier A, major): every status='queued' row has queued_at NOT NULL
AND a non-NULL, JSON-parseable backlog_metadata — the
backlog_service.drain_next replay contract. A malformed blob raises
JSONDecodeError and stalls the FIFO. Reports only the failed-predicate
reason code + ids, never the raw metadata (may carry credentials;
violations persist to canary_violations).

G-04 (Tier A, critical): a queued row's backlog_metadata matches no
known secret prefix (sk-/ghp_/gho_/ghs_/ghu_/github_pat_/xoxb-/xoxp-/
AKIA/AIza/sk_live_), word-boundary anchored so common substrings don't
false-fire. Rides E-04's collected bytes. Reports only the matched
pattern NAME + ids, one violation per row (stops at first match) — never
the secret, surrounding bytes, or raw metadata.

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

* test(canary): E-04/G-04 synthetic + collector + end-to-end coverage (#1077)

Collector test proves queued_meta is populated for queued rows only (a
terminal row carrying backlog_metadata is excluded — #1449-safe). E-04:
holds on valid rows; fires with the right reason code on NULL queued_at,
NULL backlog_metadata, and non-JSON metadata; skips an eid absent from
queued_meta (older-image fail-open); e2e over a real temp DB. G-04:
holds on benign metadata (incl. "task-" substring that must not
false-fire); fires on github_pat / openai / slack / aws exemplars; skips
NULL metadata (E-04 owns it). Every G-04/E-04 test asserts the secret /
raw metadata bytes appear NOWHERE in the persisted violation record. The
runner test now expects E-04 + G-04 in the registered invariant set.

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

* docs(canary): document E-04/G-04 Phase 4 invariants (#1077)

architecture.md lookup-key table gains E-04 + G-04 rows and the Phase 4
line now lists all four (E-04/G-04 stacked on #1450). requirements
infrastructure.md Phase 4 bullet expands to the full four-predicate set
with the credential-safety note. Catalog flips E-04/G-04 from
"gated on #1450" to SHIPPED, records the json.loads-vs-json_valid
implementation note, the queued-only scope, older-image fail-open, and
the report-reason/pattern-name-only security discipline; G-04 notes the
implemented check covers the backlog half of the title (log-line
scanning out of scope for #1077).

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

* docs(feature-flows): index row for canary Phase 4 E-04/G-04 (#1077)

Also corrects the header/separator ordering left malformed by the
earlier E-03/G-03 index-row commit (a data row had slipped above the
|---| separator).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
AndriiPasternak31 added a commit that referenced this pull request Jul 26, 2026
* chore(.claude): bump dev-methodology submodule — Product Quality Bar

Points .claude at trinity-dev 57c8b5c: adds a canonical Product Quality Bar
section to DEVELOPMENT_WORKFLOW.md (six adoption/ease-of-use principles) and
hooks it into the dev pipeline — /create-issue (acceptance criteria),
/autoplan (scope calibration → taste decisions), /implement (build-time
checklist), and /review (new 4.15 catch-in-diff check).

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

* docs(announcements): v0.8.0 announcement record (#1548)

* chore(.claude): bump dev-methodology submodule — Product Quality Bar

Points .claude at trinity-dev 57c8b5c: adds a canonical Product Quality Bar
section to DEVELOPMENT_WORKFLOW.md (six adoption/ease-of-use principles) and
hooks it into the dev pipeline — /create-issue (acceptance criteria),
/autoplan (scope calibration → taste decisions), /implement (build-time
checklist), and /review (new 4.15 catch-in-diff check).

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

* docs(announcements): v0.8.0 release announcement record (all channels)

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(avatar): prevent thinking-token truncation dropping the color-scheme block

Avatar refinement runs each identity prompt through GEMINI_TEXT_MODEL to append
a fixed technical block (background #1a1f2e/#111827, indigo rim #6366f1,
head-and-shoulders framing, 85mm lens, 5600K key) — the sole source of avatar
color scheme and cross-generation consistency.

The refinement call capped maxOutputTokens at 512. Since #1130 the default model
is gemini-3.5-flash, a *thinking* model whose reasoning phase draws from the
output budget. Reasoning consumed ~491 of 512 tokens, truncating the refined
prompt (finishReason=MAX_TOKENS) to a ~90-char fragment that dropped the entire
technical block. Avatars were then generated from a bare subject description —
wildly inconsistent and off-palette.

Fix in _call_gemini_text:
- Disable thinking (thinkingConfig.thinkingBudget=0) — refinement is a
  deterministic rewrite that needs no chain-of-thought.
- Raise maxOutputTokens 512 -> 4096 as headroom even when thinking stays on.
- Retry once WITHOUT thinkingConfig on HTTP 400: a thinking-mandatory model
  (e.g. gemini-2.5-pro) rejects budget=0, and refine_prompt silently falls back
  to the raw prompt on error, so an unhandled 400 would re-break avatars.

Verified end-to-end against the live model: all 6 technical-block tokens now
survive refinement (was 0/6). Adds 3 regression tests.

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

* fix(channels): count inbound client messages on the Sharing-tab roster (#1533) (#1554)

* fix(channels): count inbound client messages on the Sharing-tab roster (#1533)

The roster showed `message_count = 0` for every external client, `last_active`
was frozen at `/login` time, and clients who never ran `/login` never appeared
at all. All three share one cause: `get_or_create_chat_link` and
`increment_message_count` (Telegram + WhatsApp) had zero callers outside the
`database.py` facade. The shared inbound path writes `public_chat_sessions`, a
different table, so `*_chat_links` rows were only ever created by
`set_*_verified_email` — the `/login` flow.

Note: the issue's Context paragraph states that `last_active` "is touched on
inbound traffic via `get_or_create_chat_link`". It is not — that function was
dead, and returns early for an existing row without writing anything.

Revive the write path behind a default-no-op `ChannelAdapter.record_inbound_activity`
hook, called once per delivered DM from `ChannelMessageRouter._handle_message_inner`
at step 5c: after the access gate, so an unauthenticated stranger who messages the
bot cannot create unbounded chat-link rows, and skipped for groups, since a chat
link is keyed by (binding, user) and counting group traffic would list members who
never DM'd the agent. Telegram and WhatsApp override it; Slack and VoIP inherit
the no-op (Invariant #9). The call is best-effort — a counter write never blocks
message processing.

Replace `get_or_create` + `increment` with one atomic `INSERT … ON CONFLICT DO
UPDATE` (`record_inbound`), which removes a cross-worker SELECT-then-INSERT race
under `--workers 2`, halves the writes per message, and refreshes a stale display
name via `COALESCE(excluded, existing)`. Since `increment_message_count` was
`last_active`'s only live writer, one call fixes the count and the timestamp
together. Delete the now-dead methods, their facade delegations, and the
`_row_to_chat_link` helper they alone called.

Historical counts are not backfilled; the roster's "Messages" header says so.

Tests: dual-backend (SQLite + PostgreSQL) roster read-back 0->1->2, `last_active`
advance, username backfill, and `/login` interplay through the real `db_backend`
harness; the real `_handle_message_inner` for the DM, group, access-denied and
counter-failure paths; and the adapter override bodies executed for real —
mutation-verified, a typo'd metadata key turns the suite red. A facade-delegation
guard covers the wholesale-mock blindness recorded in docs/memory/learnings.md.

Verified against a live instance: real Telegram webhook payloads through the real
transport drive the roster 0->1->2; a group message reaches the router and is not
counted; an access-denied message creates no row.

Follow-up #1552 records the read-time-derivation reframe (deriving the roster from
public_chat_messages) that this tactical fix deliberately defers.

Fixes #1533

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

* test(channels): use an example.com placeholder email in the #1533 access-denied case

The public-repo rule in CLAUDE.md calls for `user@example.com`-style
placeholders. `a@b.com` was copied from the neighbouring access-gate test and
was also inconsistent with the rest of this file, which already uses
`alice@example.com`. No behavior change.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ci): auto-trigger frontend-e2e (nightly + frontend paths), keep ui opt-in (#1526) (#1551)

The frontend-e2e Playwright suite ran ONLY on `ui`-labeled PRs, so most PRs never
triggered it and the suite rotted silently — specs broke against structural/UX
changes and sat red on `dev` for weeks (four documented instances: #1134, #1508,
#1378). The gate itself was the root cause.

Add automatic triggers so a red suite is always visible:
- Nightly `schedule` (07:00 UTC) on `dev` — the durable signal. A red nightly
  opens/updates a single tracking issue (new `report` job, label
  `frontend-e2e-nightly`), à la #1185; a green nightly closes it.
- Auto-run on any PR that touches `src/frontend/**` (dependency-free `changes`
  job via `gh pr diff`), so frontend authors see their own breakage.
- The `ui` label still works as a manual opt-in on non-frontend PRs and to force a
  run (preserved for the heavier @visual/@interactive tiers, #596).
- `workflow_dispatch` for on-demand runs.

DECISION (AC): advisory, NOT a required merge gate. The recurring failure class is
modal/overlay flake on a fresh zero-user-agent stack, so a hard gate like #715's
unit gate needs a flake budget first (#596). Recorded in the workflow header.

Per-job least-privilege permissions; concurrency keyed per-PR (cancel) vs per-ref
(schedule, no cancel). Only @smoke runs in CI, unchanged.

Related to #1526

* feat(mcp): make per-agent MCP connector OSS-core (trinity-enterprise#118 Part A) (#1555)

* feat(mcp): make per-agent MCP connector OSS-core (trinity-enterprise#118 Part A)

Relocate the per-agent MCP connector (ent#46/#55/#51, shipped v0.8.0 entitlement-
gated as `mcp_connector`) from the private enterprise submodule into OSS core, and
drop the entitlement gate front and back. Decision (Eugene, 2026-07-09): sharing
agents via individual MCP connectors is a platform-adoption surface, not a paid
module.

Backend (router → service → db, Invariants #1/#2/#14):
- routers/connector.py — /api/agents/{name}/connector* (config, mint/regenerate/
  revoke key, playbooks), mounted unconditionally in main.py; no requires_entitlement.
- services/connector_service.py — snippet builder + playbook resolution.
- db/connector.py (ConnectorOperations) — config CRUD + scoped-key mint/revoke
  into mcp_api_keys (scope='connector'); facade delegators on database.py.
- models.py — ConnectorConfigUpdate/Status/KeySecret/Playbook/ClientSnippet.

Schema: enterprise_connectors table re-homed onto OSS dual-track (db/tables.py,
db/schema.py, db/migrations.py:enterprise_connectors_table + Alembic
0015_enterprise_connectors). Name kept so existing enterprise installs adopt their
data with zero migration (CREATE TABLE IF NOT EXISTS, no duplicate-table drift).
Delete/rename cascade via an enterprise_connectors AGENT_REF.

Frontend: ConnectorChannelPanel un-gated in SharingPanel.vue (dropped the
isEntitled('mcp_connector') v-if + the now-unused enterprise store wiring).

The MCP proxy tools (connector.ts), the connector-scope auth fence
(dependencies.py), and ExposedToolsPanel.vue were already OSS and edition-agnostic.
`mcp_connector` is removed from the entitlement registry by the paired enterprise
PR (deletes register_module).

Docs: requirements mcp.md §7.5 + feature-flows/mcp-connector.md + architecture/index.
Tests: tests/unit/test_118_mcp_connector_oss.py (11) — service helpers, config CRUD,
key mint/regenerate/revoke, scope='connector' validate contract.

Part B (email-auth onboarding, #848) deferred pending design sign-off.

Related to trinity-enterprise#118

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

* chore(submodule): bump enterprise to post-#121 so the connector isn't double-mounted (#118)

#1555 makes the per-agent MCP connector OSS-core. The enterprise submodule was
pinned at 630cca9e, which still ships backend/mcp_connector/ and registers it via
register_enterprise() — so an enterprise build would double-mount the connector
router alongside the new OSS-core one. Bump the pin to f5d69be6 (enterprise main
post trinity-enterprise#121), where the private module is removed.

This forward-integrates two already-on-enterprise-main commits into the pin:
  - trinity-enterprise#121 — removes backend/mcp_connector/ (the intended pair)
  - trinity-enterprise#120 — ENTERPRISE_LOCAL_DEV docs (docs only)
  - trinity-enterprise#106 — client-portal umbrella (already on enterprise main)

OSS-only CI is unaffected (submodule is update=none / "boots without enterprise").

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* docs(enterprise): explain the private feature catalog + runtime verification

ENTERPRISE.md documents the open-core seam mechanism (correct) but never
said WHERE the feature-level OSS/enterprise split lives or WHY it isn't
here. Add a short "Why there's no feature catalog here" note: the standing
rule trinity-enterprise#45 (enforced by enterprise-docs-guard.yml) keeps
the paid-feature catalog private, and entitled customers find it in the
private enterprise repo. Point readers at the runtime source of truth for
"what's enabled on this instance" (GET /api/version + feature-flags).

Mechanism-only, no named features — enterprise-docs-guard grep verified green.

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

* docs(announcements): webinar video announcement record 2026-07-10

Record of the "sovereign, isolated AI agents per client" webinar
video announcement (https://youtu.be/8w98dA6hDew) sent via /announce
to Discord, Slack, Telegram, Twitter/X (trinity + default), and
GitHub Discussions.

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

* chore(deps-dev): bump @types/node (#1531)

Bumps the patch-and-minor group in /src/mcp-server with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 26.1.0 to 26.1.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(reliability): clear per-agent Redis runtime state across the agent lifecycle (#1560) (#1568)

* fix(reliability): clear per-agent Redis runtime state across the agent lifecycle (#1560)

`agent:circuit:{name}` (transport breaker) is keyed by agent NAME, not container
identity, and carries no TTL. Nothing in the lifecycle cleared it, so a container
replaced under the same name inherited its predecessor's `dormant` verdict and
fast-failed every execution with "Agent circuit breaker open — agent is
unhealthy" — without the backend ever contacting the agent.

Adds `services/agent_runtime_state.py` as the single enumeration point for every
name-keyed per-agent Redis keyspace (the Redis-side twin of the `AGENT_REFS`
registry in `db/agent_cleanup.py`), with two entry points whose blast radii
differ by whether a container is running:

  clear_agent_breakers      heartbeat + transport circuit + dispatch breaker
                            (safe on a live container)
  clear_agent_runtime_state the above + execution slots
                            (teardown paths only — force_clear_slots would drop
                             capacity accounting for an in-flight #1083 execution)

Wired into six lifecycle points. The issue's acceptance criteria named delete,
rename and create; none is the reachable path:

  * create is unreachable — `is_agent_name_reserved` sees soft-deleted rows and
    409s, locking the name for the whole retention window;
  * the name only unlocks at the retention purge, which cleared no Redis state;
  * the reachable path is `start_agent_internal`, whose `needs_recreation` branch
    replaces the container on any config drift (subscription switch, resource
    change, auth-token rotation), so one fleet-wide rotation resurrects every
    stale verdict.

So start/recreate, purge, and the `trinity-system` bootstrap were added beyond the
written criteria. The start-path clear runs before the recreate, since
`containers_run(detach=True)` brings the replacement up, and is guarded on
`needs_recreation or not was_already_running` so a no-op start cannot reset a
breaker protecting a wedged agent.

Tests: a bidirectional parity guard fails CI when a new `agent:*` keyspace ships
unregistered; wiring tests pin all six call sites and that lifecycle.py never
clears slots; an integration test exercises the real Lua (fakeredis has no
EVALSHA) plus an opt-in leg driving the full recreate path over HTTP. Both guards
are mutation-tested.

Complementary to #1561, which removes the source of breaker poisoning; this
removes the inheritance. Both are needed.

Fixes #1560

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(1560): stop bare sys.modules mutations in the new test files (#762)

The `lint (sys.modules pollution check)` CI gate flagged 7 new violations.
They were real, not a lint technicality: the integration test replaced
`sys.modules["services"]` with a bare stub module and never restored it,
so every later test in the same session that imported the real `services`
package would have seen the stub — the exact cross-file pollution class
#762 introduced this gate for.

- Unit files: drop the `sys.modules[mod_name] = module` registration in
  `_load` entirely. `agent_runtime_state.py` is a stdlib-only leaf with no
  `@dataclass` needing `sys.modules[cls.__module__]` to resolve annotations,
  so the registration bought nothing and risked pollution. Per-test stubs
  already go through `monkeypatch.setitem`, which restores itself.

- Integration file: keep the bindings (they are what makes the production
  lazy imports resolve to the real modules against real Redis — monkeypatch
  cannot reach an import performed inside the function under test) and add
  the sanctioned `_STUBBED_MODULE_NAMES` + autouse `_restore_sys_modules`
  snapshot/restore pair, per tests/unit/test_telegram_webhook_backfill.py.

Verified: `python tests/lint_sys_modules.py` clean; running the integration
file followed by a suite that imports the real `services` package passes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): prettify Agent Permissions Matrix — bigger labels, no grid jump (#1563)

The fleet permissions matrix was hard to read (text-xs, 32px cells) and
the grid jumped on every grant/revoke: the toast rendered inline between
the toolbar and the grid, so it pushed the grid down on appear and
snapped it back on the 3.5s auto-dismiss.

- Bump base font (text-xs → text-sm), cells (w-8 h-8 → w-11 h-11),
  checkmark (text-base), row/column agent labels (font-medium), corner
  hints (10px → text-xs), and column-label headroom (8rem → 10rem).
- Move the toast into a reserved min-h-[2.5rem] slot so appear/dismiss
  no longer reflows the grid.

Presentation-only — grant/revoke logic and endpoints unchanged.
Grant/revoke verified end-to-end via the backend API.

Related to #1562

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync-health): exclude soft-deleted agents from list_git_enabled_agents (#1561) (#1565)

`list_git_enabled_agents()` selected from agent_git_config on sync_enabled
alone, without joining agent_ownership or excluding deleted_at. Soft delete
keeps the git_config row, so the 60s SyncHealthService poller kept issuing
HTTP calls to removed containers forever — each httpx.ConnectError poisoned
the transport circuit breaker, eventually driving it DORMANT and emitting a
bogus circuit_breaker_dormant operator-queue alert for a nonexistent agent.
GET /api/fleet/sync-audit (same accessor) also listed dead agents.

- Join agent_ownership + filter deleted_at IS NULL, mirroring the #834
  hardening on list_all_enabled_schedules(). Fixes both consumers at once.
- git_service.get_git_status: bare print() → logger.warning (structured).
- Regression tests: soft-deleted agent is excluded from the accessor and
  never polled (zero HTTP, no sync_state row, no operator-queue entry).

Audit sweep (per AC): list_git_enabled_agents was the sole background-loop
accessor missing the deleted_at filter. Other per-agent HTTP pollers
(monitoring, operator-queue) enumerate from Docker, so removed containers are
inherently excluded; scheduler already filters (#834); capacity drain is
DB-only (no agent HTTP). Companion breaker-inheritance defect tracked in #1560.

Related to #1561

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(canary): execution-row integrity invariants E-03, G-03, E-04, G-04 (Phase 4) (#1497)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e12, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:

1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
   — the E-06 work added a second definition next to the pre-existing one,
   so `executescript` raised "table agent_schedules already exists" and
   every fixture-dependent canary test errored. Removed the older, unused
   block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
   and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
   the invariants registry by the same PR). Added E-06 to the expected set
   and asserted it is green on a clean platform.

Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.

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

* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)

B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):

(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
    between the two reads produced a transient count mismatch → spurious
    critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
    `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
    Postgres those are two different databases, so B-01 compared Postgres
    truth to a stale/absent SQLite file (fatal under the Postgres direction +
    SQLite EOL, #1278).

Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.

Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
  `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
  snapshot field. Independent code path (SELECT id/literal 'queued' vs
  COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
  cache/status-filter regression still surfaces (non-tautology, AC #3). No
  cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
  self-heals; a persistent drift survives and fires (AC #2). An engine-read or
  unconfirmable confirm degrades to a B-01 skip — it never compares an engine
  count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
  radius is B-01-only and the sibling #1077 merge stays clean.

The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.

Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.

Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.

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

* docs(feature-flows): add #1450 canary B-01 Recent Updates row

Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.

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

* test(canary): fix duplicate agent_schedules DDL + reconcile E-06 in registry test

The canary_db fixture had two `CREATE TABLE agent_schedules` statements in one
executescript (a #1472 merge artifact), so it raised `table already exists` and
reddened the whole file. Merge them into one definition carrying every column
the canary reads (next_run_at/enabled/deleted_at + agent_name). Add
duration_ms/queued_at/backlog_metadata to schedule_executions and extend
_add_execution for the #1077 E-03/E-04 collector work. Reconcile E-06 (already
registered) into test_run_invariants_all's expected key-set.

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

* feat(canary): terminal-row collector for E-03/G-03 (#1077)

Add _collect_terminal_rows(window_seconds) + Snapshot.terminal_rows. Windowed on
started_at (not completed_at, so E-03 can see NULL-completed_at rows), scoped to
success/failed/cancelled via a local _E03_TERMINAL_STATUSES subset that excludes
skipped (which legitimately has no completed_at/duration_ms). PRAGMA guard skips
the source entirely when completed_at/duration_ms are absent (column-absent !=
value-NULL) rather than false-firing. Window = max per-agent timeout + 300s;
bounded ORDER BY started_at DESC LIMIT 5000 with a logged sampled flag (no
(status,started_at) index over 90-day retention; tripwire, not backfill audit).

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

* feat(canary): register E-03 (completed_at populated) + G-03 (clock sanity) (#1077)

E-03 (A/major): terminal rows must have completed_at NOT NULL. Predicate is
completed_at-only — the catalog's + duration_ms clause false-fires on healthy
queue-terminated rows (cancel/fail/expire set completed_at but never
duration_ms). G-03 (A/minor): started_at <= completed_at with a ~1s cross-worker
clock-skew tolerance, UTC-aware parsing (E-06 _to_utc shape) so a #1474 mixed
naive/Z pair compares without raising. Both are leading-edge tripwires over the
shared terminal-row collector and catch all producers incl. the standalone
scheduler's raw-SQL writers a unit test never exercises.

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

* test(canary): E-03/G-03 synthetic + collector + end-to-end coverage (#1077)

Per-invariant synthetic tests (holds-clean, fires-on-violation), collector tests
(started_at window in/out, NULL-completed_at still collected, skipped-status
excluded, column-absent DDL -> unavailable, LIMIT cap + sampled flag), and
end-to-end collect_snapshot tests through the real collector: the C1
cancelled-from-queue holds-clean guard (completed_at set, duration_ms NULL ->
zero E-03), half-written fires E-03, bad-clock fires G-03, sub-second skew does
not, and the #1474 naive-vs-Z compare-without-raising case.

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

* docs(canary): document E-03/G-03 Phase 4 invariants (#1077)

architecture.md canary table gains E-03/G-03 rows + Phase 4 lookup-key line;
requirements/infrastructure.md §31 gains a Phase 4 bullet (and reconciles the
stale 'Phase 2 deferred' note now that #882/#1472 shipped);
orchestration-invariant-catalog.md annotates E-03/G-03 as shipped with explicit
registry-id mapping, notes the E-03 completed_at-only predicate deviation and
G-03 started_at<=completed_at reduction, and flags the catalog-id vs registry-id
E-06 drift (catalog #129 != registry #1472).

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

* docs(feature-flows): index row for canary Phase 4 E-03/G-03 (#1077)

Canary is an internal invariant harness (no user-facing flow / dedicated flow
doc); follows the #1446 precedent of a Recent Updates row pointing at
architecture.md.

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

* feat(canary): queued-row metadata collector for E-04/G-04 (#1077)

Capture queued_at + backlog_metadata for status='queued' rows in the
existing _collect_executions query, keyed by execution_id in a new
AgentSnapshot.queued_meta map. Both columns are PRAGMA-guarded (added by
BACKLOG-001): when either is absent on an older/minimal DDL the map is
left empty so E-04/G-04 skip those eids (older-image fail-open). Scoped
STRICTLY to queued rows — never terminal — so #1449's deferred
terminal-row backlog_metadata NULL-out cannot make E-04 false-fire.

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

* feat(canary): register E-04 (queued metadata) + G-04 (no creds in metadata) (#1077)

E-04 (Tier A, major): every status='queued' row has queued_at NOT NULL
AND a non-NULL, JSON-parseable backlog_metadata — the
backlog_service.drain_next replay contract. A malformed blob raises
JSONDecodeError and stalls the FIFO. Reports only the failed-predicate
reason code + ids, never the raw metadata (may carry credentials;
violations persist to canary_violations).

G-04 (Tier A, critical): a queued row's backlog_metadata matches no
known secret prefix (sk-/ghp_/gho_/ghs_/ghu_/github_pat_/xoxb-/xoxp-/
AKIA/AIza/sk_live_), word-boundary anchored so common substrings don't
false-fire. Rides E-04's collected bytes. Reports only the matched
pattern NAME + ids, one violation per row (stops at first match) — never
the secret, surrounding bytes, or raw metadata.

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

* test(canary): E-04/G-04 synthetic + collector + end-to-end coverage (#1077)

Collector test proves queued_meta is populated for queued rows only (a
terminal row carrying backlog_metadata is excluded — #1449-safe). E-04:
holds on valid rows; fires with the right reason code on NULL queued_at,
NULL backlog_metadata, and non-JSON metadata; skips an eid absent from
queued_meta (older-image fail-open); e2e over a real temp DB. G-04:
holds on benign metadata (incl. "task-" substring that must not
false-fire); fires on github_pat / openai / slack / aws exemplars; skips
NULL metadata (E-04 owns it). Every G-04/E-04 test asserts the secret /
raw metadata bytes appear NOWHERE in the persisted violation record. The
runner test now expects E-04 + G-04 in the registered invariant set.

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

* docs(canary): document E-04/G-04 Phase 4 invariants (#1077)

architecture.md lookup-key table gains E-04 + G-04 rows and the Phase 4
line now lists all four (E-04/G-04 stacked on #1450). requirements
infrastructure.md Phase 4 bullet expands to the full four-predicate set
with the credential-safety note. Catalog flips E-04/G-04 from
"gated on #1450" to SHIPPED, records the json.loads-vs-json_valid
implementation note, the queued-only scope, older-image fail-open, and
the report-reason/pattern-name-only security discipline; G-04 notes the
implemented check covers the backlog half of the title (log-line
scanning out of scope for #1077).

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

* docs(feature-flows): index row for canary Phase 4 E-04/G-04 (#1077)

Also corrects the header/separator ordering left malformed by the
earlier E-03/G-03 index-row commit (a data row had slipped above the
|---| separator).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* fix(config): forward OPERATOR_INTAKE/DO_NOT_TRACK, DISPATCH_BREAKER_ENABLED, PUBLIC_ACCESS_REQUESTS_ENABLED through compose (#1485) (#1493)

* fix(config): honor cross-tool DO_NOT_TRACK convention for operator intake (#1486)

The operator-intake kill switch only disabled the outbound POST when
DO_NOT_TRACK was one of {"1","true","True"}, so DO_NOT_TRACK=yes|on|2|TRUE
leaked despite the obvious opt-out intent — breaking the very
consoledonottrack.com convention config.py's own comment cites.

Flip to a tracking-allowed whitelist: any value not in {0,"",false}
(case/space-insensitive) disables intake. Unset -> "0" -> tracking
allowed (unchanged). Add tests/unit/test_1486_do_not_track_truthiness.py
so a revert to the exact-tuple check fails loudly.

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

* fix(config): forward operator-intake, dispatch-breaker & public-access levers through compose (#1485)

Same packaging-gap class as PR #1067 (VOIP vars): a lever read by
src/backend/config.py that neither compose file forwards into the backend
container, so setting it in .env silently no-ops (compose reads .env only
for ${VAR} interpolation, and neither backend service uses env_file).

Forward in both docker-compose.yml and docker-compose.prod.yml backend
environment: blocks, mirroring the existing PUBLIC_CHAT_URL idiom:

- PUBLIC_ACCESS_REQUESTS_ENABLED (#1488) — secure default false
- OPERATOR_INTAKE_ENABLED / DO_NOT_TRACK / OPERATOR_INTAKE_URL (#1486)
  privacy kill switch. OPERATOR_INTAKE_URL keeps its FULL non-empty
  default — a bare :- would arrive set-but-empty and shadow the code
  default (#1076 set-but-empty class).
- DISPATCH_BREAKER_ENABLED (#1487) — global gate for the #526 dispatch
  breaker; without it the owner-facing PUT .../circuit-breaker toggle
  silently no-ops (two-tier gating needs both flags on).

.env.example: add the missing DISPATCH_BREAKER_ENABLED block (documenting
the two-tier gate) and a settable DO_NOT_TRACK=0 line. Backend-only —
src/scheduler reads none of these five vars.

Fixes #1486
Fixes #1487
Fixes #1488

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

* docs(feature-flows): note DISPATCH_BREAKER_ENABLED compose-forwarding fix (#1487/#1485)

The dispatch-circuit-breaker flow already documented the DISPATCH_BREAKER_ENABLED
env var, but before this PR that var never reached the container (the
#1039/#1067 packaging-gap class), so a reader following the doc would set it and
the owner toggle would silently no-op. Add a one-line Config-surface accuracy
note + a Recent Updates index row.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* fix(scheduler): serialize timestamps as UTC 'Z' so non-UTC browsers show correct relative times (#1474) (#1496)

* fix(scheduler): serialize timestamps as UTC with explicit 'Z' (#1474)

The standalone scheduler wrote execution/schedule timestamps via
datetime.utcnow().isoformat() (no 'Z'), while the backend writes through
utc_now_iso() (Z-suffixed). In a non-UTC browser, JS new Date(naive) parses
the naive string as local time, shifting schedule-triggered rows by the
viewer's UTC offset. Data on disk was fine; only display was wrong.

Vendor a byte-parity mirror of the backend timestamp helpers into
src/scheduler/utils.py (same regenerate-from-backend discipline as
failure_classifier.py): utc_now_iso / to_utc_iso emit 'Z'; parse_scheduler_ts
reads tolerantly and returns naive UTC.

Write + read land together (atomic): once writes emit 'Z',
datetime.fromisoformat("...Z") returns a tz-aware value on 3.11+, and the
duration math (datetime.utcnow() - started_at) would raise aware-naive.
parse_scheduler_ts converts-then-strips so the historical naive model type is
preserved and the subtraction stays naive-naive. All 24 read-parses route
through it; every write site (started_at/completed_at/last_run_at/next_run_at/
retry_scheduled_at/validated_at + process-schedule variants) emits 'Z'.

Updates the #1472 next_run_at comments to note the mapper now returns
naive-UTC (instant preserved), so both compare branches stay correct.

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

* fix(executions): normalize naive timestamps to UTC 'Z' at read boundary (#1474)

The summary/list readers returned raw dict(row) values straight from the DB,
so a scheduler-written naive started_at/completed_at serialized naive out of
Pydantic and JS new Date(naive) parsed it as local time — the reported
schedule-triggered relative-time shift. This fixes historical rows for all
consumers at the source (unit-testable, unlike the frontend layer).

Reuse the existing parse_iso_timestamp helper (assume-UTC for naive) + the
Z-emitting to_utc_iso — the same normalization the already-correct sibling
readers (_row_to_execution) apply — via a small _norm_ts shim:
  - db/schedules.py: get_agent_executions_summary (TasksPanel),
    get_fleet_executions (ExecutionsPanel), get_agent_schedules_summary
    last_run_at (Overview/Schedules).
  - db/activities.py: _row_to_activity / _mapping_to_activity
    (UnifiedActivity) started_at/completed_at/created_at.

None passes through untouched. No data migration.

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

* fix(ui): parse backend timestamps as UTC in the 5 execution panels (#1474)

Defense-in-depth for the read side (covers WebSocket-pushed timestamps and any
un-audited endpoint, alongside the backend read-boundary fix). Each panel
hand-rolled `new Date(backendStr)`, which parses a naive (no-'Z') string as
*local* time. Replace only the internal parse with the shared idempotent
`parseUTC` (appends 'Z' when no tz indicator present → correct for legacy-naive,
new-'Z', and '+00:00' alike); `new Date()` "now" calls are left untouched and
per-panel display formats are unchanged.

  - TasksPanel: formatRelativeTime + inline log-detail timestamp
  - SchedulesPanel: formatRelativeTime, isOverdue, formatOverdue, formatDateTime
  - ExecutionsPanel: timeAgo
  - UnifiedActivityPanel: formatTime
  - OverviewPanel: fmtDateTime

Established pattern (stores/network.js, ReplayTimeline.vue).

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

* docs: record #1474 scheduler Z-suffix + read-boundary under Invariant #16

Extend Architectural Invariant #16 (the #476 ISO-Z rule) with its write-side/
read-boundary cousin: the scheduler vendors src/scheduler/utils.py (Z-suffixed
writes, naive-UTC tolerant reads); the leaking backend read boundaries normalize
via parse_iso_timestamp; the 5 panels parse via parseUTC. Notes honestly that
next_run_at stays mixed-format across writers (safe — Python-compared only) and
that main.py SchedulerStatus.last_check is out of scope.

Update the #1472 learnings entry: the scheduler DB mapper now returns naive-UTC
(convert-then-strip, instant preserved), so the "schedules store AWARE
next_run_at" premise is true of the backend writer, not the scheduler read path.

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

* docs(feature-flows): document #1474 scheduler Z-suffix timestamp contract

Add a "Timestamp serialization contract (#1474)" subsection to
scheduler-service.md (vendored utils.py, Z writes, naive-UTC tolerant reads,
backend read-boundary + panel normalization, next_run_at mixed-format caveat)
plus a Revision History row; add the index Recent Updates row.

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

* test(scheduler): cover _update_business_status validated_at Z-suffix (#1474)

The service-layer VALIDATE-001 write path (service.py::_update_business_status)
is the one #1474 timestamp write site outside database.py. Add sibling-path
coverage per the incomplete-fix rule: assert validated_at is stored 'Z'-suffixed.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* docs(user-docs): add Trinity FAQ — 264 grounded Q&As across 14 topic pages

One page per topic (getting started, agents, chat/sessions, credentials,
scheduling, collaboration, channels, MCP/API, operations, sharing,
deployment, security, advanced, troubleshooting) plus a generated
question index. Answers derived from user docs and verified against
code; .claude pointer picks up the generate-user-docs FAQ step.

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

* docs(user-docs): clarify PAT scope — git transport vs gh CLI/REST API authentication

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

* feat(helper-mcp): standalone Trinity docs Q&A MCP server (#1459)

New src/helper-mcp/ package (@abilityai/trinity-docs-mcp): an npx-runnable
stdio MCP server exposing the public ask-trinity docs Q&A endpoint
(DOCS-QA-001) to any MCP client — no Trinity instance or API key required.

- Tools: ask_trinity (multi-turn; detects the endpoint's silent session
  reset and warns when context was lost) + get_agent_requirements (agent
  guide fetched live from GitHub, quick-reference fallback)
- Guards: 4k question cap, 50s abort timeout, no auto-retry,
  redirect:"error", non-JSON guard, structured error text; session_id
  handled as opaque string (live values exceed 2^53)
- Deps: official @modelcontextprotocol/sdk + zod only; console.error-only
  logging (stdout is the JSON-RPC channel); Node >=18 launcher guard
- Tests: 26 unit (mocked fetch) + pack-and-run stdio smoke test asserting
  JSON-RPC stdout purity; CI workflow helper-mcp-test.yml
- Publish: publish-helper-mcp.yml (npm provenance; documented one-time
  manual first-publish bootstrap for trusted publishing)
- Corpus: sync-docs-to-vertex.yml now also indexes the agent guide;
  feature flow corrected (user-docs/FAQ were already indexed) and extended
  with the verified endpoint contract (no citations field, silent session
  expiry)
- Docs: requirements/mcp.md entry, README + user-docs install blocks,
  package README

Fixes #1459

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

* feat(agents): ephemeral ghost agents — budgeted hard-discard lifecycle + spawn provenance (trinity-enterprise#69)

Disposable "ghost" agents: created with a hard budget (max_executions and/or
TTL — expiry ALWAYS stamped, ceiling 24h default), volume-less (container
writable layer; ghosts never recreate), hard-discarded at budget with no
soft-delete/retention/name-reservation. Creation is entitlement-gated
(ephemeral_agents; fail-closed — inert until the enterprise module registers);
all lifecycle mechanics are edition-agnostic OSS primitives.

Lifecycle:
- Schema: 5 additive agent_ownership columns (is_ephemeral, budget, expiry,
  spawned_by_agent/key_id); dual-track migration (SQLite + Alembic 0016)
- Creation gates (crud.py): entitlement 403 → ephemeral-caller refusal
  (chain-spawn kill) → per-parent spawn rate limit → TTL ceiling 400 →
  server-suffixed name (hex8) → atomic per-owner Redis quota (INCR-with-cap,
  NX reseed, DB fallback); ghosts skip volume/avatar/cred-injection/auto-sync,
  default max_parallel_tasks=1
- Budget: gate at the TOP of CapacityManager.acquire (terminal+active >= max
  or expired ⇒ EphemeralBudgetExhausted → 410 Gone / FAILED
  ephemeral_exhausted; covers every admission surface); post-CAS-win
  apply_result hook (backgrounded, fail-open) triggers discard at budget
- Hard discard (services/agent_service/ephemeral.py): SETNX-locked,
  crash-convergent — intent marker → CAS-fail non-terminal rows
  (ghost_discarded) → force-remove container → clear Redis state BEFORE purge
  → cascade purge (executions KEEP) → audit. DELETE routes ghosts here before
  the container lookup (half-discarded state force-discardable)
- GC (cleanup_service._sweep_ephemeral_agents): DB pass + Docker-as-truth
  orphan pass with 15-min newborn grace; capped per cycle

Part 2 — spawn provenance + parent control:
- Any agent-spawned creation persists spawned_by_agent/key_id and auto-grants
  the agent_permissions parent→child edge (created_by="spawn:{parent}") so a
  parent can immediately chat/list/info the child it spawned
- BEHAVIOR CHANGE: agent-scoped keys may start/stop/delete ONLY agents they
  spawned (name AND key-id match; interim until #948); sharing, permission
  grants, rename, and credential ops are now human-only (403 for agent keys)
- Ghost-key containment fence at the single auth entry point: a ghost's own
  key reaches only heartbeat/result-callback/reports/notifications/self-info

Fleet hygiene: heartbeat watch + fleet health exclude ghosts; operator-queue
polling keeps them; exec/cost stats stay inclusive; schedules on ghosts → 400;
AgentStatus.ephemeral surfaced + GHOST badge.

Tests: tests/unit/test_69_ephemeral_agents.py (40, db_harness real-engine) —
accessors, facade delegations, acquire-gate matrix, key-fence matrix, Part 2
guard matrix, budget hook, discard idempotency/crash-convergence, atomic
quota. Full unit suite verified; residual order-dependent flakes reproduced
on clean dev (pre-existing, documented in learnings.md).

Refs abilityai/trinity-enterprise#69 (Phase 0 record on the issue; closed
manually at release — cross-repo keywords don't auto-close).

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

* test(69): patch _REAL_MODULES objects directly, never string targets — fixes seed-12345 order flake

CI's regression-diff (head seed 12345) caught the discard test flaking:
string-form monkeypatch targets resolve through sys.modules at PATCH time,
which under some pytest-randomly orderings is a sibling test's leaked stale
entry — the patch lands on the wrong module object while discard's call-time
import (under the _own_real_modules pin) resolves the real one, so
get_agent_container fell through to the real function (container=None,
removal skipped). Same hazard removed from the gated_capacity and
ghost_fence fixtures (bare fixture-time imports), and the audit assertion
moved to an instance-method patch on the pinned singleton.

Verified: full suite green under all three CI seeds (12345/67890/99999),
3775 passed each.

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

* docs(learnings): string-target monkeypatch resolves stale sys.modules entries — patch by object

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

* fix(tests): pin real modules in TestAcquireCeilingClamp to kill sys.modules-leak flake (#1582) (#1584)

The trio (test_acquire_clamps_above_ceiling / test_get_slot_state_clamps /
test_get_all_states_clamps_each) failed order-dependently under
pytest-randomly on clean dev (5/7 observed runs), passed in isolation and
under -p no:randomly.

Root cause (the #762/#1446 family): `_patch_ceiling` patched
`get_max_parallel_tasks_ceiling` on the settings_service resolved via
`from services import settings_service`, but `capacity_manager.acquire`
imports `clamp_to_ceiling` at call time from `services.settings_service`.
When a sibling's module-level stub (e.g. test_fleet_status_resilience
installing a fresh `services` package with a real `__path__`) leaves two
module objects for `services.settings_service` in play, the patch lands on
one and `clamp_to_ceiling.__globals__` reads the other — the patch is
silently never hit and the clamp assertion sees the unpatched (default)
ceiling.

Fix (the pattern documented for this leak family):
- Capture the real `services` / `services.settings_service` /
  `services.capacity_manager` at collection time (this file sorts before
  every known leaker, so the import is leak-free).
- Autouse fixture re-pins them into sys.modules per test (monkeypatch
  auto-restores) so the code-under-test's call-time import and the test's
  patch target resolve to the SAME object — last-write-wins over any leak.
- `_patch_ceiling` now patches the captured real module object directly,
  never a bare re-import that could resolve a leaked stub.

Verification: 25/25 in the file; 4/4 trio; 32 passed across 6 random seeds
with test_fleet_status_resilience co-resident; 111 passed / 0 ceiling
failures across 5 full-unit randomized seeds.

Related to #1582

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reliability): decouple agent autonomy from the circuit breaker (#1557) (#1571)

Disabling autonomy called force_circuit_dormant, parking the transport
circuit breaker dormant. The execute_task gate consults that breaker for
every trigger, so a healthy paused agent fast-failed all inbound chat
(manual/Telegram/Slack/public) with "circuit breaker open — agent is
unhealthy" — never contacted. Autonomy governs proactive work only; it
now acts solely via set_schedule_enabled and never touches the breaker.
#631's flood protection is unaffected (the breaker's own failure-driven
dormant path + #1464 leader lock + #1121 monitoring-default-off).

Also splits the misleading fast-fail message to name the breaker that
fired (transport = unreachable, dispatch = auth-dead).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* feat(voice): voice replies v2 — per-message capability, agent-level config, runtime ElevenLabs key (trinity-enterprise#117) (#1549)

* feat(voice): voice replies v2 — per-message capability, agent-level config, runtime ElevenLabs key (trinity-enterprise#117)

Rework outbound voice replies (ElevenLabs TTS) along three lines:

1. Voice is a per-message capability, not a hard rule. Channel replies are text
   by default; a reply becomes a voice note only when the agent explicitly calls
   the new send_voice_reply MCP tool during the turn. The backend resolves the
   channel destination from the execution (new schedule_executions.source_channel*
   columns), gates on TTS availability + agent enable + per-channel flag, wraps
   delivery in effect_guard (#1084), and reuses each channel's send primitive.
   The always-voice adapter path (_maybe_send_voice) is removed.

2. Voice config moves to agent Settings (enable + voice selection, one place);
   channel panels keep only a per-channel on/off flag
   (agent_ownership.tts_voice_{telegram,slack,whatsapp}_enabled, default ON).
   GET/PUT /api/agents/{name}/voice-replies extended with channels + effective voice.

3. ElevenLabs API key + platform default voice are runtime-configurable in admin
   Settings (GET/PUT /api/settings/elevenlabs), key stored AES-256-GCM encrypted
   and surfaced as configured:bool only; resolved via
   settings_service.get_elevenlabs_api_key() (stored setting -> env), no restart.
   New tts_available feature flag.

Dual-track migrations (SQLite + Alembic 0015/0016). New send_voice_reply MCP tool
(voice.ts) + POST /api/agents/{name}/voice-reply + voice_reply_service. Capability
advertised in the platform prompt only when voice is enabled for the agent and the
current channel's flag is on. OSS-core.

Related to trinity-enterprise#117

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

* fix(voice): forward source_channel* through the db facade create_task_execution (trinity-enterprise#117)

DatabaseManager.create_task_execution (the `db` facade wrapper) didn't forward the
new source_channel / source_channel_chat_id / source_channel_thread kwargs to
ScheduleOperations, so a channel-triggered task raised
"unexpected keyword argument 'source_channel'" → 500. Add the passthrough.

Related to trinity-enterprise#117

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

* fix(voice): allow send_voice_reply on channel turns via --allowedTools (trinity-enterprise#117)

Channel turns run headless with a restricted `--allowedTools` (default
WebSearch,WebFetch), which blocks every MCP tool — so an agent could not call
send_voice_reply even when the capability was advertised in its prompt. When the
voice capability is advertised for a channel turn, also append
`mcp__trinity__send_voice_reply` to the channel allowed-tools list so the agent
can actually act on it.

Related to trinity-enterprise#117

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

* fix(migrations): stage the Alembic re-chain — 0017/0018 revision ids + down_revision onto 0016_agent_ownership_ephemeral

The dev-merge commit renamed the files but the id/down_revision edits were left unstaged, leaving two heads off 0015 (pg-migrations 'Multiple head revisions' failure).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* chore(dev-skills): bump .claude — /cso v1.1 Trinity-shape refresh

Pointer bump to trinity-dev 2bdd362: fixes stale audit checks (setup-token
removal ent#49, Redis ACL model, dual-track DB / two-network stack facts)
and adds checks for post-skill surfaces (agent-key self-boundaries
#307/#1083/#918, backend→agent auth #1159, webhook HMAC ent#77, vendored
parity Invariant #5, enumeration uniformity #186, MCP description leak
#846, backlog_metadata G-04 class, enterprise-docs-guard ent#45).

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

* docs(security): add CSO full-codebase audit report (2026-07-13)

Full-codebase Chief Security Officer audit (all phases, daily 8/10 gate).
No CRITICAL or exploitable-HIGH findings; prior CRITICAL (unauthenticated
agent-server, #1159) and both HIGH supply-chain items resolved. Ceiling is
MEDIUM: shared-user privilege tier, npm-ci lockfile bypass, unenforced
CODEOWNERS, plaintext backlog_metadata, unpinned deps, enterprise-doc
disclosure the guard can't see, python-multipart CVE. Two candidate findings
(orb.js XSS, Slack code takeover) downgraded to LOW by adversarial verifiers.

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

* fix(portal): hide the platform Help widget on client-portal routes (#1588)

The global "Trinity Help" chat widget is fixed at bottom-right (z-50) and
overlapped the client portal chat composer's Send button. It's an operator/
platform-docs widget (gated on platform auth) and is meaningless on the
standalone client portal, so gate it off there via a `hideHelpWidget` route
meta on the two portal routes (`ClientPortalPublic` /portal and
`EnterpriseClientPortal`). App.vue now renders it only when
`authStore.isAuthenticated && !route.meta.hideHelpWidget`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(portal): attach/upload files directly in the customer portal chat (ent#144) (#1587)

A client can now attach files in the portal chat composer. The backend already
had everything — `POST /client-portal/agents/{name}/documents` (writes to the
client inbox), and `portal_chat`'s `_collect_inbox_for_turn` attaches inbox
images as vision + lists documents — and the store already had
`uploadDocument`/`fetchUploads`. The only missing piece was the composer UI.

- `PortalChat.vue`: adds an `uploadDocument` prop and a paperclip attach control
  (hidden multi-file input). Picked files upload to the inbox immediately and
  render as chips with state (uploading / done / error) and a remove button.
  On send, the done attachments' (server-sanitized) filenames are appended as
  `[Attached: …]` so `_collect_inbox_for_turn` attaches images as vision THAT
  turn (it keys on the filename / image-intent) and lists documents. Files-only
  turns are allowed; send is blocked while an upload is in flight; a 25 MB
  client guard mirrors the backend cap (415/quota surfaced as a chip error).
- `Portal.vue`: passes `:upload-document="(name, file) => store.uploadDocument(name, file)"`.
- The attach control hides when no upload handler is wired (operator-preview
  `ClientPortal.vue` renderer), so nothing changes there.

Public-repo (gated Vue) change only — no backend/submodule change (reuses the
shipped enterprise endpoint + inbox consumption).

Refs Abilityai/trinity-enterprise#144

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(access): restore the per-recipient proactive-messaging toggle (#1577) (#1590)

The `allow_proactive` toggle (the only UI for #321/#376) was silently dropped
when the #1317 Access-tab redesign replaced the Sharing tab's Team Sharing rows
— leaving no way to opt a recipient into proactive messages except a raw API
call. Backend was fully intact; this is a UI-only restore.

- `AccessPanel.vue`: each operator row gets a Proactive toggle bound to the
  `allow_proactive` the `/access` roster already returns (no extra fetch). On
  change it persists via the store and reflects the server's confirmed value,
  reverting + surfacing the error on failure (honest status, no optimistic-only
  flip). Pending invites are toggleable too (the flag rides on the
  `agent_sharing` row, which exists pre-resolution) with a tooltip on timing. A
  static note states the owner is always allowed (owners aren't in the roster,
  so there's no misleading owner toggle).
- `stores/agents.js`: new `setProactive(name, email, allow)` →
  `PUT /api/agents/{name}/shares/proactive`.
- `feature-flows/proactive-messaging.md`: updated — it still documented the
  removed SharingPanel markup/line numbers.
- `tests/unit/test_1577_proactive_toggle_guard.py`: static regression guard (3)
  asserting the toggle + endpoint wiring survive future panel refactors (the
  frontend has no JS unit runner; a full render e2e is a `ui` follow-up).

Related to #1577

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(github): wire the managed agent PAT for the gh CLI + REST API, not just git (#1574) (#1591)

Trinity injected the resolved GitHub PAT as GITHUB_PAT (authenticating git via
the origin URL) but not the `gh` CLI or REST API, which read GH_TOKEN/GITHUB_TOKEN
— so agents had to prefix every command with `GH_TOKEN="$GITHUB_PAT" gh …`, and
`gh` wasn't even installed. This makes the SAME token cover both. No new token,
endpoint, or UI — wiring only.

Expose GH_TOKEN + GITHUB_TOKEN = the resolved PAT at every point GITHUB_PAT is
set today, gated identically (only when a repo + PAT resolve — never an empty
token that makes `gh` look logged-in but broken):
- create (`crud.py`) and recreate (`lifecycle.py`) bake them into the container env;
- the no-restart `.env` propagation (`github_pat_propagation_service._patch_env_github_pat`)
  now patches/adds all three keys, kept in sync;
- `startup.sh` exports them from GITHUB_PAT so child processes (agent server,
  terminal shells) auto-authenticate — also covering older baked images that
  only carry GITHUB_PAT, without a recreate.

Install the `gh` CLI in the agent base image (`Dockerfile`) from GitHub's official
apt repo (base image must be rebuilt for existing agents to get the binary; the
env vars are harmless on older images — git keeps working).

Honest surfaces: the `set_agent_github_pat` MCP tool description and
`docs/.../github-pat-setup.md` now state the managed token covers `gh`/REST too,
keeping the scope caveat (wiring makes the token available; it can't grant scopes
the token lacks). The credential sanitizer's `.*TOKEN.*`/`GITHUB_.*` patterns
already mask GH_TOKEN/GITHUB_TOKEN (asserted).

Tests: `tests/unit/test_1574_gh_token_wiring.py` — the .env patcher mirrors the
PAT onto all three keys (replace-in-place / append, lookalike-key-safe, value
mirrored); static guards that create/recreate/startup/Dockerfile each wire the gh
vars; sanitizer covers the new vars.

Related to #1574

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(operator-queue): quarantine persistently-failing creates to stop the sync hot-loop (#1525) (#1589)

The `created_at` KeyError in `create_item` was already made defensive on dev
(#1426 — `.get(...) or utc_now_iso()` + `on_conflict_do_nothing`). This closes
the remaining #1525 gap: the sync loop still re-attempted ANY failing create on
every ~5s cycle forever (the row never persists → `operator_queue_item_exists`
stays False → retry + ERROR-log indefinitely), so a DB error or any other
persistent create failure — not just the fixed field case — still hot-loops.

- `operator_queue_service._sync_agent`: track per-request consecutive
  create-failure counts; after `MAX_CREATE_ATTEMPTS` (3) skip that request
  (one WARN at the quarantine threshold instead of an unbounded ERROR stream).
  A create that later succeeds clears the counter; an in-memory safety valve
  caps the map so it can never grow without bound.
- `db/operator_queue.create_item`: the last hard-indexed field (`id`) now uses
  `.get` and raises a clear `ValueError` (which the caller quarantines) instead
  of an opaque `KeyError`. Also hardened the WS-broadcast `item["id"]`.

Tests: `test_1525_operator_queue_quarantine.py` (4, pure/mocked) — create is
attempted at most the cap (not once per cycle), success clears the counter, a
healthy request leaks no quarantine state, and the id-guard raises ValueError.

Related to #1525

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(mcp): one-click Copy connection config on the Expose-via-MCP panel (#1575) (#1585)

* feat(mcp): one-click Copy connection config on the Expose-via-MCP panel (#1575)

Surfaces a ready-to-paste external-client config — with a least-privilege,
agent-scoped, revocable API key already embedded — from the #846 exposure
panel, so an external MCP client connects in one flow (enable → copy → paste).

Direction: reuse the existing per-agent MCP connector (ent#46 → OSS #118)
rather than mint a duplicate credential system. The connector already provides
exactly the hard parts #1575 asks for — a scoped `scope='connector'` key,
owner-selected playbooks exposed as tools, and `build_snippets` producing
per-client `.mcp.json`/CLI blocks with the key embedded — but only on the
Sharing tab. This wires that capability onto `McpExposedPanel.vue` (Settings →
Expose via MCP), shown when `mcp_exposed` is on.

- Frontend-only, no new backend endpoint/key type: reuses
  `GET/POST/DELETE /api/agents/{name}/connector[/key]` (owner-only,
  `OwnedAgentByName`) + `ExposedToolsPanel` for the playbook allow-list.
- One-click "Copy connection config": mints (or regenerates) the scoped key and
  copies the `.mcp.json` in a single action via the robust `utils/clipboard`
  helper — the only moment the live secret exists.
- Copy-once integrity: with an existing key, "Copy config" copies the
  placeholder config and offers "Regenerate & copy" for a fresh live key;
  "Revoke" severs any connected client. Key already lists in Settings → MCP Keys.
- Safe default: the section only appears once the agent is MCP-exposed; a plain
  warning marks the config as a live secret.

Docs: mcp-connector.md gains the 2nd-surface row; architecture #846 block notes
the connect surface.

Verification: `McpExposedPanel.vue` compiles clean via @vue/compiler-sfc
(full `vite build` blocked by a pre-existing unrelated missing `mermaid` dep in
AgentWorkspace.vue). Live click-through / ui-labeled e2e needs the running
frontend+backend stack (not up locally).

Not changed (documented scope): a 422 on an invalid playbook name — the picker
only offers live user_invocable playbooks, so the invalid path is UI-unreachable;
left to the connector's existing read-time filter.

Related to #1575

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

* feat(mcp): flash a "Copied!" animation on the connection-config copy buttons (#1575)

Adds an inline copied-state affordance to the connect-config copy actions
(Copy connection config / Copy config / per-client snippet Copy): on a
successful clipboard write the button swaps to a green "✓ Copied!" with a
scale-pop checkmark for ~1.6s, then reverts. One shared timer (cleared on
unmount); only fires when the copy actually succeeded; honors
prefers-reduced-motion.

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

* feat(mcp): make the copied state transform the button itself (#1575)

Stronger, unmissable copy feedback: on a successful copy the primary
"Copy connection config" button fills solid success-green, gains a
ring + glow, bumps to a bolder "Copied to clipboard!" with a larger
checkmark, and plays a one-shot pop + outward ring-pulse
(copied-btn-flash) before settling. The existing-key "Copy config"
button gets the same solid-green ring treatment. Reverts after ~1.6s;
honors prefers-reduced-motion.

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

* feat(mcp): animate the connector snippet Copy buttons too (#1575)

The MCP connector panel (Sharing tab, ConnectorChannelPanel) is the other
surface that shows per-client copy snippets; its plain "Copy" links gave no
feedback. Give them the same copied-state treatment as the exposure panel:
on a successful copy the button flashes solid success-green with a ring +
pop + "Copied!" checkmark, reverting after ~1.6s (honors reduced-motion).
Also route its copy() through the robust utils/clipboard helper instead of
raw navigator.clipboard.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tests): conftest no longer deletes real agents named test-* (#1558) (#1586)

* fix(tests): stop conftest from deleting real agents named test-* (#1558)

The session-scoped `api_client` fixture deleted EVERY agent whose name began
with `test-` on whatever `TRINITY_API_URL` pointed at — silent, unprompted
data loss that destroyed a developer's real `test-agent-2` (bound Telegram
bot, chat history, GitHub sync). Any `test-*` agent on a staging/prod instance
was one test invocation from deletion.

Fix — fail-closed on every axis:
- Dedicated prefix: the suite now names every agent it creates
  `pytest-ephemeral-*` (a namespace no human uses), never the broad `test-`.
  Renamed all conftest agent generators (test_agent_name, module_agent_name,
  stopped_agent, shared_agent).
- Session registry: created names are registered (`register_created_agent`)
  and reclaimed by name at session end (belt over each fixture's own teardown).
- Startup leftover-sweep is now OPT-IN (`TRINITY_TEST_CLEANUP_SWEEP`), refuses
  any non-localhost target (`is_local_target`), and only removes provably
  suite-owned names (`select_sweepable_agents` — pure, fail-closed). Default:
  no sweep at all.
- `cleanup_test_agent(require_suite_owned=True)` refuses (no stop/delete call)
  a name the session can't prove it created — used by every sweep path.
- Docstring + tests/README now state plainly that the suite mutates the target
  instance and must point at localhost.

…
AndriiPasternak31 added a commit that referenced this pull request Jul 31, 2026
…nce examples

Closes ent#128 AC #3-4.

**Reference examples (AC #4).** The substrate the original plan targeted is gone —
`3317247e` deleted `config/agent-templates/cornelius/` in favour of seeding from
the public upstream repo — so AC #4 lands on what the bundle actually has:

  * `scout` / `sage` / `scribe` (the ent#124 seeded trio) declare an explicit
    `credentials: {}` with the zero-credential contract written out. Absent and
    empty mean the same thing to Trinity, but *absent* is ambiguous to a HUMAN —
    it could equally mean the author forgot. `{}` says "considered, and there are
    none", so the catalog's 0-credential badge is trustworthy.
  * `test-codex` carries the enriched reference: its one real variable gets a
    title, description, `required`, `secret`, `format` and `setup_url`.

Deliberately NO `GEMINI_API_KEY` in any example: it is platform-injected, so an
example asking for it would violate the very rule the guide documents — and it
makes a K-002 fixture pass VACUOUSLY, which is how a test proves nothing while
looking green. A test asserts no bundled example asks for a platform-injected var.

Framed honestly rather than oversold: with one enriched declaration and one
names-only one in the bundle, the parity test ("every bundled template normalizes
with zero errors") is thin today. Its value is as a RATCHET for ent#137's curated
fleet.

**The guide (AC #3).** New `## Declaring Credentials` section, TOC renumbered
5→21. Covers the field table, the decorate-don't-declare rule with the actual
error text, why `credentials:` stays names-only, the zero-credential contract,
degrade-don't-demand, the platform-injected list, fork-to-own composition
(ent#109), and the two brace forms Trinity's readers cannot see (`${my-key}`
silently dropped, `${VAR:-default}` mis-substituted to an empty string).

It also states the AUTHOR COST plainly instead of claiming the design is free:
declaring a variable is a separate edit from referencing it in
`.mcp.json.template`, and three of Trinity's own six default GitHub templates
declare zero credentials while referencing 2-6 and documenting 7-12. Those are
K-002-red today and stay red until someone does the edit. Kept that way on purpose
— if `.mcp.json.template` counted as a declaration it would become a second
authority on what an agent needs, which is the drift this design exists to
prevent. The practical order is stated: seed `credentials:` first, enrich second.

**Memory docs.** `requirements/credentials.md` §3.5's ✅ was false in both halves
and is corrected in place with the correction recorded: the extractor it credited
has no production caller, and nothing showed configured-vs-missing status because
the badge read a key no template defines. `template-processing.md` and
`templates-page.md` get the "two shapes, two owners" table that reconciles the
objects-vs-strings contradiction (catalog `required_credentials` = names,
`credential_requirements` = objects, extractor `required_credentials` = a
different function with the same key name), plus the corrected regex. Compatibility
checklist gains six credential rows and a starts-with-nothing-configured row.

**No DB change → Rule #9 (dual-track migration) does not apply.**

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…ial paths from name: (#1900) (#1935)

* fix(templates): contain local: template id resolution on the read path (#1900)

`GET /api/templates/{template_id:path}` handed `local:<name>` straight to
`get_local_template`, which joined `<name>` onto the templates root with no
validation. The `:path` converter permits `/`, so `local:../<x>`,
`local:/<abs>/<x>` and a root-escaping symlink each read
`<escaped-dir>/template.yaml` and echoed its contents in an authenticated 200.

Reachable by any authenticated principal of any role — including an
agent-scoped MCP key, so a prompt-injected agent qualifies. In a container the
reachable set includes `/data/deployed-templates/<victim>`, where every user's
uploaded template archive lands: a cross-tenant read.

Two corrections to the issue's framing, both verified here:
  * it is NOT arbitrary file read — the filename is fixed (`template.yaml`), it
    must parse as a YAML mapping, and only a fixed key set is echoed. But those
    keys' VALUES are arbitrary YAML subtrees, not just strings.
  * `local:..` alone is not an existence oracle: a directory with no
    `template.yaml` returns the same 404 as an unknown id.

Fix: `contained_template_dir(name, root)` — the two-step barrier the CREATE
path has had since #950 (`crud._safe_local_template_path`), brought to the read
path. A name allowlist runs BEFORE any path math (this is also what CodeQL
recognises as a `py/path-injection` barrier; resolve-only was flagged
high-severity twice on this codebase), then `resolve()` on BOTH sides plus
`is_relative_to`. `str.startswith` is not equivalent — it passes the sibling
escape `<root>-evil`.

An escaping id returns `None`, so the router's 404 stays byte-identical to an
unknown template: no error code, no path, no root name. A distinct error would
be a NEW enumeration oracle, which is what #1759's single-sentence 404 exists
to close. Rejections log at DEBUG, sanitized — the endpoint has no rate limit,
so a per-rejection WARNING would be an authenticated log-flood primitive.

The helper is public: the remote-template-registry work (trinity-enterprise#14)
edits this same resolver family in this same module and should import it rather
than copy it.

Tests: every rejection test PLANTS a real `template.yaml` at the escaped
location, because unpatched code returns `None` for any id whose target simply
does not exist — a rejection test with nothing planted is green before and
after and proves nothing. Each is labelled REPRO (verified red pre-fix) or
HYGIENE (cannot be made red; contract only). The router guard lives in
`tests/unit/` because no gating CI job collects `tests/test_templates.py`.
`test_1900_containment_survives_a_symlinked_root` is the landmine guard for
resolving both sides: a half-resolved variant passes all 130 pre-existing tests
and only that test catches it.

Refs #1900

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

* fix(templates): stage .mcp.json from the validated template dir, not template name: (#1900)

The second traversal sink, found by this issue's own AC #4 audit ("audit the
by-name create path for the same join"). It is NOT the create path's `local:`
id resolution — that has been contained since #950/#1759 via
`crud._safe_local_template_path`, applied at both seams #1759 named. It is the
create path's CREDENTIAL STAGING, which threw that validated path away and
re-derived a directory from scratch:

    template_name = template_data.get("name", "")          # untrusted
    mcp_template_path = templates_dir / template_name / ".mcp.json"

`name:` comes from an uploaded template.yaml, so any `creator` reaches it via
`deploy_local_agent`. `name: ../../data/deployed-templates/<victim>` read
another tenant's `.mcp.json` — a credential-bearing file type under
Invariant #12 — into the attacker's OWN agent, where they read it at leisure.
A victim who hardcoded a token rather than a `${VAR}` placeholder leaks it.

Assessed on its own axes, NOT inherited from the read sink: different trigger
(`creator` role + an upload + an agent create, vs a bare authenticated GET) and
a higher impact ceiling (credential values, not template metadata). Also P2, for
different reasons.

The derivation was also simply wrong. `name:` is not a directory name — 5
shipped templates declare a display string there ("Test Echo Agent"), so
`local:test-echo` resolved to `<curated>/Test Echo Agent/.mcp.json`, which does
not exist. That kills the "validate the name" framing: the value should not
resolve paths at all.

Fix is root-cause, not another guard: `_stage_config_files` already calls
`_safe_local_template_path` itself for the `/template` bind decision, so the
validated directory is available in the same function. Extract the two-root
ladder as `_resolve_local_template_dir` and pass its result as
`template_base_path`. The untrusted join is gone from the live path, and the
#1759 "seams must agree" property becomes structural across all THREE seams
(resolver, bind decision, credential stager) instead of two.

Deliberately NOT threaded through `_TemplateResolution` or the return tuple:
`_resolve_local_template` returns a 2-tuple that three existing tests depend
on, two as monkeypatched `lambda config: ({}, None)` doubles — widening it
breaks the test doubles, not just the callers. Its signature, its return arity,
`_safe_local_template_path`, `_LOCAL_TEMPLATE_ROOTS`, and everything inside the
CodeQL-sensitive `if template_yaml.exists():` block are untouched (#1793 had to
revert exactly that reshape).

The residual `template_base_path is None` arm is kept and made fail-closed: it
is a public function with a `template_base_path=None` default, so a future
caller can still reach it. It now contains through the same barrier as the id,
which also absorbs a non-string `name:` — `Path(root) / 123` raised TypeError,
i.e. an uncaught HTTP 500 during agent creation (the ent#128 bug class, one
seam over).

One disclosed behaviour change: a deploy-local template that BOTH declares
`credentials.mcp_servers` AND ships a `.mcp.json` now gets `${VAR}`
substitution, where the old curated-root lookup always missed. Verified no
collision with `deploy._prepopulate_workspace_from_template`, which writes the
archive's raw copy into the workspace volume: `startup.sh` copies
`/generated-creds/.mcp.json` unconditionally (gated only on the directory
existing) and AFTER the template-copy block (gated on `.trinity-initialized`),
so the substituted file deterministically wins — which is the intended
behaviour, the raw copy still carrying unsubstituted placeholders. Not one of
the 26 shipped curated templates contains a `.mcp.json`, so the curated rows
are provably unchanged.

The crud seam tests are mandatory, not decorative: every service-level test
calls `generate_credential_files` directly, so an "extracted but never wired"
mistake leaves all of them green while deploy-local resolution silently
regresses into the fallback arm.

Refs #1900

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

* docs: record the #1900 containment contracts for template id + credential staging

Rule #1 (requirements before implementation) — `core-agent.md`:

* §4.1 gains the **read-path resolution contract** as a sibling to the existing
  create-time contract: `GET /api/templates/{id}` resolves `local:<name>`
  through the same two-step barrier the create path has had since #950, and a
  failing name returns a 404 byte-identical to an unknown template (the #1759
  non-disclosure rule). Records the deliberate, known asymmetry that
  `get_local_templates()` still enumerates by `iterdir()` and so could LIST a
  root-escaping symlink that detail and create both refuse — the listing is the
  outlier, and planting one needs local filesystem write access, not a request.

* §4.3 records that the `credentials.mcp_servers` template lookup now resolves
  from the validated path rather than the template's own untrusted `name:`
  field, including the one disclosed behaviour change (deploy-local templates
  now get `${VAR}` substitution) and why the substituted file wins over the
  archive's raw copy.

`architecture.md` gets one clause on the `templates.py` router catalog entry
(the catalog rule caps entries at 2 lines, and no Cross-Cutting Subsystems
block is warranted). A bug fix would normally be commit-message-only under the
tiered-docs rule; the exception is that this ships a public, importable
containment primitive in the exact module and resolver family the remote
template registry (trinity-enterprise#14) will edit, and one catalog clause is
the cheapest way that author finds it instead of copying the flaw.

Not updated, deliberately: no feature-flow doc (no new vertical slice), no user
docs (no user-visible change for honest callers), no schema/migration (no DB
change, so the dual-track SQLite/Alembic rule does not apply).

Refs #1900

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

* docs(feature-flows): sync template-processing + local-agent-deploy for #1900

`/sync-feature-flows` was NOT a no-op here — three concrete staleness points in
`template-processing.md`, which owns the `local:` resolution surface:

* The inlined two-root ladder is now `_resolve_local_template_dir`; the code
  block showed the pre-extraction form.
* "**Two** seams read `_LOCAL_TEMPLATE_ROOTS` and must stay in agreement" was
  the #1759 claim and is now wrong in the direction that matters: there was
  always a third seam (the credential-file stager) which did NOT agree — it
  re-derived the directory from the template's untrusted `name:`. Corrected to
  three, with the extraction as the structural guarantee.
* `generate_credential_files` was cited by stale line range (`:228-299`) and
  documented none of where the `.mcp.json` template is actually located.
  Replaced the fragile line-range citation with a symbol reference and added
  the provenance, the residual fail-closed arm, and the disclosed deploy-local
  substitution delta.

Also documents the read-path containment (`get_local_template` →
`contained_template_dir`) beside the existing #1513 catalog-curation note,
including the deliberate list-vs-detail asymmetry for a planted symlink.

`local-agent-deploy.md` gets one line at the credential-merge step: a
deploy-local template's `.mcp.json` now resolves from the deploy-local
directory, so a hostile `name:` cannot read another tenant's file and
`${VAR}` substitution finally applies to that template.

`credential-injection.md` was checked and NOT touched — its `.mcp.json`
references are unrelated (credential inject/export/import), and the template
lookup lives in template-processing.

One dated row added to the feature-flows index (no new flow document — this is
a fix to documented behaviour, not a new vertical slice).

Tests: adds the end-to-end `test_get_template_rejects_path_traversal` beside
the existing 404 test. Stated plainly in the test's own docstring that this
file is root-level and collected by NO gating CI job — it runs under
`/verify-local` only, so it must not be read as CI coverage. The CI-gated guard
is `tests/unit/test_1900_template_id_traversal.py`. The multi-level case is
percent-encoded because httpx collapses a literal `../../` client-side.

Refs #1900

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

* fix(docs,templates): state the #1900 credential-staging delta honestly (review)

/review + /cso on the #1900 branch. One MEDIUM finding, verified by execution
rather than by reading, plus two comment-accuracy defects.

MEDIUM — the disclosed behaviour delta was described as a gain and is a loss.

Three docs claimed a deploy-local template "now genuinely receives `${VAR}`
substitution". It does not. `generate_credential_files` has exactly one
production caller (`crud._stage_config_files`, verified by grep across src/),
and it passes `agent_credentials={}` — CRED-002 injects real values AFTER
creation, not at staging. So `agent_credentials.get(var_name, "")` rewrites
every placeholder to the empty string. Measured against the shipped code:

    archive .mcp.json : {"env": {"TOKEN": "${MY_TOKEN}", "FIXED": "literal"},
                         "args": ["-y", "${MY_TOKEN}"]}
    staged .mcp.json  : {"env": {"TOKEN": "",           "FIXED": "literal"},
                         "args": ["-y", ""]}

and the staged file WINS over the archive's raw copy, for exactly the
`startup.sh` ordering reason the branch already documents. Net effect for a
deploy-local template shipping a `.mcp.json`: its placeholders are destroyed,
and nothing is substituted in. Non-placeholder content survives verbatim.

This is not a reason to revert the root-cause threading — the `.env` arm of the
same function has always blanked an un-supplied `credentials.env_file`
variable, so blank-at-staging is the platform's model, and `.mcp.json.template`
(compatibility check S-009) remains the durable record of required variables,
pre-populated untouched. It IS a reason to stop advertising it as an
improvement: prose stronger than the code is itself a defect (learnings
2026-07-28), and this one would have shipped into the requirements file.

Corrected in requirements §4.3, template-processing.md and local-agent-deploy.md,
and pinned by a new test so the flattering restatement cannot come back:
`test_1900_staging_with_an_empty_credential_map_blanks_placeholders` asserts the
measured output (`""`, `["-y", ""]`, `${MY_TOKEN}` absent) and that hardcoded
entries are preserved.

LOW — `_LOCAL_TEMPLATE_ROOTS`' own comment still said "Read at TWO seams",
which is the pre-#1900 claim and stale in the direction that matters: this PR
exists because the third seam did not agree. Corrected to three, naming the
extraction as what makes the agreement structural.

LOW — "crud -> template_service is forbidden" (helper docstring + parity-test
docstring) contradicts crud.py:32, which imports this module today. The ban is
on what may be GATED on it (the #1484 MagicMock harness), not on the import
edge; a reader who checks the citation and finds it false is one step from
"helpfully" importing the regex. Narrowed to say so.

Verification performed for this review, beyond the above:
* every REPRO test re-run against reverted sources — 19 of 30 red pre-fix; the
  11 that are green pre-fix are all correctly labelled HYGIENE /
  anti-over-block / landmine-guard, so no test is decorative;
* `test_1900_containment_survives_a_symlinked_root` re-proved as the sole guard
  for resolving both sides (a candidate-only-resolve variant fails it and
  nothing else);
* independent attack harness, 55 hostile inputs through `contained_template_dir`
  and `get_local_template`: zero escapes, zero raises, zero disclosures, and
  every legitimate name still accepted. The only non-None hostile input is the
  documented `"sage\n"` `$`-parity edge, which is CONTAINED;
* symlink loops, a 200 KB name and a regex-backtracking probe: no exception, no
  superlinear time (the endpoint is unauthenticated-adjacent and rate-limitless);
* `startup.sh` re-verified independently: `/generated-creds/.mcp.json` is copied
  at :376-383, gated only on the directory existing, AFTER the
  `.trinity-initialized`-gated template blocks that end at :367.

Refs #1900

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

* docs(tests): state honestly that test_templates.py has no automated runner

The docstring claimed the #1900 traversal assertions "run under
/verify-local". They do not. Every automated stage collects a
subdirectory -- CI runs `pytest unit/`, /verify-local runs `pytest unit/`
then `pytest integration/` -- and this file is root-level, so NEITHER
collects it. The assertions have no automated runner at all.

Corrected to say so, with the manual invocation that does exercise them
(needs a booted backend), a pointer to the CI-gated guard that actually
protects the fix, and a note that giving this file a runner is a
follow-up. Docstring only -- no assertion changed.

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

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…#1849)

- tests/registry.json: entry for tests/unit/test_1849_ede_diagnostic_filtered.py.
- learnings.md: new entry for the durable class — a cross-surface contract
  carried by third-party FREE TEXT has no schema and breaks silently when the
  vendor reformats. Broken twice in six weeks (#1673 -> #1849), green CI both
  times, because no test drove producer -> transforms -> the real consumer
  predicate. Also covers the "raise inside a swallowing parser is a false
  success" and "mirror a vendor's own consumer-side filter" corollaries.
- parallel-headless-execution.md: revision-history row, AND a fix to the stale
  "How Errors Are Classified -> Source 1" section, which quoted pre-#1673 code
  and attributed it to claude_code.py — a file that has not owned this since
  the #122 extraction. Appending a row above a wrong snippet would leave the
  doc self-contradictory, and that snippet is exactly what a future contributor
  reads before "simplifying" the helper.
- session-tab.md: hazard clause on the resume-fallback step. The contract is
  restored, not changed — but it was documented without noting that it depends
  on an agent-side free-text string surviving filter -> join -> sanitize ->
  [:300], which is why it has silently broken twice.

No requirements/ or architecture.md change: this is a bug fix (Rule #4), and
architecture.md documents nothing about result.errors parsing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndriiPasternak31 added a commit that referenced this pull request Aug 2, 2026
…nce examples

Closes ent#128 AC #3-4.

**Reference examples (AC #4).** The substrate the original plan targeted is gone —
`3317247e` deleted `config/agent-templates/cornelius/` in favour of seeding from
the public upstream repo — so AC #4 lands on what the bundle actually has:

  * `scout` / `sage` / `scribe` (the ent#124 seeded trio) declare an explicit
    `credentials: {}` with the zero-credential contract written out. Absent and
    empty mean the same thing to Trinity, but *absent* is ambiguous to a HUMAN —
    it could equally mean the author forgot. `{}` says "considered, and there are
    none", so the catalog's 0-credential badge is trustworthy.
  * `test-codex` carries the enriched reference: its one real variable gets a
    title, description, `required`, `secret`, `format` and `setup_url`.

Deliberately NO `GEMINI_API_KEY` in any example: it is platform-injected, so an
example asking for it would violate the very rule the guide documents — and it
makes a K-002 fixture pass VACUOUSLY, which is how a test proves nothing while
looking green. A test asserts no bundled example asks for a platform-injected var.

Framed honestly rather than oversold: with one enriched declaration and one
names-only one in the bundle, the parity test ("every bundled template normalizes
with zero errors") is thin today. Its value is as a RATCHET for ent#137's curated
fleet.

**The guide (AC #3).** New `## Declaring Credentials` section, TOC renumbered
5→21. Covers the field table, the decorate-don't-declare rule with the actual
error text, why `credentials:` stays names-only, the zero-credential contract,
degrade-don't-demand, the platform-injected list, fork-to-own composition
(ent#109), and the two brace forms Trinity's readers cannot see (`${my-key}`
silently dropped, `${VAR:-default}` mis-substituted to an empty string).

It also states the AUTHOR COST plainly instead of claiming the design is free:
declaring a variable is a separate edit from referencing it in
`.mcp.json.template`, and three of Trinity's own six default GitHub templates
declare zero credentials while referencing 2-6 and documenting 7-12. Those are
K-002-red today and stay red until someone does the edit. Kept that way on purpose
— if `.mcp.json.template` counted as a declaration it would become a second
authority on what an agent needs, which is the drift this design exists to
prevent. The practical order is stated: seed `credentials:` first, enrich second.

**Memory docs.** `requirements/credentials.md` §3.5's ✅ was false in both halves
and is corrected in place with the correction recorded: the extractor it credited
has no production caller, and nothing showed configured-vs-missing status because
the badge read a key no template defines. `template-processing.md` and
`templates-page.md` get the "two shapes, two owners" table that reconciles the
objects-vs-strings contradiction (catalog `required_credentials` = names,
`credential_requirements` = objects, extractor `required_credentials` = a
different function with the same key name), plus the corrected regex. Compatibility
checklist gains six credential rows and a starts-with-nothing-configured row.

**No DB change → Rule #9 (dual-track migration) does not apply.**

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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