Skip to content

Feature/process engine - #10

Merged
vybe merged 69 commits into
mainfrom
feature/process-engine
Jan 20, 2026
Merged

Feature/process engine#10
vybe merged 69 commits into
mainfrom
feature/process-engine

Conversation

@oleksandr-korin

@oleksandr-korin oleksandr-korin commented Jan 19, 2026

Copy link
Copy Markdown
Contributor

Note

Introduces comprehensive process documentation and onboarding content, updates process templates/schema, extends the system agent with a Process Creation Assistant, and tweaks docker config for mounting docs/templates and configurable frontend port.

  • Adds config/process-docs (getting started, patterns, reference, tutorials) with index.json and editor-help.json
  • Extends trinity-system/CLAUDE.md with Process Creation Assistant, YAML schema/patterns, and MCP usage
  • Updates process templates: normalize version: "1.0", replace legacy condition/user_task with gateway/human_approval, and switch outputs valuesource
  • docker-compose: mount config/process-templates and config/process-docs; make frontend port configurable via FRONTEND_PORT
  • Adds platform docs: known issues and multiple process-engine backlogs (access/audit, reliability, onboarding)

Written by Cursor Bugbot for commit 037770d. Configure here.

Implements DDD integration tests for ExecutionEngine with real infrastructure:
- Real SQLite repositories (in-memory)
- Real domain services (DependencyResolver, OutputStorage)
- Real event bus with event capture
- Mock only external boundary (AgentGateway)

Test files (33 tests total):
- test_execution_lifecycle.py (4 tests)
- test_sequential_execution.py (4 tests)
- test_parallel_execution.py (5 tests)
- test_error_retry.py (5 tests)
- test_gateway_routing.py (4 tests)
- test_timer_steps.py (3 tests)
- test_event_publishing.py (5 tests)
- test_output_persistence.py (3 tests)

Infrastructure:
- MockAgentGateway with configurable responses and call tracking
- EventCapturingBus for asserting domain events
- IntegrationTestContext helper for async event handling
- 6 process definition builders
- 3 assertion helpers

Reference: BACKLOG_RELIABILITY_IMPROVEMENTS.md (RI-01 through RI-09)
Implements execution recovery service that runs on backend startup to detect
and recover executions interrupted by restarts (deployment, crash, OOM).

Recovery Actions:
- RESUME: Execution between steps → continue from next pending step
- RETRY_STEP: Step was RUNNING → reset to PENDING and resume
- MARK_FAILED: Execution > 24h old → fail with timeout message
- SKIP: Already in terminal state → no action needed

Implementation (RI-10 to RI-14):

1. ExecutionRecoveryService (services/process_engine/services/recovery.py)
   - recover_on_startup() method called from main.py lifespan
   - RecoveryConfig for customization (max_age_hours, dry_run)
   - RecoveryReport with detailed counts and errors

2. Domain Events (domain/events.py)
   - ExecutionRecoveryStarted: Scan begins
   - ExecutionRecovered: Per-execution recovery action
   - ExecutionRecoveryFailed: Recovery attempt errored
   - ExecutionRecoveryCompleted: Scan complete with summary

3. Backend Integration (main.py, routers/executions.py)
   - Recovery runs during startup lifespan
   - GET /api/executions/recovery/status endpoint for health checks
   - Non-blocking: startup continues even if recovery fails

4. Integration Tests (12 tests in test_execution_recovery.py)
   - test_resume_execution_between_steps
   - test_resume_pending_execution
   - test_retry_running_step
   - test_retry_first_step_running
   - test_mark_failed_old_execution
   - test_mark_failed_custom_age_threshold
   - test_skip_completed_execution
   - test_mixed_batch_recovery
   - test_recovery_continues_on_error
   - test_recovery_events_emitted
   - test_dry_run_mode
   - test_last_recovery_report_stored

Documentation:
- BACKLOG_RELIABILITY_IMPROVEMENTS.md: Added stories RI-10 to RI-14
- PROCESS_ENGINE_ROADMAP.md: Marked IT5 P0 as complete

All 45 integration tests passing.

Reference: PROCESS_DRIVEN_THINKING_IT5.md Section 2.3
Reference: BACKLOG_RELIABILITY_IMPROVEMENTS.md RI-10 to RI-14
- Add ProcessPermission enum (13 permissions) and ProcessRole (5 roles)
- Implement ProcessAuthorizationService with role-based access checks
- Add AuditService with append-only SqliteAuditRepository
- Create audit API endpoints (GET /api/audit - admin only)
- Add ExecutionLimitService (global: 50, per-process: 3 concurrent)
- Integrate authorization into process and execution API routes
- Add limits status endpoint (GET /api/executions/limits/status)
- 64 unit tests for authorization (42) and audit (22)
- Update BACKLOG_INDEX.md and PROCESS_ENGINE_ROADMAP.md

Refs: IT5 Section 5 (Access Management)
Phase 1: Test Agents
- process-echo: Minimal agent with predictable JSON output
- process-worker: Standard agent with file operations
- process-failer: Configurable failure modes for error testing

Phase 2: Test Cases (22 total)
- Tier 1 (T1.1-T1.4): Critical path - single step, sequential, dependencies
- Tier 2 (T2.1-T2.4): Conditional logic - XOR gateway, default route, parallel
- Tier 3 (T3.1-T3.4): Human approval - approved, rejected, timeout, artifacts
- Tier 4 (T4.1-T4.5): Error handling - errors, timeout, retry, skip, cancel
- Tier 5 (T5.1-T5.5): Edge cases - 10-step chain, diamond, nested expr, limits

Phase 3: Results Tracking
- Test run template for documenting findings
- Initial test run document (2026-01-17)

Documentation updates:
- BACKLOG_INDEX.md: Added manual_run reference
- PROCESS_ENGINE_ROADMAP.md: Marked infrastructure ready

Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Test Results (4/4 PASS):
- T1.1: Single agent step (~10s)
- T1.2: Two sequential steps (~20s)
- T1.3: Three steps with dependencies (~50s)
- T1.4: Four steps with parallel execution (~40s)

Fixes applied to test files:
- Changed trigger type 'manual' to 'webhook' (manual not supported)
- Changed version '1.0.0' to '1.0' (only major.minor format)
- Increased timeout from 30s to 120s for agent tasks
- Replaced '/structured' command with natural language request
- Updated CPU resource from '0.5' to '1' (integer required)

Issues discovered:
- CLAUDE.md not auto-injected during deploy-local (needs code fix)
- Slash commands cause empty response in Claude agents

Documentation:
- Updated test-run-2026-01-17.md with full results
- Documented root causes and fixes

Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
When deploying an agent via the deploy-local endpoint, automatically
inject the CLAUDE.md custom instructions into the agent's workspace
if the file is present in the deployment archive.

This ensures that agents deployed locally receive their custom
instructions without requiring a manual injection step.

Changes:
- Added step 12 to deploy.py: check for CLAUDE.md in extracted archive
- If present, POST content to /api/trinity/inject endpoint
- Follows same pattern as credentials hot-reload (with 2s wait)
- Non-blocking: logs warning if injection fails

Discovered during Process Engine manual testing when agents didn't
receive their custom instructions after deploy-local.
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
Test Results:
- T3.1: Human approval (approved) ✅
- T3.2: Human approval (rejected) with on_error:skip_step ✅
- T3.3: Approval timeout ⚠️ (not implemented - needs scheduler)
- T3.4: Approval with artifacts/context ✅

Key Findings:
- Approval API works: GET /api/approvals, POST .../approve, POST .../reject
- Rejection causes step to FAIL by design (use on_error:skip_step)
- Timeout enforcement NOT implemented (deadline set but not checked)
- Approval description can include dynamic context

Known Limitations:
- Issue #5: Approval timeout requires scheduler to enforce
- Issue #6: Rejection = failure (by design, documented)

Running Total: 11/22 tests passing (50%)
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Test Results:
- T4.1: Agent error (AGENT_UNAVAILABLE) ✅
- T4.2: Agent timeout ⚠️ (bug: step status not updated)
- T4.3: Retry policy ✅
- T4.4: Skip on error (on_error:skip_step) ✅
- T4.5: Cancel execution ✅

Key Findings:
- Non-existent agent triggers clean AGENT_UNAVAILABLE error
- on_error: {action: skip_step} works correctly
- Cancel API works immediately

BUG FOUND (Issue #7):
- Step timeout detected (error.code='TIMEOUT')
- But step status remains 'running' instead of 'failed'
- Execution doesn't transition to failed state
- Impact: Timeout processes may hang indefinitely

Running Total: 15/22 tests passing (68%) - ABOVE TARGET ✅
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Test Results:
- T5.1: 10-step sequential chain ✅ (~60s)
- T5.2: Diamond pattern (double fork/join) ✅ (~70s)
- T5.3: Nested expressions ✅
- T5.4: 3 concurrent executions ✅
- T5.5: Recovery test ⚠️ (manual - requires restart)

Key Findings:
- Long chains (10 steps) work without issues
- Complex dependency graphs (diamond pattern) resolve correctly
- Concurrent executions supported
- Average ~6s per agent step

FINAL SUMMARY:
- Total: 19/22 tests PASSING (86%)
- Target was: 14/22 (64%)
- Exceeded target by 22%

All 5 tiers complete!
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
Live testing with human approver:
- Approval flow: ✅ Works end-to-end
- Rejection flow: ✅ Works (triggers skip via on_error)

UI observations noted:
- 'Paused' status not clear it's waiting for approval
- Approve/Reject buttons require extra click to reveal
- No console errors ✅
Interactive Test Results:
- I1: Approval Routes ✅
- I2: Multi-Stage Approval ✅ (revealed output bug)
- I3: Complex Workflow (Gateway + Approval) ✅
- I4: Parallel Work + Approval ✅

NEW BUGS FOUND:
- Issue #8: Approval decision NOT stored in step output
  - Conditions like 'steps.X.output.decision == approved' fail
  - Impact: Cannot route based on approval value

UI ISSUES CONFIRMED:
- Page doesn't auto-refresh when approval step becomes active
- Must manually refresh to see Approve/Reject buttons
- Confirmed across I1, I2, I3, I4
- Skipped steps don't show WHY they were skipped

4 interactive scenarios created for future testing:
- processes/interactive/i1-approval-routes.yaml
- processes/interactive/i2-multi-stage-approval.yaml
- processes/interactive/i3-complex-workflow.yaml
- processes/interactive/i4-parallel-work-approval.yaml
Bug Fixes:
- T4.2 FIXED: Added TIMEOUT to non_retryable_errors
  - Timeouts now cause immediate step failure (no retries)
  - Step status correctly transitions to 'failed'

- T5.5 PASS: Verified ExecutionRecoveryService works
  - Backend restart during execution
  - Recovery service resumed execution successfully

Partial Fix:
- T3.3: Added deadline check in human_approval handler
  - Checks deadline when handler is re-invoked
  - Full enforcement needs background scheduler

Code Changes:
- execution_engine.py: Added TIMEOUT, APPROVAL_TIMEOUT to non_retryable_errors
- human_approval.py: Added deadline expiry check on execute()

Test Results: 21/22 passing (95%)
- Only T3.3 (approval timeout) needs scheduler
- All other tests passing
UI Observations Addressed:
1. 'Paused' status now shows '🔔 Awaiting Approval' when step is waiting_approval
2. Prominent approval alert banner at top of ExecutionTimeline (auto-visible)
3. Added 'Review Now' button in banner to jump to approval step
4. Auto-refresh now includes 'paused' status (needed for approvals)
5. Skipped steps now show WHY they were skipped (reason, error code)
6. Added 'Awaiting Approval' filter option in ExecutionList

Components Updated:
- ExecutionTimeline.vue: Approval banner, skipped reason display
- ProcessExecutionDetail.vue: Enhanced status, paused auto-refresh
- ExecutionList.vue: Display status helpers, filter option
- style.css: Subtle pulse animation for approval alerts
- Show 'Awaiting Approval' for ALL paused executions in list view
- List view doesn't have step data, so can't check waiting_approval status
- Since paused = waiting for approval, this is correct behavior
Implementation of premium onboarding experience and in-app documentation:

**Backlog & Planning (E20-E24)**
- Created BACKLOG_ONBOARDING.md with 17 stories across 5 epics
- Updated BACKLOG_INDEX.md with E20-E24 epics (102 total stories)
- Created feature flow documentation

**E20: Empty States & Quick Wins (Sprint 7)**
- E20-01: Enhanced ProcessList empty state with:
  - Hero section with value proposition
  - Quick-start template cards (3 templates)
  - Create from scratch / import YAML options
- E20-02: OnboardingChecklist component:
  - 5-item progress checklist (3 required, 2 optional)
  - LocalStorage persistence per user
  - Auto-detect completion from API data
  - Collapsible/dismissible UI
- useOnboarding composable for state management

**E21: Documentation Tab (Sprint 8)**
- E21-01: Added Docs tab to ProcessSubNav
- E21-02: ProcessDocs view with:
  - Sidebar navigation with collapsible sections
  - Markdown rendering with syntax highlighting
  - Responsive mobile sidebar
  - Previous/next navigation
- E21-04: Getting started content (3 docs)
- E21-05/E21-06: Reference content (4 docs)
- Backend docs router for content serving

**Documentation Content**
- getting-started/: what-are-processes, first-process, step-types
- reference/: yaml-schema, variables, triggers, error-handling
- troubleshooting/: common-errors

Remaining for future sprints: E21-03 (search), E22 (contextual help),
E23 (guided tours), E24 (wizard)
- Replace 'Start' with 'See above ↑' when already on target page
- Add 'Restart Getting Started' button to Docs page sidebar
- Add confirmation dialog before restart
- Remove debug console.log statements
- Remove hidden keyboard shortcut (replaced by UI option)
- Add notification toast for restart feedback

Refs: BACKLOG_ONBOARDING.md E20-05
- E21-07: Pattern Documentation (sequential, parallel, approvals)
- E21-08: Missing Step Types (notification, sub_process)
- E21-09: Progressive Learning Path with tutorials
- Update story count to 21
- Update dependency graph with new stories

Refs: Review of onboarding coverage gaps
- Getting Started: what-are-processes, first-process, step-types
- Reference: yaml-schema, variables, triggers, error-handling
- Troubleshooting: common-errors
- Backend docs router to serve markdown content

Refs: BACKLOG_ONBOARDING.md E21-04, E21-05, E21-06
- Enhanced empty state with quick-start templates
- Onboarding checklist composable with localStorage persistence
- Auto-detection of completion based on API data
- Feature flow documentation

Refs: BACKLOG_ONBOARDING.md E20-01, E20-02
- Sequential: linear chains, data flow, error handling in chains
- Parallel: fan-out/fan-in, diamond pattern, handling partial failures
- Approvals: single gate, multi-level chains, timeout handling, conditional paths
- Each pattern includes diagrams, complete YAML examples, best practices

Refs: BACKLOG_ONBOARDING.md E21-07
- notification: channels (slack, email, pagerduty), recipients, urgency
- sub_process: process invocation, input_mapping, nested workflows
- Updated overview table with all 6 step types
- Examples for each use case and best practices

Refs: BACKLOG_ONBOARDING.md E21-08
- Level 1: Getting Started (existing - what-are-processes, first-process, step-types)
- Level 2: Intermediate tutorials (NEW)
  - second-process.md: Parallel execution, fan-out/fan-in
  - human-checkpoints.md: Approval gates, routing decisions
- Level 3: Advanced tutorial (NEW)
  - complex-workflows.md: Gateways, multi-path routing, combining patterns
- Updated index.json with tutorials section
- Clear learning progression with prerequisites and 'What's Next' links

Refs: BACKLOG_ONBOARDING.md E21-09
- E21-07: Pattern Documentation ✅
- E21-08: Missing Step Types ✅
- E21-09: Progressive Learning Path ✅

Refs: Sprint 8.5 complete
- Add ./config/process-docs:/app/config/process-docs:ro volume mount
- Enables docs API to serve documentation content

Fixes: 404 errors on /api/docs/* endpoints
- Make restart button amber-colored for better visibility
- Add restart button to mobile sidebar menu
- Minor doc content improvements for clarity
E20-04: First-Run Detection Service
- Enhanced useOnboarding.js with isFirstRun, shouldShowOnboarding
- Added markOnboardingComplete(), dataState tracking
- Auto-detection based on process/execution counts

E22-03: Execution Status Explainers
- Added status explanations with hover tooltips
- Execution-level tooltips in ProcessExecutionDetail
- Step-level tooltips in ExecutionTimeline
- Info icon on status badges

E20-03: Template Cards in Empty State
- Already implemented (verified working)

E22-01: YAML Editor Help Panel
- New EditorHelpPanel component with contextual help
- Cursor position tracking in YamlEditor
- Help content JSON with field descriptions
- Toggle button to show/hide panel
- State persisted in localStorage
- Updated docs router to serve JSON files
- Created onboarding test tier with 35+ test scenarios
- Test categories: UI navigation, empty states, checklist, docs, help panel, status tooltips
- Sample process definitions for testing
- Clear test execution steps and expected results
Only show 'See above ↑' when:
1. User is on the target page AND
2. Prerequisite steps are completed

- runExecution: requires createProcess to be done
- monitorExecution: requires runExecution to be done
- YAML from assistant auto-syncs to editor as it types (no manual apply needed)
- Select code in YAML preview → 'Explain' or 'Edit this' buttons appear in chat
- YamlEditor now emits 'selection-change' events
- Shows 'Auto-synced ✓' indicator instead of 'Apply to Editor' button
- Removed unused manual apply functionality
- Don't ask 'want me to show you?' - just show the updated YAML
- Briefly explain the change, then include the full YAML
- Updated both system agent CLAUDE.md and frontend context
- Pass processStatus prop to ProcessChatAssistant
- Add context about published processes being read-only
- Assistant now tells users to click 'New Version' for published processes
- Also handles archived process status
@oleksandr-korin
oleksandr-korin requested a review from vybe January 19, 2026 17:52
@vybe
vybe merged commit 97fdabb into main Jan 20, 2026
vybe added a commit that referenced this pull request Apr 21, 2026
Closes warning W1 from PR #438 validation — records the `?last-event-id=`
query param, regex gate, REPLAY_GAP_LIMIT ceiling, services/event_bus.py
entry, and updates invariant #10 to name the new transport so future
broadcast sites don't bypass the manager shims.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Apr 21, 2026
#438)

* docs(plan): pivot orchestration sprint to #306 keystone

Adds CLAUDE.md pointer to the orchestration reliability plan and
records the 2026-04-20 revision: pause #294/#291 pending #306, treat
Redis Streams event bus as the keystone for Tier 2.5 simplification,
and gate cleanup collapse on a 2-week push-path soak.

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

* feat: Redis Streams event bus for WebSocket delivery (#306)

Replaces the in-process ConnectionManager + `except: pass` broadcast with
a Redis Streams transport. Reconnects no longer drop events: clients send
`?last-event-id=<stream_id>` and receive missed events via XRANGE catchup
(capped at 5000 entries; trimmed cursors trigger a `resync_required`
marker that the frontend answers with a REST refetch).

Key pieces:
- services/event_bus.py — EventBus publisher (fire-and-forget XADD,
  bounded outbound queue, 10000 MAXLEN env-tunable) + StreamDispatcher
  (one XREAD BLOCK per process, in-memory fan-out, per-client
  asyncio.Queue(256), 3-failure eviction, supervised reader with
  exponential backoff, 2s graceful drain on shutdown).
- main.py — ConnectionManager / FilteredWebSocketManager kept as thin
  shims over the bus so the 33 legacy broadcast call sites don't change.
  /ws and /ws/events accept an optional last-event-id query param
  (regex-validated to `^\d+-\d+$`).
- Frontend WS clients capture `_eid` and replay on reconnect; resync
  handlers refetch authoritative state (agents + activity history +
  pending notifications).
- 23 unit tests: id validation, scope visibility, XADD envelope, fallback
  buffer, eviction, slow-consumer resync, monotonic cursor guard,
  invalid-cursor resync. Plus live roundtrip + reconnect replay verified
  against localhost stack.

Keystone for Tier 2.5 simplification per
`docs/planning/ORCHESTRATION_RELIABILITY_2026-04.md`: #428 (capacity
consolidate), #429 (cleanup collapse), #307 (heartbeat push) will reuse
the same stream primitive; #408 dissolves once agent-push completion
retires the 1h blocking HTTP call. Those are follow-ups — this PR is
WebSocket delivery only.

Closes #306

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

* docs(architecture): document Redis Streams WebSocket transport (#306)

Closes warning W1 from PR #438 validation — records the `?last-event-id=`
query param, regex gate, REPLAY_GAP_LIMIT ceiling, services/event_bus.py
entry, and updates invariant #10 to name the new transport so future
broadcast sites don't bypass the manager shims.

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 Aug 3, 2026
…ership retrofit (ent#109) (#1947)

* fix(lifecycle): one owner for git env across both rebuild paths (ent#109)

`recreate_container_with_updated_config` seeds env from the OLD container and
re-derived only `GITHUB_PAT`, replaying whatever `GITHUB_REPO` / `GIT_SYNC_*`
each container happened to be carrying. The git-env derivation lived only in
`_apply_persisted_auth_env` (`recreate_missing_container`). That split is a
pre-existing fleet-wide bug, not a cosmetic one: the recreate has exactly one
production caller, `start_agent_internal`, which fires on nine config-drift
predicates AND on base-image drift at cold start — so a base-image rebuild
arms the replay for every agent at once.

`_apply_git_env_from_db` is now the single writer. Three load-bearing details:

* **The PAT gate is a parameter, never inherited.** The two paths gate
  differently on purpose. `per_agent_only` (config-drift recreate) preserves
  #211 verbatim — resolve the effective PAT only when the container already
  carries one or a per-agent PAT row exists — so a global-only platform PAT is
  never injected into a previously-tokenless container. A verbatim lift would
  have swapped that for the 2-tier per-agent -> GLOBAL resolver used by
  `effective` (the rebuild-from-nothing path, which has no old container to
  inherit a token from): `configure_push_remote` then clears the push
  blackhole and a tokenless agent can push a private KB to the shared public
  upstream. learnings.md ent#162 names this class exactly.
* **Set-or-clear**, since the recreate writes into a carried-forward dict. A
  deleted `agent_git_config` row pops the whole owned set; a `source_mode`
  flip clears the mode/branch pair. `GITHUB_PAT` alone stays set-only while a
  repo is bound — clearing it would revoke a live agent`s push on an unrelated
  recreate.
* **`GIT_SYNC_AUTO` = DB flag OR baked env**, plus a convergence backfill.
  crud.py`s two writers genuinely disagree (`and not config.ephemeral` sits
  inside a swallowing try/except on the DB side only; the column defaults to
  0), so deriving from `auto_sync_enabled` alone would silently stop auto-push
  for that slice of the fleet. The backfill writes the column the moment the
  disagreement is observed, so the OR retires itself. Making the #389 toggle
  authoritative is a separate follow-up.

ent#123 is preserved: the gate is the REPO, not the PAT, so a tokenless agent
rebuilt after container loss still clones (#843/#1439 silent-empty class).

One deliberate divergence from a verbatim lift, asserted by test: a container
with a baked `GITHUB_PAT` and NO git binding previously had that token
refreshed from the global platform PAT on every recreate; it is now popped.
The per-agent PAT is a column ON `agent_git_config`, so "no row" means no
per-agent credential and no repo to push to by construction.

Tests: tests/unit/test_ent109_git_env_seam.py — each of the four behaviours
proved to have teeth by mutation (un-gate the PAT, flip the call site to
`effective`, derive GIT_SYNC_AUTO DB-only, drop the clear sweep, drop the
source-mode clear, diverge the GIT_SYNC_AUTO literal, unguard the backfill:
all seven go red). Plus a static call-site guard, so flipping either gate
fails CI even though no behavioural test of the helper alone would catch it.

Refs Abilityai/trinity-enterprise#109 (PR 1 of 3)

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

* docs(architecture): _apply_git_env_from_db owns git env on both rebuild paths (ent#109)

Adds the missing agent_service/lifecycle.py catalog entry and records the
per-call-site PAT gate, the set-or-clear contract, and the GIT_SYNC_AUTO
OR-derivation. Amends the ent#123 clause to point at the new shared seam
instead of _apply_persisted_auth_env.

Refs Abilityai/trinity-enterprise#109

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

* test(registry): register test_ent109_git_env_seam.py

Refs Abilityai/trinity-enterprise#109

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

* docs(feature-flows): sync the git env-derivation seam (ent#109)

github-sync.md: retitle the rebuild-recovery section to
"Container-rebuild env — lifecycle.py::_apply_git_env_from_db" and document
the per-call-site PAT gate, the set-or-clear contract, the GIT_SYNC_AUTO
OR-derivation, and the two vars deliberately NOT owned.

git-sync-health.md: GIT_SYNC_AUTO is re-derived on every rebuild as
auto_sync_enabled OR the baked env (the two creation writers disagree), with a
self-retiring backfill; kill-switch row and file table corrected.

agent-lifecycle.md: Revision History row.
feature-flows.md: hand-added Recent Updates row (the skill drops it past ~400
lines). Note: that table is at 56 rows against its stated ~20 cap (#1360) —
pre-existing drift, deliberately not trimmed here.

Refs Abilityai/trinity-enterprise#109

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

* test(#735): re-anchor the lifecycle PAT call-site guard onto _apply_git_env_from_db

ent#109 moved GITHUB_PAT derivation out of the inline block in
recreate_container_with_updated_config (which the guard anchored on via the
comment "Update GITHUB_PAT") into the shared _apply_git_env_from_db. The
guard intent is unchanged and still enforced: that block resolves the
effective per-agent PAT, never the platform-only get_github_pat().

Also fixes a silent-degradation flaw in the guard itself. str.find returns
-1 on a miss, and src[-1:-1+300] slices to an EMPTY string — so a moved
anchor made the guard assert "get_github_pat_for_agent in \x27\x27", failing with no
hint about why. The anchor is now asserted first with a message naming the
fix (re-point it, do not delete it), and the block is sliced to the next
top-level def rather than a fixed byte window.

Both failure modes proved red by mutation: swapping the helper to
get_github_pat() and renaming the anchored function.

Refs Abilityai/trinity-enterprise#109

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

* fix(lifecycle): correct-never-introduce git env; drop the auto-sync backfill (ent#109)

Two defects found reviewing the ent#109 PR 1 env seam.

1. The config-drift recreate blackholed push for agents bound post-creation.
   The repo half of the block is repo-gated (ent#123) while the PAT half keeps
   #211's narrower per-agent gate, and those two disagree for one real row
   shape: an agent bound via POST /{agent}/git/initialize on the GLOBAL
   platform PAT. That path writes an agent_git_config row and pushes, but never
   recreates the container, never bakes git env, never persists a per-agent PAT
   row, and never writes the token into the workspace .env — so its only
   credential is the one embedded in .git/config's origin URL, and startup.sh's
   #1264 fallback does not cover it. Handing startup.sh GIT_SYNC_ENABLED=true
   with no GITHUB_PAT is exactly what it reads as "deliberately tokenless": the
   restart branch rewrites origin to the credential-less CLONE_URL, destroying
   that token, and configure_push_remote blackholes the push remote — silently,
   and fleet-wide on the same base-image drift this helper exists to fix.

   `per_agent_only` now writes the block only when the old container already
   carried GITHUB_REPO or a PAT resolves. It still corrects a stale repo, a
   flipped source_mode and a deleted row — every case the fix is about; a
   tokenless ent#123 agent carries GITHUB_REPO from creation, so the flagship
   is unaffected. `effective` is exempt: with no old container, NOT introducing
   the block is the #843/#1439 silently-empty-agent bug.

2. The GIT_SYNC_AUTO backfill erased an owner's explicit disable.
   PUT /{agent}/git/auto-sync writes the row and nothing else while the agent
   gates on container env, and creation sets both true for the ordinary
   non-source-mode PAT agent — so "baked true / DB 0" is also exactly what an
   owner's disable looks like. The backfill re-enabled it on the next recreate
   and erased the only record of the intent, so the toggle could never stick.
   It was a privilege boundary too: PUT .../auto-sync is OwnedAgentByName while
   POST .../start, which triggers the recreate, is AuthorizedAgentByName — so a
   shared non-owner, or an agent-scoped key resolving to its owner with the
   owner's role (trinity-ops-agent#232), flipped an owner-only flag arming a
   15-minute background commit-and-push loop.

   The OR-derivation stays (crud.py's two creation writers genuinely disagree,
   and DB-only derivation would silently stop auto-push for that slice). The
   write-back is gone; the disagreement is logged. Making the #389 toggle
   authoritative remains the tracked follow-up that retires the OR honestly.

Tests 17 -> 22: a TestIntroduceGuard class (unbaked container untouched,
carried repo still corrected, resolvable PAT still introduces, effective
exempt, clear sweep unaffected) and the derive-only assertion. Both fixes
proved to have teeth by mutation — removing the guard and restoring the
backfill each go red on exactly one test. Two learnings.md entries.

Refs trinity-enterprise#109

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

* test(ent#109): pin the git-env WRITER SET with an AST guard, not a source grep

The previous call-site guard sliced `lifecycle.py` by function header and
counted a single-line literal in each half. Two blind spots:

  1. It pinned only the two KNOWN sites. ent#109's bug WAS that git env had
     two writers and one of them was wrong; a THIRD writer added later on any
     container-seeded path re-opens exactly that hole, and the grep version
     stayed green through a planted `pat_gate="effective"` writer (verified by
     mutation).
  2. `lifecycle.py` names the helper in two comments, so a substring count
     read prose as call sites — the same first blind spot the #1871 guard hit.

The AST walk maps `{enclosing function: pat_gate literal}` and asserts the set
equals exactly `{recreate_container_with_updated_config: per_agent_only,
_apply_persisted_auth_env: effective}`. It also fails loud on a non-literal or
omitted `pat_gate` and on a duplicate call in one function — each of which
would make the guard silently vacuous, which is worse than the leak it guards.

Also drops the stale "convergence backfill" wording from the module docstring
and the registry entry (d8da9d08 removed the backfill; the description still
described it) and re-states the idempotence test as "the DB row is never
mutated".

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

* docs: describe the ent#109 guard as a writer-set gate, not a call-site check

Follow-on to the AST guard: `architecture.md` and the `agent-lifecycle.md`
change log both said the static guard "fails CI if either call site flips",
which understates what it now enforces. It pins the whole writer SET, so a
third writer on any container-seeded path fails CI too — the property that
matters, since ent#109's bug was two writers with one of them wrong.

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

* docs(requirements): add github §11.12 post-creation repo binding (ent#109)

Trinity Rule #1 — requirements before implementation.

§11.12 specifies the "bind to your own repo" retrofit: FR-1 the explicit
supported-row table keyed on source_mode (the column the partial unique index
actually keys on) with named structural refusals for everything else, FR-2
source_mode preserved at 1 so no branch reservation is needed, FR-3 the
destination-scoped fail-closed lock + CAS + compensating restore (never
delete_git_config on a pre-existing row — that is destruction, not rollback),
FR-4 the PAT persisted last, FR-5 the mandatory recreate because startup.sh
rewrites origin unconditionally from baked env, FR-6 owner-only AND human-only
with explicit PAT disclosure, FR-7 the no_write_credentials surfaces.

Also amends §11.11 FR-5: the tokenless push refusal no longer teaches the
create-a-new-agent-and-import workaround.

Refs ent#109

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

* refactor(fork-to-own): extract the shared destination primitive (ent#109 §4.5)

AC #4 asks the post-creation rebind to reuse ent#93's machinery rather than
build a parallel path. The seam is NOT the destination triage lifted whole —
that is not expressible, because the create path's reuse branch IS the
template-tip SHA comparison, interleaved with the triage in one if/elif/else.

So the seam is one level lower: inspect_or_create_destination_repo() reports
created | empty | branches and never decides. Reuse/refuse POLICY stays in
each caller, because the two callers genuinely disagree — the create path
compares against a template tip; the rebind has no template, its content
source is the agent's workspace volume, so any existing branch is a refusal.

validate_destination_pat() is a SIBLING, not folded in: the create path
validates the PAT before resolving the template tip, so 'bad PAT + unreachable
template' reports FORK_PAT_INVALID. Folding it into the inspect primitive
(which runs after the tip resolves) would silently reorder that into a
template error.

Behaviour preservation is asserted, not claimed: the 40 pre-existing
test_fork_to_own.py tests pass unchanged, and both new guards were shown to
have teeth — making the primitive refuse instead of report turns the create
path's SHA-match reuse red, and swapping the validate/resolve order turns the
ordering guard red.

Refs ent#109

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

* feat(git): bind an agent to a GitHub repo you own (ent#109)

POST /api/agents/{name}/git/bind-to-own-repo — create a user-owned repo from a
LIVE agent's current workspace, rebind origin in place, persist the per-agent
PAT, and re-bake the container env so the rebind survives a restart. Plus a
GET .../status companion so a client that eats a proxy timeout can resolve the
outcome from state rather than from a remembered request.

Shape, per requirements §11.12:

- Orchestration in services/agent_service/repo_binding.py, NOT the router
  (Invariant #1). It raises BindError and never HTTPException; the router is a
  thin mapper owning only the two locks, the idempotency claim, and the audit.
- Classification partitions on source_mode — the column the partial unique
  index actually keys on — and refuses every other shape BY NAME rather than
  mis-routing it. Credential state is an orthogonal column and is not used.
- Concurrency: a DESTINATION-scoped lock is the one that serializes the real
  collision (two different agents, one destination repo); the agent-scoped
  lock only guards double-submit. Both FAIL CLOSED with 503 + Retry-After —
  agent_data's fail-open is calibrated for a tar round-trip, not for two repo
  creates and two concurrent recreates of one container.
- The CAS in db.rebind_git_config is the whole commit point, its predicate
  named in the docstring. The loser path restores the captured previous values;
  it never calls delete_git_config, which on a pre-existing row is destruction
  (the next recreate would drop GITHUB_REPO — #843/#1439).
- The PAT is persisted LAST and strictly before the recreate: earlier makes the
  agent look already-writable on a retry, later bakes a repo-bound container
  with no token that startup.sh then blackholes.
- Post-rewire, origin is read back and confirmed — a set-url that exits 0
  without taking effect is exactly the silent mismatch AC #5 forbids.
- Owner-only AND human-only (reject_agent_principal): an agent-scoped key
  resolves to its owner carrying the owner's role, so a role gate alone is
  satisfied by any agent's injected key on a default admin-owned install.

Decision #17 (check_github_repo_env_matches) is deliberately CUT: the only
drift-proof way to build it is to call _apply_git_env_from_db, which turns PR
1's AST writer-set guard red, and idempotent retry already supplies the
convergence it was meant to buy. BIND_RECREATE_FAILED states the retry path
instead of a convergence promise, and warns against a plain restart.

Refs ent#109

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

* test(git): cover the repo-binding commit point, ordering and secret hygiene (ent#109)

31 tests over the properties that would otherwise only be true by inspection:

- Classification: the supported shape succeeds; every other shape is refused
  BY NAME. Two cases asserted rather than argued — an already-writable agent
  is an ORDINARY rebind (the refusal that used to sit there is what made the
  documented retry unreachable), and trinity-system is refused through the
  no-git-config path so it never reaches the recreate that bypasses #1816's
  running-system gate.
- Commit point: a moved row yields 409 with nothing partial, and the
  post-commit loser is RESTORED to its captured previous values. Asserts
  delete_git_config is never called — on a pre-existing row that is
  destruction, and the row is asserted to still exist afterwards.
- Ordering: rebind -> pat -> recreate, proven by recorded call order. A push
  failure persists no PAT; a PAT-persist failure blocks the recreate; and
  fail-at-push -> retry -> success is an explicit regression test for the
  contradiction that a 409-on-retry used to produce.
- The CAS statement runs against a REAL SQLite engine, not a double — the
  predicate is the whole safety argument, so a stub cannot verify it. Includes
  two racers reading the same expected value: exactly one wins.
- Secret hygiene: the PAT is absent from the outcome, the audit dict and every
  error path, and a stale baked token in git output is redacted too.

That last group found a real defect, now fixed: repo_binding composed its
failure messages from foreign text (git output, a docker exception) and
relied on the producer having scrubbed. git_service scrubs what it reads from
a container, but the docker and GitHub exception paths arrive through
libraries that never saw the token. Added _scrub() as a belt at the boundary
where the PAT is actually in scope.

Refs ent#109

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

* fix(git): retire the no_write_credentials create-a-new-agent workaround (ent#109)

ent#230's sharpest AC, which ent#109 omitted: the no_write_credentials
surfaces must point at the retrofit once it exists. Both change together
(Invariant #13):

- git_service.NO_WRITE_CREDENTIALS_MESSAGE (consumed by sync_to_github and
  reset_to_main_preserve_state, mapped 409 in routers/git.py)
- the MCP 409 hint in src/mcp-server/src/tools/git.ts

Neither now teaches 'create a new agent with fork-to-own and import your
data' — an instruction that discards the agent's identity, its 180-day name
reservation and its history. ent#123's carve-out is preserved: this branch
still suppresses the chat_with_agent remedy, because a chat turn cannot
conjure credentials.

The third surface — startup.sh's push-remote blackhole sentinel — is
deliberately unchanged and now asserted as such: it is a git remote URL (one
shell-safe token) that already names a remedy, and editing it would force a
base-image rebuild for cosmetics.

The parity guard was teeth-checked in both directions: reverting the MCP hint
turns it red, AND breaking the source anchor turns it red with a named error
rather than silently asserting against an empty slice — the way a source-grep
guard usually dies.

Refs ent#109

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

* feat(ui): 'Bind to your own repo' panel on the Git tab (ent#109)

A new BindRepoPanel.vue mounted in GitPanel.vue rather than more markup inside
it, for two reasons: GitPanel is already 639 lines, and #1430's raw-color
ratchet is PER FILE — appending a form there would raise counts that may only
shrink. GitPanel's numbers are unchanged at 24 nongray / 146 gray; the new
panel is at ZERO raw non-gray with 51 semantic tokens (its 46 grays are the
contract's own surface/ink vocabulary — there are no Base* primitives in the
repo yet to absorb them).

Design-system contract (read first, per CLAUDE.md rule #10): semantic tokens
only (action-primary / status-success / status-warning / status-danger), both
themes first-class, gray-750 for dark chrome, and no dark:text-gray-500 —
the dark ink floor.

Behaviour worth noting:

- The store method uses raw axios with an explicit 300s timeout, following the
  surrounding idiom. It deliberately does NOT use api.js, whose instance-wide
  30s timeout is far below this call's worst case; aborting the client mid-bind
  strands the user past the commit point with no response, which is the exact
  situation the status endpoint exists to rescue rather than manufacture.
- The PAT is read out of the reactive ref BEFORE the await and cleared
  immediately, so it never lingers regardless of how the request ends.
- A client timeout is reported as PARTIAL, never as a clean failure — the
  request may well have landed.
- Post-commit failures render as 'Partly applied — action needed' in warning
  colour rather than as an error, because the binding genuinely IS saved and
  telling the user it failed would send them looking in the wrong place.
- The restart warning states what happens, what is preserved, and how long.

Both SFCs verified against the real @vue/compiler-sfc (parse + script +
template); npm run check:tokens passes.

Refs ent#109

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

* docs: architecture + feature flow for post-creation repo binding (ent#109)

- architecture.md: two endpoint rows, a Post-Creation Repo Binding subsystem
  block, the two Redis lock keyspaces, repo_binding.py + git_service's new
  primitives in the service catalog, the shared destination seam on the
  fork_to_own entry, and the ent#123 paragraph tail now that its
  no_write_credentials refusal points at the retrofit. PR 1's
  _apply_git_env_from_db prose is already present on this branch and was NOT
  re-added.
- New feature-flows/agent-repo-binding.md: the end-to-end trace, the five
  decisions that carry the design (source_mode partition, destination lock,
  CAS + restore-not-delete, PAT-last, mandatory recreate), the error registry
  with which codes are partial, the ent#93 sharing seam, security, and known
  limits — including why Decision #17's drift predicate was cut.
- feature-flows.md: Recent Updates row added BY HAND (/sync-feature-flows
  drops it past ~400 lines) plus the category-table entry.
- Cross-linked the three affected flows: github-sync.md and mcp-git-tools.md
  had the retired workaround quoted verbatim in their prose, and
  github-repo-initialization.md now names its post-creation sibling and the
  boundary between them.

Refs ent#109

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

* test(git): cover the bind ENDPOINT surface (ent#109)

/update-tests review found the router layer uncovered — its own rule says a
new or changed endpoint needs a caller that exercises the path params and auth
dependency (#1069's 422-every-call class). 26 tests over the five things no
service-level test can see:

- reject_agent_principal really called, and wired in the handler rather than
  merely imported (an agent-scoped key resolves to its owner CARRYING the
  owner's role, so an owner/role gate alone is satisfied by any agent's
  injected key on a default admin-owned install)
- route path-param matches the handler parameter, for both routes
- locks FAIL CLOSED on a Redis outage, on a raising SETNX, and on contention;
  the destination key is case-folded; locks release on success AND failure
- idempotency key is verb-folded; absent header derives nothing; in-flight
  409; completed replay returns the snapshot with X-Idempotent-Replay
- audit on EVERY exit path incl. lock contention and the unexpected 500

Also fixes a regression this work introduced: test_ent123_tokenless_clone.py
asserted the literal retired wording of NO_WRITE_CREDENTIALS_MESSAGE, and I had
not re-run that suite after changing the shared constant. Re-anchored on the
CONSTANT plus the invariant ent#123 actually cares about (named message, still
actionable) — stronger than before, and it cannot drift again; the exact copy
is owned by test_ent109_no_write_credentials_message.py.

Both new guards mutation-verified: deleting reject_agent_principal and making
the lock fail open each turn two tests red.

Full unit suite: 6350 passed, 14 skipped, 0 failed. Identical under random and
fixed order (no sys.modules pollution across the new modules).

Refs ent#109

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

* docs(flows): /sync-feature-flows pass over the ent#109 binding surface

Verification of the hand-written docs against the code found two gaps:

- template-processing.md described the fork-to-own copy pipeline's steps 1-2
  as living inline in fork_to_own.py. They are now the SHARED half
  (validate_destination_pat + inspect_or_create_destination_repo), so a
  reader tracing the code would have found the triage in a different function
  than documented. Updated to name the seam and why it sits one level below
  the triage, with the reuse/refuse policy explicitly still owned by that
  caller. Behaviour there is unchanged.
- The new flow's error registry was missing three codes that ARE reachable on
  the bind path: FORK_DESTINATION_UNREACHABLE (shared primitive),
  BIND_DESTINATION_UNREACHABLE (fail-closed guard-read failure) and
  BIND_UNEXPECTED_ERROR (router catch-all). Verified by diffing the codes in
  the source against the codes in the doc; the seven still absent are
  create-path-only and correctly omitted.

Checked and deliberately NOT changed: git-sync-health.md and
dark-mode-theme.md reference the touched files but document nothing this PR
alters. The Recent Updates table is 66 rows against its own stated ~20 cap —
pre-existing drift (65 before this PR); trimming 46 of other people's entries
is unrelated churn on a feature PR.

Refs ent#109

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

* fix(git): converge the documented bind retry; stop a PAT leaking on rejection (ent#109)

Fixes from /review (C1, I1, I3, I6, I2) and /cso --diff (S1) on PR 2.

── /review C1: every post-commit failure promises an idempotent retry, and all
   four are refused ──────────────────────────────────────────────────────────

The CAS is the commit point, so after it the row names the destination while
the container's origin still names the old repo — and both pre-flight gates
read that skew as a refusal:

  push/rewire fail   -> row moved, origin did not -> 409 BIND_STATE_UNCLASSIFIED
  PAT/recreate fail  -> destination holds our own pushed history
                                                  -> 409 BIND_DESTINATION_EXISTS

The §0.4 class the plan wrote a section to eliminate for the PAT ordering,
re-entering through the classification guard. The vestige was in the signature:
`_classify(agent_name, destination_repo)` never used `destination_repo` — the
carve-out had been designed and not written.

A row already naming the requested destination is now a resumption:

* origin may lag — it never selects what is pushed (step 4 pushes
  refs/heads/<branch> from the workspace by explicit URL, writes origin after),
  and it cannot be tightened anyway: a committed CAS has overwritten the old
  repo name, so "still the old repo" and "something else" are
  indistinguishable, and treating the ambiguity as fatal strands the agent.
* existing branches are accepted — bounded by git, not trust: the push carries
  no --force and no `+` refspec, so unrelated history is rejected
  non-fast-forward and an unrelated branch is untouched.
* previous_repo=None on a resume leaves `upstream` alone instead of repointing
  it at the destination itself, erasing the provenance the rebind preserves.

A mismatch against any OTHER repo stays BIND_STATE_UNCLASSIFIED.

The regression test written for exactly this was green because its double
returned `origin_repo=fake_db.config.github_repo` — the container's observed
state WAS the row, so they could never disagree — and a hand-set
`dest_state = "empty"` stepped around the other gate. The fixture now tracks
the container independently and mirrors the real side effects.

── /cso S1: a GitHub PAT reaches the response body and the platform log ──────

A PAT is sent as `Authorization: Bearer <pat>`, and h11 rejects an illegal
header value by ECHOING it (verified: `LocalProtocolError: Illegal header value
b'Bearer ghp_...\r'`). The validator only checked non-emptiness and returned the
value UNSTRIPPED, so a token carrying a trailing \r or \n — what a paste from a
terminal or clipboard routinely produces — surfaced raw in a 500 body and, via
logger.exception, in the Vector-captured platform log. Trigger is far more often
an ordinary paste than an attacker.

* `models._validate_pat_secret` strips whitespace and rejects anything outside
  printable ASCII, on BOTH BindAgentRepoRequest and ForkToOwnRequest (ent#93's
  create path feeds the same GitHubService constructor).
* That alone would only RELOCATE the leak: Pydantic v2 records the rejected
  value in errors()["input"] and FastAPI returns exc.errors() verbatim — proven
  against a real TestClient. `error_handlers.validation_error_without_input`
  strips `input` from every 422 entry. Dropped for all fields, not for names
  that look sensitive: a name allowlist is the new-producer-missing-from-the-
  consumer's-list class, and the caller already has the value they sent.
* The router catch-all and the PAT-persist log line now scrub, and the
  dual-scrub itself collapses from two copies into one home in
  `utils/credential_sanitizer` (fork_to_own re-exports for its callers).

── Also ─────────────────────────────────────────────────────────────────────

* The bind is `recreate_container_with_updated_config`'s SECOND production call
  site and skipped the `clear_agent_breakers` that `start_agent_internal` runs
  immediately before its own call — both breakers are agent-name-keyed with no
  TTL, so the replacement container inherited its predecessor's verdict
  (#1560). Cleared before the recreate, not after. Two stale "one production
  caller" claims corrected.
* Audit rows on the two idempotency-replay exits, so "exactly once per exit
  path" (#905) is literally true.
* Client timeout resolves against the status endpoint instead of telling the
  user to reload the tab.
* Five test modules registered in tests/registry.json.

Each of the six behaviour fixes was mutation-checked (revert -> red -> restore),
including the breaker clear in both directions (absent, and after the recreate).
Verified: 6332 backtest unit tests pass; the original C1 probe — written before
the fix and unchanged — now reports both post-commit shapes converging; frontend
`vite build` and the design-token check pass; GitPanel's raw-color counts are
unchanged from baseline and BindRepoPanel is at raw_nongray 0.

Refs Abilityai/trinity-enterprise#109

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

* test(git): assert the bind routes are wired to the enumeration-safe deps (ent#109)

Plan §7 lists "uniform 404 for unknown *and* inaccessible agent (Invariant #8)"
as a PR 2 case, and it was the one bullet with no test behind it.

The 404 BEHAVIOUR is not re-tested here — `test_186_enumeration_uniformity.py`
already proves parametrically that both helpers evaluate existence and access
before branching, so nonexistent and inaccessible come back byte-identical.
Re-asserting that would only re-test the shared dependency.

What no dependency-level test can see is whether *this* endpoint routes through
it. So the assertion is the identity of the callable actually bound to
`agent_name` on each route — `get_owned_agent_by_name` on the mutating verb,
`get_authorized_agent_by_name` on the read-only status verb — mirroring the
existing `reject_agent_principal(current_user)` getsource guard: an annotation
that merely looks right in a diff, or a hand-rolled lookup with a 404-then-403
split, is how the enumeration oracle gets reintroduced.

The two scopes are not interchangeable, so both are pinned: swapping them would
either lock a shared reader out of a surface the Git tab already shows them, or
let one rebind an agent they do not own.

Not vacuous: the two dependencies are distinct objects, so binding the wrong one
fails the assertion. Route introspection goes through `route.dependant`, not
`get_flat_dependant` — that symbol drifts in the verify venv.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
getsal pushed a commit to getsal/trinity that referenced this pull request Aug 15, 2026
…ge + raw-color scanner & ratchet baseline

Stands up the frontend design system's written layer per Abilityai#1430:

- docs/memory/design-system.md — system of record: token taxonomy,
  both-theme rules incl. the dark ink ladder, type/spacing/radius scales,
  10-primitive catalog with exact token recipes, the data-loading motion
  standard (scanline beam + wipe reveal; first load animates, background
  refresh is invisible), and 28 UI Construction Principles
- docs/memory/design-system-contract.md — condensed binding contract to
  load before any src/frontend change
- docs/memory/design-system-reference.html — approved visual spec (self-
  contained; both themes; live motion demo)
- src/frontend/scripts/scan-raw-colors.mjs — raw-color scanner aligned
  with check-design-tokens.mjs token families
- src/frontend/raw-color-baseline.json — ratchet seed at dev@5b28999:
  753 raw non-gray / 383 hardcoded colors; counts may only shrink
- CLAUDE.md — Rule of Engagement Abilityai#10 + Memory Files row making the
  design system the mandatory reference for frontend work

Refs Abilityai#1430

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 16, 2026
…at let the table rot (#2207)

Codex reports no cost of its own, so `calculate_codex_cost` IS the number
Trinity records — it feeds execution rows, analytics, cost alerts, and the
loop budget `max_cost_usd` (#1155). A budget is a spend CONTROL, so the stale
table was a control bypass, not a cosmetic metric error.

CODEX_PRICING was stranded on the gpt-5.1 generation, so everything newer
prefix-matched `gpt-5` at $1.25/$10. Measured on a real turn: 3.6x under for
gpt-5.6-sol, and 21x under for gpt-5.5-pro (a model the issue did not list).

The rows were the symptom; the matcher was the defect. `_resolve_pricing`
used a bare longest-prefix `startswith`, which makes every key a catch-all for
future generations — a `gpt-5.7-*` would have resolved to the oldest, cheapest
entry, re-arming the identical bug with this fix already merged. The prefix
match is now boundary-aware: `-` is a variant/date suffix, `.` is a new version.

Deliberately NOT unified with `model_context._FAMILY_PREFIX_WINDOWS`, which
keeps its bare `startswith` because there falling through picks the smallest
window (over-reports usage = safe) while here it picks the cheapest rate
(under-bills = dangerous). Both files now carry a comment saying so.

- Rates restated per 1M tokens, exactly as OpenAI publishes them, so the table
  diffs against the rate card by eye; adds the 5.6 family, 5.5, 5.4, and the
  -pro tiers. Long-context tier (>272K input -> 2x in / 1.5x out) implemented.
- Cache writes are now priced. They were documented as unobservable; a real
  turn showed `cache_write_input_tokens` present, a SUBSET of `input_tokens`
  (the `reasoning_output_tokens` trap again) and 99.97% of that turn's input.
- `default` is the flagship rate, not the cheapest: an unpinned agent reaches
  it on EVERY turn, because `thread.started` carries no model id, while the CLI
  really runs gpt-5.6-sol. The template pins the model for the same reason.
- Context window: the 5.6 family is 1,050,000. The old flat 272K is a PRICE
  break, not a window — conflating them made the gauge read ~3.9x too full.
- `@openai/codex` 0.139.0 -> 0.147.0 (needs a base-image rebuild).

Verified against the live rate card and a live `GET /v1/models`, which also
corrected the issue's headline premise: `gpt-5.1-codex` still resolves for
API-key callers (Trinity's auth mode), so that pin was stale, not broken.

Real chat + task turns completed end-to-end on the rebuilt image and were
priced by Trinity's own parser (AC #10): chat $0.071401, task $0.079468 with
all three input components exercised — both reconcile to the card exactly.

Filed #2208 separately: Codex API-key auth is inert (nothing writes
$CODEX_HOME/auth.json, so every turn 401s). Pre-existing on both CLI versions,
found while verifying this issue, out of scope here.

Fixes #2207

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 16, 2026
…at let the table rot (#2207) (#2209)

* fix(codex): price every current GPT-5.x model, and fix the matcher that let the table rot (#2207)

Codex reports no cost of its own, so `calculate_codex_cost` IS the number
Trinity records — it feeds execution rows, analytics, cost alerts, and the
loop budget `max_cost_usd` (#1155). A budget is a spend CONTROL, so the stale
table was a control bypass, not a cosmetic metric error.

CODEX_PRICING was stranded on the gpt-5.1 generation, so everything newer
prefix-matched `gpt-5` at $1.25/$10. Measured on a real turn: 3.6x under for
gpt-5.6-sol, and 21x under for gpt-5.5-pro (a model the issue did not list).

The rows were the symptom; the matcher was the defect. `_resolve_pricing`
used a bare longest-prefix `startswith`, which makes every key a catch-all for
future generations — a `gpt-5.7-*` would have resolved to the oldest, cheapest
entry, re-arming the identical bug with this fix already merged. The prefix
match is now boundary-aware: `-` is a variant/date suffix, `.` is a new version.

Deliberately NOT unified with `model_context._FAMILY_PREFIX_WINDOWS`, which
keeps its bare `startswith` because there falling through picks the smallest
window (over-reports usage = safe) while here it picks the cheapest rate
(under-bills = dangerous). Both files now carry a comment saying so.

- Rates restated per 1M tokens, exactly as OpenAI publishes them, so the table
  diffs against the rate card by eye; adds the 5.6 family, 5.5, 5.4, and the
  -pro tiers. Long-context tier (>272K input -> 2x in / 1.5x out) implemented.
- Cache writes are now priced. They were documented as unobservable; a real
  turn showed `cache_write_input_tokens` present, a SUBSET of `input_tokens`
  (the `reasoning_output_tokens` trap again) and 99.97% of that turn's input.
- `default` is the flagship rate, not the cheapest: an unpinned agent reaches
  it on EVERY turn, because `thread.started` carries no model id, while the CLI
  really runs gpt-5.6-sol. The template pins the model for the same reason.
- Context window: the 5.6 family is 1,050,000. The old flat 272K is a PRICE
  break, not a window — conflating them made the gauge read ~3.9x too full.
- `@openai/codex` 0.139.0 -> 0.147.0 (needs a base-image rebuild).

Verified against the live rate card and a live `GET /v1/models`, which also
corrected the issue's headline premise: `gpt-5.1-codex` still resolves for
API-key callers (Trinity's auth mode), so that pin was stale, not broken.

Real chat + task turns completed end-to-end on the rebuilt image and were
priced by Trinity's own parser (AC #10): chat $0.071401, task $0.079468 with
all three input components exercised — both reconcile to the card exactly.

Filed #2208 separately: Codex API-key auth is inert (nothing writes
$CODEX_HOME/auth.json, so every turn 401s). Pre-existing on both CLI versions,
found while verifying this issue, out of scope here.

Fixes #2207

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

* docs(architecture): Codex context window is family-dependent, not a flat 272K

The context-window catalog entry stated `Codex→272K` unconditionally. This
branch makes that family-dependent — gpt-5.6 (sol/terra/luna) resolves to the
verified 1,050,000 window, and only the legacy families keep 272K — so the
catalog now names a value that is wrong for the very model this same commit
pins as the test-codex template default.

Corrected in place rather than left for /validate-architecture: the stale
number is the exact confusion #2207 documents (272K is the long-context PRICE
break, not a universal ceiling).

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

* docs: correct the two remaining flat-272K Codex window claims

The context-window catalog is restated in three places besides architecture.md.
The branch already updated agent-runtimes.md and codex-runtime.md; these two
still asserted a single Codex window:

  - feature-flows/model-selection.md — restates the fallback ladder verbatim
  - faq/chat-and-sessions.md — user-facing, said "Codex around 272K"

Both now name the gpt-5.6 family separately. A catalog that disagrees with
itself across four files is how the original stale value survived.

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

---------

Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 16, 2026
…) (#2231)

* refactor(auth): retire slack.py inv8 carve-out — migrate 11 gates to #1310 helpers (#1710)

Migrate all 11 inline auth gates in routers/slack.py onto the existing #1310
imperative guard helpers and delete all 11 `# noqa: inv8` markers, closing the
expiry-less waiver #1310 left behind.

- 3 read gates → assert_agent_access; 8 owner gates → assert_agent_owner.
- Every detail= string preserved verbatim (byte-identical 403 body) — this is a
  wiring consolidation, not a policy change (AC #6). All sites are access-first,
  so they stay 403/self-uniform per Invariant #8 (imperative helpers, not the
  path-dependencies that would flip denial to 404).
- Site #10 (set_slack_channel_proactive) keeps its ent#223
  reject_agent_principal(current_user) human-only guard; site #11 stays
  agent-callable (deliberate grant-vs-send asymmetry).

The helpers add _enforce_connector_scope first, a no-op for the REST flow
(connector keys are already fenced at the auth entry point).

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

* docs(architecture): drop the retired slack.py inv8 deferral clause (#1710)

Invariant #8's permanent-exceptions list no longer names slack.py as a
`# noqa: inv8`-marked deferred follow-up — the carve-out is retired. No new
prose: the invariant already carries "Access-first inline handlers ... stay
403", which documents why the migrated slack sites keep imperative-403.

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

* test(auth): stop calling the noqa hatch "the deferred slack.py sites" (#1710)

The slack.py sites are migrated onto the shared helpers, so no `# noqa: inv8`
marker remains in the tree. Update both docstrings (module + the live-tree
guard) to describe the marker as a general, individually-reviewed escape hatch
rather than a slack-specific deferral. The mechanism (`_line_has_noqa` +
`test_noqa_inv8_suppresses_line`) stays dormant for a future exception.

Guard verified green: 12 passed, zero slack offenders.

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

* test(auth): behavioral proof for the 11 migrated slack gates (#1710)

Add section A9 to the #1310 consolidation suite — the behavioral half the AST
wiring guard is blind to. Over all 11 migrated sites, driving the real
db.can_user_* predicates against a temp schema (db_harness):

- stranger → 403 + the exact per-site detail (catches a forgotten `detail=`);
- a shared (non-owner) reader is ADMITTED on the 3 read gates and DENIED on all
  8 owner gates (catches a mis-classified assert_agent_access/owner);
- owner and admin are never denied by the migrated gate;
- site #10 keeps its ent#223 human-only reject (agent principal → 403 with the
  human-only detail) while site #11 stays agent-callable (grant/send asymmetry).

Verified adversarial: read→owner swap, detail drift, and dropping the site-#10
reject each break the matching test. 46 passed, order-independent across seeds.

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

* test(auth): runtime human-only proof for the site-#10 consent toggle (#1710)

The existing ent#223 guard is source-grep only (`"reject_agent_principal" in
src`) — it catches outright deletion but not a behavioral/ordering regression,
and after #1710 the surrounding gate is a plain assert_agent_owner call the AST
guard can't see. Add a runtime case: an agent-scoped principal calling
set_slack_channel_proactive gets 403 + the human-only detail, with every
post-gate db call (owner check + mutation) made loud to prove the reject fires
first. Keep the source-grep as a cheap backstop.

Verified adversarial: dropping the reject line fails both. 10 passed.

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

* docs(feature-flows): re-sync slack Authorization Checks table with #1710

The migration moved slack.py's auth gates off inline `db.can_user_*` onto the
shared #1310 helpers, so a reader following the doc to slack.py no longer finds
the predicate inline. Update the Authorization Checks Method column to name the
helper (and the predicate it wraps), refresh the stale File:Line values to the
current gate lines, and add a Revision History row. Behavior-preserving — the
403 + detail is unchanged. Index untouched (existing flow, name/desc unchanged).

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

* docs: re-sync auth.md + feature-flows index with the retired slack inv8 carve-out (#1710)

The #1310 follow-up updated architecture.md and slack-integration.md but two
live docs still described the carve-out as active:

- requirements/auth.md still said 'slack.py deferred (10 sites) ... each carries
  a # noqa: inv8 marker' — false after the migration, and it reintroduces the
  exact 'considered exception vs abandoned TODO' ambiguity #1710 exists to kill.
- feature-flows.md index had no #1710 row (the flow doc's own changelog was
  updated, the index was not).

Docs-only; no code change.

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

* test(auth): fix test_1649 slack router fixtures for the migrated owner gate (#1710)

The 3 TestRoutersPersist cases call send_agent_slack_channel_message, now
gated by assert_agent_owner → _enforce_connector_scope, which reads
current_user.connector_agent. The SimpleNamespace fakes lacked that field,
raising AttributeError before can_user_share_agent. Add connector_agent=None
(a real non-connector User carries it); owner admission stays byte-preserving.
Proven load-bearing: the 3 cases fail on the pre-fix fixtures, pass with it.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
vybe added a commit that referenced this pull request Aug 17, 2026
* test(e2e): List-mode spec + five specs adjusted for the retired Agents page (ent#260)

NEW dashboard-list-view.spec.js: system-row render (data-agent hooks, SYSTEM
badge, detail link; deliberately no toggle-visibility assertions — CI's only
agent is the guarded system agent), AC-4 mode persistence, AC-2 redirect
(param stripped, saved mode NOT rewritten), ?onboarding=1 survives the
redirect + opens the wizard, name-filter narrowing + filtered-empty Clear-all
recovery, three-mode round-trip.

smoke.spec.js: Agents nav-link assertion dropped (nav list comment updated);
'agents page loads' becomes the redirect assertion. browser-tab-titles: the
Agents hop removed from the SPA chain (Dashboard → Templates still proves the
client-side repaint); /agents → 'Trinity — Dashboard' joins the redirect-title
test. dashboard-grid-view: header comment (three modes; exact-name toggle
selectors unaffected). dashboard-stats-overflow: 'list' added to MODES — the
third toggle + chassis Create button widen the controls cluster in every mode.
navbar-overflow test 3: the 640px squeeze branch is now conditional on the
MEASURED overflow (4-link OSS bar may fit where 5 links overflowed; an
entitled build still exercises the scroll-recovery branch) — sm=640 is the
floor, below it the link row hides, so narrowing further was not an option.

Full e2e run defers to CI (frontend-e2e on the ui label) — the live local
stack is not exercised from this worktree.

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

* docs: Dashboard List view — architecture block, new feature flow, index + superseded banner (ent#260)

architecture.md: Grid-view paragraph reworded to three modes; new Dashboard
List view block (AgentListPanel extraction, visibleAgents seam ownership,
N+1 deletion, redirect + NavBar consolidation, zero backend change).
NEW feature-flows/dashboard-list-view.md (overview, data-flow diagram,
decisions D1/D2/D7/D8/D11 + the two flagged plumbing calls, teardown
state-loss note, testing). dashboard-grid-view.md mode-count wording.
agents-page-ui-improvements.md superseded banner (kept as history).
feature-flows.md: List-view index row, Agents-page row → superseded pointer,
changelog entry.

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

* docs(flows): repoint cross-cutting flow docs off the retired Agents page (ent#260)

/sync-feature-flows coverage pass over the full branch diff: eleven flow docs
carried live current-state claims about views/Agents.vue (avatar/toggle/meter
usage tables, sync-health dot renderer, system-agent display, tag bulk-ops
entry points, skills-on-start entry, dark-mode coverage row, timeline-view
mode-toggle description). Live references now point at
components/AgentListPanel.vue / the Dashboard chassis; dated changelog entries
and explicitly-labelled historical sections are left as history. Also fixes
dashboard-timeline-view.md's pre-existing stale '[Graph] [Timeline]' toggle
claim to the current three-mode set.

Touched: agent-avatars, agent-lifecycle, agent-tags, autonomy-mode,
autonomy-toggle-component, dark-mode-theme, dashboard-timeline-view,
git-sync-health, internal-system-agent, parallel-capacity, read-only-mode,
skills-on-agent-start.

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

* fix(dashboard): review fixes — Create-button icon-only degrade + gated fetch write-through (ent#260)

Two review-stage fixes on the ent#260 branch:

1. Create Agent label degrades to icon-only below `md` (title +
   aria-label kept). The controls cluster is flex-shrink-0; measured
   against the #1830 stats-ladder constants (agents-only floor 71px),
   the full label at 640px in grid mode (+ the third mode button)
   leaves ~47px — the stats-overflow spec's clip assertion would fire.
   This is the plan's pre-decided degrade, applied ahead of CI.

2. The fetchAgents → agentsStore.agents write-through is now gated on
   no active quick-tag filter (params.tags narrows the response
   server-side; a filtered subset must not clobber the full-fleet list
   agentsStore consumers read — the Executions dropdown's cold-start
   self-heal is length===0-gated and never recovers from a non-empty
   wrong value) and writes a shallow copy (array identity per store,
   shared row objects so in-place status/label patches still propagate).

Docs synced (architecture block, flow doc, agents.js contract comment)
+ a learnings.md entry for the write-through class.

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

* docs(requirements): §9.9 seam wording matches the implemented deviation (ent#260)

The plan's D8 wired both convertAgentsToNodes call sites through the
visibleAgents computed; the implementation deliberately deferred the
timeline wiring to ent#261 (ReplayTimeline :agents prop switch —
rewiring the node paths was rejected as timeline-mutation risk) and the
code comment + flow doc + architecture all record that. §9.9 still
claimed the node-rebuild call sites consume the seam — align it.

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

* refactor(frontend): rename Templates.vue to Library.vue (pure move) (ent#263)

Byte-pure git mv with zero content edits so git rename detection holds
and a parallel edit to Templates.vue (ent#260) resolves as a content
merge inside Library.vue rather than a modify/delete conflict. All
content changes land in follow-up commits.

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

* feat(frontend): Library page + route/nav rename with /templates redirect (ent#263)

- Library.vue: h1 'Library', new subtitle, templates content wrapped in an
  'Agent Templates' section (own loading/error/empty states; inner headings
  demoted h2->h3); fetch migrated to the shared api client (Invariant #7)
- router: /library route (meta.title Library) + /templates function-form
  redirect carrying query AND hash; route name Templates->Library (no named
  pushes exist)
- NavBar: label Library, to=/library, active via startsWith('/library')
- CreateAgentModal: its single raw-axios /api/templates call migrated to the
  shared api client so no half-migrated consumer of the endpoint remains

Page-identity naming only (AC#4 reading): the asset-kind noun 'template'
survives inside the Library (Starter/GitHub Templates sections, Use Template).

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

* feat(frontend): Library skills section — fleet browse over the skills library (ent#263)

- stores/skillsLibrary.js (new): fleet-scoped store, deliberately separate
  from stores/skills.js (KeepAlive-cached AgentDetail means SkillsPanel's
  clear() never fires on nav-away — shared refs would poison the cached tab);
  imports nothing from stores/skills.js. 4-state emptyReason discriminator
  (unconfigured/not_cloned/empty + error carried separately); sync() with a
  180s timeout and ECONNABORTED -> status-refetch (a first clone can outlive
  the 30s api.js default; client timeout != server failure)
- components/LibrarySkillsSection.vue (new): sync-state header leads with
  commit_sha + skill_count (disk-derived; last_sync is per-worker in-memory
  and renders only when truthy); repo URL admin-only, userinfo-stripped,
  labeled 'Primary source', hidden when status.sources reports >1 (#1901
  forward-compat); admin Sync now; per-kind empty states teaching the next
  action; dormant source_name/shadowed_by slots; interpolation only
- components/skills/{SkillContractChips.vue,contract.js} (new): the #183
  contract-chips seam extracted from SkillsPanel so both the per-agent tab
  and the Library browse render package facts from one seam
- SkillsPanel.vue: consumes the shared seam (local SkillMeta/formatBytes/deps
  removed); stores/skills.js untouched
- Library.vue: skills section wired in + header jump anchors (no ?kind=)

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

* test: Library e2e anchors + remove dead template-endpoint tests (ent#263)

- smoke.spec.js: '@smoke library page loads' asserts chrome-only getByRole
  headings (h1 Library + both section headings — the old getByText(/template/i)
  passed on the un-renamed page and proved nothing); new '@smoke templates path
  redirects to library'; stale nav comment fixed (Health/Ops merged in #1109)
- browser-tab-titles.spec.js: nav click asserts 'Trinity — Library'; redirect
  test also covers /templates -> /library title resolution
- tests/test_templates.py: remove TestEnvTemplate + TestTemplateRefresh —
  GET /api/templates/env-template and POST /api/templates/refresh no longer
  exist (router has exactly 2 GET routes; live-stack probe returns 404);
  test_get_template_by_id now exercises the REAL detail endpoint instead of
  the dead env-template detour that always skipped. 5 remaining tests pass
  against the live stack.

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

* docs: Library page — requirements, architecture, feature flow, stale-endpoint rot sweep (ent#263)

- requirements/core-agent.md: new §4.5 Library Page — unified /library surface
  (agent templates + fleet skills browse), query+hash-preserving /templates
  redirect, stacked sections, per-kind empty states, the AC#4 page-identity
  naming rule; fleet assignment visibility named as Not Built
- requirements/skills.md (surgical — §21.3/§22.2/new §22.3 only, avoiding PR
  #1901's §21.1/§21.5 hunks): §21.3 stale 'Skills tab is hidden' note corrected
  (visible since ent#235/PR #1877); §22.2 rewritten as visible/rebuilt; new
  §22.3 Library Page fleet skills browse — browse-only over the existing
  /api/skills/library reads, own skillsLibrary store + the KeepAlive rationale,
  admin-only URL/Sync, #1901 forward-compat, assignment read = Not Built
- architecture.md: 'Top-nav IA — Library (ent#263)' paragraph beside the #1109
  Operations one; stale 'Templates (4 endpoints)' table corrected to the 2 real
  routes (POST /refresh AND GET /env-template both verified absent)
- feature-flows: templates-page.md git-mv'd to library-page.md + full rewrite
  (the old file was deeply stale — AgentSubNav, dead endpoints); index row +
  platform-settings.md Related-Flows link repointed
- template-processing.md + CREDENTIAL_MANAGEMENT.md: dead env-template
  endpoint references removed/replaced (same rot class as the architecture
  table); Templates.vue references repointed at Library.vue
- user docs (creating-agents.md, faq/agents.md): Templates page → Library
  (+ redirect note)

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

* docs(requirements): TGRAM-PROGRESS — Telegram in-progress status indicator (ent#264)

New §15.1h: reaction ack + elapsed-time placeholder + channel-agnostic
start/progress/resolve seam, group gating (mention/reply OR all-mode;
observe stays silent), degradation ladder, default-ON per-binding toggle,
GET /telegram access hardening. §15.1c/§15.1e touch-ups.

Requirements-first per Rules of Engagement #1.

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

* feat(channels): in-flight progress indicator seam + Telegram reaction ack and elapsed-time placeholder (ent#264)

Channel-agnostic start/progress/resolve seam (Invariant #9):

- base.py: default-no-op indicate_progress hook + progress_threshold_seconds/
  progress_interval_seconds capability attrs (None => the router never arms a
  driver, so Slack/WhatsApp/VoIP behave byte-identically).
- message_router.py: router-owned per-turn driver — _arm_progress_driver after
  step 8 (call + arm wrapped so a raising hook can never abort the turn),
  _progress_loop (elapsed origin captured BEFORE the threshold sleep; per-tick
  try/except), _resolve_indicator at all three terminals (cancels AND awaits
  the driver dead, settles the shielded in-flight placeholder send, THEN
  indicate_done — closes the tick-after-resolve and resolve-vs-first-send
  races by construction; carries success for the adapter's neutral fallback
  line), and an idempotent try/finally _cancel_progress_driver backstop.
- telegram_adapter.py: indicate_processing upgraded (single binding read,
  per-turn cfg stash, typing preserved verbatim, 👀 reaction ack — gated on
  the default-ON per-binding toggle and ack-eligibility; whole body never
  raises); NEW indicate_progress (placeholder send-then-edit, message_id
  recorded INSIDE the shielded helper, disable_notification, explicit HTML,
  2-consecutive-failure degraded flag over sends/edits/timeouts alike) and
  indicate_done (clear reaction at every terminal — no success-👍 swap;
  delete placeholder, neutral edit-to-done fallback); fail-soft primitives
  _set_message_reaction/_edit_message_text/_delete_message/
  _send_placeholder_message mirroring _send_message's shape (429 retry_after
  capped 30s; never log the token-bearing URL).
- parse_message stashes progress_ack_eligible: DMs, @mention/reply group
  turns, and `all`-trigger-mode groups; observe mode stays typing-only.
- All per-turn state on NormalizedMessage.metadata (the adapter is a shared
  singleton); decrypt_telegram_bot_token facade passthrough so the hook
  decrypts from the row already in hand.

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

* feat(telegram): per-binding progress-indicator toggle — dual-track migration, API, UI (ent#264)

Default-ON toggle for the in-progress indicator, per-agent == per-binding:

- Dual-track migration (Invariant #3 / Rule #9), all six touch points:
  SQLite `telegram_progress_indicator` in db/migrations.py + Alembic
  0031_telegram_progress_indicator (down_revision 0030; renumber-at-rebase
  rule vs ent#265 applies to whichever PR merges second) + schema.py +
  tables.py DDL + _BINDING_COLUMNS + _row_to_binding. No backfill UPDATE:
  ADD COLUMN ... DEFAULT 1 populates existing rows (default ON is the AC).
- Read predicate evaluated in Python, never SQL (`NULL != 0` is NULL):
  enabled ⇔ `v is None or v != 0` — only an explicit 0 disables.
- db: set_progress_indicator_enabled + set_telegram_progress_indicator facade.
- API: GET /api/agents/{name}/telegram surfaces progress_indicator_enabled
  (pinned in the hand-built response, #1809 lesson — configure PUT too) and is
  access-hardened get_current_user → AuthorizedAgentByName (the response
  carries webhook_url, which embeds the webhook secret — previously readable
  by ANY authenticated user; uniform-404 accessor, Invariant #8). New
  PUT /api/agents/{name}/telegram/progress-indicator — OwnedAgentByName +
  reject_agent_principal (behavior toggles are human-only, ent#223 lesson),
  404 when no binding; dedicated route so toggling never re-sends the token.
- models.py: TelegramProgressIndicatorRequest + response field (Invariant #14).
- UI: TelegramChannelPanel.vue switch (SlackChannelPanel allow_proactive
  precedent), optimistic flip with revert-on-error, dark-mode aware.

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

* docs(architecture): channel-adapter progress-indicator seam deltas (ent#264)

base.py indicate_progress hook + capability attrs; message_router.py per-turn
driver (arm/tick/resolve + backstop, inline-sync coupling named for #1081
pickers); telegram_adapter.py reaction ack + elapsed placeholder + default-ON
per-binding toggle.

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

* test(telegram): progress-indicator unit suite — adapter, router driver, toggle (ent#264)

61 tests, no backend required. Highlights per the plan's scrutiny list:
resolve-vs-first-send race (shielded id recording → delete finds it),
cancel-and-await ordering at both terminal flavors, 429-backoff cancel
promptness, [NO_REPLY]-still-resolves, step-8 wrap regression guard,
singleton metadata isolation, degraded-quiesce after 2 consecutive failures,
static-template-only egress pin (ent#224 class), live-select column test
(4-file schema rule), real-DB facade round-trip (#1533 no-MagicMock),
legacy-table SQLite migration + renumber-safe Alembic-twin existence,
GET JSON field pinning (#1809) + AuthorizedAgentByName hardening pin,
PUT reject_agent_principal 403.

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

* docs(feature-flows): telegram in-progress status indicator section (ent#264)

New section in telegram-integration.md: three fail-soft layers, the
channel-agnostic start/progress/resolve seam, race closures (tick-after-
resolve, resolve-vs-first-send, singleton concurrency), gating matrix
(incl. `all`-mode groups; observe stays typing-only), toggle surface,
degradation ladder / Bot API constraints, restart residual + the inline-sync
coupling named for #1081/#1083 pickers, test inventory. Index unchanged
(existing flow). Revision-history row added.

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

* fix(telegram): access-harden the groups GET; document the bounded settle residual (ent#264)

Review-stage fixes:

- GET /api/agents/{name}/telegram/groups moves from bare get_current_user
  to the uniform-404 AuthorizedAgentByName accessor — the sibling read of
  the panel flow this PR already hardened. Group chat ids/titles/welcome
  text are tenant data; the only follow-up action (the group-message POST)
  was already OwnedAgentByName, so the read tier is strictly broader than
  every usable consumer and no flow narrows (incl. the MCP
  list_channel_groups path, whose paired send is owner-gated).
- Route-dependency test pinning the groups-GET accessor (mirrors the
  binding-GET hardening pin).
- Docs honesty: the resolve-path in-flight settle is bounded at 10s — a
  first placeholder send slower than that (429 retry_after >= 10s) is
  abandoned and can strand a self-dating placeholder. Named in the feature
  flow Residuals + requirements Known residuals instead of implying the
  race is closed unconditionally.

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

* feat(db): source_channel_agent + telegram allow_proactive columns (ent#265)

Dual-track migration (Invariant #3 / #1183): SQLite entry
channel_report_back_columns + Alembic 0031_channel_report_back (single
linear head off 0030), plus db/schema.py + db/tables.py so fresh builds
stay correct.

- schedule_executions.source_channel_agent (nullable TEXT): the agent
  whose channel binding owns the execution's INHERITED context (D1
  Option A). Written only at the /task row-creation point; NULL for
  direct rows (reporter falls back to the executing agent).
- telegram_group_configs.allow_proactive INTEGER DEFAULT 1: per-group
  completion-report consent, default ALLOW for existing AND new groups
  (opt-out mute; no backfill UPDATE needed).
- create_task_execution accepts + inserts source_channel_agent; row
  mapper + ScheduleExecution model surface it.
- AgentRef(schedule_executions.source_channel_agent, KEEP) so
  cascade_rename re-keys the binding agent (D1a); parity-test locks
  consciously updated (_AGENT_ID_COLUMNS + KEEP set).
- Telegram group-config ops carry the flag (columns tuple, row mapper
  default-allow on NULL, update_group_config arm, explicit insert);
  database.py facade adds get_telegram_chat_link and converts the
  group-config passthrough to keyword args (eng M3).
- TelegramGroupConfigResponse/UpdateRequest carry allow_proactive
  (Invariant #14; the response model IS the GET field allowlist).

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

* fix(chat): persist inherited channel context at /task row creation (ent#265 D0)

The ent#224 inheritance wire was severed: run_async_task threaded
_inherited_channel_context's values into execute_task(source_channel=...),
but execute_task writes channel columns ONLY in its no-execution_id
creation branch — and the /task path always pre-creates the row in
create_task_execution_and_activities. Every delegated row carried NULL
channel context at terminal time, so the shipped Slack delegated
report-back was latent dead code on its flagship path.

- Resolve inheritance at the single row-creation point both the async and
  sync /task branches route through (the fork to _dispatch_async/_dispatch_sync
  happens AFTER creation), and persist all four fields on the row itself
  via db.create_task_execution.
- _inherited_channel_context returns a 4-tuple: + source_channel_agent
  (D1 Option A — parent's own binding agent, else the parent's agent name;
  transitive across A→B→C; all-None when the parent lacks source_channel,
  so a channel-less child never carries a dangling agent pointer).
- Provenance guard (security): db.get_execution(parent_id) is a global
  lookup; the inherited identity now also resolves a bot token. Agent
  caller (x_source_agent, already past the SELF-EXEC-001 spoof guard in
  derive_source_and_trigger) must BE the parent's executing agent; human
  caller must have access to the parent's agent. Failure → no inheritance,
  info log — fail-open to no-context, never to someone else's chat.
- Remove the dead source_channel* threading from run_async_task
  (execute_task keeps its channel params — message_router uses them for
  direct turns).

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

* feat(channels): Telegram completion report-back + failure-applier hook (ent#265)

Telegram edition of the ent#224 Slack report-back: a Telegram-triggered
long/delegated task posts its terminal back to the originating chat,
threaded to the triggering message.

channel_completion_report.py rework:
- D10 resolver dispatch map (_CHANNEL_RESOLVERS: slack + telegram);
  SUPPORTED_CHANNELS derives from the map keys, so WhatsApp is additive.
- D1 binding-agent resolution for BOTH channels: consent + bot token
  evaluate against binding_agent = row.source_channel_agent or
  row.agent_name — the bot the user actually addressed delivers (on
  Telegram no other bot even CAN deliver the DM). NULL column = legacy
  fallback, byte-identical pre-#265 behavior. Also fixes Slack's
  delegated case (previously suppressed whenever worker B wasn't bound
  to the originating channel); narrowing direction (channel consented to
  B but not to originating A → now suppressed) is intentional.
- D2 Telegram consent: known group → is_active AND allow_proactive
  (default allow, opt-out mute); known DM chat link →
  consent-by-construction (the user cold-started the bot; a block is a
  403 the send handles gracefully); unknown destination → suppress+log.
- D5 rendering: pre-escape &/</> before _markdown_to_html (unescaped
  `<class 'ValueError'>` trips "can't parse entities" and the strip-HTML
  fallback deletes the substring), then post-conversion re-cap to 4096
  ("message too long" is a 400 the parse-fallback does not catch).
- D6 threading: reply_to_message_id when thread is numeric, DMs too;
  allow_sending_without_reply makes a deleted original safe.
- D1c attribution: Telegram has no per-message sender name — when
  binding_agent != executing agent the head line names the worker.
  Slack keeps username=executing agent (unchanged).
- D9 pin: effect_guard keeps agent_name = the EXECUTING agent
  (row.agent_name), never binding_agent — resolve_and_validate_execution
  fail-opens on mismatch, silently disarming dedup for exactly the
  delegated rows this feature exists for.
- D4: failed send returns False INSIDE the guard (claim completes) —
  at-most-once bias, never blind-retry an ambiguous send.

task_execution_service.py (D3, channel-agnostic — fixes Slack AC#2 too):
the failure applier's CAS-won block emitted the #1578 event but never
the channel report — the path agent-reported failure envelopes take.
Sibling spawn added; CANCELLED envelopes report too (uniform with
_write_terminal_and_gate).

routers/telegram.py: PUT group-config passes allow_proactive through;
the allow_proactive arm ONLY is human-only (reject_agent_principal —
an agent-scoped key resolves to the owner and could self-grant consent,
ent#223's own post-ship pitfall).

TelegramChannelPanel.vue: per-group "Completion reports" opt-out
checkbox in the panel's house idiom (updateGroup passthrough).

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

* test(channels): ent#265 suite + facade passthrough fix caught by it

New tests/unit/test_265_telegram_completion_report.py (41 tests):
- Wired-mock layer: Telegram delivery/consent/rendering decisions —
  delegated group report + threading, DM consent-by-construction,
  muted/inactive/unknown-destination suppression, graceful bot-cannot-post,
  inline-trigger no-double-post, D5 escape+cap, D1c attribution, D1
  binding-agent resolution (telegram + slack) incl. the intentional Slack
  consent-narrowing pin (D1b).
- Chokepoint layer: apply_result failure branch spawns the report on
  CAS-win only (D3), success-branch pin, CANCELLED-envelope report.
- Real-DB layer (db_harness): D0 row READ-BACK inheritance tests (a
  SimpleNamespace mock row cannot see a severed write path — the exact
  reason ent#224 shipped broken), both provenance-guard arms, channel-less
  parent all-NULL, two-hop transitive root binding agent; REAL effect_guard
  replay with source_channel_agent != agent_name (D9/M1 — a binding_agent
  passthrough fail-opens resolution and posts twice → red); fan-out
  one-report-per-child (G3); live column SELECTs through db/tables.py
  metadata; group-config default-allow + kwargs round-trip (eng M3);
  router PUT round-trip incl. agent-principal 403 and the surgical-gate
  proof that trigger_mode stays agent-callable.

Conscious test_224 edits: non-slack scope test → whatsapp-only (telegram
grew a leg); consent fixture keyed on the binding agent.

The read-back tests immediately caught a REAL gap: database.py's
create_task_execution facade passthrough was missing source_channel_agent
(the ops layer had it) — every /task dispatch would have raised TypeError.
Fixed here; the exact severed-wire class D0 exists to kill.

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

* docs(requirements): Dashboard type-to-filter — new §9.10, §9.9 seam note updated (ent#261)

Trinity Rule #1: requirements before implementation. §9.10 specifies the /
hotkey filter across Timeline/Grid/List — store-seam predicate, pre-query node
invariant, pill honesty, Esc layering, chassis query-empty overlay, kbd hint,
list-mode composition, and the deliberate timeline owner-filter behavior change.

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

* feat(dashboard): / type-to-filter across timeline, grid, and list (ent#261)

Store seam (network.js): non-persisted filterQuery + setFilterQuery; the
visibleAgents seam splits into ownerFilteredAgents (tag ∘ owner, pre-query)
and the query-aware visibleAgents (slug + display label via agentDisplayName,
#1642). ALL THREE convertAgentsToNodes call sites now read the pre-query
ownerFilteredAgents — incl. the 30s poll that previously rebuilt from the RAW
list (nodes must never be query-filtered: timeline row enrichment would
degrade after Esc).

Dashboard chassis: document / keydown with guards (defaultPrevented/repeat,
chords, IME, editable targets, open modals) + Firefox quick-find preventDefault;
floating filter pill (open OR active — an applied-but-hidden filter is the
dishonest state) with live 'X of Y match', Esc hint, x button; input-scoped
Esc + gated document Esc backstop (tag-dropdown layered dismissal, native
select skip); Enter blurs and keeps the filter; header kbd / toggle button
(mouse/touch parity); ONE chassis query-empty overlay with panes MOUNTED
underneath; true-empty onboarding CTA branches guarded && !filterActive;
timeline :agents switched to the visibleAgents seam (owner filter now applies
to timeline rows too — deliberate, release-noted); query cleared on unmount.

AgentListPanel: N/M badge suppressed while the chassis query is active (two
disagreeing denominators never render simultaneously). ReplayTimeline:
data-agent test hook on row labels (no logic change).

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

* test(e2e): dashboard type-to-filter spec — 8 tests across all three modes (ent#261)

Covers: grid live-filter + query-empty with the pane MOUNTED + Esc restore
(@smoke); timeline row hiding via the :agents seam; list query-empty preceding
the onboarding CTA + pill-x clear; editable-target guard + literal '/' inside
the pill; kbd-hint toggle; non-persistence across reload; cross-mode filter
survival + the document-Esc backstop after focus wanders; Enter blur-and-keep.

Spec rules per plan: focus-wait before keyboard.type (nextTick focus race);
regex count assertions (/^1 of \d+ match$/ — X is the claim, Y is
environment). Full run defers to CI via the ui label.

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

* docs(flows): type-to-filter folded into the three dashboard flows + architecture note (ent#261)

architecture.md: Grid-view block gains the type-to-filter seam sentence; the
List-view block's seam description now covers all three panes. Flow deltas:
grid — filter rides the absence-as-filtering layout path (pane stays mounted
under the query-empty overlay); timeline — FILTER-001 gains a deliberately
NOT-persisted row + a new ':agents = visibleAgents seam' section naming the
owner-filter behavior change and the pre-query node invariant; list — D8 seam
updated to landed state + the chassis-query ∘ panel-filter composition rules
(badge suppression, overlay precedence). feature-flows.md changelog entry (no
standalone flow doc, per plan D11).

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

* fix(dashboard): review fixes — modal z-order, dual empty-state CTA, Esc scope, structural e2e backstop (ent#261)

Four review findings on the type-to-filter, fixed in place:

- CreateAgentModal was fixed z-10 — UNDER the new z-30 filter pill and z-20
  query-empty overlay, so chassis chrome floated above the open modal.
  Raised to z-50, the house modal tier (SystemViewEditor / OnboardingWizard).
  Safe in all three mounts (wizard renders it v-if-exclusive with its own
  z-50 chrome).
- AgentListPanel's filtered-empty card assumed the panel never mounts with a
  zero-agent prop — no longer true under a chassis query zero-match, so it
  rendered a second contradicting CTA under the query-empty overlay. Gated
  on a non-empty prop; the chassis overlay owns query-zero messaging (the
  behavior the list-view flow doc already described). e2e test 3 now pins it.
- Document-Esc backstop generalized from the select-only guard to all
  editable targets: Esc inside the list panel's search box (or any other
  input/textarea/contenteditable) belongs to that control — it must not
  clear the chassis filter. Pill input unaffected (own handler stops).
  §9.10 wording updated to match.
- e2e test 7's coordinate click (400,250) on the timeline pane could land on
  an agent row/toggle on seeded fleets — replaced with a structural
  pillInput.blur().

npm run build green; spec collects 8 tests.

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

* docs(learnings): overlay-chrome z-order re-pricing + mount-invariant comments (ent#261 review)

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

* docs(learnings): sibling-route access-hardening class from the ent#264 review

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

* docs(flows): sync pass — seam note in skill-assignment, Recent Updates row, dead skills-management link purge (ent#263)

/sync-feature-flows verification pass over the branch diff:
- skill-assignment.md: note that SkillsPanel's package-fact chips now render
  via the shared components/skills/ contract seam (extracted in ent#263)
- feature-flows.md: Recent Updates row for ent#263; removed the stale 'Skills
  Management UI' index row — skills-management.md was split/archived long ago
  (the archive table records it) and the live row pointed at a missing file
- library-page.md: repointed its two skills-management.md links (propagated
  from the stale index row) at skill-assignment.md

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

* fix(library): harden stripUserinfo against silent-no-op and non-parseable URL shapes; share it with SkillsPanel (ent#263 review)

Adversarial testing of the display-layer credential scrubber proved five
leak shapes. Two families: the regex fallback under-matched what new URL()
rejects (git+ssh:// schemes, protocol-relative //user:token@host, leading
whitespace), and — worse — schemeless/scp user:token@host shapes PARSE as
a WHATWG URL with an opaque hostless path where .username/.password
assignment is a silent no-op, so the credential sailed through the success
lane untouched.

- move stripUserinfo to the shared components/skills/contract.js seam
- trust the parsed lane only when host is present AND the strip verifiably
  took (post-assignment username/password empty); otherwise fall through
  to a widened textual scrub (scheme charset [A-Za-z][\w+.-]*, protocol-
  relative, schemeless colon-user, trim)
- variant analysis: SkillsPanel's library_empty state rendered the RAW
  stored URL to any agent accessor — now scrubbed through the same helper
- verified by an executed 26-case suite (23 adversarial leak shapes ALL
  PASS + 3 must-survive-unmangled regressions: plain https, scp
  git@host:path, path-@ preserved)
- durable class captured in docs/memory/learnings.md

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

* docs: purge the second dead skills-management link; cso --diff report (ent#263 review)

- feature-flows.md Archived Flows table pointed at
  archive/skills-management.md, which does not exist (the archive/ dir
  never received it) — same dead-link class d23005af purged from the
  skills index; row now says 'document not preserved' and names the
  dedicated flows it split into
- add the /cso --diff report for the ent#263 review: zero backend
  surface delta; one MEDIUM display-layer scrubber finding (proven,
  remediated in 820895bf with variant coverage); secrets/enterprise-
  disclosure/XSS/supply-chain/CI categories clean

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

* docs(channels): channel report-back flow + requirements — pays the ent#224 debt (ent#265)

- requirements/public-access.md: NEW §15.1h "Channel Completion Report-Back
  (CHANNEL-REPORT — ent#224 Slack, ent#265 Telegram)" — generic mechanism
  (inherited-context-only, D0 row-creation persistence + provenance guard,
  binding-agent resolution, chokepoints incl. D3, effect-guard at-most-once,
  sanitize-before-truncate), per-channel consent units, the two-DM-consent-
  regimes rationale (F6), known v1 limits, the deliberate ungated
  proactive-send scope cut. (§15.1f was already taken by WHATSAPP-001 —
  plan's placement kept, id shifted to h.)
- feature-flows/channel-completion-report.md: NEW flow doc — entry points,
  D0/D3 fixes, chokepoint coverage table with the v1 boundaries
  (lease-reaper, bulk sweeps, pull sink, operator-terminate, restart
  mid-inline-turn, FAILED→SUCCESS resurrection, fan-out per-child,
  pre-migration NULL rows), destination/consent resolution per channel,
  D1 identity design with every rejected alternative, failure modes,
  Testing. Pays the #1763 flow-doc debt (ent#224 shipped undocumented).
- feature-flows.md: Recent Updates row + Collaboration-table entry.
- telegram-integration.md: ent#265 section (column + toggle + pointer) +
  revision row.
- task-completion-events.md: sibling-spawn paragraph (report rides beside
  the #1578 emit at the same CAS-won chokepoints incl. the failure applier).
- architecture.md: services-catalog entry for channel_completion_report.py,
  schedule_executions DDL line for source_channel_agent, telegram DB-module
  line mentions allow_proactive.
- learnings.md: the D0 class — a threaded parameter only one callee branch
  consumes is a severed wire; mock-row suites are blind to it AND to its
  facade-passthrough sibling (caught pre-merge by the row-read-back tests).

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

* fix(channels): key the inheritance provenance guard on the principal, not the header (ent#265)

The D0 provenance guard selected its arm from the raw `X-Source-Agent`
header. The SELF-EXEC-001 spoof guard in `derive_source_and_trigger` only
fires when `current_user.agent_name` is set, so for a human caller that
header is unvalidated client input (routers/chat.py documents the identical
trap for the resume-session IDOR). Setting it to the parent execution's own
agent name — a value the row itself discloses — satisfied the agent arm
trivially and skipped the human arm entirely: any user with access to ANY
agent could POST /task with someone else's `parent_execution_id` and have
that task's terminal reported into the parent agent's Telegram DM or Slack
thread, delivered by a bot binding they do not own.

Second, narrower issue in the same guard: the human arm used
`can_user_access_agent`, which admits share recipients. Posting into a
channel chat is a proactive-send capability and every other proactive
surface is owner-gated (`OwnedAgentByName` for group sends) or
per-recipient-consented (#321); a share recipient can already read the
owner's execution ids (`GET /api/executions` is accessor-scoped), so an
accessor arm let them push a report into the owner's chat.

Fix: arms are selected by the authenticated principal —
- agent-scoped key must BE the parent's executing agent,
- human must OWN the parent agent (`can_user_share_agent`) or be admin,
- connector key (consumption-only, ent#46) never inherits,
- no principal at all refuses.
`x_source_agent` is still passed, now for logging only.

Three regression tests, each verified red against the pre-fix guard.

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

* docs(learnings): header-selected auth-guard arms collapse to the weakest arm (ent#265)

Second instance of a class the codebase already warned about: routers/chat.py
documents the same X-Source-Agent trap for the resume-session IDOR, and the
ent#265 provenance guard reproduced it a few hundred lines away.

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

* docs(design-system): system of record, builder contract, reference page + raw-color scanner & ratchet baseline

Stands up the frontend design system's written layer per #1430:

- docs/memory/design-system.md — system of record: token taxonomy,
  both-theme rules incl. the dark ink ladder, type/spacing/radius scales,
  10-primitive catalog with exact token recipes, the data-loading motion
  standard (scanline beam + wipe reveal; first load animates, background
  refresh is invisible), and 28 UI Construction Principles
- docs/memory/design-system-contract.md — condensed binding contract to
  load before any src/frontend change
- docs/memory/design-system-reference.html — approved visual spec (self-
  contained; both themes; live motion demo)
- src/frontend/scripts/scan-raw-colors.mjs — raw-color scanner aligned
  with check-design-tokens.mjs token families
- src/frontend/raw-color-baseline.json — ratchet seed at dev@5b28999:
  753 raw non-gray / 383 hardcoded colors; counts may only shrink
- CLAUDE.md — Rule of Engagement #10 + Memory Files row making the
  design system the mandatory reference for frontend work

Refs #1430

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

* fix: fleet restart adopts rebuilt base images through the canonical lifecycle path (#1860) (#1912)

* fix: route fleet restart through the canonical lifecycle path so agents adopt rebuilt base images (#1860)

POST /api/ops/fleet/restart stopped/started agents with raw Docker calls,
bypassing start_agent_internal — no config-drift predicate ran and a rebuilt
trinity-agent-base was never adopted on "Restart All" (#1809's cold-start
gate never fired).

- lifecycle.restart_agent_internal(): the canonical stop→cold-start helper
  (explicit stop is load-bearing for the #1809 image predicate; future home
  of #1817's per-agent start lock)
- restart_fleet routes through it; per-agent recreated/recreate_reason
  surfaced via explicit allowlist copy, summary.recreated count
- skips ephemeral ghosts (config predicates aren't ephemeral-gated — a
  recreate would destroy a volume-less ghost workspace, ent#69)
- reject_agent_principal beside assert_admin (the endpoint now replaces
  containers — Invariant #8 escalation rule, #1816 precedent)
- single-flight Redis SETNX lock ops:fleet_restart (409 on contention,
  own-lease refresh, compare-and-delete release, fail-open) — guards the
  client-timeout→retry overlap (#799/#1817 wedge class)
- partial-safe fleet_restart audit entry with a per-agent recreate map
  (restores the entry dropped in 0ec3a7fc); sync cleanup ordered before the
  awaited audit so a shutdown CancelledError can't leave the lock held
- actionable containerless-recovery errors (#1559), context-stats cache
  invalidation, 16 mocked unit tests, flow/architecture docs, learnings,
  CSO diff report (PASS)

Fixes #1860

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

* fix: never flow a raw exception message into fleet-restart results (CodeQL py/stack-trace-exposure)

Per-agent failure rows now carry HTTPException .detail (platform-authored)
or the exception class name only; the full message + traceback go to the
backend log (exc_info). The #1559 containerless recovery hint is preserved.
Tests strengthened into leak regression guards (raw message asserted absent);
flow-doc line refs re-verified.

Refs #1860

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(auth): bulk portal-session revocation primitive + operator client panel (#1902)

* feat(auth): bulk portal-session revocation primitive + operator client panel

The OSS half of an operator kill switch for signed-in portal clients. A client
who signs in through sharing holds a 12-hour portal session with no way to end
it short of a backend restart, which logs out everyone.

`revoke_portal_sessions_for_email(email)` is the edition-agnostic primitive:
OSS owns the mint, the decode and this bulk revoke; the entitled module that
mints portal sessions decides WHEN to call it (the same split as the delegated
mint).

Mechanism is a per-email CUTOFF, not a jti list. `jti` is random per token and
nothing indexes email -> issued jtis, so answering "which tokens does this
address hold?" would need a write-side index maintained at every mint. One
timestamp per email is O(1) to write and read, self-expiring at the max session
lifetime (same bounded-growth property as the #187 blacklist, no sweep), and —
the property that matters — covers every mint path by construction, since they
all go through `create_portal_session_token`. That is the failure mode a
hand-maintained index has and this does not.

`create_portal_session_token` now stamps an explicit `iat` so tokens can be
dated against the cutoff; set there rather than in `create_access_token` so no
other token type's claim set changes.

`decode_portal_session` rejects `iat <= cutoff`. Rounding toward revoking is
deliberate: for a kill switch, a token minted in the same second as the revoke
must die. A token with NO `iat` is treated as revoked — fail closed. Only
sessions minted before this shipped lack one (all expired within
PORTAL_SESSION_EXPIRE_HOURS of the upgrade), and only for an email an operator
actively revoked; letting an undatable token survive an explicit kill switch is
the worse failure.

The Redis read stays fail-OPEN, matching #187 and the platform posture. That is
why revocation is not the whole feature: the durable half lives in the entitled
module and keeps working with Redis down. `revoke_...` returns a bool so a
caller can report honestly instead of claiming a success the operator would act
on.

Frontend: `PortalClientsPanel.vue` on the Sharing tab — per-client log out /
block / unblock with current state. Entitlement-gated (`client_portal`), so an
OSS build renders nothing. Block is hidden for non-admins rather than offered
and 403'd, and the panel reports a failed revoke as a failure rather than
"signed out". It deliberately shows no live-session count: portal sessions are
stateless JWTs with no server-side store, so any number would be a guess.

Related to trinity-enterprise#281

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

* fix(ui): use the real status-danger design token in the portal clients panel

`status-error-*` does not exist — the token family is
success/warning/danger/info/urgent (tailwind.config.js), and
`npm run check:tokens` caught every reference. No visual change intended:
danger is the red alias the invented name was reaching for.

Related to trinity-enterprise#281

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

---------

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

* feat(skills): library lifecycle automation — auto-sync, fleet re-inject, removal-on-unassign (abilityai/trinity-enterprise#236) (#1883)

* feat(skills): library lifecycle automation — auto-sync, fleet re-inject, removal-on-unassign

Closes the three "Not Built" gaps in the skills lifecycle (requirements §21.1/§21.4).
All three default OFF/no-op, so a zero-config install is unchanged.

Removal-on-unassign
- `compute_removal` is `compute_prune` against an empty new manifest, so path
  confinement, `..` rejection, and the cap live in one place and cannot drift.
- `remove_skills` takes the SAME per-agent lock as injection (both mutate
  ~/.claude/skills and read-modify-write CLAUDE.md). Only manifest paths are
  deleted, so agent-authored files and runtime artifacts survive; a directory the
  removal empties is reaped via os.rmdir, which refuses a non-empty dir.
- Wired into BOTH the single DELETE and the bulk PUT — the bulk PUT is the primary
  UI/MCP path and drops skills far more often.
- The DB unassign is authoritative and always succeeds; a stopped agent, busy lock,
  or dead transport degrades to a named `removal_deferred:*`.
- A partially-failed removal keeps the meta AND the .gitignore line: dropping the
  meta strands survivors as unmanaged orphans, and dropping the ignore line lets
  the 15-min auto-sync commit leftover injected files (#1595/#1596 class).

Start-path reconciliation
- The assignment row is gone by the time a stopped agent starts, so removal is
  reconciled, not replayed: the agent's platform-managed skill dirs are diffed
  against the assignment set. No tombstone table, no migration, and every removal
  route converges. Runs after injection and also at zero assigned skills — that is
  exactly the "unassigned the last skill" case.
- Blast-radius guard: >10 removals for one agent refuses wholesale and alarms. A
  wiped agent_skills table is indistinguishable from a mass-unassign, and keeping
  files is the recoverable direction (#1638/#1644).

Scheduled auto-sync + fleet re-inject
- New leader-locked backend service (skills:sync:leader). Backend-hosted because the
  sweep must reach agent containers and the scheduler is platform-network-only.
- Sweeps only when the library commit actually changed; running non-ghost agents;
  force=False so the ent#183 tree-SHA skip makes unchanged skills free; bounded
  concurrency; inject-lock contention is skip-and-report.
- Honest aggregate report in Settings + operator alarm only when an agent failed.
- Config re-read each cycle, so an interval change needs no restart.

Durable sync status
- `_last_sync`/`_last_commit_sha` were per-process; under `--workers 2` the worker
  answering /status was usually not the one that synced, so the panel showed a stale
  timestamp and could never show an error at all. Now mirrored to system_settings on
  both the success and failure branches, and the commit-changed comparison reads the
  durable row (the in-memory field would make every restart look like a change).

Hardening found by /review and /cso on this branch
- The persisted sync error is PAT-scrubbed: sync_library's outer handler passed a raw
  str(e), and the authenticated remote URL is an argument in the subprocess command
  list, so an OSError could carry a token into system_settings and the admin panel.
- Cross-worker lock around the shared clone: scheduled + manual sync both run
  `git fetch` + `git reset --hard` on /data/skills-library. Contention returns 409 and
  writes no status row, so a contended click cannot paint "Last sync failed".
- remove_skills re-reads assignments inside the lock and refuses a still-assigned
  skill, closing the bulk-PUT check-then-act race (#1445 pattern).
- PUT /api/settings/skills-library is human-only (reject_agent_principal): it is the
  on-switch for an unattended fleet-wide write of SKILL.md files, which Claude
  executes as instructions. assert_admin answers "what role", never "is this a
  human" — third occurrence of the trinity-ops-agent#232 class. The remaining half of
  that chain (skills_library_url writable by an agent key via the generic settings
  PUT) is pre-existing and filed as abilityai/trinity-enterprise#293.

Dedicated range-validated GET/PUT /api/settings/skills-library; the three keys are
blocked on the unvalidated generic PUT. 58 new tests.

Fixes abilityai/trinity-enterprise#236

* fix(skills): wire ent#236 tuning vars into both compose files + .env.example

`SKILLS_RECONCILE_MAX_REMOVALS` and `SKILLS_FLEET_INJECT_CONCURRENCY` were read
via os.getenv() but never reached the container — the #1056 / trinity-enterprise#31
packaging class. The reconcile-refusal alarm names the first var in its own
remediation text ("raise SKILLS_RECONCILE_MAX_REMOVALS"), so on a deployed stack
the operator was told to turn a lever that does not exist, leaving a legitimate
mass-unassign permanently blocked at the default cap of 10.

Found by /validate-pr on PR #1883.

* fix(ui): agent tab panel polish — card borders, section spacing, and an honest Sync-now affordance

Three cosmetic fixes plus one UX gap on the agent detail tabs.

- SkillsPanel had no card wrapper, so its content sat flush against the tab
  bar unlike every sibling tab. Wrapped in the same card the other panels use.
- FoldersPanel and InfoPanel each had a `v-else` content wrapper with no
  spacing class, so the root `space-y-6` never reached the cards inside and
  they rendered touching. Same bug in both files.
- SkillsPanel: saving assignments writes the DB rows, but the files only reach
  the container on a sync or the next agent start. That was stated in muted
  text above the button, which is easy to miss — an operator can save, message
  the agent, and be told the skill doesn't exist, because it isn't there yet.
  "Sync now" now goes prominent while that gap is open, and only while the
  agent is running (shouting at a disabled control helps nobody). A failed or
  409-busy sync keeps it lit, since the gap is still open.

* fix(ui): inset the Settings and Skills tab content like every other tab

8 of the agent-detail tab wrappers use p-6 (overview, info, brain, dashboard,
schedules, playbooks, git, folders). Settings and Skills had none, so their
cards ran flush into the enclosing panel's left and right edges instead of
sitting inset like the rest.

* fix(skills): re-clone a library path that is not a repository, and make the panel's failures actionable

Three fixes found while testing this PR on a live instance. All pre-existing on
dev, but each is sharpened by putting sync on an unattended timer.

1. sync_library() chose pull-vs-clone on `library_path.exists()` — directory
   existence, not repo-ness. A path holding no `.git` (clone interrupted by a
   full disk, a stray mkdir, a restored backup) failed `git pull` with "not a
   git repository" on every attempt, with nothing able to re-clone: permanent,
   and recoverable only by shell access. Now tests for `.git` and re-clones.

   Detecting it is only half a fix, since `git clone` refuses a non-empty
   destination — so a non-repo directory is moved aside first. Renamed, never
   deleted: the platform owns the path exclusively so removal would probably be
   safe, but "probably safe" is not the standard for an unattended timer
   deleting a directory derived from an operator-supplied setting (#1638/#1644).
   One quarantine is kept, so a recurring fault cannot grow without bound.

2. syncSkillsLibrary() saves settings first (setting showSuccess), then syncs;
   the catch set `error` without clearing it, so a failed sync showed "Settings
   saved successfully!" and "Clone failed" together. Both true, which is
   precisely what makes the pair untrustworthy.

3. The panel never said a private library needs a GitHub PAT — it is in two
   internal docs only, and what an operator hits is raw git ("could not read
   Username"), which names no remedy and does not hint that the PAT field is
   one section above. Added that line. The error-string mapping is deliberately
   NOT done: matching git stderr is brittle across versions and locales, and
   the raw error is at least surfaced rather than swallowed.

Tests: 5 new (4 fail without the fix). Also fixes 7 existing tests that created
the library path without `.git` — under the corrected predicate they took the
clone path and reached for the network; the suite drops 5.01s -> 0.62s.

* fix: ownership-checked fleet-restart lease with loss detection + acquire inside try/finally (#1919) (#1928)

The per-iteration refresh was a bare EXPIRE gated on a local flag — after a
TTL lapse it extended a concurrent caller's lease while both loops ran. The
refresh is now a pre-action ownership gate (GET-compare via the shared
redis_breaker_util.lock_token_matches): a foreign token stops the run with
honest partial accounting (summary/audit gain processed + stopped_early), an
absent token is re-acquired via SETNX so an unraced run completes instead of
aborting, EXPIRE→0 routes to the absent path, and refresh Redis errors stay
fail-open with one throttled warning per run. list_all_agents_fast() moved
inside the try/finally so nothing can ever leak the lock between acquire and
release; an abnormal exit audits as stopped_early="error" + exception class
name only. TTL 900→2100, sized above the slowest single agent (skill
injection alone is bounded at 1800s) so a mid-agent lapse is no longer
arithmetically guaranteed. Release stays compare-and-delete, attempted even
after detected loss (foreign-safe by construction).

+11 unit tests (27 total in the file); live-validated on the local stack
(foreign takeover mid-run, absent re-acquire to completion, 409 concurrency,
TTL 2098 observed, release verified). Sibling hand-rolled lock sites and the
system_seed_service unconditional release → #1920.

Fixes #1919

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(templates): contain local: template ids and stop deriving credential 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 live…
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