Skip to content

feat: SMARTS trading pipeline with Telegram notifications and Miro visualization - #13

Closed
AndriiPasternak31 wants to merge 14 commits into
Abilityai:mainfrom
AndriiPasternak31:feature/smarts-miro-diagram
Closed

feat: SMARTS trading pipeline with Telegram notifications and Miro visualization#13
AndriiPasternak31 wants to merge 14 commits into
Abilityai:mainfrom
AndriiPasternak31:feature/smarts-miro-diagram

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Feb 6, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds comprehensive SMARTS trading pipeline support:

SMARTS Agent Templates

  • Added 8 new agent templates: market-regime, news-sentiment, discovery, analysis, decision, execution, portfolio-manager, feedback
  • Support for config.yaml format (SMARTS-style agents)
  • System-wide template configuration (system.yaml)

Telegram Notifications

  • Added Telegram to NotificationConfig in process engine
  • New smarts_summary_service.py for generating trading summaries
  • Telegram notification handler integration

API & Scheduler

  • New SMARTS summary endpoints
  • Scheduler support for periodic summaries

Miro Visualization

  • Live flow visualization from Supabase to Miro boards
  • Auto-generated pipeline architecture diagrams
  • Proper HTML formatting (<br> tags) for card text
  • data/smarts-flows/ directory for JSON exports

Other

  • Refactored find_template_file usage across codebase
  • SMARTS cascade deployment script
  • Documentation updates

Test plan

  • Agent templates load correctly
  • Telegram notifications send successfully
  • Miro diagrams render with proper formatting
  • JSON exports contain complete pipeline data

🤖 Generated with Claude Code

AndriiPasternak31 and others added 14 commits February 6, 2026 01:03
- Add scripts/smarts_diagram/ package with:
  - parser.py: Extracts architecture from agent templates
  - miro_generator.py: Generates diagram layout
  - miro_client.py: Miro REST API v2 client
- Add scripts/update_smarts_diagram.py entry point
- Update .claude/commands/update-docs.md with diagram step
- Add MIRO_ACCESS_TOKEN and MIRO_BOARD_ID to .env.example

Usage: python3 scripts/update_smarts_diagram.py --dry-run

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add 8 SMARTS pipeline agents + supporting templates:
- market-regime: Market condition detection
- news-sentiment: News and sentiment analysis
- discovery: Trading opportunity scanner
- analysis: Deep technical analysis
- decision: Position sizing and decisions
- execution: Order execution via Alpaca
- portfolio-manager: Risk oversight
- feedback: Performance tracking

Also includes:
- analyst-agent variants (bull, bear, risk, quant)
- scanner-agent, executor-agent, synthesis-agent
- smarts-trading, smarts-trader-minimal bundles
- gcp-log-monitor utility agent
- system.yaml configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add SMARTS summary service for daily trading reports:
- Pulls data from Supabase integration_context
- Formats comprehensive summaries per agent
- Sends to Telegram with deduplication
- Scheduler for automated daily reports

Add Telegram channel to notification handler:
- Support bot_token and chat_id configuration
- Environment variable fallback support
- Markdown formatting for messages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add new endpoints in ops router:
- POST /api/ops/smarts/summary - Generate and send summary
- GET /api/ops/smarts/test-telegram - Test Telegram connection

Integrate summary scheduler in main.py:
- Start scheduler on app startup
- Stop scheduler on app shutdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add find_template_file() to check template.yaml then config.yaml
- Support both template formats for backwards compatibility
- Add default resources configuration
- Improve credential extraction for SMARTS agents

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update all template loading code to use find_template_file()
for consistent config.yaml support:
- routers/credentials.py
- routers/templates.py
- services/agent_service/crud.py
- services/system_agent_service.py

Also use DEFAULT_RESOURCES constant for consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add Telegram channel support to notification step configuration:
- bot_token: Telegram bot token (supports env var)
- chat_id: Telegram chat ID (supports env var)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add changelog entries for SMARTS features
- Update architecture documentation
- Update roadmap progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Script to deploy all SMARTS agents in cascade order.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add flow_visualizer.py to retrieve complete analysis flows from Supabase
- Create update_smarts_flow.py CLI entry point
- Extract and visualize full decision chain: market_regime → discovery → analysis → decision → execution
- Support symbol-specific and auto-select modes
- Add SUPABASE_URL/SUPABASE_ANON_KEY to .env.example

Usage:
  python scripts/update_smarts_flow.py --symbol AAPL
  python scripts/update_smarts_flow.py --dry-run

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add MiroClient.create_board() for creating new boards via API
- Flow visualizer now prefers MIRO_FLOW_BOARD_ID over MIRO_BOARD_ID
- Update .env.example with separate board IDs for architecture vs flows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove all text truncation from flow cards (show full data)
- Reorganize flow layout:
  - Market Regime at top-left
  - News Sentiment cards stacked vertically (up to 3)
  - Main pipeline in horizontal row: Discovery → Analysis → Decision → Execution
  - PM Directive below pipeline with connectors
- Add visual separator line between architecture and flow sections
- Increase card sizes for better readability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Replace \n with <br> tags for Miro sticky note compatibility
- Add <br> spacers between sections for visual grouping
- All format_*_content() functions now use HTML line breaks
- Cards display with proper section separation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add changelog entry for flow visualization feature
- Create data/smarts-flows/ directory for JSON exports
- Add README with usage instructions
- Gitignore JSON files (contain live trading data)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@AndriiPasternak31 AndriiPasternak31 changed the title feat: SMARTS Miro flow visualization with improved formatting feat: SMARTS trading pipeline with Telegram notifications and Miro visualization Feb 6, 2026
vybe added a commit that referenced this pull request Apr 18, 2026
…idate-architecture

Backend: unregister 7 Process Engine routers (processes, executions, approvals,
triggers, alerts, process_templates, audit) plus the process-docs router; drop
startup hooks for execution recovery and the process-engine WebSocket publisher.
Services under services/process_engine/ remain in place as dormant code.

Frontend: remove 11 process-related routes (/processes, /processes/new,
/processes/docs, /processes/wizard, /processes/:id, /executions, /approvals,
/executions/:id, /process-dashboard) and the dead isProcessSection computed in
NavBar. Keep /alerts and /events legacy redirects to Operating Room.

Docs: correct stale count claims in architecture.md (main.py line count,
router count 45 -> 53, service count 23 -> 37, MCP tool modules 15 -> 16)
and expand database.py scope description to reflect 27 domain op classes.

Skill: expand validate-architecture to detect drift between arch.md and code:
count alignment (D1), scope coherence (D2), enforced MCP parity under #13
(tool module OR '# mcp: none' opt-out), and inline authorization sprawl
detection under #8. Output now includes suggested arch.md edits with line
numbers, not just pass/fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 23, 2026
* feat(mcp): room tools for shared multi-agent sessions (ent#169)

Five tools — create_room, list_rooms, read_room, post_to_room, close_room —
proxying the enterprise rooms API (Invariant #13: tool code ships in the public
server even when the feature is gated).

Thin by design: all authorization is server-side. Membership is the grant, the
acting identity comes from the API key (so an agent-scoped key always posts as
its own agent and cannot post as another participant), and a non-member gets a
uniform 404. Nothing here decides access.

On an OSS build the routes are absent, so the tools return a structured
"shared_sessions_not_enabled" result rather than throwing a transport error at
the agent — verified live against a backend with the module unregistered.

Known gap: TrinityClient.request() takes no headers argument, so post_to_room
cannot send an Idempotency-Key. The endpoint accepts one (Invariant #18 is
satisfied at the boundary); wiring it through the MCP client needs a client
signature change and is left as a follow-up.

Related to Abilityai/trinity-enterprise#169

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

* fix(executions): make triggered_by=room actually filter (ent#169)

_VALID_TRIGGERS is a filter allow-list, and an unrecognised value degrades to
None — i.e. NO filter — so an unlisted trigger silently returns EVERY execution
instead of that trigger's. Observed live before the fix:

    triggered_by=room          -> 14 rows, kinds=['agent','public','room']
    triggered_by=bogus-trigger -> 14 rows, kinds=['agent','public','room']

Identical to a garbage value: the filter lied rather than erroring or returning
nothing. ent#169 posts executions with triggered_by="room", so without listing
it the Sessions UI (ent#170) cannot filter room executions and any user
filtering for them gets silently wrong data.

Adding the string is not an enum/schema change — triggered_by is a plain TEXT
column and this set only gates the query filter.

After: triggered_by=room -> 8 rows, all kinds=['room'].

The broader wart (an INVALID value silently returning everything rather than
422) is pre-existing and left alone.

Related to Abilityai/trinity-enterprise#169

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho pushed a commit that referenced this pull request Jul 24, 2026
…success report (trinity-enterprise#125)

POST /api/systems/deploy is best-effort by default: a per-agent create
failure is collected into failed[] ({name, short_name, template, reason,
status_code}) and the remaining agents still deploy, instead of the first
failure aborting the whole fleet as an opaque 500.

- Tri-state status: "deployed" (all created, 200) / "partial" (some
  failed, 200) / "failed" (none created, 500 with the full report body so
  code-only callers don't read total failure as success); "valid" (dry_run)
  unchanged.
- strict: true restores abort-on-first-error, preserving the failing
  agent's ORIGINAL status code (4xx no longer flattened to 500).
- Post-create config (folders/permissions/schedules/tags) scoped to the
  survivor map, and each phase individually degrades to a warning — a
  config failure after successful creates can no longer void the report.
- trinity_prompt write moved post-loop, gated on >=1 created agent, so a
  totally-failed deploy never mutates the platform-wide prompt.
- failed[].reason normalized (dict detail -> error field), credential-
  sanitized + URL-userinfo-redacted (git errors embed PAT-bearing remote
  URLs, learnings 2026-07-14) + truncated; new shared
  redact_url_userinfo() in utils/credential_sanitizer.
- Warnings: partial deploys flag the _N-suffix duplicate-on-redeploy trap
  (converge deferred to trinity-enterprise#124); orchestrator-workers
  preset with a failed orchestrator flags a possibly non-functional fleet.
- MCP deploy_system: strict param + failed[] response typing (Invariant #13).
- Tests: 14 hermetic unit tests (router mounted alone, collaborators
  patched at the module binding) + 3 integration tests using the
  deterministic pre-side-effect cpu:"3" failure vector.

Follow-up filed: #1759 (absent local: template silently creates a blank
agent — discovered during review).

Prerequisite for trinity-enterprise#124 (first-run seed) and
trinity-enterprise#126 (UI manifest install).

Refs Abilityai/trinity-enterprise#125

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 28, 2026
`dry_run: true` returned `status: "valid"` with zero warnings for a manifest
whose agent referenced a template that cannot resolve — the same manifest then
404s that agent on the real deploy. The preview validated manifest SHAPE only,
so the cheapest mistake to make (a typo'd or renamed template id) was exactly
the one it could not catch.

That gap is worth closing rather than documenting because recovery from a
partial deploy is manual: the deploy path's own warning says re-running the
manifest creates suffixed duplicates of the agents that already succeeded, so
the operator fixes it agent by agent. A preview is the control that avoids
paying that, and #1793/#1759 had just made an unresolvable `local:` template a
hard 404 at create time — validation-time resolution is the matching half.

The preflight reuses the CREATE path's `_resolve_local_template` on a throwaway
config rather than re-deriving "does this template exist", so the preview cannot
drift from the deploy: the reason string and status code are produced by the
same code that will produce them for real. A test asserts that equality
directly.

Scope, deliberately: `local:` is resolved (a filesystem read); `github:` is NOT
probed, since validating it means a network call to GitHub with the platform PAT
on a preview endpoint — slow, rate-limited, and a new outbound call on a path
that had none. A dry run therefore still cannot promise a github-template
manifest deploys, and the tests pin that the resolver is never reached for one.

`status` gains `invalid` for a preview with blockers, matching the deploy path's
existing `partial` / `failed` vocabulary rather than reporting success next to a
populated `failed[]`. `agents_to_create` still lists the full plan. Response
model comment and the MCP `deploy_system` tool description updated (Invariant
#13).

Tests: 4 new cases (flagged failure with the real reason + status code, clean
manifest stays `valid`, github not probed, preview/deploy reason equality),
verified failing 2/2 against the pre-fix path. The ent125 fixture now stubs the
resolver seam, since the preflight reads the real catalog root and pytest has
none.

Verified live against a running instance: the manifest from the issue now
returns `status: "invalid"` with the verbatim 404 reason, where it returned
`"valid"` with no warnings before.

Related to #1841
dolho added a commit that referenced this pull request Jul 30, 2026
…160)

The MCP surface for the A2A interoperability management plane — the third
surface (Invariant #13) over the entitlement-gated enterprise backend
(trinity-enterprise#160, /api/enterprise/a2a/*). Distinct from the runtime
call_a2a_agent (#736).

New src/mcp-server/src/tools/a2a.ts (7 tools):
- get_agent_a2a_config, set_agent_a2a_exposure, get_agent_a2a_card (proxies the
  OSS #737 served-card endpoint), set_a2a_inbound_allowlist,
  register_a2a_endpoint, list_a2a_endpoints, remove_a2a_endpoint.
- Honest gating: an unentitled 403 ("not licensed") / OSS-only 404 return a
  structured { not_entitled | not_found } — never a silent success. Mutations
  are owner/admin + human-only, enforced at the backend (agent-scoped key → 403
  human_only). Outbound credentials are write-only — the backend returns only
  has_credentials, so no tool echoes a secret.

client.ts: 8 A2A methods (getA2AExposedMap swallows OSS-404/unentitled-403 → {}).
agents.ts: list_agents/get_agent best-effort merge a2a_exposed (mirrors
mcp_exposed, #846) — omitted in editions without A2A, no OSS↔enterprise coupling.
server.ts: register the tool group (connector-denied visibility, like the rest).

Tests: src/mcp-server/src/tools/a2a.test.ts (10) — proxy contract, credentials
never echoed, entitlement/human-only/404 gating. Full suite 100 pass; tsc clean.

Related to #160

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
obasilakis added a commit that referenced this pull request Jul 30, 2026
…citations (#848)

Addresses the /validate-pr review on #1707.

BLOCKING — the flag reached neither container. MCP_INLINE_AUTH_ENABLED was
defined in config.py, read in server.ts, documented in .env.example, gated at
the router and covered by a parametrized endpoint test — and wired into zero
compose services. Compose reads .env only for ${...} interpolation, never to
inject into a container, and there is no env_file: or Dockerfile ENV, so the
whole feature was permanently off with no operator lever. Now wired in all four
places (backend + mcp-server x dev + prod), verified with compose's own
resolution rather than grep.

It fails safe, which is why nothing caught it: CI and /verify-local both boot at
defaults, so a flag that can never be enabled boots clean and goes green. The
durable fix is tests/unit/test_848_inline_auth_compose_wiring.py — a static
packaging guard (precedent: test_1489_vite_bug_build_args.py) asserting all four
wirings, the ${VAR:-false} shape (a hardcoded value is the same un-switchable
bug with extra steps; a non-false default would make a network-exposed keyless
path opt-out), and the reader set itself, so a third process reading the flag
without wiring it fails here. Mutation-checked: removing one wiring fails 2.

fastmcp citations were wrong, twice over. The reviewer caught 4.4.0 vs the
pinned 4.8.0; the dev merge has since bumped to 4.12.1, so 4.4.0 was only what a
stale local node_modules held. Re-verified both load-bearing behaviours against
the version package-lock actually resolves — the falsy-auth filter-skip is still
present, so the 87b2abb allow-list hardening is warranted — and corrected every
citation to symbol-first with the line as an "as of" locator, since the minified
chunk filename moves between releases. Also corrected the mechanism: canAccess is
never re-invoked per call; enforcement is by absence from the toolsMap
setupToolHandlers builds from the filtered list.

Per the review, that assumption is now pinned by a test instead of prose:
tool-visibility.test.ts boots a real FastMCP server and drives it with a real MCP
client, asserting a filtered-out tool is not merely hidden from tools/list but
rejected on call AND that its body never executes. Behavioural, so it survives
chunk renames — the churn that made the citations wrong. Mutation-checked
against the pre-#848 deny-check.

Corrected a live error in learnings.md: the entry claiming canAccess filtering
happens "once at session construction, so any log-in-and-new-tools-appear design
needs a client reconnect". toolsListChanged re-filters live sessions and the #846
reconciler fires it every ~20s. The static-surface design stands for the opposite
reason — a login-keyed gate would flip non-deterministically at reconciler
timing. New entry for the compose-wiring class.

Docs (Invariant #13 third-surface record): architecture.md gains routers/mcp_auth.py,
services/mcp_auth_service.py, the tools/auth.ts row, the four /api/internal/mcp-auth/*
endpoints, and the missing `connector` + `anonymous` scope rows. Module counts left
alone — already stale independent of this PR, and reconciling them is
/validate-architecture drift. feature-flows.md gains its Recent Updates row.

Also: MCP_INLINE_AUTH_TIMEOUT_MS documented and wired (mcp-server only — the
backend never reads it, asserted); mcp_inline_auth_enabled surfaced on
GET /api/settings/feature-flags as observability, mirroring its two siblings and
giving operators a post-deploy check that the two halves agree; CSO report
renamed to the cso-diff-DATE-issue convention; CSO N1 (set INTERNAL_API_SECRET
explicitly rather than relying on the SECRET_KEY fallback) carried into
.env.example and requirements §7.6 — there is no unreleased-notes file, and
.env.example is where an operator looks when enabling the flag.

Verified: backend unit 5620 passed / 0 failed (the previously-known pre-existing
test_1474 failure now passes, fixed by the dev merge); mcp-server 127/127 (was
125) and tsc --noEmit clean, both against the real 4.12.1 after npm ci.
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…ult list (#1931)

A fresh install's Library showed 14 local templates — 11 of them the VC
due-diligence demo fleet — plus 6 GitHub repos last pushed Dec-2025/Jan-2026
that no install had ever overridden. The visible catalog is now the 3 starters
we actually stand behind.

  get_local_templates() on the real catalog: 14 -> 3 (sage, scout, scribe)
  GitHub entries on a default install:        6 -> 0
  bundled dirs omitting `hidden:`:           14 -> 0
  bundled dirs total:                        25 -> 25  (nothing deleted)

Catalog
- 11 x `hidden: true` on dd-*/template.yaml, placed and commented to match the
  11 directories that already declare it. Not deleted, not moved: it is a demo
  we still run, `local:dd-lead` stays creatable by id, and the resolver never
  reads `hidden` (verified: get_local_template("local:dd-lead") still resolves).
- 3 x `hidden: false` on sage/scout/scribe — the AC asks a new directory to
  DECLARE its catalog intent, so the declaration has to be mandatory.

- DEFAULT_GITHUB_TEMPLATE_REPOS = []. Every consumer already tolerates it (five
  existing unit tests stub exactly this), and the one path that could have
  regressed does not: recreating an agent made from `github:abilityai/agent-ruby`
  routes through get_github_template, whose `if repo in DEFAULT_...` branch and
  "Dynamic" fallback are byte-identical two-line bodies. Zero behavioural delta.
  Emptying a BROWSE list deletes no data and stops no agent, so the #1638
  "mutable code default read at action time" lesson does not bite here.
  Side-effect, intended: GET /api/templates now makes zero outbound GitHub calls
  on a cold metadata cache, where it previously blocked on up to six.

Still deployable as a set
- config/manifests/vc-due-diligence.yaml, PROMOTED from
  docs/demos/vc-due-diligence/system-manifest.yaml — not authored. The system
  name and short names are load-bearing: deployed names are f"{name}-{short}"
  and dd-lead/CLAUDE.md hardcodes its roster as `vc-due-diligence-dd-*`, so a
  tidier `vc-demo` + `founder` would deploy 11 healthy containers whose Deal
  Lead reaches nobody. Added the nine dd-lead -> specialist permissions (the
  manual post-deploy step the old copy told you to run by hand); dropped
  `prompt:` (overwrites trinity_prompt), `auto_start:`/`folders:` (not read by
  parse_manifest) and per-agent `resources:` (each dd-* template.yaml overwrites
  it at creation). dd-lead is listed FIRST so a creator hitting the default
  10-agent quota loses a specialist, not the orchestrator.
- The old copy gets a SUPERSEDED banner and the demo README points at the new
  location; keeping it (rather than moving) preserves any existing link, and the
  banner is what stops the two drifting.

Tests
- test_1931_catalog_intent.py (new, dependency-free): every bundled directory
  declares `hidden:`, it is a real bool, and the visible set is pinned to
  sage/scout/scribe. Own file so `import yaml` stays out of
  test_local_templates_listing.py's import block, and because
  _build_local_template's `bool(data.get("hidden", False))` destroys exactly the
  present-vs-absent information this asserts on. The RUNTIME default stays
  visible-by-default on purpose — flipping it turns a forgotten key into a
  silent absence, which is the worse failure.
- test_1931_manifest_roster.py (new): validate_manifest over the glob, plus the
  assertion that would have caught the naming trap. It anchors on the SHORT NAME
  (`<prefix>-<short>` in a deployed template's CLAUDE.md must equal that
  manifest's resolved name), not on the manifest's own name — the latter goes
  vacuously green on precisely the rename it exists to catch. Verified to fire
  on both a system rename and a short-name rename, and to find zero false
  positives across all four bundled manifests.
- test_local_templates_listing.py: `dd-` joins the visible-prefix ban;
  test_real_catalog_surfaces_starters_ahead_of_suite renamed and reworked — its
  `if dd_positions:` clause could now only go vacuous, so it asserts the
  priority: 20 mechanism instead, with the ordering clause generalised and
  labelled inert.
- test_ent124_default_system_seed.py: the two on-disk checks widened from
  default-system.yaml to a glob over config/manifests/*.yaml, parametrised so a
  failure names the manifest. They are properties of any bundled manifest.

Docs
- config/agent-templates/README.md: the dd table moves out of "Starter
  templates" into its own "Demo fleet" subsection under "Not starting points",
  both demo manifests are named, and the authoring rule becomes the
  intent-declaration contract.
- mcp-orchestration.md + mcp-server create_agent description (Invariant #13):
  drop the `github:abilityai/agent-ruby` example that will never again appear
  in list_templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…epo list as starting points (#1934)

* docs(templates): requirements + flow delta for the catalog-honesty change (#1931)

Rule #1: land the requirements/flow delta before the code.

requirements/core-agent.md
- §4.1: two bullets — catalog intent is DECLARED, not defaulted (every bundled
  directory must set `hidden:`; the runtime default is deliberately unchanged,
  because flipping it turns a forgotten key into a silent absence); and demo
  fleets ship hidden but stay deployable via a bundled manifest, with the
  system-name/short-name coupling to dd-lead's hardcoded roster spelled out.
- §4.2.1: the shipped GitHub default list is empty and why; None-vs-[] now
  differ only in the `source` badge; `github:owner/repo` create is untouched.
- §4.5: the GitHub-zero empty state — marketplace-first, then the owner/repo
  CTA, then the role-branched curation hint; plus the precedence rule that
  makes it mutually exclusive with the page-level empty state.

feature-flows
- library-page.md: the GitHub-zero placeholder, the 4-row precedence truth
  table, and a #1931 revision-history row.
- template-processing.md: the sort no longer "orders starters ahead of the
  rest" — after this change there is no rest; component is Library.vue.
- mcp-orchestration.md: drop the agent-ruby example that will never again
  appear in list_templates.

No architecture.md change: it documents the mechanism (the `hidden:` filter,
the None-vs-[] fallback, the section render), and no mechanism changes here.
Adding a catalog-contents paragraph would give requirements/core-agent.md §4 a
second home, against that file's own editorial rule #1.

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

* fix(templates): hide the dd-* demo fleet, empty the stale GitHub default list (#1931)

A fresh install's Library showed 14 local templates — 11 of them the VC
due-diligence demo fleet — plus 6 GitHub repos last pushed Dec-2025/Jan-2026
that no install had ever overridden. The visible catalog is now the 3 starters
we actually stand behind.

  get_local_templates() on the real catalog: 14 -> 3 (sage, scout, scribe)
  GitHub entries on a default install:        6 -> 0
  bundled dirs omitting `hidden:`:           14 -> 0
  bundled dirs total:                        25 -> 25  (nothing deleted)

Catalog
- 11 x `hidden: true` on dd-*/template.yaml, placed and commented to match the
  11 directories that already declare it. Not deleted, not moved: it is a demo
  we still run, `local:dd-lead` stays creatable by id, and the resolver never
  reads `hidden` (verified: get_local_template("local:dd-lead") still resolves).
- 3 x `hidden: false` on sage/scout/scribe — the AC asks a new directory to
  DECLARE its catalog intent, so the declaration has to be mandatory.

- DEFAULT_GITHUB_TEMPLATE_REPOS = []. Every consumer already tolerates it (five
  existing unit tests stub exactly this), and the one path that could have
  regressed does not: recreating an agent made from `github:abilityai/agent-ruby`
  routes through get_github_template, whose `if repo in DEFAULT_...` branch and
  "Dynamic" fallback are byte-identical two-line bodies. Zero behavioural delta.
  Emptying a BROWSE list deletes no data and stops no agent, so the #1638
  "mutable code default read at action time" lesson does not bite here.
  Side-effect, intended: GET /api/templates now makes zero outbound GitHub calls
  on a cold metadata cache, where it previously blocked on up to six.

Still deployable as a set
- config/manifests/vc-due-diligence.yaml, PROMOTED from
  docs/demos/vc-due-diligence/system-manifest.yaml — not authored. The system
  name and short names are load-bearing: deployed names are f"{name}-{short}"
  and dd-lead/CLAUDE.md hardcodes its roster as `vc-due-diligence-dd-*`, so a
  tidier `vc-demo` + `founder` would deploy 11 healthy containers whose Deal
  Lead reaches nobody. Added the nine dd-lead -> specialist permissions (the
  manual post-deploy step the old copy told you to run by hand); dropped
  `prompt:` (overwrites trinity_prompt), `auto_start:`/`folders:` (not read by
  parse_manifest) and per-agent `resources:` (each dd-* template.yaml overwrites
  it at creation). dd-lead is listed FIRST so a creator hitting the default
  10-agent quota loses a specialist, not the orchestrator.
- The old copy gets a SUPERSEDED banner and the demo README points at the new
  location; keeping it (rather than moving) preserves any existing link, and the
  banner is what stops the two drifting.

Tests
- test_1931_catalog_intent.py (new, dependency-free): every bundled directory
  declares `hidden:`, it is a real bool, and the visible set is pinned to
  sage/scout/scribe. Own file so `import yaml` stays out of
  test_local_templates_listing.py's import block, and because
  _build_local_template's `bool(data.get("hidden", False))` destroys exactly the
  present-vs-absent information this asserts on. The RUNTIME default stays
  visible-by-default on purpose — flipping it turns a forgotten key into a
  silent absence, which is the worse failure.
- test_1931_manifest_roster.py (new): validate_manifest over the glob, plus the
  assertion that would have caught the naming trap. It anchors on the SHORT NAME
  (`<prefix>-<short>` in a deployed template's CLAUDE.md must equal that
  manifest's resolved name), not on the manifest's own name — the latter goes
  vacuously green on precisely the rename it exists to catch. Verified to fire
  on both a system rename and a short-name rename, and to find zero false
  positives across all four bundled manifests.
- test_local_templates_listing.py: `dd-` joins the visible-prefix ban;
  test_real_catalog_surfaces_starters_ahead_of_suite renamed and reworked — its
  `if dd_positions:` clause could now only go vacuous, so it asserts the
  priority: 20 mechanism instead, with the ordering clause generalised and
  labelled inert.
- test_ent124_default_system_seed.py: the two on-disk checks widened from
  default-system.yaml to a glob over config/manifests/*.yaml, parametrised so a
  failure names the manifest. They are properties of any bundled manifest.

Docs
- config/agent-templates/README.md: the dd table moves out of "Starter
  templates" into its own "Demo fleet" subsection under "Not starting points",
  both demo manifests are named, and the authoring rule becomes the
  intent-declaration contract.
- mcp-orchestration.md + mcp-server create_agent description (Invariant #13):
  drop the `github:abilityai/agent-ruby` example that will never again appear
  in list_templates.

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

* feat(library): teach the next action when there are no GitHub templates (#1931)

With DEFAULT_GITHUB_TEMPLATE_REPOS emptied, a default install has zero GitHub
templates — and the section was wrapped in `v-if="githubTemplates.length > 0"`,
so it silently VANISHED. requirements §4.5 promises "per-kind empty states
teach the next action"; that promise was being honoured only by accident,
because the count had never been zero before.

Library.vue
- New `noTemplatesAtAll` computed over the WHOLE /api/templates response (both
  sources). The section now renders on `!noTemplatesAtAll` — not
  `githubTemplates.length > 0 || !noTemplatesAtAll`, whose first disjunct is
  provably dead since githubTemplates is a filter over templates. The
  page-level "No templates configured" block is UNCHANGED and keeps sole
  ownership of the wholly-empty case; the 4-row truth table is inlined at the
  computed, and exactly one empty state renders in every row.
- Placeholder card, marketplace-first (operator decision): the
  abilityai/abilities marketplace + create-agent wizards lead, because they
  exist today and a fresh install's Settings panel does not. Then the
  secondary "already have a repository?" action, then the curation hint.
- The CTA is `useTemplate({ id: 'github-custom' })`, CreateAgentModal's own
  sentinel for the free-form owner/repo option — deliberately NOT
  `useTemplate(null)`, which is byte-identically the Blank Agent button two
  sections up and would land the user on the wrong option under a different
  label. The sentinel arrives via the existing `initial-template` prop and is
  explicitly exempted from that component's unknown-template reset, so no
  CreateAgentModal change is needed.
- Only the curation hint branches on role (`useRole()`), mirroring
  LibrarySkillsSection.vue on this same page — the templates half must not
  ship the opposite convention to the skills half. The ACTION is offered to
  both roles so a non-admin is never left holding only an admin-only path.
- Tag-along: the page-level hint said "Configure GitHub templates in config.py",
  which is not an operator surface and is now empty by design.

Settings.vue — the destination must not dead-end
  The Library's own hint sends an admin to Settings → GitHub Templates, where a
  fresh install said "…or reset to defaults" next to a Reset button that is
  :disabled in exactly that state and would now reset to the same empty list.
  This change would otherwise satisfy the AC on the Library and newly violate
  it one click away. Two strings: drop the impossible action from the empty
  row, and stop badging an empty set as "Using defaults".

Verified: `npm run build` clean. No e2e spec asserts any changed string —
smoke.spec.js matches the headings 'Library' / 'Agent Templates' with
exact: true (the new card's heading is "No GitHub templates configured"), and
settings-tabs.spec.js only route-mocks /api/settings/github-templates.

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

* test(templates): prove the empty GitHub default list is a zero-delta change (#1931)

/update-tests + /sync-feature-flows tail pass.

The riskiest claim in this change was verified only by READING: that emptying
DEFAULT_GITHUB_TEMPLATE_REPOS cannot break an existing agent created from
`github:abilityai/agent-ruby`, because get_github_template's `if repo in
DEFAULT_...` branch and its "Dynamic" fallback are byte-identical two-line
bodies. That is the sibling-path rule — a guard proven on one codepath has to
be proven on every path reaching the same behaviour — so it is now proven by
RUNNING:

  test_1931_empty_github_defaults.py
  - an unconfigured github: id still resolves with the list empty
  - the emptied and configured paths return the IDENTICAL template dict
  - get_all_templates() with [] makes ZERO outbound metadata fetches
    (the intended side-effect: no ThreadPoolExecutor, no HTTP, no PAT read)
  - counter-test: a CONFIGURED repo is still fetched, so the assertion above
    cannot go vacuously green if fetching breaks entirely
  - the shipped constant is []

Verified to fail when the list is refilled. `tests/lint_sys_modules.py` green
(monkeypatch.setitem, no bare sys.modules assignment); order-independent
(_metadata_cache cleared per load, since it is module-global and survives).

Flow docs — two surfaces the plan had not named:
- platform-settings.md (TMPL-001): the section said admins configure repos
  "replacing the hardcoded config.py list" and that no-DB-config falls back to
  "[...from config.py...]". Both now misleading. Records the empty default,
  that None and [] produce the same catalog and differ only in the badge, that
  Reset-to-Defaults reverts to empty (and is already :disabled there), that
  create capability is untouched, and the zero-outbound-calls side-effect.
- system-manifest.md: a new bundled manifest is DATA, NOT A TRIGGER
  (BUNDLED_MANIFEST_PATH is hard-coded to default-system.yaml; nothing globs
  the directory) — the basis on which an 11-agent / ~40 GB manifest was safe to
  add. Plus the ent124 glob widening and the new roster guard.
- feature-flows.md: one Recent Updates row.

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

* test(templates): scan every prompt surface for a hardcoded roster (#1931)

Review findings on this branch's own new tests.

1. The roster guard walked `CLAUDE.md` only. A literal collaborator name is
   just as load-bearing — and breaks just as silently — in a slash command,
   and the sibling ent#239 check already treats `.claude/commands/` as a
   first-class shipped surface. Restricting the walk left four real literals
   unguarded in the bundled corpus:
     sage/.claude/commands/request-research.md          -> acme-scout
     demo-analyst/.claude/commands/briefing.md          -> research-network-researcher
     demo-analyst/.claude/commands/request-research.md  -> research-network-researcher
   Widened to CLAUDE.md + .claude/commands/*.md + .claude/skills/**/*.md via a
   deduped, sorted `_prompt_files()` helper. Token pattern unchanged.
   Corpus: 13 -> 28 matching tokens across 4 manifests, zero offenders.
   Mutation-proved twice: renaming the manifest `name:` goes red (as before),
   and breaking a name inside a .claude/commands file now goes red too — the
   coverage that did not exist before.

2. `test_shipped_default_is_empty` did a bare `sys.path.insert(0, ...)` in the
   test body: a permanent, un-undone global side-effect (one duplicate entry
   per session) that `tests/unit/conftest.py` already makes unnecessary.
   Dropped; the test still passes standalone.

3. Dropped an unused `import yaml` inside the roster test, and corrected a
   comment claiming `_metadata_cache` "survives across loads" — each
   `exec_module` builds a fresh module, so the `.clear()` is defensive only.

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

* docs(test): correct the unguarded-literal count to three (#1931)

The `test_manifest_resolved_names_satisfy_hardcoded_rosters` docstring said
restricting the walk to `CLAUDE.md` left "four" real literals unguarded. It is
three. Verified twice:

    grep -rlE "acme-scout|research-network-researcher" config/agent-templates/

returns 6 files, of which exactly 3 are not `CLAUDE.md` —
`demo-analyst/.claude/commands/{briefing,request-research}.md` and
`sage/.claude/commands/request-research.md` — and an occurrence count confirms
one match per file. The parenthetical that follows already enumerated three;
only the count word was wrong.

Docstring-only. No assertion, scan surface, or behaviour changes.

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

* chore(secret-scan): allowlist the sanctioned YOUR_TOKEN placeholder (#1931)

gitleaks' default `curl-auth-header` rule flags the
`-H "Authorization: Bearer YOUR_TOKEN"` line in the DEPLOY header comment of
config/manifests/vc-due-diligence.yaml (entropy 3.121928). It is a
documentation placeholder, not a credential, and is verbatim-identical to the
pre-existing line in config/manifests/research-network.yaml:10 — that one never
tripped the scanner only because the workflow scans the PR commit range, not the
whole tree. So this PR followed the house convention rather than introducing a
new practice; the manifest comment is left untouched.

Add YOUR_TOKEN to the existing `regexes` allowlist beside the other
CLAUDE.md-sanctioned placeholders (`your-api-key`, `your-domain.com`).
Deliberately NOT a `paths` entry: per the note already in this file, a path
allowlist is a PRE-SCAN file skip and would stop config/manifests/ being scanned
for real secrets, whereas a targeted regex suppresses only this placeholder.

Verified locally with the CI-pinned gitleaks 8.30.1 over the same commit range:
1 leak before, "no leaks found" after; a planted ghp_ PAT under
config/manifests/ is still detected.

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

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 3, 2026
…160)

The MCP surface for the A2A interoperability management plane — the third
surface (Invariant #13) over the entitlement-gated enterprise backend
(trinity-enterprise#160, /api/enterprise/a2a/*). Distinct from the runtime
call_a2a_agent (#736).

New src/mcp-server/src/tools/a2a.ts (7 tools):
- get_agent_a2a_config, set_agent_a2a_exposure, get_agent_a2a_card (proxies the
  OSS #737 served-card endpoint), set_a2a_inbound_allowlist,
  register_a2a_endpoint, list_a2a_endpoints, remove_a2a_endpoint.
- Honest gating: an unentitled 403 ("not licensed") / OSS-only 404 return a
  structured { not_entitled | not_found } — never a silent success. Mutations
  are owner/admin + human-only, enforced at the backend (agent-scoped key → 403
  human_only). Outbound credentials are write-only — the backend returns only
  has_credentials, so no tool echoes a secret.

client.ts: 8 A2A methods (getA2AExposedMap swallows OSS-404/unentitled-403 → {}).
agents.ts: list_agents/get_agent best-effort merge a2a_exposed (mirrors
mcp_exposed, #846) — omitted in editions without A2A, no OSS↔enterprise coupling.
server.ts: register the tool group (connector-denied visibility, like the rest).

Tests: src/mcp-server/src/tools/a2a.test.ts (10) — proxy contract, credentials
never echoed, entitlement/human-only/404 gating. Full suite 100 pass; tsc clean.

Related to #160

Co-Authored-By: Claude Opus 4.8 (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>
vybe pushed a commit that referenced this pull request Aug 3, 2026
…ty-enterprise#127) (#1948)

* fix(agents): stop a malformed `credentials:` also costing runtime + shared_folders

`_resolve_local_template` read `creds.get("mcp_servers", {}).keys()` straight
through the block. A null / list / string `credentials:` raises AttributeError
there, and that read sits FIRST in a run of `config` mutations wrapped in one
broad `except Exception` — so the failure skipped every mutation after it. A
single malformed key therefore silently cost the agent its `runtime:` (wrong
harness) and its `shared_folders:` config too, with only a WARNING to show for
it.

Reads through PR-A's tolerant `credential_mcp_server_names()` instead, so the
credential parse degrades on its own and the unrelated settings survive.

The five malformed shapes are pinned as parametrized regressions; all five fail
on the pre-fix code.

Refs Abilityai/trinity-enterprise#128

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

* fix(agent-server): tolerant `credentials:` read on GET /api/template/info

Same uncaught reach-through PR-A fixed on the backend, still live on the agent
image: `.get("credentials", {}).get("mcp_servers", {}).keys()` raises
AttributeError on a null / list / string block at EITHER level, and the
endpoint's own `try/except` wraps only the YAML load — so the crash escaped as a
500 on the Info tab and the brain-orb route guard. `template.yaml` here is read
from the agent's own workspace, which the agent itself can rewrite, so this is
reachable without an operator touching anything.

The agent server ships in its own image and structurally cannot import
`src/backend`, so the reader is DUPLICATED, not imported. The two in-repo
precedents for that (`credential_paths.py`, `model_context.py`) are vendored
byte-identically WITH a parity test; a 6-line reader does not earn a whole
vendored module, but it does earn the same guard — before this commit NO parity
test covered `agent_server/routers/info.py`, so the copies could diverge freely.
Added in the `test_1713_scheduler_utils_parity.py` shape: one shared 17-row table
of malformed shapes driven through BOTH implementations, asserting agreement on
OUTPUT (the copies are textually divergent by design, so a source diff cannot
verify them).

Also routes the endpoint through the existing `get_template_path()` helper —
`/api/metrics` already does — instead of a second copy of the path literal, so
the regression is testable without patching `Path`.

This is the change that makes `/verify-local` mandatory WITHOUT `--skip-agent`.

Refs Abilityai/trinity-enterprise#128

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

* fix(compatibility): a credential detector must not read narrower than it audits

K-001 (HARD) compared `.mcp.json.template`'s `${VAR}` references against an
UPPERCASE-ONLY view of `.env.example`. Trinity's substitution engines impose no
charset at all — the agent-side writer is a `str.replace` and the `.env` writer
slices `env_val[2:-1]` — so `${my_var}` IS substituted at runtime, and a template
that correctly documents `my_var=` was HARD-failed for a gap that does not exist.

`services/credential_charset.py` is the one place that decision now lives, named
for its ROLE (`CREDENTIAL_DETECTOR_CHARSET` — "the widest charset a detector must
accept so it is never narrower than the engine it audits"), not for a reach it
does not have. Four detectors adopt it; the docstring carries an explicit
NON-MEMBERS list with a reason per entry, because the previous framing ("the
charset every Trinity surface agrees on") is false and reads as an instruction to
the next engineer who greps `[A-Z][A-Z0-9_]*`:

  * `mcp_validator._ENV_VAR_REF_RE` is a FAIL-CLOSED gate (`.mcp.json` inject →
    400, `.credentials.enc` import, deploy-local), deliberately paired with the
    WIDEST finder (`[^}]*`). Widening it admits input that is currently rejected.
  * `skill_packaging.ENV_KEY_RE` is an adjacent domain with its own length cap.
  * `static_checks._ASSIGN_RE` carries the quantifier shape behind an
    already-FIXED py/polynomial-redos alert, on an agent-supplied-text path.
  * `c_d006` is a different vocabulary that merely looks similar.

The constant lives in a pure-stdlib leaf module, NOT in `services/compatibility/`:
that package's `__init__` imports `database`, and `static_checks` imports
`template_service`, so a `template_service` → compatibility edge is a hard cycle
(reproduced: "cannot import name '_is_platform_injected' from partially
initialized module").

Behaviour changes, both named:
  * K-001 (HARD) `fail → pass` for a documented lowercase variable — the fix.
  * K-003 (SOFT) `pass → fail` for a lowercase-only, comment-free `.env.example`.
    `_env_example_vars` is K-003's precondition for DEMANDING comments, so growing
    it makes the verdict worse. The verdict is correct — that file genuinely has
    no comments — but it is a `pass → fail` and is release-noted, not smuggled.
  * S-010 (SOFT) does NOT flip: its `generic` blocklist is uppercase-exact, so no
    newly-visible lowercase name can join it. Asserted, not assumed — it is safe
    by coincidence of casing.

The two `template_service` extractors are included because both feed the LIVE
`collect_mcp_credential_warnings` → `deploy.py` path; leaving them out would
half-fix the very inconsistency this closes while a four-way agreement test
passed. Direction there is FEWER spurious warnings. `test_deploy_local_validation.py`
(the 8-assertion suite on that path) stays green.

Also hardens two latent crashes in the same file: a null / non-mapping
`template.yaml` document reaching `extract_credentials_from_template_yaml`, and
`extract_agent_credentials` reaching through `credentials:` at three levels. New
`credential_mcp_env_vars()` reader returns non-empty strings only, so an
`env_vars` element smuggled in as a mapping can never reach a consumer — that
element is exactly what turns a set comprehension into
`TypeError: unhashable type: 'dict'`.

Refs Abilityai/trinity-enterprise#128

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

* fix(compatibility): K-002 compared ${VAR}s against section names, and could go dark

Two defects in the same HARD gate, one of them a way for the gate to stop
protecting entirely.

**1. It read the structure, not the declaration.** `listed` was
`set(creds.keys())` — `{"mcp_servers", "env_file"}` — so the documented structured
form `credentials.mcp_servers.stripe.env_vars: [STRIPE_API_KEY]` satisfied nothing
and HARD-failed a correctly declared template, while `${env_file}` and
`${mcp_servers}` PASSED. The admitted set was "whichever section names this
template happens to use", so the blind spot was template-dependent — the worst
kind, because it cannot be found by reading the check.

`declared_credential_names()` (the union of `mcp_servers.*.env_vars` and
`env_file`, over PR-A's tolerant readers) is now unioned in, and the three known
STRUCTURE keys are subtracted. A flat `credentials: {STRIPE_API_KEY: '...'}`
mapping is still admitted — that legacy shape is legitimate and keeps passing.

The section subtraction is a deliberate `pass → fail` for a genuinely broken
template. Shipped named, tested and release-noted, NOT smuggled under a
monotonicity claim: the blanket "strictly monotone, fail→pass only" claim is false
and a reviewer would find the counter-examples.

**2. It could go dark.** `run_static` caught `Exception` → `skipped`, and `_counts`
counted only `status == "fail"`, so a raise inside a HARD check DROPPED
`hard_count` and could flip `overall_status` from `issues` to `compatible` on an
agent with a genuinely undeclared credential. `c_k002` delegates to `c_t015`, so
ONE raise took both HARD gates dark together, and the result is indistinguishable
from a clean pass in the counts. The trigger is four lines of untrusted YAML:

    credentials:
      mcp_servers:
        s:
          env_vars:
            - {STRIPE_SECRET_KEY: "please"}

`template.yaml` here is read from a live agent workspace, whose git repo the agent
itself owns — a self-attestation bypass on the surface whose job is to police it.
The same `TypeError: unhashable type: 'dict'` is the failure mode that argued
against enriching `credentials.env_file` in the first place, so reintroducing it
at the new call site would have been the plan diagnosing a bug and then shipping
it.

Three layers, deliberately:
  * `c_t015` wraps ONLY the new term and degrades to the narrower set — which
    makes `missing` LARGER, i.e. errs toward failing — never to `skipped`.
  * `run_static` returns FAIL for a check that raises. A check that could not
    evaluate is not a check that passed; one bad check still never breaks the
    report.
  * `_counts` also counts `skipped` + `skip_reason == "check_error"` as a finding,
    at the sink (#1525), so the property survives a future path reintroducing the
    skip. A benign precondition skip (`no_template`, `ai_not_run`) still counts as
    nothing — that distinction is why the skip path exists.

`declared_credential_names` guarantees `str` elements structurally, and the call
site filters `isinstance(name, str)` anyway: the gate must not depend on the
reader's contract holding.

Refs Abilityai/trinity-enterprise#128

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

* fix(templates): MCP-server precedence, and a credentials badge that counts

Three catalog defects PR-A deferred, all in the two builders.

**Defect D — precedence was backwards.** `_build_local_template` read
`credential_mcp_server_names(credentials_block) or data.get("mcp_servers", [])`, so
a `credentials:` block silently OUTRANKED the template's own `mcp_servers:`
declaration. `agent_server/routers/info.py` has always read them in the other
order, so the catalog and the agent's own Info tab disagreed for any template
declaring both. Operands flipped; the `credentials:` path stays as the fallback.

**W14 — the GitHub builder had no fallback at all**, so a GitHub template declaring
only `credentials.mcp_servers` showed an empty list in the catalog while its Info
tab listed them. That was the third of three surfaces; all three now agree.

**Defect C / W6 — the badge.** Both builders read a flat top-level
`required_credentials:` key that ZERO templates declare — 25 bundled and all 7
configured GitHub repos — so `Templates.vue` rendered 0 for everything. Now derived
from the declared base set, with `platform_injected` vars EXCLUDED.

That exclusion is the badge's semantic, and it is not cosmetic: measured on the
real shipped catalog, a naive derivation is correct on 1 of 7 repos and wrong in
both directions — the ent#124 first-run agent would read 5 where the operator
supplies 2, while three shipped repos stay at 0. The chip is read as "how much work
is this to set up", so counting `GEMINI_API_KEY` / `GITHUB_PAT` / `TRINITY_*`
inflates it with rows nobody can fill. A consumer that wants every declared
variable wants `declared_credential_names`, not this.

Derived unconditionally rather than "explicit key wins, else derive": that override
branch is unreachable (no template declares the key), so keeping it would be one
dead code path guarding a live one.

No frontend change: `Templates.vue:103,107,171,175` read only `.length`, so the
shape it already expects is preserved and a variable name never reaches the DOM.

Refs Abilityai/trinity-enterprise#128

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

* feat(templates): `credential_setup:` — per-variable credential setup metadata

Closes ent#128 AC #1-2. A template can now describe each credential an operator
must supply — title, description, required, secret, format, setup_url, default —
and `template_service` surfaces the normalized result as `credential_requirements`
on every catalog entry.

**Enrichment lives in a NEW sibling top-level key; `credentials:` is FROZEN as
names-only, forever.** An already-deployed older Trinity reads `env_file` through
`credential_env_file_names` and then does `agent_credentials.get(var_name, "")` —
hand it a list of mappings and that is `TypeError: unhashable type: 'dict'` at the
moment it writes the agent's `.env`. A sibling key is structurally invisible to
that binary, so there is no floor version and enrichment distributes immediately.

**Base-set-plus-overlay, so the two keys cannot drift.** One record per variable
`credentials:` declares, decorated by `credential_setup:` entries joined BY NAME.
An entry naming nothing is a named three-line error (problem, cause, FIX) and is
dropped; valid siblings survive. `credential_setup:` can only ever decorate — the
sibling-key shape's usual failure mode is closed by construction, not by
discipline. Stated honestly: for an EXTERNAL template that error is neither
impossible nor visible in the UI — `credential_errors` has zero frontend and zero
MCP consumers, so the only human channel is the backend log. It is LOGGED.

`required` is a tri-state. Enriched-and-omitted means `True` (an author who
described a variable meant it); a legacy bare `- FOO` is `"unknown"`, never `True`
— it carries no authorial intent, and reading it as required makes a guided
checklist cry wolf. `"unknown"` doubles as the enriched/un-enriched discriminator,
which is why no `enriched: false` flag is needed. `secret` defaults `True`
(fail-safe). Path-free by construction, so trinity#570's `template.yaml` →
`trinity.yaml` rename cannot reach it.

**The normalizer never raises, and that is load-bearing.** `_build_template` runs
in bare list comprehensions in `get_all_templates()`, OUTSIDE PR-A's per-template
fence (which covers `_build_local_template` only) — a raise there is HTTP 500 with
an EMPTY CATALOG, i.e. PR-A's exact bug reopened by the change that surfaces the
new metadata. And no bomb is needed: `title: 123` or a bare `title:` was enough.
So the builders ALSO wrap the call and degrade to `[]` plus a named error, rather
than fencing the comprehension — that keeps the named error the resilience
contract promises. The property does not rest on one function's discipline.
(Which earned its keep immediately: the wrapper caught a real NameError during
development instead of emptying the catalog.)

Trust boundary — `title`/`description`/`setup_url`/`name`/`source` are
author-controlled strings from arbitrary GitHub repos flowing into an
operator-facing "paste your API key" checklist:

  * **Type-guard before touching.** Never `str()` a container from untrusted YAML:
    `str()` EXPANDS a shared alias during the walk (443 B → 52 MB in 1.5 s, x10 per
    level), and both the sanitizer and the record cap act after that cost is paid.
  * **Cap the INPUT**, entries AND errors AND the base set. Capping records while
    leaving `errors` uncapped built a 35 MB response out of the cap meant to
    prevent it; and `default` had no type row, so the 100-record cap acted as a
    x100 multiplier on it.
  * **`source` is sanitized** — it carries the raw MCP server name, the exact
    string `_sanitize_for_warning`'s own docstring names as the threat, and it was
    not on the list.
  * **Per-field length caps.** Reusing the 80-char terminal-warning default
    truncated a realistic 159-char description and made a real 90-char vendor
    console URL unusable.
  * **`setup_url` above scheme-only**: https (case-insensitive — `HTTPS://` is a
    legitimate author), a parseable host, NO userinfo (`https://google.com@evil.tld`
    renders as one host and resolves to another — the display/resolve split IS the
    attack), ≤2048, printable. Validate THEN sanitize, and never through a
    truncator. Residual documented, not claimed closed: `isprintable()` rejects
    RTL/ANSI but an IDN homograph survives, so a consumer must render the parsed
    hostname beside the link.
  * **Never mutates its input** — `_metadata_cache` holds the parsed dict for 600 s
    and YAML aliases genuinely share nodes, so one in-place normalize would rewrite
    both aliased fields and persist for ten minutes. Asserted against a deep-copy
    snapshot, including a real `&anchor`/`*alias` document.

`credential_shape_errors` also gains the per-server and per-ELEMENT rows for
`mcp_servers`, mirroring what `env_file` already had. The element row is the one
that matters — an `env_vars` entry smuggled in as a mapping was the single most
dangerous shape in the block and was unnamed. Note this makes the write path
(`generate_credential_files` → 400) reject a template that previously created an
agent with a garbage declaration: correct per PR-A's fail-loud write contract, and
release-noted.

`generate_credential_files` is deliberately UNTOUCHED — it still reads `env_file`
names-only, which is what makes the forward-compatibility argument true rather
than asserted.

Refs Abilityai/trinity-enterprise#128

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

* docs(schemas): trinity-agent-credentials.schema.json — the declaration contract

Closes ent#128 AC #3's machine-readable half. Follows the established
`docs/schemas/` convention (`agent-pipeline.schema.json`): Draft 2020-12,
date-stamped `$id` so a future revision keeps answering for templates written
against this one, and self-described as the authoritative documentation contract
while the backend reader stays deliberately tolerant.

**Rooted at `template.yaml`, not at `credentials:`.** The two keys are ONE contract
joined by a mandatory cross-reference, and validating either alone cannot check it.

**`additionalProperties: true` at the root and on `credentials`** — template.yaml
carries many keys this schema deliberately says nothing about, and a template
predating the schema must stay VALID. Accepted asymmetry, and it is asserted as a
test rather than left as a surprise: a made-up top-level key IS valid here.

**`config_files` is enumerated and `deprecated: true`, not omitted.** The earlier
posture was "don't delete, don't advertise", which made the authoritative contract
answer VALID to `path: "/etc/cron.d/pwn"`. Undocumented is not a control against an
author who knows the key — only against the reviewer who doesn't. So it is
documented as deprecated, with a containment `pattern` that rejects absolute and
`..` paths and a description saying plainly that it writes files into the agent's
credential directory. Still reversible, still invalidates nobody. (Whether to
DELETE the key is a public behaviour change and stays @vybe's call.)

Carries the A2 consumer requirements in `$comment`, because the schema is the
artifact a downstream implementer reads:
  * a record with `required: "unknown"` carries no authorial intent and MUST NOT be
    presented as a required field — without this a naive UI renders a seeded agent
    as five mandatory rows, three of them platform variables nobody can fill;
  * `platform_injected: true` MUST NOT be asked of an operator;
  * `secret: true` (the default) MUST be masked;
  * `setup_url` MUST be rendered with its parsed hostname shown, because the IDN
    homograph residual is real and documented rather than claimed closed;
  * there is intentionally NO reverse cross-reference requirement — a declared
    variable with no `credential_setup:` entry is normal.

Also states the author cost honestly in the authoring note: declaring in
`credentials:` is a separate edit from referencing `${VAR}` in
`.mcp.json.template`, K-002 checks the two agree, and that is deliberate because
`.mcp.json.template` must not become a second declaration authority. Plus the two
brace forms Trinity's readers cannot see (`${my-key}`, `${VAR:-default}`).

Tests pin the schema against the implementation — field caps, the format
vocabulary, the allowed-key set, the record cap — so the reviewed text and the
enforced text cannot drift. The 13 document cases run under `importorskip`
(`jsonschema` is not a declared Trinity dependency); the security-relevant pattern
assertions are unconditional.

Refs Abilityai/trinity-enterprise#128

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

* docs(templates): the Trinity-installable credential contract + reference examples

Closes ent#128 AC #3-4.

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

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

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

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

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

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

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

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

Refs Abilityai/trinity-enterprise#128

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

* test(templates): close the ent#128 coverage gaps the gate surfaced

A transition diff over a corpus with zero coverage of the diff is not evidence, so
the changed statements were measured against PR-B's real base (`c07afab7` =
origin/dev + PR-A) rather than assumed. The gate found the new paths that no test
reached and this closes them:

  * §4's new `mcp_servers` shape-error rows — per-server AND per-element, six
    parametrized cases plus the sanitized-server-name case. The element row is the
    dangerous one and it had no test.
  * The write-path consequence, asserted explicitly: `generate_credential_files`
    now raises on `env_vars: [{K: v}]`, where before it created the agent silently.
  * `_setup_url_error`'s `urlsplit` ValueError branch (malformed IPv6 literal).
  * The dedup early-return in the base-record builder — a variable declared under
    two servers AND `env_file` yields one record with a stable `source`.
  * A non-string mapping key in a descriptor (`{1: "x"}`), which must not reach the
    "did you mean" helper.
  * The caller-less `extract_agent_credentials` across eight malformed shapes. It
    has no production caller, which makes hardening cheap rather than unnecessary —
    the next caller would have inherited the crashes. Now exercised instead of
    merely present.

Result: 227 changed statements, 225 executed. The two remaining are a defensive
`except OSError` around a `Path.resolve()`, and the three gate files
(`static_checks.py`, `compatibility/__init__.py`, `credential_charset.py`) are at
100% of changed statements.

Refs Abilityai/trinity-enterprise#128

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

* docs(feature-flows): sync the compatibility flow + index for ent#128

`/sync-feature-flows`. `template-processing.md` and `templates-page.md` were already
updated with the declaration standard; this adds the flow the code change actually
lands hardest on and which nothing had touched: `agent-compatibility-validation.md`.

Both credential HARD gates changed, and the flow doc described neither the defect
nor the new semantics:

  * "a detector must never read narrower than the mechanism it audits" — the shared
    root cause of K-001 and K-002/T-015, with the NON-MEMBERS list spelled out so
    the next reader does not "align all the regexes" and widen
    `mcp_validator._ENV_VAR_REF_RE`, which is a fail-closed GATE and not a detector;
  * "a HARD gate must not be able to go dark" — the `run_static` →`skipped` +
    `_counts`-counts-only-`fail` interaction that let 4 lines of untrusted YAML drop
    `hard_count` 1→0, and the three fail-closed layers that replace it;
  * the complete verdict-transition set, because the blanket "strictly monotone"
    claim is false and a reader will find K-003's `pass→fail`. The claim that
    survives is "no agent gains a HARD failure".

Testing section records why the bundled templates cannot prove any of this — 0
`.mcp.json.template` and 0 `.env.example` files, so every changed check
short-circuits before reaching changed code and a green diff there is
green-because-vacuous — and points at the 49-fixture synthetic corpus instead.

Plus the Recent Updates row in `feature-flows.md` (the step this skill's own docs
warn gets skipped).

Observation, deliberately NOT fixed here: the Recent Updates table carries 57 rows
against its own documented "newest ~20" cap (#1360), so the index is 434 lines vs
the 400-line guideline. Trimming it means deleting 37 other engineers' entries,
which is not this PR's call.

Refs Abilityai/trinity-enterprise#128

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

* test(templates): use an unambiguous placeholder credential value

`sk-live-xxx` is stripe-shaped and gitleaks' default ruleset covers `sk-`. The value
is arbitrary in this test — it only has to round-trip byte-identically through the
`.env` writer — so there is no reason to hand CI a secret-shaped string to reason
about.

Refs Abilityai/trinity-enterprise#128

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

* test(agents): teach the crud harnesses the tolerant credentials accessor

Real regression I introduced in `886aab5b` and initially mis-attributed as
pre-existing. Recording both the fix and how the mis-attribution happened, because
the second part is the reusable lesson.

**The bug.** `test_1484_create_agent_characterization.py` and
`test_1759_local_template_not_found.py` MagicMock the whole
`services.template_service` module and stub each function crud actually calls with a
faithful return value (`generate_credential_files` → `{}`, `get_github_template` →
`None`). `_resolve_local_template` now calls a THIRD one —
`credential_mcp_server_names` — and it was unstubbed, so it returned a truthy Mock
that passed `if mcp_servers:`, landed in `config.mcp_servers`, and blew up later
inside a `yaml.dump` as `ValueError: dictionary update sequence element #0 has
length 1; 2 is required`. 18 tests, entirely a harness gap: in production the real
function returns a list.

Stubbed with a faithful 3-line mirror rather than a fixed `[]`, so a fixture that
DOES declare `credentials:` cannot be silently masked by the stub.

**One test needed a real update, not a stub.**
`test_malformed_field_still_creates_and_names_the_template` used
`credentials: "a string"` as its trigger for the broad-except degrade path. That is
exactly what `886aab5b` fixes — `credentials:` is no longer a trigger BY DESIGN,
because it raised FIRST in that run of mutations and so cost the agent its
`runtime:` and `shared_folders:` config as collateral. Swapped the trigger to
`shared_folders: not-a-mapping`, which still raises, so the degrade path and the
two identifiers in its warning stay under test. The docstring records why and points
at the new coverage.

**How I mis-attributed it.** I compared with `git stash push -- src/backend`, which
reverts only the WORKING TREE — commits 1 and 2 were already committed, so my
"baseline" still contained the cause and the failures looked identical on both
sides. The `-k`-filtered selection also happened to include only 1 of the 13
`test_1484` failures, which made the set look small and stable. Only a worktree at
`c07afab7` (PR-A's tip, PR-B absent) showed the truth: 2 failures there vs 20 on the
branch. **A baseline has to be a worktree at the base commit, not a stash.**

Now identical to `origin/dev` and to `c07afab7`: 2 failures, both genuinely
pre-existing (`test_agent_analytics::test_day_stacks_present_in_by_type`,
`test_1069_voip_call_path_param` — the documented `get_flat_dependant` venv drift).

Refs Abilityai/trinity-enterprise#128

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

* fix(templates): the tolerant credential reader must not blow up or raise

Two holes in the "never raises, never amplifies" property PR-B rests on, both
found by asking which OTHER producers reach the surface the new cap protects.

1. `credential_shape_errors` was uncapped. The cap shipped on the NEW function
   (`normalize_credential_requirements`), but the same PR added a per-ELEMENT
   loop to this PRE-EXISTING one, and it feeds the same two surfaces: the
   catalog's `credential_errors`, and the `"; ".join(errors)` that becomes
   `CredentialDeclarationError`'s agent-creation 400 body. A cap is a property
   of the producer, not of the PR that invented the concept.

   YAML anchors make input size a useless proxy for output size, so the bound
   has to stop the WALK, not slice the result. Measured on a 6,738-byte
   `template.yaml` (one 200-element anchor aliased across 200 servers):
   40,000 errors / 3.64 MB joined (540x) before, 101 errors / 8,973 bytes after.
   `origin/dev` returns 0 on the same input, so the amplification is this
   branch's own — reachable since ent#123 by any creator-role user pointing at
   an arbitrary public repo.

2. `source_trust not in _SOURCE_TRUST_LEVELS` is frozenset membership, so an
   UNHASHABLE value raised `TypeError` *on the guard line* — before the
   degrade-to-`github` branch that guard exists to reach. Unreachable from
   parsed YAML today (every call site passes a literal), but this is the one
   function whose docstring makes "NEVER RAISES" load-bearing: a raise here is
   an empty catalog and a dark HARD gate. The property should be literally
   true, not true-by-call-site-audit.

Both regression tests were confirmed to FAIL with their fix reverted and pass
with it restored.

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

* build(tests): cap fastapi to prod's 0.115.x line

The unit suite was validating against a FastAPI ~25 minor versions ahead of
the one production ships. `docker/backend/Dockerfile` pins `fastapi==0.115.6`
exactly; `tests/requirements-test.txt` carried an unbounded floor that resolved
0.140.13. The comment at :43 already claimed these "match the floors set in
docker/backend/Dockerfile" — that file uses exact pins, so the claim was untrue.

Surfaced as `test_1069_voip_call_path_param` failing with `ImportError: cannot
import name 'get_flat_dependant'`. That test is only the messenger: it is the
one test coupled to a private FastAPI symbol (`src/backend` imports none, and
the other test touching `fastapi.routing` uses the public `APIRoute`).

The obvious ceiling does not work: `0.140.13 < 0.141` is true, so `<0.141`
still admits the breaking version. Bisected against the real wheels — present
in 0.140.6, gone in 0.140.7 — a private API dropped in a PATCH release, so no
minor-level bound is trustworthy. Tracking prod's line is the durable fix.

Why now rather than "separate follow-up": CI is green only on a warm pip cache.
backend-unit-test.yml keys `cache-dependency-path` on this file, and 0.140.13
allows py3.11, so the next edit to this file for ANY reason busts the key,
re-resolves, and breaks CI for everyone. Capping is the safe way to bust that
cache — the change that invalidates the key is the one that makes re-resolution
correct.

Follows this file's own precedent (`bcrypt>=4.2.0,<5`, added when bcrypt 5.0.0
removed the `__about__` shim passlib reads): floor + ceiling + a comment saying
why, rather than an exact pin that would break the file's `>=` convention.

Verified by execution, not argument:
  - full tests/unit at 0.115.14: 5861 passed, 16 skipped, 2 xfailed, 0 failed
    (at 0.140.13 the same command is 1 failed, 5860 passed)
  - the edited file installs clean in a fresh venv and resolves 0.115.14
  - an existing verify venv self-heals: pip downgrades 0.140.13 -> 0.115.14

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

* refactor(templates): take the trust label from the caller, not a tainted path

`_build_local_template` derived `is_bundled` itself:

    is_bundled = template_dir.resolve().parent == _local_templates_dir().resolve()

`template_dir` on the by-id path is `_local_templates_dir() / name` where `name`
comes from a user-supplied `local:<name>` template id, so this called `.resolve()`
on attacker-influenced input. CodeQL flagged it as `py/path-injection` (alert 260,
high) — a new tainted-path sink introduced by ent#128 purely to pick a log level
(`source_trust` selects `logger.warning` vs `logger.info` and nothing else).

`is_bundled` is now a required keyword arg supplied by whoever knows the
provenance:

  - `get_local_templates()` iterates the curated root, so its children are
    bundled by construction -> `is_bundled=True`.
  - `get_local_template()` decides from the id STRING (plain single segment, no
    separator, not a dot-segment) rather than a path operation on it.

Behaviour, measured against the old predicate across 9 ids: 7 identical, 2
divergent — `'../agent-templates/sage'` and `'a\b'` go True -> False. Both moves
are old=True -> new=False, i.e. strictly more conservative: the new check never
grants the `bundled` label where the old one withheld it, only the reverse. An id
that traverses to arrive inside the curated root is not curated, so the new
answer is also the more correct one; the blast radius either way is one log
level.

This is deliberately NOT a traversal guard — and as of the 2026-08-02 rebase it
no longer needs to be. An earlier version of this message said the traversal was
"being routed as its own issue rather than fixed"; that issue, #1900, has since
been fixed on `dev` by #1935, which this branch is now rebased onto.
`get_local_template` therefore routes `name` through `contained_template_dir()`
— a name allowlist plus resolve + `is_relative_to` — BEFORE the label check runs.
(The traversal was real while it lasted: `local:..` escaped the templates dir,
reachable by any authenticated user via `GET /api/templates/{id:path}`.)

That makes the `is_plain_segment` check redundant today — provably True wherever
it is reached, since the barrier above rejects every non-plain name first. It is
kept as defence in depth: it decides a trust LABEL, and `contained_template_dir`
is a shared primitive the remote-template-registry work (trinity-enterprise#14)
is expected to edit. A label that silently became `bundled` if that barrier were
ever widened is the exact failure this keyword argument exists to prevent. It
re-adds no tainted-path sink — it reads the id string, never the filesystem.

Two test call sites updated for the new signature.

Verified on the rebased branch: full tests/unit 6410 passed / 16 skipped /
0 failed; no `template_dir.resolve()` remains in the module.

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

* feat(credentials): canonicalise setup_url hosts the way a browser does

`credential_setup[].setup_url` is author-controlled and lands beside a "paste
your API key here" input. `_setup_url_error` rejects the `user@host` form but
its own docstring records the residual it does NOT close — IDN homographs
survive, so "a consumer MUST render the parsed hostname next to the link".
ent#127 is that consumer and the field's first renderer.

`services/setup_url_display.describe_setup_url` is that half. Three properties,
each load-bearing:

- UTS-46 nontransitional via the `idna` package, NOT `str.encode("idna")`. The
  stdlib codec is IDNA2003 and disagrees with every browser on exactly the
  deviation set that matters: `faß.de` encodes to `fass.de` where a browser
  resolves `xn--fa-hia.de` — a different registrable domain. A mitigation that
  displays a domain the click does not resolve manufactures the very split it
  exists to close.
- Fails CLOSED. Every failure path returns `display_host is None`, which the UI
  must render as inert text rather than an anchor. Falling back to the raw host
  would make a failed check byte-identical to a passed one.
- Leads with the registrable domain (eTLD+1), because punycode is irrelevant to
  `accounts.google.com.evil.tld` — the commonest shape and pure ASCII.

`idna` is pinned explicitly in both the backend image and the test
requirements: it is currently an unpinned transitive of httpx, and a dropped
transitive is invisible to /verify-local because the source imports fine on the
host (the #1033 class).

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

* feat(credentials): bounded in-container probe for live .env key status

The status engine for the ent#127 checklist: one fixed, base64-injected probe
that reports which declared credential variables actually hold a value.

"Set" is defined as agreement with the agent's OWN post-injection exporter
(`agent_server/routers/credentials.py`), pinned by a parity test whose replica
is anchored on the owning function via `ast` — not a `str.find` offset, which
returns -1 on a rename and silently asserts against nothing. Two deliberate,
documented departures sit outside that parse: bytes are decoded with
`errors="replace"` (the exporter's strict `read_text()` raises and exports
NOTHING, so one bad byte would report a fully-configured agent as empty), and
emptiness is tested after `.strip()` (a whitespace-only value is a green row in
front of an agent that will 401).

The exec is bounded three ways, because none is sufficient alone.
`execute_command_in_container` accepts a `timeout` and never references it
again; `container_exec_run` has no timeout parameter, docker-py's `exec_run`
has none, and its socket reader polls with no timeout before every `recv`. The
call runs on a `ThreadPoolExecutor(max_workers=4)` shared by EVERY Docker
operation in the backend, so four wedged calls stop the whole Docker layer —
and it is agent-triggerable, since the agent owns `/home/developer/.env` and
`mkfifo` on it blocks `open()` forever. So: container-side `timeout(1)` (the
load-bearing one — self-termination closes the socket and actually reclaims the
pool thread, which an asyncio cancel cannot), `asyncio.wait_for` to bound the
request, and `stat.S_ISREG` before `open()` to close the FIFO vector at source.
`compatibility/collector.py` has the identical hole; that is filed separately.

Zero policy crosses the image boundary except the predicate itself, which is
spliced in from real source so the tested code and the shipped code are the
same code. No charset filter (it would be a hidden fifth member of
credential_charset.py's MEMBERS list, and narrower than the runtime it audits),
no YAML parse (alias expansion is a 443 B -> 52 MB amplifier). The probe emits
key NAMES only, never a value, length or hash; `result["output"]` is never
logged, because on failure it holds an exception string.

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

* feat(credentials): assemble the per-agent requirements report

Joins the ent#128 declaration against the live probe. Authority is the LIVE
workspace, because a forked or hand-edited agent's requirements drift from the
catalog entry it was created from — AC #3's case; the catalog is the fallback
only, and it is reachable ONLY from an already-failed live read.

Four decisions worth naming:

- `degraded` DOMINATES `no_credentials_required`, unconditionally. A degraded
  lookup and a genuinely credential-free agent produce a textually identical
  empty requirement set, and "Ready — this agent needs no credentials" is the
  one state a user never investigates. An EMPTY catalog result counts as
  degraded, not as data: `get_github_template` returns `_build_template(repo,
  {})` — empty requirements, not None — when the fetch fails, and with no PAT
  GitHub's 60-req/hr anonymous limit makes that the *expected* outcome for the
  ent#123 tokenless fleet.

- `.env` absent is a definite `missing`, never `unknown`. `_stage_config_files`
  guards on `template_data`, which only the `local:` arm populates, so a
  `github:` agent has no generated `.env` at all — and that fleet is AC #3's
  literal audience. `unknown` is reserved for "we could not look".

- A fourth state, `declaration_incomplete`. AC #1 names three sources; using
  `credentials:` alone yields a confidently-wrong green, since 12 of 25 bundled
  templates declare `credentials: {}` and 13 declare nothing, so a legacy
  template with `${SLACK_BOT_TOKEN}` in `.mcp.json.template` would render as
  needing nothing. Those names are an anti-green signal only — advisory, never
  required, never blocking. `.mcp.json.template` does not become a declaration
  authority.

- Tri-state `required` survives end to end and never counts toward `blocking`;
  platform-injected variables are excluded from the rows and counted separately,
  read through the PUBLIC `operator_supplied_credential_names` so this module
  keeps its zero-edit relationship with template_service.

Hardening: YAML aliases are refused at compose time (this template.yaml comes
from an agent-writable workspace, and alias expansion is a measured 443 B ->
52 MB amplifier here); the normalizer is wrapped at the CALL only, never around
the build, because blanket swallowing is what turns a raise into a verdict
indistinguishable from a pass; and the GitHub catalog arm goes through
`asyncio.to_thread`, since `_get_cached_metadata` uses a synchronous httpx
client whose 10s timeout would otherwise stall the whole worker.

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

* feat(credentials): GET /api/agents/{name}/credential-requirements

Owner-only AND human-only, deliberately stricter than the coarse
`/credentials/status` beside it.

`get_authorized_agent_by_name` resolves an agent-scoped MCP key to the owner
user carrying the owner's role — only *connector* principals are fenced — so
under the read gate an agent's own injected `TRINITY_MCP_API_KEY` would reach
this for every sibling its owner can access, which on a default admin-owned
install is the whole fleet including other users' agents. What it discloses is
a targeting map, not a status light: it names `STRIPE_SECRET_KEY` per agent and
says which are populated (worth stealing) and which are empty (whose operator
is about to paste one). `/credentials/status` gets away with the read gate
because it returns a COUNT and names nothing. Every sibling route that names or
writes credentials is already owner + human-only, and a read gate must equal
the write gate it drives: a shared user cannot submit anyway, so the looser gate
would give them a checklist of dead inputs whose only working function is
disclosing which of the owner's secrets are missing.

Backpressure, because every uncached call spawns a container process against
the backend's shared 4-slot Docker pool: a per-user rate limit at the router,
plus a cross-worker single-flight lock and a short cache in the service (the
router holds no logic, Invariant #1 — the same split `compatibility/fixes.py`
uses). The cache is generation-checked through Redis and invalidated by both
`.env` writers: it is per-worker while a POST lands on whichever worker served
it, so purely local invalidation would leave the other worker reporting
"missing" for a variable the operator just set.

An audit row is written on the read — every sibling credential route logs one,
and silence on the route that enumerates a credential inventory reads as an
oversight. Counts only; a variable name never reaches the audit log.

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

* feat(credentials): guided credential setup checklist UI

The operator-facing half: what the agent needs, what is already set, and where
to get each one — writing through the EXISTING owner-gated inject path, so
there is one writer and no new backend write surface.

Rendering contract, enforced by a source-anchored guard because this repo has
no component-test runner (only Playwright e2e against a live stack):

- Author text is interpolated as TEXT and deliberately NOT routed through
  `utils/markdown.js`. Markdown would be a widening, not a mitigation: it hands
  the template author an arbitrary `[label](url)` surface immediately beside a
  credential input, which is what having one validated `setup_url` exists to
  prevent.
- The anchor text is always the parsed host, never `title` — a `<a
  href="https://evil.tld">OpenAI API keys</a>` recreates the userinfo attack in
  pure HTML with no validator in the way. An unverified host renders as inert
  text, and `https:` is re-checked at render rather than trusted.
- The registrable domain is emphasised inside the full host, because
  `accounts.google.com.evil.tld` is the commonest shape and punycode says
  nothing about it.
- `secret` masks on `!== false`, so an absent or malformed value still masks;
  `default` is a placeholder only, and only when the author marked the variable
  non-secret — prefilling it would turn author YAML (or a prompt-injected
  agent's own rewritten template.yaml) into a one-click credential write.

The checklist renders for a STOPPED agent — the endpoint answers with a
degraded body, and copying `loadCredentialStatus`'s running-guard onto it would
have made the whole degraded design dead code. Only the inputs are gated.

Two latent defects in the write path are fixed, because a per-row checklist
promotes read-merge-write from a rare bulk paste to the normal interaction:

- The merge base is now MANDATORY. `formatEnvContent` rewrites `.env`
  wholesale, so swallowing a transient read failure as "start fresh" wiped
  every credential already configured; only a genuine 404 is a safe empty base.
- `parseEnvText` now unescapes what `formatEnvContent` escaped. The round trip
  was lossy in one direction only, so a value containing a quote grew one
  backslash PER SUBMIT — for every other credential in the file, not just the
  one being edited. Proven by an executed node round-trip, not a grep.

The store action goes through `api.js` (Invariant #7 — it owns the auth
interceptor and the 401 redirect); its raw-axios neighbours predate it.

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

* docs(credentials): requirements §3.6, architecture, and the ent#127 flow

Tiered-docs classification is "new capability", so all three land: requirements
§3.6 (appended — §3.5's existing "the per-credential checklist is ent#127"
forward pointer is left untouched), three architecture edits (endpoint row,
two service-catalog entries placed under Auth & Credentials rather than the Core
block, and the CRED-002 note), and a new feature flow.

A new flow doc rather than an extension of template-processing.md: that flow is
catalog/template-time, this is per-agent runtime. One cross-link added there.

The architecture note deliberately records three decisions so a later reader
does not "fix" them: nothing is vendored and there is no agent-server mirror
(so no Invariant #5 obligation attaches), the probe is deliberately separate
from the #668 compatibility collector, and there is deliberately no MCP tool —
recorded in architecture.md, not only the PR body, so /validate-architecture can
see the Invariant #13 decision.

Both index rows added — Recent Updates and the Authentication & Security
category table.

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

* test(credentials): route-wiring smoke + test-runner catalog for ent#127

The unit suite mounts `routers/credentials.py` on a synthetic FastAPI app with
the auth dependencies overridden, so it structurally cannot catch the #1069
escape class: whether the route resolves through the real `main.py` and whether
the real `get_owned_agent_by_name` + `reject_agent_principal` chain runs. A
path-param mismatch or a shadowing sibling would 404 every call with the unit
suite still green. Three live-backend smoke tests close that, needing no agent —
a nonexistent name is enough to prove the dependency ran, and the assertion is
that the detail is NOT FastAPI's bare routing "Not Found".

The uniform 404 those tests see is the point, not a limitation:
`get_owned_agent_by_name` deliberately answers identically for "no such agent"
and "not yours" (Invariant #8 self-uniformity), so the test cannot distinguish
them either.

Catalog updated: Credentials & Configuration entries, a dated Recent Test
Additions block, and the unit-test statistics line.

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

* docs(credentials): sync credential-injection.md with the live write path

/sync-feature-flows over the ent#127 commits. `routers/credentials.py` and
`CredentialsPanel.vue` both changed, which maps to credential-injection.md.

Flow 1 cited `composables/useAgentCredentials.js:177-237` — a file that still
exists and is re-exported from `composables/index.js` but that NO component
imports. The live implementation is `CredentialsPanel.vue`, and the composable
is a dead duplicate still carrying the pre-ent#127 versions of both defects.
Documented as such so a future reader does not "restore" the live path from it.

Flow 1 now shows the mandatory merge base and the quote round-trip, with the
agent-side escaping mismatch recorded as a named residual rather than implied
fixed. Added the cache-invalidation note on the inject/import writers and a
Related Flows section pointing at guided-credential-setup.md.

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

* fix(credentials): three failures that each render as a pass, and the docs gate

Review follow-ups on ent#127. All three share one shape: a defence that fires
correctly and then reports the result on the success path.

1. A caught normalizer raise read as "Ready — needs no credentials".
   `_safe_normalize` wrapped `normalize_credential_requirements` narrowly (the
   ent#128 `run_static` lesson) and returned `[], [error]` — but `build_report`
   then set `requirements_source="live_workspace"` with `degraded_reason=None`,
   so the empty list scored `no_credentials_required`: a green headline with the
   real reason folded into a collapsed `<details>`. The module's own comment
   forbids exactly this. `_safe_normalize` now returns an `ok` flag the caller
   converts into `degraded_reason="template_unreadable"` — reusing the existing
   enum rather than minting a fifth value, since every consumer already handles
   it and `errors[]` distinguishes the two causes.

   The covering test passed throughout: it asserted the exception was caught and
   stopped there. Catching is half the fix; propagating it into the state machine
   is the other half, and a test that stops at "it was swallowed" cannot tell
   them apart. It now asserts the resulting STATE.

2. A trailing DNS root dot moved the eTLD+1 emphasis off the attacker.
   `_registrable_domain` splits right-anchored, so `evil.tld.` adds an empty
   label and shifts every label one place: `accounts.google.com.evil.tld.`
   emphasised `tld.` and dimmed the true registrant. That inverts the module's
   PRIMARY defence — punycode canonicalisation is irrelevant to an all-ASCII
   subdomain attack; the bold IS the mitigation — and it costs one character the
   template author fully controls, next to a "paste your API key" input. Fixed
   with `rstrip(".")` plus a fail-closed empty check, and the adversarial table
   now carries the trailing-dot form of each case.

3. The single-flight lock released leases it no longer owned.
   `_LOCK_TTL_SECONDS` is reachable in normal operation, not pathologically: the
   probe is bounded at `_REQUEST_TIMEOUT` (20s) and the catalog fallback adds
   `get_github_template`'s own 10s HTTP timeout — exactly the TTL. Past it another
   worker may hold the key, and the bare `DELETE` in the `finally` freed it, letting
   a third caller probe the same container concurrently: the precise failure the
   lock exists to prevent, silently. Now a random per-acquisition token released by
   compare-and-delete via the shared `lock_token_matches` (#1919); a constant value
   makes the compare a tautology. Fail-open on Redis absent/erroring is unchanged —
   a Redis outage must degrade to "no backpressure", never to a 409.

Frontend: that 409 is a concurrency signal, not a verdict, and a second viewer
(another tab, operator, or uvicorn worker) inside the ~1s probe window gets it on
a healthy agent. The checklist renders `v-if="error"` AHEAD of
`v-else-if="report"`, so surfacing it blanked a report that had already loaded.
Retried once behind a resettable latch (once per episode, not once per session),
and a failed refresh no longer clobbers a report on screen. Also `:title` on the
setup_url anchor: the visible text is deliberately host-only, so the full
destination needs to be reachable — and it must be the URL, never the author's
label, which would rebuild the deception one layer down where no validator looks.

Docs: the flow doc was missing `## Testing` and `## Related Flows` (present in
every sibling flow) — added, with the coverage table, the four named edge cases,
and an explicit "not covered" note. Backpressure and eTLD+1 sections updated for
the behaviour changes above. Two comments claiming a follow-up was "filed
separately" now say plainly that it is not filed on either tracker: a comment
asserting a ticket exists is the reason nobody re-checks.

208 unit tests pass.

Refs trinity-enterprise#127

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>
dolho added a commit that referenced this pull request Aug 3, 2026
…160)

The MCP surface for the A2A interoperability management plane — the third
surface (Invariant #13) over the entitlement-gated enterprise backend
(trinity-enterprise#160, /api/enterprise/a2a/*). Distinct from the runtime
call_a2a_agent (#736).

New src/mcp-server/src/tools/a2a.ts (7 tools):
- get_agent_a2a_config, set_agent_a2a_exposure, get_agent_a2a_card (proxies the
  OSS #737 served-card endpoint), set_a2a_inbound_allowlist,
  register_a2a_endpoint, list_a2a_endpoints, remove_a2a_endpoint.
- Honest gating: an unentitled 403 ("not licensed") / OSS-only 404 return a
  structured { not_entitled | not_found } — never a silent success. Mutations
  are owner/admin + human-only, enforced at the backend (agent-scoped key → 403
  human_only). Outbound credentials are write-only — the backend returns only
  has_credentials, so no tool echoes a secret.

client.ts: 8 A2A methods (getA2AExposedMap swallows OSS-404/unentitled-403 → {}).
agents.ts: list_agents/get_agent best-effort merge a2a_exposed (mirrors
mcp_exposed, #846) — omitted in editions without A2A, no OSS↔enterprise coupling.
server.ts: register the tool group (connector-denied visibility, like the rest).

Tests: src/mcp-server/src/tools/a2a.test.ts (10) — proxy contract, credentials
never echoed, entitlement/human-only/404 gating. Full suite 100 pass; tsc clean.

Related to #160

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 4, 2026
Adds `ask_trinity` to the Trinity MCP server, proxying the existing public
docs Q&A endpoint (DOCS-QA-001 — Vertex AI Search + Gemini). Agents on
agent-scoped keys and external MCP clients can now ask grounded questions
about Trinity itself without leaving the tool surface; `docs.ts` previously
exposed only a static file read.

**Vendored, not reinvented.** `src/helper-mcp` (`@abilityai/trinity-docs-mcp`,
shipped by #1579) already contains a hardened adapter for this exact
endpoint, and its own header states it was written "contract-identical with
the main Trinity MCP server's ask_trinity" — this is the other side of that
contract finally existing. The two are separate npm packages with no
workspace between them, so the copy follows Trinity's vendored-mirror
pattern.

Parity is **behavioural, not byte-for-byte**, and deliberately so: the
helper's module also carries an agent-guide fetcher, and copying that in
would give this package a second, network-based guide fetcher beside the
disk-based `readAgentGuide()` it already has. Copying dead code to satisfy a
`cmp` is the wrong trade. `ask_trinity.test.ts` drives both implementations
through ONE table of 14 endpoint responses and asserts identical output —
verified non-vacuous: changing a single word in the vendored copy fails 6
tests.

Session semantics are the part a reimplementation gets wrong, so they are
pinned explicitly:

  * `session_id` is an opaque STRING — real values exceed
    `Number.MAX_SAFE_INTEGER`, and parsing one as a number corrupts it
    silently, because the corrupted id still returns HTTP 200;
  * expiry is SILENT — an expired id yields 200, SUCCEEDED, and a NEW id, so
    a changed id is surfaced to the caller as an explicit context-lost
    warning rather than letting the conversation quietly reset.

`ASK_TRINITY_ENDPOINT` is wired through BOTH compose files onto the
mcp-server service and documented in `.env.example`. An env var the
container never receives is not actually configurable — the same
"un-flippable on deploy" trap already called out for
`MCP_AGENT_CHAT_PULL_ENABLED` two lines above it.

Registered in the existing `createDocsTools()` group, so it inherits the
standard audit wrapper with no special-casing. MCP-server-only: no backend,
no DB, no migration — Invariant #13's three-surface sync reduces to one.

133/133 MCP tests pass; `tsc --noEmit` clean.

Related to Abilityai/trinity-enterprise#328

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 4, 2026
* fix(scheduler): record who initiated an execution (#1970)

`schedule_executions` has carried five origin columns for audit since
AUDIT-001, and the backend populates them on every path it owns. The
scheduler is a separate service with its own DB module, and its
`create_execution()` listed none of them in the INSERT — nor accepted
them in its signature, so there was nowhere to put a caller even if one
had been forwarded. Every scheduler-created row was written with all
five NULL.

`triggered_by='manual'` therefore recorded *that* a human ran something
and never *who*. The attribution lived only in backend/MCP-server logs,
bounded by log retention, so past a few weeks the durable record could
not answer "did anyone trigger this run, and who?".

The identity was dropped at three points, not one:

  1. the backend's delegating POST sent no body at all, so the
     authenticated caller — in scope right there — never crossed the hop;
  2. `_trigger_handler` had no parameter to receive one;
  3. `create_execution()` had nowhere to put it.

An `ExecutionOrigin` value object is threaded through all three. One
object rather than five parallel parameters at four call depths: five
positional siblings is how one of them silently stops being forwarded.

Two paths the DB fix alone would have left blank are covered too. A
retry inherits the original run's origin — it has no caller of its own,
but a chain of retries that drops the initiator makes the first attempt
the only attributable one; the read is fail-open, since an audit lookup
must not be able to stop a retry from running. A reminder inherits the
provenance #1296 already persisted.

Cron ticks stay NULL. Attributing an autonomous fire to, say, the
schedule's owner would make the column actively misleading — a blank
reads as "unknown", a wrong name does not.

Also hardened while here:

- the untrusted trigger body is validated at the scheduler boundary.
  `source_user_id` is dropped rather than coerced when it is not an int:
  `bool` IS an `int` in Python, so `True` would have persisted as user
  1, a real account attributed to a run it had nothing to do with.
  Strings are length-capped and blank-to-None, so "" and NULL are not
  two spellings of "unknown".
- the backend prefers the validated `current_user.agent_name` over the
  raw `X-Source-Agent` header — the reverse of chat.py's precedence,
  which is fine for a collaboration hint but would let a caller pin its
  run on a sibling agent in an audit column.
- the MCP trigger tool forwards the origin headers `chat()` already
  sends (Invariant #13). Without it an MCP-triggered run attributes to
  the key OWNER but not to which key or agent fired it — the part that
  identifies the actor when one human owns many of both.

Not a vulnerability: nothing authorizes on these columns.

Backward compatible in both rolling-deploy directions — an old scheduler
ignores the new body fields, and a new scheduler treats a bodyless POST
as an unattributed manual trigger.

tests/unit/test_1970_execution_origin.py — 27 checks, 25 of which fail
against the pre-fix tree.

Related to #1970

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

* fix(scheduler): bound source_user_id and fix is_empty (#1970, /edge-cases)

Two real bugs in this PR's own `ExecutionOrigin`, found by a boundary +
property pass over `from_payload` and fixed here.

**1. Range was never checked.** The parser dropped wrong-TYPED values and
capped string LENGTH, but accepted any `int`. `source_user_id` lands in an
INTEGER column, and SQLite raises `OverflowError: Python int too large to
convert to SQLite INTEGER` rather than truncating — verified end-to-end
against a real `create_execution`. On this PR's path the exception lands in
`_execute_manual_trigger`'s blanket `except`, so the run is logged and
silently lost *after* the endpoint already answered `"triggered"`. A field
of an untrusted payload decided whether the schedule ran, which is the exact
thing this function exists to prevent — the range check is the missing
sibling of a guard that was otherwise present.

**2. `is_empty()` used truthiness.** `not any((self.user_id, ...))` reports
an origin carrying `user_id=0` as empty. Hypothesis shrank it to
`{"source_user_id": 0}`. Latent — nothing in `src/` calls it yet — which is
why it was worth fixing now, before the first caller inherited a helper that
lies about a valid value.

Both lived inside code with **100% statement and branch coverage** from
`test_1970_execution_origin.py`. Full coverage says every line ran, not that
every value class was tried; that gap is the whole reason for a separate
edge-case pass.

tests/unit/test_execution_origin_properties.py — 31 boundary rows + 6
Hypothesis properties.

Related to #1970

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

---------

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

The MCP surface for the A2A interoperability management plane — the third
surface (Invariant #13) over the entitlement-gated enterprise backend
(trinity-enterprise#160, /api/enterprise/a2a/*). Distinct from the runtime
call_a2a_agent (#736).

New src/mcp-server/src/tools/a2a.ts (7 tools):
- get_agent_a2a_config, set_agent_a2a_exposure, get_agent_a2a_card (proxies the
  OSS #737 served-card endpoint), set_a2a_inbound_allowlist,
  register_a2a_endpoint, list_a2a_endpoints, remove_a2a_endpoint.
- Honest gating: an unentitled 403 ("not licensed") / OSS-only 404 return a
  structured { not_entitled | not_found } — never a silent success. Mutations
  are owner/admin + human-only, enforced at the backend (agent-scoped key → 403
  human_only). Outbound credentials are write-only — the backend returns only
  has_credentials, so no tool echoes a secret.

client.ts: 8 A2A methods (getA2AExposedMap swallows OSS-404/unentitled-403 → {}).
agents.ts: list_agents/get_agent best-effort merge a2a_exposed (mirrors
mcp_exposed, #846) — omitted in editions without A2A, no OSS↔enterprise coupling.
server.ts: register the tool group (connector-denied visibility, like the rest).

Tests: src/mcp-server/src/tools/a2a.test.ts (10) — proxy contract, credentials
never echoed, entitlement/human-only/404 gating. Full suite 100 pass; tsc clean.

Related to #160

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 5, 2026
`schedule_executions` has carried five origin columns for audit since
AUDIT-001, and the backend populates them on every path it owns. The
scheduler is a separate service with its own DB module, and its
`create_execution()` listed none of them in the INSERT — nor accepted
them in its signature, so there was nowhere to put a caller even if one
had been forwarded. Every scheduler-created row was written with all
five NULL.

`triggered_by='manual'` therefore recorded *that* a human ran something
and never *who*. The attribution lived only in backend/MCP-server logs,
bounded by log retention, so past a few weeks the durable record could
not answer "did anyone trigger this run, and who?".

The identity was dropped at three points, not one:

  1. the backend's delegating POST sent no body at all, so the
     authenticated caller — in scope right there — never crossed the hop;
  2. `_trigger_handler` had no parameter to receive one;
  3. `create_execution()` had nowhere to put it.

An `ExecutionOrigin` value object is threaded through all three. One
object rather than five parallel parameters at four call depths: five
positional siblings is how one of them silently stops being forwarded.

Two paths the DB fix alone would have left blank are covered too. A
retry inherits the original run's origin — it has no caller of its own,
but a chain of retries that drops the initiator makes the first attempt
the only attributable one; the read is fail-open, since an audit lookup
must not be able to stop a retry from running. A reminder inherits the
provenance #1296 already persisted.

Cron ticks stay NULL. Attributing an autonomous fire to, say, the
schedule's owner would make the column actively misleading — a blank
reads as "unknown", a wrong name does not.

Also hardened while here:

- the untrusted trigger body is validated at the scheduler boundary.
  `source_user_id` is dropped rather than coerced when it is not an int:
  `bool` IS an `int` in Python, so `True` would have persisted as user
  1, a real account attributed to a run it had nothing to do with.
  Strings are length-capped and blank-to-None, so "" and NULL are not
  two spellings of "unknown".
- the backend prefers the validated `current_user.agent_name` over the
  raw `X-Source-Agent` header — the reverse of chat.py's precedence,
  which is fine for a collaboration hint but would let a caller pin its
  run on a sibling agent in an audit column.
- the MCP trigger tool forwards the origin headers `chat()` already
  sends (Invariant #13). Without it an MCP-triggered run attributes to
  the key OWNER but not to which key or agent fired it — the part that
  identifies the actor when one human owns many of both.

Not a vulnerability: nothing authorizes on these columns.

Backward compatible in both rolling-deploy directions — an old scheduler
ignores the new body fields, and a new scheduler treats a bodyless POST
as an unattributed manual trigger.

tests/unit/test_1970_execution_origin.py — 27 checks, 25 of which fail
against the pre-fix tree.

Related to #1970

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 5, 2026
…ps (ent#326)

The backend half of ent#94's grid-widget foundation. Three tile sub-issues
(#96 executions-by-trigger, #98 fleet cost, #101 fleet context) were blocked
on an endpoint the epic listed but never filed; they now share one query
instead of each growing their own.

Time-series sibling of `/api/executions/stats`: same table, same access
model, buckets instead of scalars. Read-only — no schema change, no
migration, no MCP surface (Invariant #13's three-surface cost buys nothing
for a dashboard read).

**An analytics axis cannot degrade the way a filter can.** `/stats` and the
list route coerce an unknown `hours` to 24. That is right for a filter — the
worst case is more rows than you asked for — and wrong for a chart, where it
silently redraws a window the caller never requested and gives them no way
to tell. Both `group_by` and `hours` therefore 422 by name.

`hours=0` is refused for `hour`/`day` specifically: an all-time axis emits
one bucket per interval since the fleet's first execution, which is an
unbounded response nobody asked for. It stays allowed for `trigger`/`agent`,
which have no continuum and are bounded by the number of distinct values.

Trigger folding happens in Python through `_TRIGGER_BUCKETS`, not a SQL
CASE, so a newly-added trigger type lands in the explicit `Other` catch-all
instead of vanishing from a chart the first time someone forgets the SQL.
Buckets slice the stored ISO-Z `started_at` with `substr` rather than a date
function — dialect-agnostic across SQLite and PostgreSQL, and the same UTC
the row was written with (Invariant #16).

**The token question ent#326 requires settling: option 1.**
`schedule_executions` has no usage-token column — `output_tokens` lives only
on `chat_messages`, which covers chat turns rather than fleet executions. So
the endpoint reports context-window OCCUPANCY under the name
`context_used`, and ent#94's #101 tile must be labelled to match. Presenting
this as "tokens consumed" is exactly the liveness-vs-quality mislabel the
issue warns against. A guard fails if such a column is ever added, so the
schema-change option gets revisited deliberately rather than silently.

That guard matches by explicit NAME, not substring: `claim_token` (the #1081
pull-lease CAS value) contains "token" and answers a completely different
question — my first version of the check tripped on it.

tests/unit/test_ent326_executions_timeline.py — 30 checks, including the
dangerous access direction (an empty allow-list returns an empty series,
never everything) and the DB layer driven against a real SQLite table.

Related to Abilityai/trinity-enterprise#326

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
obasilakis added a commit that referenced this pull request Aug 5, 2026
… key (#848) (#1707)

* harden(mcp): operator tool visibility is an allow-list, not a deny-check (#848 prereq)

The operator-tool gate was `auth?.scope !== "connector"` — it admitted every
scope it had not heard of, including a context with no auth at all. Replace it
with an explicit `OPERATOR_SCOPES = {user, agent, system}` allow-list that
fails closed.

Not currently exploitable: our `authenticate` callback throws on a missing or
invalid key rather than returning undefined, so no falsy-auth session exists
today. It is a trap door sitting exactly where #848 must step — the natural way
to let an unauthenticated caller reach `request_login` is to return undefined
instead of throwing, and fastmcp@4.4.0 turns that into full operator exposure
via two independent behaviours:

  1. `FastMCP#createSession` skips filtering entirely for falsy auth:
     `auth ? this.#tools.filter(...canAccess...) : this.#tools`
     (dist/chunk-MDIESGNI.js:1762) — every registered tool is advertised and
     `canAccess` never runs.
  2. The stateful httpStream branch we run does NOT reject an `authenticate()`
     returning undefined; only the stateless branch guards it (:1640 vs :1690).

Blast radius had it been tripped: disclosure of the full operator tool catalog
(create_agent, delete_agent, inject_credentials, export_credentials,
get_credential_encryption_key, get_agent_ssh_access, ...). Execution of most
would still fail in `getClient()` for want of an mcpApiKey — but that is a
client-construction guard, not an authorization check, so any tool not routed
through it would genuinely run.

Renames `connectorDenied` -> `operatorOnly` across server/index/dynamic-agents;
the old name would actively misdescribe the new semantics on a security
predicate. ent#46 connector isolation is unchanged (`connectorOnly` untouched).

Adds src/tool-visibility.test.ts (9 cases) pinning: the three operator scopes
admitted, connector denied, an anonymous pre-login sentinel denied, unknown and
future scopes denied, absent auth denied under MCP_REQUIRE_API_KEY, dev-mode
semantics preserved, the two gates mutually exclusive, and the scope set itself
pinned so widening it must be deliberate. Each fail-open case also asserts the
legacy predicate admitted it, so the tests prove the fix changes behaviour.

99/99 mcp-server tests pass; tsc --noEmit clean.

Refs #848

* feat(mcp): anonymous session tier + inline-auth tools scaffolding (#848)

First half of inline email auth. Flag-gated OFF (MCP_INLINE_AUTH_ENABLED),
so this commit changes no runtime behaviour.

- requirements/mcp.md §7.6 written first per RoE #1: credential model
  (session not key, and the per-connection lifetime that implies), the
  anonymous tier, why the tool surface is static across login, the internal
  backend path, whitelist bypass + why, and enumeration safety.

- McpAuthContext gains `anonymous` scope plus verifiedEmail / pendingEmail /
  sessionId. verify_login upgrades the session by mutating this object IN
  PLACE — FastMCP hands every tool the same reference, so the upgrade needs no
  library support. `scope` deliberately STAYS "anonymous" after login: the
  session still holds no API key and must never satisfy operatorOnly.

- authenticate(): an ABSENT Authorization header opens an anonymous sentinel;
  an INVALID key still throws. The sentinel is a truthy object with no
  `authenticated` key — fastmcp@4.4.0 skips canAccess entirely for falsy auth
  and rejects `{authenticated:false}` outright.

- Gates: `anonymousOnly` for the login tools, `connectorOrAnonymous` for the
  connector tools. The anonymous tool list is deliberately IDENTICAL before and
  after login — a session's tools are resolved once at construction with no
  per-session refresh API, so gating visibility on login state would need a
  client reconnect to take effect. Login flips behaviour, not visibility.

- tools/auth.ts: request_login / verify_login. request_login returns one
  constant body on every path (unknown address, malformed input, per-session
  limit, backend error) and emits no audit event — wording, timing or an audit
  row would each be an enumeration oracle (#186). Backend errors are swallowed
  for the same reason. verify_login failures are uniform (never wrong-code vs
  unknown-email). Per-session attempt caps sit on top of the backend limiters,
  which Telegram's inline /login lacks entirely.

- client.ts: requestInlineLoginCode / verifyInlineLoginCode bypass _fetch (an
  anonymous session has no bearer token) and authenticate the CALLER with
  X-Internal-Secret. The secret proves who is asking, never what they may do —
  the backend gates on email_has_agent_access.

Still to come: connector tools acting for a verified email, the four internal
backend endpoints, tests both sides, feature-flow doc.

tsc --noEmit clean; 99/99 mcp-server tests pass.

Refs #848

* feat(mcp): connector tools serve an email-verified session (#848)

Completes the MCP-server half. Connector tools now handle two caller kinds
that resolve BOTH the agent and the backend credential differently:

  - connector key (ent#46): bound agent from the auth context, backend reached
    with the key. Unchanged.
  - email-verified anonymous session (#848): no key at all; the agent is chosen
    from the set shared with the verified email, and the backend is reached
    over the internal surface carrying that email.

The tools gain an OPTIONAL `agent` argument — unambiguous when one agent is
available, required when several. It selects among ALREADY-authorized agents
and is never a way to reach an unauthorized one: `session.agents` is a
convenience for defaulting and error messages, and the backend re-gates every
call on `email_has_agent_access`, so a stale or tampered list cannot widen
access. For a connector key the bound agent stays authoritative and a
disagreeing `agent` argument is REFUSED rather than ignored — silently reaching
a different agent than the client named is the worse failure.

A pre-login anonymous session is advertised these tools (the list is frozen at
session construction) and refuses to act, returning a structured
`login_required` rather than throwing.

Adds src/inline-auth.test.ts (21 cases) pinning the security-relevant
properties, not the cosmetic ones:
  - request_login is byte-identical across known / unknown / malformed /
    rate-limited / backend-threw — every enumeration path asserted equal
  - a malformed address is never relayed to the backend
  - verify_login upgrades in place but scope STAYS "anonymous" and no key is
    attached (it must never satisfy operatorOnly)
  - the verify response leaks no credential (asserts absence of
    trinity_mcp_/api_key/token/secret anywhere in the payload)
  - failures are uniform; a failed verify never binds the session
  - pre-login refusal on all three connector tools
  - an agent outside the authorized set dispatches nothing
  - the exposed-playbook allow-list still holds on the inline path
  - connector-key behaviour unchanged, incl. bound-agent mismatch refused

tsc --noEmit clean; 120/120 mcp-server tests pass.

Refs #848

* feat(backend): internal surface for MCP inline email auth (#848)

Backend half. Four endpoints under /api/internal/mcp-auth (X-Internal-Secret,
reusing routers/internal.py's C-003 dependency verbatim), router → service → db
per Invariant #1, models in models.py per Invariant #14. No new tables — reuses
email_login_codes. No migration.

  /request    email a 6-digit code, iff the address is already known
  /verify     check the code, resolve reachable agents, audit the outcome
  /playbooks  exposed playbooks for a verified email
  /chat       one turn via TaskExecutionService, attributed to that email

The internal secret authenticates the CALLER, never the action. /playbooks and
/chat re-gate on email_has_agent_access + connector-enabled per call, so a
compromised MCP server still cannot reach an agent the asserted email cannot,
and nothing here ever returns a credential.

Security properties, each pinned by test:

- /request is not an open email relay. A code is generated only for an address
  with a users row or an agent_sharing entry. That lookup FAILS CLOSED — being
  unable to answer "do we know this address" must not degrade into "email
  anyone who asks".
- Every /request branch is byte-identical: 202 + {"status":"ok"} for known,
  unknown and rate-limited, send dispatched fire-and-forget (strong-ref set,
  the asyncio GC footgun), and NO audit row — an audit entry is itself an
  enumeration oracle, which is why routers/auth.py emits none either. A test
  asserts known.content == unknown.content and pins the key set to {"status"},
  so a future expires_in_seconds or per-branch message fails loudly.
- Rate limiting is keyed on EMAIL, not IP: every call arrives from the MCP
  server, so one IP bucket would be fleet-shared — useless as a limit and a
  trivial fleet-wide DoS. session_id is logged only, never a limiter key (a
  client picks its own session ids and could rotate past any cap).
- The data gate returns ONE 403 body for no-access / connector-disabled /
  no-such-agent; splitting them enumerates the fleet (Invariant #8).
- The gate runs BEFORE the idempotency claim on /chat, so an unauthorized
  caller cannot occupy a key slot for an agent it cannot reach.
- get_or_create_email_user is called with no role argument; a test asserts the
  created role is `user` (the #314 silent-promotion regression).
- The verify response is scanned whole for token/api_key/secret/bearer and its
  key set asserted exhaustively — a nested credential would fail, not just a
  top-level one.

Shared rather than duplicated: _fetch_live_playbooks moves out of
routers/connector.py into connector_service.fetch_live_playbooks, used by both
the owner route and the inline route. Verbatim move, no behaviour change; the
module docstring now admits it is no longer purely pure.

New db accessors: get_agents_shared_with_email (the existing get_shared_agents
is username-keyed, and inline auth must answer "is this address known" BEFORE
any user row exists) and list_connector_enabled_agents.

Docs: requirements §7.6 updated to match what shipped — the per-call denial is
a flat 403 and deliberately does NOT write an access_requests row, because that
gate runs per tool call and would be a spam vector (the channel gate runs once
per conversation, which is why it can). An explicit request-access affordance
is noted as deferred. feature-flows/mcp-connector.md gains the end-to-end Part B
flow. learnings.md records the two fastmcp findings from this work, including a
correction to a wrong claim in a dated security report.

.env.example documents MCP_INLINE_AUTH_ENABLED with the posture warning.

Tests: tests/unit/test_848_mcp_inline_auth.py, 33 cases.
Full backend unit suite deterministic (-p no:randomly): 4635 passed, 1 failed —
test_1474_read_boundary_z.py::test_schedules_summary_last_run_at_normalized,
verified PRE-EXISTING by running it in a clean worktree at HEAD with zero
backend changes, where it fails identically.

Refs #848

* fix(848): review findings — cross-user replay, ungated backend, IP-bucket DoS

/review with two independent adversarial passes (backend + mcp-server). Seven
findings fixed; two were confirmed empirically by the reviewers, not just read.

CRITICAL — cross-user disclosure on /chat
  The idempotency scope was `agent:{name}` with a caller-supplied key, so two
  different verified users of the same shared agent produced the SAME
  (scope, key): the second was served the first's stored response snapshot and
  execution_id. Reachable by accident as well as malice — MCP clients derive
  deterministic keys from call args, so two users asking one agent the same
  question collide by design. New `make_inline_auth_scope(agent, email)` folds
  the identity in. Every other `make_agent_scope` caller sits behind a per-user
  auth dependency; inline auth is the first where identity arrives in the BODY.
  The old test replayed with ONE identity, proving caching but never isolation —
  the new test asserts a different principal with the same key does NOT replay.

CRITICAL — the backend was not gated at all
  MCP_INLINE_AUTH_ENABLED existed only in the mcp-server. `include_router` was
  unconditional, so a surface that bypasses the email whitelist, creates
  accounts and dispatches chat answered on EVERY install. My own PR description
  claimed "flag-gated, default OFF" — false for the backend half. Now
  config.MCP_INLINE_AUTH_ENABLED + a router dependency that 404s the surface
  (404 not 403, so a disabled deploy does not advertise it), with a
  parametrized test over all four endpoints.

HIGH — /verify poisoned a shared per-IP lockout bucket
  client_ip is always the MCP server, so all users collapsed into one bucket.
  At 30 fails/5min, ONE anonymous client could lock inline login out fleet-wide
  and burn real web logins from the same egress — the platform-wide DoS #591
  removed, reintroduced structurally. Added account-only limiter variants; the
  IP bucket is never written from this path.

HIGH — /request timing oracle (measured 1.89x)
  The known branch did a committing INSERT the unknown branch did not, so
  identical bytes still leaked membership. All branch-dependent work moved
  behind Starlette BackgroundTasks (after the response is flushed).
  NOT asyncio.to_thread: that was tried and silently broke every send — SQLite
  is thread-affine, `_email_is_known` raised in the worker and its fail-closed
  handler swallowed it into "unknown address". Caught only because a test
  asserts the send fires.

HIGH — /request had no bound on the unknown-address branch
  The per-address cap sits behind the known-check, so unknown addresses were
  never counted. Added a coarse global ceiling, checked BEFORE the branch so it
  cannot itself become a differential; over-limit is a silent skip, same 202.

MEDIUM — mcp-server
  * `available.length > 0 &&` let ANY requested agent through when nothing was
    shared — the exact state meaning "you have nothing". Guard removed.
  * verify_login echoed upstream errors to an unauthenticated caller (raw HTTP
    statuses; the INTERNAL_API_SECRET env-var name when unset) and created a
    second distinguishable failure shape. Now one constant body, cause logged.
  * The per-session counter Map had no eviction and no session-close hook —
    unbounded growth from anonymous connections. Bounded, oldest-first.
  * Four new fetch calls had no timeout while the key-based path bounds itself,
    so a hung agent pinned an anonymous tool call forever. All bounded.
  * Audit rows for inline calls were unattributable: the new `agent` param was
    invisible to resolveTargetId, and run_playbook's `name` (a PLAYBOOK) was
    being stamped as target_type:"agent". Fixed, plus actor_email threaded
    end-to-end — note InternalAuditRequest would have SILENTLY DROPPED it
    (Pydantic extra='ignore'), so the field was added to the model, the router
    and platform_audit_service.log as a resolver fallback.

Also fixed in my own work, before the reviewers ran:
  tool-visibility.test.ts hand-copied OPERATOR_SCOPES from server.ts with a
  comment claiming it was "kept in sync" — it pinned its own copy, so widening
  the real set would have left it green. That is the drift trap already in
  learnings.md (2026-07-16). Predicates now exported and imported; guard proven
  to fire by temporarily widening the real set.

CORRECTED DOCS — a load-bearing claim of mine was wrong
  I wrote that FastMCP has "no per-session refresh API", and built the static
  tool-surface rationale on it. False: `toolsListChanged` re-filters LIVE
  sessions against current auth (dist:548-553), fanned by addTool/removeTool
  (:2202-2206) — which Trinity's own #846 reconciler fires every ~20s. The
  design stands, for the OPPOSITE reason: a login-keyed gate would flip
  non-deterministically at reconciler timing. Corrected in requirements §7.6,
  the feature-flow doc, server.ts, types.ts and connector.ts. Also noted:
  `updateAuth` REPLACES the auth object, which would discard the in-place
  upgrade entirely.

learnings.md: three entries — idempotency scope vs. caller identity,
to_thread-breaks-SQLite hidden by a fail-closed handler, and per-IP limiters
behind a single-egress proxy.

Tests: backend 41 (was 33), mcp-server 125 (was 120).
Full backend suite -p no:randomly: 4643 passed, 1 failed — the pre-existing
test_1474 failure, previously verified in a clean worktree at HEAD.

Refs #848

* feat(848): keyless connector setup — the last acceptance criterion (AC6)

AC6: "the default .mcp.json snippet in docs / UI works without a pre-filled API
key." Delivered in both surfaces.

- connector_service: `build_keyless_snippets` + a shared `_client_snippets`
  builder that `build_snippets` now also uses, so the keyed and keyless
  variants cannot drift. The keyless config is the keyed block minus the
  Authorization header, with a note pointing at request_login/verify_login.
- ConnectorStatus gains `inline_auth_available` + `keyless_snippets`, populated
  by the connector router ONLY when config.MCP_INLINE_AUTH_ENABLED is on — an
  anonymous MCP session is rejected otherwise, so offering a keyless config on a
  disabled install would be dead setup instructions. Independent of has_key:
  the keyless flow is an ALTERNATIVE to minting a key, so it shows with or
  without one.
- ConnectorChannelPanel.vue: a "Share without a key — sign in by email" block,
  gated on status.inline_auth_available, reusing the existing copy affordance.
  The UI reads the flag off the connector status it already fetches — no new
  endpoint, and NO feature-flags entry (an earlier draft added one; it was
  redundant with inline_auth_available and out of the issue's scope, so it was
  dropped).
- docs: the keyless config block + flow in feature-flows/mcp-connector.md and
  requirements §7.6.

Tests: keyless snippets carry no Authorization and keep client parity with the
keyed set; the status endpoint offers keyless ONLY when the flag is on, and does
so even with no key. Frontend builds clean.

Closes the last open box on #848.

Refs #848

* docs(security): CSO --diff audit of #848 — no findings above gate

7 attack axes independently refuted (default-safe posture, internal-secret
authz, cross-user replay, enumeration/relay, anon->operator reach, verify_login
disclosure, audit integrity). Verifies the 7 /review fixes hold. Two
informational accepted-risk notes (internal-secret widening; pre-existing
per-account bucket). Report: docs/security-reports/cso-2026-07-21.{json,md}.

Refs #848

* fix(mcp): wire MCP_INLINE_AUTH_ENABLED into compose; correct fastmcp citations (#848)

Addresses the /validate-pr review on #1707.

BLOCKING — the flag reached neither container. MCP_INLINE_AUTH_ENABLED was
defined in config.py, read in server.ts, documented in .env.example, gated at
the router and covered by a parametrized endpoint test — and wired into zero
compose services. Compose reads .env only for ${...} interpolation, never to
inject into a container, and there is no env_file: or Dockerfile ENV, so the
whole feature was permanently off with no operator lever. Now wired in all four
places (backend + mcp-server x dev + prod), verified with compose's own
resolution rather than grep.

It fails safe, which is why nothing caught it: CI and /verify-local both boot at
defaults, so a flag that can never be enabled boots clean and goes green. The
durable fix is tests/unit/test_848_inline_auth_compose_wiring.py — a static
packaging guard (precedent: test_1489_vite_bug_build_args.py) asserting all four
wirings, the ${VAR:-false} shape (a hardcoded value is the same un-switchable
bug with extra steps; a non-false default would make a network-exposed keyless
path opt-out), and the reader set itself, so a third process reading the flag
without wiring it fails here. Mutation-checked: removing one wiring fails 2.

fastmcp citations were wrong, twice over. The reviewer caught 4.4.0 vs the
pinned 4.8.0; the dev merge has since bumped to 4.12.1, so 4.4.0 was only what a
stale local node_modules held. Re-verified both load-bearing behaviours against
the version package-lock actually resolves — the falsy-auth filter-skip is still
present, so the 87b2abb allow-list hardening is warranted — and corrected every
citation to symbol-first with the line as an "as of" locator, since the minified
chunk filename moves between releases. Also corrected the mechanism: canAccess is
never re-invoked per call; enforcement is by absence from the toolsMap
setupToolHandlers builds from the filtered list.

Per the review, that assumption is now pinned by a test instead of prose:
tool-visibility.test.ts boots a real FastMCP server and drives it with a real MCP
client, asserting a filtered-out tool is not merely hidden from tools/list but
rejected on call AND that its body never executes. Behavioural, so it survives
chunk renames — the churn that made the citations wrong. Mutation-checked
against the pre-#848 deny-check.

Corrected a live error in learnings.md: the entry claiming canAccess filtering
happens "once at session construction, so any log-in-and-new-tools-appear design
needs a client reconnect". toolsListChanged re-filters live sessions and the #846
reconciler fires it every ~20s. The static-surface design stands for the opposite
reason — a login-keyed gate would flip non-deterministically at reconciler
timing. New entry for the compose-wiring class.

Docs (Invariant #13 third-surface record): architecture.md gains routers/mcp_auth.py,
services/mcp_auth_service.py, the tools/auth.ts row, the four /api/internal/mcp-auth/*
endpoints, and the missing `connector` + `anonymous` scope rows. Module counts left
alone — already stale independent of this PR, and reconciling them is
/validate-architecture drift. feature-flows.md gains its Recent Updates row.

Also: MCP_INLINE_AUTH_TIMEOUT_MS documented and wired (mcp-server only — the
backend never reads it, asserted); mcp_inline_auth_enabled surfaced on
GET /api/settings/feature-flags as observability, mirroring its two siblings and
giving operators a post-deploy check that the two halves agree; CSO report
renamed to the cso-diff-DATE-issue convention; CSO N1 (set INTERNAL_API_SECRET
explicitly rather than relying on the SECRET_KEY fallback) carried into
.env.example and requirements §7.6 — there is no unreleased-notes file, and
.env.example is where an operator looks when enabling the flag.

Verified: backend unit 5620 passed / 0 failed (the previously-known pre-existing
test_1474 failure now passes, fixed by the dev merge); mcp-server 127/127 (was
125) and tsc --noEmit clean, both against the real 4.12.1 after npm ci.

* docs(security): /cso --diff re-audit of #848; fix a docstring naming a known landmine

CSO --diff pass over the post-review branch. 0 findings above the daily 8/10
gate; 3 informational, 1 fixed here.

The re-run is not a formality. The 2026-07-21 audit assessed gates that were
correct but UNREACHABLE — MCP_INLINE_AUTH_ENABLED reached zero containers, so
its "default-safe posture" was unconditional rather than opt-in. With the four
wirings in place the surface is genuinely reachable on an opt-in deploy, so the
live controls were re-verified against code rather than re-read from the prior
report: router-level gating of all 4 routes, assert_email_may_reach_agent
running before the idempotency claim and failing closed, uniform 403 on both
denial reasons, account-only limiters that never touch _ip_key, constant-time
/request with everything branch-dependent in BackgroundTasks, name-only
build_tool_description (#846 precedent), and the OPERATOR_SCOPES allow-list.
Parity/uniformity guards: 38 passed.

N2, found and fixed: routers/mcp_auth.py claimed the branch-dependent work runs
"in a worker thread". It does not and must not — asyncio.to_thread there
silently stops every code send, because SQLite connections are thread-affine so
_email_is_known raises in the worker and its own fail-closed handler swallows
that into "unknown address" while the endpoint keeps answering 202. The router
is the file a maintainer opens first, so the natural "make these consistent"
edit was to reintroduce a total silent outage. Not a security finding — the
invited failure is availability, not a bypass — but a real defect sitting on the
enumeration-safety contract. Now names BackgroundTasks and says why a thread is
wrong.

N1 (INTERNAL_API_SECRET can act as any verified email) carried forward
unchanged and still accepted; its operational recommendation is no longer
theoretical now the surface is reachable, and was carried into .env.example and
requirements §7.6 in the previous commit. N3: 4 transitive npm vulns in
src/mcp-server, all isDirect:false, pre-existing on dev and untouched by this
PR — noted because package.json already overrides fast-uri, so the pin is
evidently not resolving the advisory.

Stated plainly in the report: this is the author auditing their own change on a
keyless auth surface, same caveat as the prior audit. Independent assurance
needs a fresh-context run by someone who did not write it.
vybe pushed a commit that referenced this pull request Aug 6, 2026
…nterprise#126) (#1911)

* fix(manifests): drop acme-consulting.yaml — broken duplicate of the live seed

The upcoming UI manifest picker (trinity-enterprise#126) renders one card per
file in config/manifests/, which promotes these files from zero-consumer samples
to the primary one-click install path on a fresh install. acme-consulting.yaml
does not survive that promotion:

* `cpu: 1.0` is an unquoted YAML float. `normalize_cpu` compares against
  VALID_CPU = ("1","2","4","8","16") as strings, and the scout/sage/scribe
  templates declare no `resources` of their own, so the manifest value survives
  the template merge and every one of the 3 agents fails at create — deploy
  returns status "failed" / HTTP 500. The dry-run does NOT catch it: preflight
  builds a throwaway AgentConfig carrying only name+template.
* `auto_start:` and `trinity_prompt:` are not parsed by parse_manifest and were
  silently dropped. `trinity_prompt` is a typo for `prompt:`, so the prompt this
  manifest's author intended was never installed.
* It declares the SAME system name (`acme`) and the same three short names as
  default-system.yaml, the first-run seed. The seed runs on every fresh install,
  so clicking this card would resolve to acme-scout_2/acme-sage_2/acme-scribe_2 —
  a duplicate fleet whose recovery is manual and per-agent. The cpu bug was
  masking this; fixing cpu alone would have unmasked it.

Deleted rather than renamed. Renaming the system to `acme-consulting` unmasks a
worse problem: the shared-folder mount is /home/developer/shared-in/{full_agent_name}
(db/shared_folders.py:231) and the consumed templates hard-code the sibling names
(scribe/CLAUDE.md:21-22, sage/CLAUDE.md:21,70, sage/.claude/commands/request-research.md:9),
so the renamed fleet would document shared-in/acme-scout/ while mounting
shared-in/acme-consulting-scout/ — exactly what default-system.yaml:16-17 warns
about. Making the templates name-agnostic instead would change the agent-facing
behaviour surface of the live default fleet from inside a UI issue.

default-system.yaml already IS this fleet, correctly specified, and remains
untouched. The structural fix for the defect class — validating merged resources
in the dry-run preflight — lands with the rest of ent#126.

Refs trinity-enterprise#126

* test(systems): characterize configure_permissions + create_schedules pre-refactor

ent#126 extracts the permission-topology and schedule-iteration logic out of
these two shipped writers into pure resolvers, so the dry-run preview and the
real deploy compute the same thing from the same code. That refactor needs
behaviour-preservation evidence, and neither obvious candidate supplies it:

* A parity test asserting `resolve_permission_edges()` matches a writer that was
  just refactored to loop that same resolver is tautological — resolver and
  writer drift together and the test stays green.
* test_ent125_resilient_system_deploy.py monkeypatches BOTH configure_permissions
  and create_schedules (lines 71-88), so that suite never executes either
  function and proves nothing about them.

These 25 tests pin the writers' observable behaviour — the exact ordered
db.set_agent_permissions / db.create_schedule call sequences and the integers
returned — hand-derived from the shipped code and written independently of the
resolvers. Captured GREEN here, BEFORE any production change; that run is the
artifact the refactor is measured against.

They pin, specifically, the truthiness guards that are easiest to "tidy" into a
behaviour change:

* full-mesh `if targets:` — a lone agent is skipped entirely, never written as []
* orchestrator-workers `if workers:` guards the WHOLE body, so a lone
  orchestrator with zero workers writes nothing and counts 0
* orchestrator-workers counts len(workers) by assignment, not by summation, and
  the worker-clearing calls are not counted
* `explicit: {}` is falsy => the branch is skipped and nothing is cleared, which
  is NOT the same as the `none` preset
* explicit phase 1 clears every agent that is not an explicit SOURCE, so a
  target-only agent is cleared first and granted-to second
* explicit targets are filtered by membership (unknown targets silently dropped);
  an unknown source is skipped with a log warning
* create_schedules maps manifest `cron` -> `cron_expression` and defaults
  enabled=True / timezone=UTC / description=None
* count-only-on-success: a falsy db.create_schedule return is logged, not counted
* create_schedules has no internal try/except — the raise escapes to
  deploy_manifest step 9 and prior writes stand (no schedule rollback)

The odd permission branches are reachable in production despite validate_manifest
rejecting unknown explicit sources/targets from a manifest: the partial-deploy
path calls configure_permissions with `created_map`, a SUBSET of resolved names.

Sync `asyncio.run` idiom rather than a bare `async def test_*`: tests/unit/pytest.ini
is the effective inifile for this directory, so pyproject's asyncio_mode="auto"
does not apply here and bare async tests are not collected.

Refs trinity-enterprise#126

* refactor(systems): extract pure permission + schedule resolvers

Preparation for the ent#126 dry-run preview, which must show permission topology
and schedules. Those cannot be derived client-side: only the backend knows the
resolved `_N`-suffixed agent names (resolve_agent_names already runs on the
dry-run path), and a preview that re-implemented the preset rules would drift
from the writer the first time either side changed.

So the decision moves into pure functions that BOTH the preview and the writers
consume:

* `resolve_permission_edges(agent_names, permissions) -> (write_set, count)`
  returns the exact ordered sequence of db.set_agent_permissions calls the deploy
  would make, plus the integer it would report. An ordered list of pairs rather
  than a dict, so the write SEQUENCE stays faithful — clearing an agent and then
  granting to it is observable ordering a dict would normalise away.
* `resolve_schedule_previews(agent_names, agents_config) -> [SystemSchedulePreview]`
* `_build_schedule_create(schedule_data)` — the one manifest->ScheduleCreate
  mapping, shared by the resolver and the writer, so the key rename
  (`cron` -> `cron_expression`) and the three `.get()` defaults cannot diverge
  between preview and deploy.

configure_permissions and create_schedules become thin loops over these.

Behaviour is unchanged, and this is measured rather than asserted: the 25
characterization tests from the previous commit were captured GREEN against the
pre-refactor writers and are still green (80 passed with the ent124/ent125/1759
system suites). Every truthiness guard the resolver preserves is documented in
its docstring and pinned by those tests — full-mesh's `if targets`,
orchestrator-workers' whole-body `if workers` and assignment-not-summation count,
`explicit: {}` being falsy, explicit phase 1 clearing target-only agents, and the
silent filtering of unknown targets.

Logging: the per-branch messages are unified into one line per write plus a
mode/count summary. Net MORE coverage than before (the `none` preset previously
logged only a summary, never per agent) while keeping the mode context. No test
asserts on these strings.

models.py (Invariant #14): adds SystemSchedulePreview, BundledManifestSummary and
BundledManifestDetail for the catalog, MANIFEST_MAX_BYTES (256 KB) with a
Field(max_length=...) cap on SystemDeployRequest.manifest — which was previously
unbounded, and is a size cap only, NOT a YAML-bomb defence — and three additive
SystemDeployResponse fields (permission_edges, schedules_preview,
system_view_requested). system_view_requested disambiguates a
system_view_created of None, which today means both "no view requested" and
"view creation failed and was swallowed".

Refs trinity-enterprise#126

* feat(systems): dry-run preview shows topology + schedules, and validates resources

AC #2 of trinity-enterprise#126: the preview must show agents-to-create,
permission topology AND schedules. Only the first existed.

* `permission_edges` + `schedules_preview` on the dry-run branch, computed by the
  pure resolvers the real writers consume. `permission_edges` collapses the
  resolver's ordered write-set to {source: targets} for display — lossless for
  the set of writes, since each branch writes any agent at most once. The topology
  is OPTIMISTIC (resolved against the full agent map, while a partial deploy
  configures against the created subset); the UI says so.
* `permissions_configured` / `schedules_created` deliberately stay 0 on this
  branch. They mean "written", and repurposing a shipped field would mislead any
  existing consumer; callers count the new arrays.
* A schedule that ScheduleCreate rejects now becomes a preview BLOCKER instead of
  a post-deploy warning discovered once the fleet already exists.

The structural half of the manifest fix — the reason the deleted manifest's
`cpu: 1.0` reached production at all is that `_preflight_template` validated
template SHAPE only. It now also validates resources through the create path's
own `normalize_cpu`/`normalize_memory`, with the create path's own precedence:
`_resolve_local_template` overwrites config.resources when the template declares
a block, so a manifest value only survives when the template is silent — exactly
the case that failed. Verified end to end: a `cpu: 1.0` manifest previews as
status "invalid" carrying "Invalid cpu '1.0': must be one of 1, 2, 4, 8, 16"
where it previously previewed clean and then failed 100% of its agents.

For a `github:` template the merge needs the network call this function refuses to
make, so the DECLARED values are validated instead. That can over-report when the
remote template overrides them — accepted deliberately and documented, because
the alternative is silence about a value that is either fatal or dead config, and
the fix is harmless either way.

Two smaller honesty fixes:

* `parse_manifest` records unrecognised top-level keys on the manifest and
  `validate_manifest` warns about them. Warned, never rejected — rejecting would
  400 manifests that deploy today. This is the durable guard for the class that
  let `trinity_prompt:` (a typo for `prompt:`) and `auto_start:` sit in a shipped
  manifest doing nothing; both now surface. Recorded via a model field rather than
  a changed parse_manifest signature, which has two external callers.
* A REQUESTED system view that fails to create now appends a warning.
  create_system_view swallows its exception and returns None, so the response was
  previously indistinguishable from "no view requested" and a caller would
  silently navigate to an unfiltered dashboard.

Behaviour preservation: 84 tests green, including the 25 pre-refactor
characterization tests and the untouched ent124/ent125/1759 suites.

Refs trinity-enterprise#126

* feat(systems): read-only bundled-manifest catalog endpoints

AC #1 of trinity-enterprise#126 asks for paste/upload AND/OR pick. Paste and
upload need no backend at all, but "pick" had nothing to read: the only reference
to config/manifests anywhere in src/ was the first-run seeder's single hard-coded
filename. This adds the two read-only endpoints the picker needs.

  GET /api/systems/manifests            -> [BundledManifestSummary]
  GET /api/systems/manifests/{id}       -> BundledManifestDetail (+ raw YAML)

Both require_role("creator"), mirroring POST /deploy rather than the looser
get_current_user on the neighbouring list/get routes: a surface you cannot act on
should not be advertised, and require_role also rejects connector principals.

Invariant #4 is load-bearing TWICE here, which is why both routes are declared
above the parameterized ones:
  * GET /manifests would be captured by GET /{system_name} and 404 as
    "system 'manifests' not found" — a silent, plausible-looking failure.
  * GET /manifests/manifest would ALSO be captured by GET /{system_name}/manifest
    with system_name="manifests".
Verified by real requests: /manifests/manifest returns the detail route's JSON 404,
not get_system_manifest's PlainTextResponse.

`valid` means all THREE stages passed — parse, validate, and the same
side-effect-free template/resource preflight the dry-run uses. parse_manifest
alone is not a validity check: it accepts invalid names, unsupported template
prefixes and bogus presets, and raises AttributeError (not ValueError) on a
non-mapping `agents:`. If this is ever reduced to parsing, the field must be
renamed `parseable`.

Listing is fail-soft per file — an unreadable/oversized/invalid manifest is listed
with valid:false and a reason instead of 500-ing the request, because one bad file
hiding the other two is exactly how a broken bundled manifest stays invisible.

Path confinement on {manifest_id} is layered, because no single check suffices:
  1. character allowlist (also kills percent-encoded traversal, which FastAPI has
     already decoded by the time we see it, and ASCII-only kills homoglyphs)
  2. EXPLICIT rejection of "", ".", ".." and any id containing ".." — the regex
     does NOT do this, since `.` is inside its character class and `..` matches it
     happily. Relying on the regex here is the #1759 lesson.
  3. a length cap, so an over-long id is a 400 and not an escaping
     OSError(ENAMETOOLONG) surfacing as a bare 500 (found by probing)
  4. the suffix is ours by construction (the id is a stem), so a caller cannot
     steer the extension at all; an explicit .yaml/.yml is tolerated and stripped
  5. resolve() both sides then is_relative_to, which is what actually defeats a
     symlink inside the directory pointing outside it
Reads open ONCE and fstat that same descriptor with O_NOFOLLOW, reading at most
cap+1 bytes — config/manifests is a host bind mount in both compose files, so a
stat-then-read sequence has a real swap/growth window and the file checked must be
the file read. Symlinked entries are declined explicitly rather than surfacing as
an unexplained ELOOP. Probed against 13 traversal shapes.

MANIFESTS_DIR is env-overridable (TRINITY_MANIFESTS_DIR) and read at call time:
the bare relative default is right at runtime (WORKDIR /app + the :ro mount) but
CWD-dependent under pytest, and a catalog silently returning [] because the CWD
differs is a silent failure.

Not exported over MCP (Invariant #13) — this is a UI affordance and deploy_system
already exists there.

Also corrects the deploy docstring, which listed four statuses and omitted
"invalid" (added by #1841), and now records that `status` covers agent creation
only.

Refs trinity-enterprise#126

* test(systems): bundled-manifest smoke, catalog endpoints, dry-run preview

89 tests across three files, completing the ent#126 backend coverage.

test_ent126_bundled_manifests.py (15) — the test whose ABSENCE let `cpu: 1.0`
ship. Table-driven over the real config/manifests directory (located from the test
file, not the CWD), so a manifest added later is covered automatically. Asserts
each file parses, validates, dry-runs `valid` through the real service, has no
unrecognised top-level keys, and — the actual regression — that every agent's
MERGED resources pass the create path's own normalize_cpu/normalize_memory with
the create path's own template-wins precedence. Plus a cross-file check that no
two manifests declare the same system name, and a pin on default-system.yaml
staying schedule-free and prompt-free (its header states both; the picker now
makes it clickable, so they are enforced rather than merely documented).

Verified this suite actually catches the defects rather than merely passing:
restoring the deleted acme-consulting.yaml turns 4 of these red — unknown keys,
merged-resource validators, dry-run status, and the duplicate system name.

test_ent126_manifest_catalog.py (47) — the endpoints, against a tmp_path catalog
via TRINITY_MANIFESTS_DIR so nothing depends on the repo's own directory or the
CWD. Covers summary fields, sets_prompt, already_deployed (incl. degrading on a DB
error), fail-soft listing (unparseable / invalid / oversized / bad-resources files
listed as valid:false, and one bad file not hiding the good ones), .yml and
mixed-case suffixes, non-YAML ignored, symlinks declined, read-one incl. the
tolerated explicit extension and the "invalid manifests must still open in the
editor" case, and the creator/connector authorization matrix.

Both Invariant #4 collisions are asserted through REAL requests and distinguished
by response SHAPE, not just status: /manifests must return a list (not
get_system's dict) and /manifests/manifest must return the detail route's JSON 404
(not get_system_manifest's PlainTextResponse). A third test proves the sibling
export route still works for a genuinely-named system, so the guard did not break
what it shadows.

The traversal tests are split deliberately after probing showed two DIFFERENT
layers stop these: dot segments like ".." and "." are collapsed by URL
normalisation before routing (".." lands on GET /api/systems/, "." on the catalog
listing — neither a traversal), while anything surviving normalisation hits the
guard. So one test asserts 400 for the shapes that actually reach the handler
(incl. "..yaml", where extension-stripping would otherwise hand a bare ".."
onward, and "%2e%2e", proving the decode happens upstream of the regex), and a
second asserts the property that actually matters across all 12 shapes: no
response ever carries manifest content. Asserting 400 uniformly would have been
asserting the wrong thing.

test_ent126_dry_run_preview.py (27) — permission_edges across all three presets,
explicit, `explicit: {}` and no-permissions, asserted at the RESPONSE level so the
preview cannot silently disagree with the writer it describes; schedules_preview
incl. enabled-defaults-True; the shipped counters staying 0 on a dry run; bad
cpu/memory as `invalid` while template-supplied resources correctly override a bad
manifest value (no false blocker); #1841's unresolvable-local-template blocker
still firing; and the three error shapes the frontend normalizer must handle
(400 string detail, 422 list detail).

Full unit suite: 5779 passed, 1 failed — test_agent_analytics.py
TestTimelineGapFill::test_day_stacks_present_in_by_type, which reproduces
identically on pristine origin/dev in a throwaway worktree (a UTC-vs-local date
boundary; it was run near midnight local). Pre-existing, unrelated to this branch.

Refs trinity-enterprise#126

* feat(ui): install a system from a manifest — paste/upload/pick, preview, deploy

The UI half of trinity-enterprise#126. `POST /api/systems/deploy` has existed for
a while but was reachable only by curl or MCP.

Home is a `?tab=`-driven catalog on the existing Templates page
(`?tab=agents` | `?tab=systems`) rather than a 7th NavBar entry — the bar already
has 6, and "install an agent template" and "install a system" belong in one hub.
ent#15's agent-import wizard and ent#108's registry slot in as further tabs, which
is the reconciliation the issue asks for. The existing Agents content moves under
its tab unchanged, including its raw-axios fetch (Rule #2 — that Invariant #7
drift is real but not this PR's business).

stores/systems.js is a new domain store (Invariant #6), deliberately not bolted
onto systemViews.js: a "System" is a manifest-deployed set of agents sharing a
name prefix, a "System View" is a saved tag filter. Different domains that share a
word. Goes through the single `api` instance (Invariant #7).

Two things drove the design, both of them traps in the backend contract:

1. `normalizeError` collapses SIX outcomes into one renderable shape, switching on
   `status` and never on the HTTP code. `partial` and `invalid` arrive as HTTP 200
   (a naive .then() renders a degraded outcome as clean success), and `failed`
   arrives as HTTP 500 WITH THE FULL REPORT AS THE BODY — so a naive catch throws
   away exactly the `failed[]` list AC #3 has to render. It is returned as a
   result, not an error. The remaining shapes are a 400 string detail (the
   commonest outcome for a paste UI), a 422 LIST detail, a bare 5xx, and no
   response at all.

2. `preview` is bound to `previewedText`, the exact string it was produced from,
   and Deploy is gated on them matching. Without that a user previews manifest A,
   edits to B, and deploys B while reading A's preview. Any source change —
   keystroke, file, or bundled card — invalidates it.

Honesty rules the components follow, because each corresponds to a real way the
backend can mislead:

* Deploy result is headed "agents created", never "success". `status` describes
  agent creation ONLY — folder, permission, schedule, tag and start failures all
  land in `warnings[]` while `status` stays "deployed", so a fleet where every
  schedule failed and nothing started still reports "deployed". Warnings therefore
  get their own prominent panel, not a footnote.
* The preview never says a manifest "will deploy": `github:` templates are not
  probed, and the topology is resolved against ALL agents while a partial deploy
  wires up only those created. Both are stated in the UI.
* Deploying a manifest that sets `prompt:` or carries enabled schedules is gated
  behind an explicit acknowledgement checkbox, not a banner. It replaces the
  platform-wide prompt for every agent on the instance and/or starts recurring
  autonomous executions that spend budget — a banner is not consent for that.
* An `_N`-duplicate warning is a confirm-grade panel, since on a fresh install
  re-installing a bundled manifest hits it by default and recovery is manual.
* A timeout or a bare 5xx renders "outcome unknown — may still be running" and
  deliberately does NOT offer retry: cancelling the request does not cancel the
  server, and re-deploying duplicates every agent that succeeded. It offers the
  agent list instead.

AC #5 (no dead empty state): deploy always tags every created agent with the
system name, so `/?tags=<system>` is a fallback that always works, with
`/?view=<id>` preferred when the manifest declared a system_view and it was
created. Dashboard.vue gains a small additive reader for both, mirroring its
existing `?onboarding=1` handling and yielding to an active system view.

Plain textarea, not the orphaned monaco YamlEditor.vue: monaco is a declared dep
but unreachable (that component has had zero consumers since the Process Engine
was decommissioned), and prod CSP is `script-src 'self'` with no unsafe-eval and
no worker-src, while the dev CSP allows unsafe-eval — so `npm run dev` cannot
prove prod. Every AC is satisfiable without it. Deferred with that reason recorded
so it is not re-litigated blind.

Systems tab gated on `hasMinRole('creator')` mirroring the endpoint (AC #6), with
an explanatory empty state rather than a blank panel for lower roles. Note
hasMinRole is a plain function — the composable's own docstring says
`hasMinRole.value(...)` and is stale.

All manifest-derived text (descriptions, failure reasons, warnings) renders as
plain text, never v-html (H-005): `reason` is credential-sanitized server-side but
NOT HTML-sanitized.

Verified with a real production build (`npm run build`) since /verify-local is
blind to src/frontend — clean, and all three components plus the store are present
in the Templates chunk rather than silently tree-shaken.

Refs trinity-enterprise#126

* test(e2e): system-install surface — tab, preview, and both transport traps

Playwright coverage for the ent#126 install surface, driving the real stack:
?tab= deep-linking and reload survival, a bundled card loading into the editor,
the preview's agents/topology/schedules tables, the acknowledgement gate for a
manifest with enabled schedules, edit-after-preview disabling Deploy, and named
error messages for both a 400 validation failure and malformed YAML (AC #4 —
asserting the absence of "[object Object]" and "Traceback", the two shapes a
naive normalizer produces).

Both backend transport traps are exercised, since each is a distinct way a store
can be wrong:
  * `status: "invalid"` arrives at HTTP 200, so a store switching on the HTTP code
    would render a blocked manifest as a clean, deployable preview;
  * `status: "failed"` arrives at HTTP 500 with the full report AS THE BODY, so a
    naive catch discards exactly the failed[] list AC #3 has to render.

No test deploys for real by default. A deploy creates containers and there is no
un-deploy — re-running a manifest creates `_N`-suffixed duplicates rather than
converging — so an automated deploy would litter whatever stack it runs on. The
one deploy assertion is behind SYSTEM_INSTALL_DEPLOY=1 and uses a manifest that
cannot create anything (an unresolvable local: id), so even opted in it has no
side effects.

Advisory, not a required gate: frontend-e2e auto-runs on any PR touching
src/frontend/** since #1526, and its known failure class is modal/overlay flake on
a fresh zero-agent stack.

Refs trinity-enterprise#126

* docs(systems): record the UI manifest install surface (ent#126)

Rule #1 deltas for trinity-enterprise#126.

feature-flows/system-manifest.md is the highest-signal edit: it told every reader
"### UI — Status: Not yet implemented" and "## Frontend Layer — No UI
implementation yet (API-only feature)", listing SystemManifestEditor.vue /
SystemsList.vue / SystemDetail.vue as planned. Rewritten to what actually ships
(three components + the store + the Templates tab host), with the six-shape error
contract as a table, the honesty constraints and why each exists, and an explicit
"still not built" section — SystemDetail.vue and a deployed-systems browser stay
unbuilt and unowned, and the two retired component names are retired rather than
left standing as a promise the doc keeps making.

roadmap.md: new §16.5.2, and two corrections to §16.5 itself — it called `status`
"tri-state ... plus valid", omitting `invalid` (added by #1841), and never recorded
that `status` covers AGENT CREATION only, so a consumer rendering `status` without
`warnings` can report a fleet as deployed when every schedule failed and nothing
started. The trailing "prerequisite for ... UI manifest install" pointer now
resolves to §16.5.2.

architecture.md: the router and service catalog lines (kept to the ≤2-line catalog
rule, detail in the flow doc), a Frontend note for the Templates tabs +
stores/systems.js, and a System Manifests endpoint table carrying the Invariant #4
warning for BOTH collisions plus the `/api/systems/manifests` vs
`/api/systems/{name}/manifest` naming adjacency.

feature-flows.md: a Recent Updates index row, added by hand — /sync-feature-flows
reliably forgets it.

user-docs: an "Installing a system from the UI" section written for an operator,
not a reader of the source. It leads with what the confirmation gates mean (a
top-level `prompt:` replaces the global prompt for every agent; a schedule is
enabled unless you say otherwise, so the fleet starts spending budget on a timer),
how to read each of the five outcomes, why "agents created" is not "success", and
why an "outcome unknown" must be checked rather than retried. Also states the two
deliberate preview limits: `github:` templates are unverified until deploy, and
agent names are provisional.

Refs trinity-enterprise#126

* test(systems): guard the manifest-catalog route order in the assembled app

/update-tests coverage review found the one gap the ent#126 suite structurally
could not cover. `test_ent126_manifest_catalog.py` mounts `routers/systems.py`
alone on a bare FastAPI app, which proves ordering WITHIN the router but says
nothing about the real application, where 60+ routers are mounted and the handler
is decided by the first FULL match across all of them. This is the #1069 class the
test-runner catalog already calls out for the brain-orb routes.

So this imports the assembled `main.app` and asserts the first FULL match for both
Invariant #4 collisions — `/api/systems/manifests` vs `GET /{system_name}`, and
`/api/systems/manifests/manifest` vs `GET /{system_name}/manifest` — plus that all
three shadowed siblings (`get_system`, `get_system_manifest`, `deploy_system`)
still resolve, so `manifests` is a genuine static-before-param precedence rather
than a total shadow.

OpenAPI is an order-independent path set and is therefore blind to this, so a
schema check cannot catch it; a match-order assertion is the only guard. Mirrors
`test_1483_route_order.py`, including its self-sufficient env setup and its
loud-skip guard for the whole-directory sweep (an earlier module binds
sys.modules['utils'] to tests/utils, so `import main` fails there — the assertions
run standalone).

Confirmed to fail on the bug rather than merely passing: moving the two routes
below `/{system_name}` turns both collision tests red with
"resolved to get_system" and "resolved to get_system_manifest" respectively.

Note: /update-tests also updates `.claude/agents/test-runner.md`, which lives in
the private trinity-dev submodule — that edit is deliberately NOT committed here,
since bumping the gitlink would point this public branch at an unpushed private
commit.

Refs trinity-enterprise#126

* docs(tags): record the ?tags=/?view= deep link as a Dashboard filter entry point

/sync-feature-flows gap: `views/Dashboard.vue` changed on this branch, and
`agent-tags.md` — not `system-manifest.md` — is the doc that owns the Dashboard
quick-tags / activeFilterTags mechanism in detail. It described two entry points
into that state (a quick-tag click and a System View selection); ent#126 added a
third (`applyDeepLinkFilters()` in onMounted), and a reader of that doc had no way
to know a URL can seed the filter.

Documents the precedence (`?view=` wins because a view carries its own filter tags;
`?tags=` yields entirely when a view is already active) and why it exists — manifest
deploy always tags every created agent with the system name, so `/?tags=<system>` is
the always-works fallback that keeps a fresh install off an unfiltered dashboard.

Refs trinity-enterprise#126

* fix(ui): the deploy acknowledgement must not survive a manifest edit

Self-review of the ent#126 diff found the consent gate had the exact hole the
preview/previewedText binding was built to close, one field over.

`acknowledged` was reset when picking a bundled card, choosing a file, or starting
over — but NOT when the user typed in the textarea. So: preview a manifest with
enabled schedules, tick "I understand and want to continue", edit the YAML,
preview again → the box is still ticked and Deploy re-enables. The user consented
to manifest A's consequences (replacing the platform-wide prompt, starting
recurring autonomous executions) and deployed manifest B's.

Consent is per-manifest, so it now dies with the text it was given for: a watcher
on `store.manifestText` clears it. That covers every source, because typing, file
upload and bundled-card selection all funnel through `setManifestText`. Pinned by
a new e2e case that ticks the box, edits to a different manifest with the same
hazard shape, re-previews, and asserts the box is clear and Deploy is blocked.

Also drops `duplicateWarnings` from the store: it was exported but never consumed
(ManifestPreview.vue computes its own split, since it needs both halves — the
duplicates get a confirm-grade panel and everything else a notes list), so it was
a second copy of the same heuristic with nothing to keep it in step.

Rebuilt clean.

Refs trinity-enterprise#126

* fix(systems): stop the schedule preview claiming it validates cron

Self-review caught a code comment that promised more than the code delivers. The
plan this was built from called it a "cheap completeness win — build a throwaway
ScheduleCreate inside the resolver purely to borrow its validation, so a bad cron
surfaces in the preview instead of degrading to a post-deploy warning", and that
premise is simply false: `ScheduleCreate` declares `cron_expression: str` with
**zero** validators (verified — the model has no field_validator/model_validator at
all), and `validate_manifest` only checks that the `cron` KEY is present. Nothing
parses the expression.

A comment that overstates a guard is worse than no comment: the next reader trusts
it and stops looking. So the docstring now states what the mechanism actually
catches (a schedule entry whose field TYPES the model rejects — reachable, because
validate_manifest's checks are presence-only, so a non-string `name` passes
validation and then fails construction) and states the gap explicitly: a
syntactically invalid cron previews clean, deploys clean, and surfaces only when
the scheduler tries to arm it.

Left as a documented gap rather than silently half-fixed, because validating cron
here would change a shipped path — manifests with a bad cron deploy today.

Both halves are now pinned, including the gap: `test_a_syntactically_invalid_cron_is_NOT_caught`
asserts the current contract, so if cron validation is ever added that test is the
one that must change, deliberately. Also adds coverage for the
model-rejects-a-schedule branch, which was written but untested.

Refs trinity-enterprise#126

* fix(config): wire TRINITY_MANIFESTS_DIR into both compose files + .env.example

/validate-pr's Config Packaging gate (the #1056 / trinity-enterprise#31 class):
a backend `os.getenv()` that is not in `backend.environment:` of BOTH compose
files is an inert lever on deploy. `TRINITY_MANIFESTS_DIR` was in 0 of the 3
files while its sibling `TRINITY_DEFAULT_SYSTEM_MANIFEST` — same feature area,
same shape — is in all 3. Prod compose launches standalone with no base-compose
merge and no `env_file:`, so dev-only wiring would not have carried over anyway.

Worth noting the gate's own grep did NOT catch this: it matches literal
`os.getenv("X")`, and this call site reads `os.getenv(MANIFESTS_DIR_ENV)` through
a module constant. Found by diffing against the sibling variable instead.

Verified by RENDERING the container environment (`docker compose config`) rather
than grepping the source — both files now emit `TRINITY_MANIFESTS_DIR` into the
backend env.

That render also surfaced a real consequence: with `${TRINITY_MANIFESTS_DIR:-}`
every deployment now sets the var to the EMPTY STRING unless an operator
overrides it. `_manifests_dir()` uses `os.getenv(...) or default` so empty falls
back correctly — but had it used `os.getenv(name, default)`, `""` would have won,
`Path("")` is the CWD, and the catalog would have listed nothing on every single
install while looking perfectly configured. That is the #1759 trap one seam over
(an empty `HOST_TEMPLATES_PATH` making `Path("") / name` an empty named volume),
so it is now pinned by a test rather than left as a lucky choice of operator.

Docs state the operator contract honestly: the env var alone is not enough, since
the directory must also be bind-mounted, and an unreadable path yields an EMPTY
catalog rather than an error.

Refs trinity-enterprise#126

* fix(systems): harden the manifest read path against its own error reporting

Four review findings, each a case where a guard could fail on the input it
was guarding.

- The unknown-top-level-key warning (added so a `trinity_prompt:`-for-`prompt:`
  typo stops being silently dropped) sorted raw YAML keys. PyYAML is YAML 1.1:
  bare `on`/`off`/`yes`/`no` parse as booleans and `2:` as an int, so a mixed-type
  key set made `sorted` raise TypeError and the catch-all turned it into a raw
  500 -- on a manifest that deployed fine before the check existed. Coerce with
  `str()`. A hygiene check whose purpose is better error reporting must not be
  able to raise on the input it reports about.

- `max_length` counts characters; the stated limit is bytes. Kept as a cheap
  necessary pre-check and added a byte-exact validator, so the request cap, the
  bundled reader's `st.st_size` and the UI's `file.size` cannot disagree on a
  multibyte manifest.

- Catalog `reason`s went out as raw `str(e)`. PyYAML parse errors echo the
  offending source line and `validate_manifest` interpolates manifest values, so
  route them through `_failure_reason` -- the same credential-sanitizing,
  userinfo-redacting, length-capping exit the deploy report already uses. The
  join of N capped reasons needed its own cap.

- `_resolve_manifest_path` refused only symlinks escaping the directory, while
  the listing skips symlinks outright. A symlink pointing *inside* was therefore
  invisible in the catalog yet readable by id -- "not listed" stopped meaning
  "not served". Checked pre-`resolve()`, which has already erased the link.

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

* fix(ui): keep the stale-preview hint reachable and let ?tags= win

Two dead paths in the install surface.

`invalidatePreview` cleared `previewedText` alongside the preview payload, so
the "manifest changed -- preview again" branch could never be true: editing
after a successful preview told the user to "Preview first", as if they never
had. The marker exists precisely to tell "never previewed" apart from "preview
is stale", so it now outlives the payload; only `reset()` (start over) drops it,
where "Preview first" is honest again. It cannot re-enable Deploy on its own --
`previewIsCurrent` also requires a non-null `preview`, and that IS cleared. The
hint was additionally keyed on `preview` rather than the marker, which made the
branch unreachable a second time.

`applyDeepLinkFilters` bailed out whenever a system view was active, deferring
to it. But `initialize()` restores that selection from localStorage before this
runs, so the post-deploy "View this fleet" link silently no-opped for anyone
carrying a view from a previous session -- and the `activeFilterTags` watcher
would overwrite the tags anyway once views loaded. An explicit `?tags=` now
clears the selection, exactly as picking a tag chip does. That is AC #5's whole
point: the deploy must not end in a dead end.

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

* docs(systems): document the bundled-manifest catalog surface

The two catalog endpoints shipped without reaching the feature flow at all --
absent from Entry Points, absent from the Router Layer, and their security
properties undocumented. Adds both, plus a table of the layered path
confinement stating what each layer actually guards against (the character
allowlist does NOT reject `..` -- `.` is inside its class -- and the symlink
check must precede `resolve()`), the fail-soft contract, and the naming
adjacency with `/api/systems/{name}/manifest`, which reads alike and is
unrelated.

Also reconciles requirements + architecture with this branch's review fixes:
the byte-exact manifest cap, the YAML 1.1 key coercion, the sanitized catalog
reasons, and the symlink parity check. Adds the missing ent#126 Revision
History row, and records the "a hygiene check became the 500 it existed to
prevent" pitfall in learnings.md.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant