Purpose: The always-loaded core — system shape, the full invariant list, and a map to every area file. Deep detail lives in
docs/memory/architecture/, read on demand.Editorial rules: (1) One home per feature — each subsystem is described exactly once, in exactly one area file; every other mention is a pointer. (2) Catalogs are catalogs — router/service/endpoint entries are ≤2 lines; deeper behavior lives in the owning area file or
docs/memory/feature-flows/. (3) No changelog narration — git history records what was replaced and when; issue tags (#526) are kept as lookup keys. (4) Write path — architecture updates go to the owning area file named in the Architecture Map; this core file changes only when the map itself changes (a new area, a new invariant, a topology change). (5) Size budget — this core stays under 500 lines. It is@-imported byCLAUDE.md, so every line is paid by every session; area files are referenced by path and are never imported.
Trinity is an autonomous agent orchestration and infrastructure platform — sovereign infrastructure for deploying, orchestrating, and governing fleets of autonomous AI agents on your own hardware. Each agent runs as an isolated Docker container with standardized interfaces for credentials, tools, and MCP server integrations.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Trinity Agent Platform │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Frontend │ │ Backend │ │ MCP Server │ │ Vector │ │
│ │ (Vue.js) │ │ (FastAPI) │ │ (FastMCP) │ │ (Logs) │ │
│ │ :80 │ │ :8000 │ │ :8080 │ │ :8686 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │ │
│ └─────────────────┼─────────────────┼─────────────────┘ │
│ │ │ │
│ ┌──────┴──────┐ ┌──────┴──────┐ │
│ │ Redis │ │ Docker │ │
│ │ :6379 │ │ Engine │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌───────────────────────────────────┼───────────────────────────┐ │
│ │ │ │ │
│ ┌────┴────┐ ┌─────────┐ ┌─────────┴┐ ┌─────────┐ │ │
│ │ Agent 1 │ │ Agent 2 │ │ Agent 3 │ │ Agent N │ │ │
│ │ :8000 │ │ :8000 │ │ :8000 │ │ :8000 │ │ │
│ └─────────┘ └─────────┘ └──────────┘ └─────────┘ │ │
│ Agent Network (172.28.0.0/16) │ │
└─────────────────────────────────────────────────────────────────────────────┘
| Layer | Technologies |
|---|---|
| Frontend | Vue.js 3 (Composition API), Tailwind CSS 3, Pinia 2, Vite 5 |
| Backend | FastAPI 0.100+, Python 3.13, Docker SDK 7.x, SQLite 3, Redis 7, httpx 0.24+ |
| Agent runtime | Python 3.13, Node.js 20, Go 1.21, Claude Code (latest) |
| Infrastructure | Docker, nginx (prod reverse proxy), Cloudflare Tunnel (public endpoints), Tailscale (private VPN), GCP, Vertex AI Search (docs Q&A) |
Python version is a single declaration, guarded (#1891). The three image Dockerfiles (docker/{backend,scheduler,base-image}/Dockerfile) are the source of truth; every python-version: in .github/workflows/ must equal that pin, enforced by tests/unit/test_1891_python_version_parity.py (scans all workflows, so a new one with a stale pin fails too; publish-cli.yml is allowlisted — PyPI packaging is governed by CLI consumers, not the image runtime). CI previously ran 3.11 against 3.13 images, which by construction cannot catch the stdlib-removal class that had already shipped twice (crypt → #1615, audioop → the audioop-lts VoIP pin). The same principle covers the tz database one layer down (#1823): the backend and scheduler images must ship the IANA backward-compatibility links (tzdata-legacy + the tzdata wheel — zoneinfo reads the system database first and falls back to the wheel only for keys the system lacks), because the floating python:3.13-slim tag silently migrated bookworm→trixie, where those links were split out of tzdata, and every schedule stored under a legacy alias (Europe/Kiev) stopped resolving — 500 on create, silently non-firing in the scheduler — while CI stayed green because tests/requirements-test.txt already declared tzdata (#1771), i.e. the test environment was more capable than production; guarded by tests/unit/test_1823_tz_capability_parity.py, so a future image slim-down that drops either source fails CI instead of re-shipping it.
Every area below is read on demand, not auto-loaded. The third column is the point: it names the regression that reading the file prevents, because a topic label gives an agent no reason to spend the tool call. If you are about to change a file matched by the middle column, open the area file first.
Loading is a deliberate step, not an ambient one (#2642). Before changing a path in the "Owns" column, read the named area file first — as an explicit action, at the point the reasoning needs it, not after the first edit. Nothing injects it for you: no hook fires, and a hook would fire too late anyway (an edit comes after the reasoning that needed the file). Tooling that resolves a path to its owner parses this table via scripts/docs/architecture_map.py; there is no second copy of the map to drift.
| Area file | Owns | Read it first, because |
|---|---|---|
backend.md |
src/backend/routers/**src/backend/services/**src/backend/db/**src/backend/main.pysrc/backend/models.pyconfig/vector.yaml |
docker_service.agent_container_states() is deliberately tri-state; the older helpers collapse no container and Docker unreadable into one falsy value, so a naive reconcile empties a customer's roster on any daemon fault (#2196). |
api-endpoints.md |
src/backend/routers/**src/backend/main.py |
Static routes must be registered before the /{name} catch-all (Invariant #4). Declared after it, GET /api/systems/manifests answers "system 'manifests' not found" instead of listing manifests. |
execution.md |
src/backend/services/task_execution_service.pysrc/backend/services/chat_execution_service.pysrc/backend/services/capacity_manager.pysrc/backend/services/slot_service.pysrc/backend/services/backlog_service.pysrc/backend/services/dispatch_breaker.pysrc/backend/services/dispatch_admission_service.pysrc/backend/services/session_turn_service.pysrc/backend/services/loop_service.pysrc/backend/services/reminder_service.pysrc/backend/services/idempotency_service.pysrc/backend/services/event_dispatch_service.py |
Every terminal side effect is gated on winning the status CAS, and each CAS winner must close its own dispatch activity. A terminal writer that skips the close leaves the row started until the 120-minute sweep fabricates duration_ms = now - started_at, permanently recording a 15-minute run as a ~120-minute failure (#1804, third occurrence of that class after #45 and #767). |
reliability.md |
src/backend/services/cleanup_service.pysrc/backend/services/retention_guard.pysrc/backend/services/canary_service.pysrc/backend/services/canary_alerts.pysrc/backend/canary/**src/backend/services/heartbeat_service.pysrc/backend/services/monitoring_service.pysrc/backend/services/db_backup_service.pysrc/backend/db/backup_primitives.pysrc/backend/services/redelivery_governor.py |
OPS_SETTINGS_DEFAULTS is a safety floor, not a policy knob. It is read at prune time and is the fallback for every install that never wrote a row, so lowering one retroactively hard-DELETEs those installs' history seconds after their next boot. #1638 cost a live instance ~3 months of data and shipped green because a test asserted the destructive default. |
agent-lifecycle.md |
src/backend/services/agent_service/**src/backend/services/git_service/**src/backend/services/template_service.pysrc/backend/services/template_schedules.pysrc/backend/services/template_plugins.pysrc/backend/services/skill_service.pysrc/backend/services/skill_packaging.pysrc/backend/services/compatibility/**src/backend/services/subscription_auto_switch.py |
lifecycle._apply_git_env_from_db takes its PAT gate as a required per-call-site parameter, never a default. Sharing one gate bakes the global platform PAT into a previously tokenless container, configure_push_remote then clears the push blackhole, and a private knowledge base can reach the shared public upstream (the ent#162 class). An AST guard pins the exact writer set, so a third writer on any container-seeded path fails CI. |
agent-runtime.md |
docker/base-image/** |
.env is re-read at every spawn (#1999), but it must NOT be authoritative for ANTHROPIC_API_KEY while subscription auth is active: Claude Code prefers that key over the OAuth token, no recreate ever cleans it off the workspace volume, and SUB-003 then mis-attributes the failure to each healthy subscription in turn (#2114). Four policy modules here are byte-identical vendored mirrors of backend files (Invariant #5) and are parity-tested. |
integrations.md |
src/backend/adapters/**src/backend/services/event_bus.pysrc/backend/services/ws_ticket_service.pysrc/backend/services/voip_service.pysrc/backend/services/slack_service.pysrc/backend/services/tts_service.pysrc/backend/services/voice_reply_service.pysrc/backend/routers/agent_brain_orb.pysrc/backend/services/agent_shared_files_service.py |
/ws is SCOPE_ALL and unfiltered, so a broadcast that carries a payload discloses it to every connected client. Broadcasts must be thin triggers carrying identifiers only, with listeners refetching through the access-controlled REST route (#918, pinned by test_918_report_broadcast.py). |
observability.md |
src/backend/routers/reports.pysrc/backend/routers/evaluations.pysrc/backend/services/report_service.pysrc/backend/services/report_export.pysrc/backend/services/activity_service.pysrc/frontend/src/components/reports/** |
Report renderer drift is silent: the write succeeds and the report degrades to a raw JSON dump. test_1535_report_prompt_guidance.py pins the prompt shapes against the display_hint enum and the renderer keys, and shapeOk must stay inside ReportRenderer.vue or that guard's matched set empties (#1535, #2162). |
workspace.md |
src/backend/client_portal/**src/frontend/src/views/Portal.vuesrc/frontend/src/stores/clientPortal.jssrc/frontend/src/components/portal/** |
The roster payload is the only capability channel a portal principal has. GET /api/settings/feature-flags is get_current_user-gated and its store returns an empty list for every external client, so a UI gate written against it is dead for exactly the audience it targets (#2128). Per-agent fields on that payload also fail in deliberately opposite directions and must not be normalised to match. |
frontend.md |
src/frontend/src/**src/frontend/scripts/** |
The dashboard renders three modes over one store seam. A filter added outside stores/network.js::visibleAgents silently applies to one mode only (ent#261). Any change here is also bound by docs/memory/design-system-contract.md and the raw-color ratchet, whose per-file counts may only shrink. |
mcp-server.md |
src/mcp-server/**src/helper-mcp/** |
Tool visibility is an allowlist (OPERATOR_SCOPES), never a deny-check. The previous !== "connector" shape advertised every operator tool to any scope it had not heard of, including a null auth context where canAccess filtering is skipped entirely (#848). |
database.md |
src/backend/db/schema.pysrc/backend/db/migrations.pysrc/backend/db/tables.pysrc/backend/migrations/versions/**src/backend/db/alembic_runner.py |
Every schema change needs BOTH tracks: a versioned SQLite entry and a new Alembic revision (Invariant #9). Two revisions sharing a down_revision are two heads, and upgrade head resolves its target before applying anything, so it applies ZERO revisions while git reports no conflict (#2068). |
security.md |
src/backend/dependencies.pysrc/backend/error_handlers.pysrc/backend/services/credential_encryption.pysrc/backend/services/secret_settings.pysrc/backend/services/credential_paths.pysrc/backend/utils/url_validation.pysrc/backend/utils/safe_yaml.pydocker-compose.ymldocker-compose.prod.yml |
require_admin and assert_admin reject agent principals themselves. An agent-scoped MCP key resolves to its owner CARRYING the owner's role, so on a default admin-owned install any agent's injected key satisfied every admin gate. That was five separate incidents (trinity-ops-agent#232, #1644, #1816, ent#236, ent#293) before the gate was fixed rather than the endpoints; never write require_role("admin"), which is a third spelling that lets agent keys through. |
background-services.md |
src/backend/main.py |
Every loop here starts in EVERY uvicorn worker. A cycle without a Redis leader lease double-probes the fleet, double-persists its rows, and gives each cross-cycle marker two writers (#1464, #1632, #1881). Whether the lease fails open or closed is decided per loop and stated in each entry. |
These are structural patterns that must be preserved. Breaking them causes cascading issues.
-
Three-Layer Backend: Router → Service → DB — Every feature follows
routers/X.py→services/X_service.py→db/X.py. Routers hold no business logic, services hold no SQL, db modules hold no HTTP concerns. A layer that outgrows one file becomes a package, never a second module beside it (#1028) —routers/settings/(ten domain modules),services/git_service/(six) andservices/agent_client/(circuit/http_pool/client), each composing onto the same public name so every existingfrom routers.settings import …/from services.git_service import …resolves unchanged. Two rules make the split a refactor rather than a rewrite. (a) The package re-exports the PUBLIC surface only. A private collaborator mirrored on both the package and its owning module gives one function two monkeypatch targets, and a patch applied to the wrong one silently detaches — so a stale patch must raise instead, and a test reaches a private through the module that owns it (agent_client.circuit._CIRCUIT_HASH_PREFIX). (b) Callers reach a sibling through the module object, not a from-import alias (public_chat_service.agent_requires_email(...)), for the same reason one level up. Route-registration ORDER is the one behaviour a regrouping can change silently (Invariant #4), sorouters/settings/__init__.pyincludes the/{key}catch-all last andtests/unit/test_1028_settings_package.pypins the fork-point route set as a frozen literal — read out of git, it skipped on every CI run, since every checkout isfetch-depth: 1. Guards that walk a layer must userglob:routers/settings/was the first subdirectory underrouters/, and the Invariant #8 and #14 scanners were still globbing one level deep, going blind on ten modules while staying green. -
DB Layer: Class-per-domain with Mixin Composition — Each
db/file defines anXOperationsclass. Agent-specific settings use mixins (db/agent_settings/) composed intoAgentOperations. New agent settings → new mixin, not a bigger class. A larger domain follows the same shape when a single file grows unreviewable:ScheduleOperationsis composed indb/schedules/__init__.py(#1481) from ten concern-scoped mixins —ScheduleCommonMixin(MRO-shared_generate_id+ the_norm_tsleaf helper) /ScheduleCrudMixin/ScheduleWebhooksMixin/ScheduleExecutionsMixin/ScheduleQueueMixin/ScheduleCleanupMixin/ScheduleAnalyticsMixin/ScheduleStatsMixin/ScheduleGitConfigMixin/ScheduleRetentionMixin. The facade import pathfrom db.schedules import ScheduleOperationsis preserved by the package re-export, and cross-slice references resolve at runtime via the composed class's MRO (self.<method>()) with no import edges between the mixin files — the only intra-package import is the bare_norm_tsmodule-global. -
Schema in
db/schema.py, Migrations indb/migrations.py— All OSS table DDL lives inschema.py. Schema changes require a versioned migration inmigrations.py(tracked in theschema_migrationstable). Never create tables ad-hoc in service code. Runner safety (#1160):init_database()wraps both migration passes +init_schemain a cross-processflock(db/migration_lock.py) so workers + scheduler can't race; table-rebuild migrations use_atomic_rebuild(rename-swap insideBEGIN/COMMIT) so a crash mid-rebuild rolls back; a failed migration is named viaadd_noteand surfaced asfirst_pendingin the/health503. Backend split (#1183): thedb/migrations.pyrunner (PRAGMA +INSERT OR IGNORE) is SQLite-only; PostgreSQL is owned by Alembic —init_database()'s non-SQLite branch callsdb/alembic_runner.upgrade_to_head()(src/backend/migrations/+alembic.ini;env.pytargetsdb/tables.pyMetaData). Fresh PG built by the0001_baselinerevision (reusesinit_schema_postgresDDL); pre-Alembic PG stamped at baseline. Multi-worker serialisation (#1425): theflockabove only guards the SQLite runner — on PG,upgrade_to_head()wraps its stamp+upgrade in a PostgreSQL session advisory lock (pg_advisory_lock, fixed key) so concurrent worker/scheduler boots can't both entercommand.upgrade()and deadlock inside a revision. Revision-id width (#1420):alembic_version.version_numisVARCHAR(255)(env.pyversion_table_column_type+0001_baselineDDL; the0008a_widen_alembic_versionmigration widens existing 32-wide DBs) because Trinity's descriptiveNNNN_<table>_<change>ids exceed Alembic's 32-char default and PostgreSQL enforces the width (SQLite doesn't — so the truncation only breaks PG boot). Keep revision ids ≤255; thepg-migrationsCI job runs a realalembic upgrade headandtests/unit/test_alembic_revision_id_length.pylints the ids. One head per version-line (#2068):scripts/ci/check_alembic_heads.py(pure stdlib, parsesrevision/down_revisionwithout importing anything; wired unconditionally intoschema-parity, so unlike path-filtered-and-advisorypg-migrationsit is a required, always-evaluated gate) fails any version-line it is pointed at that does not resolve to exactly one head. Two revisions sharing adown_revisionare two heads, and sinceupgrade headis singular and resolves its target before applying anything, such a graph applies zero revisions — every revision merged since the fork stops arriving, not only the one that forked — while git reports no conflict, because each file is individually valid and the defect exists only in the relationship. A merge revision's tupledown_revisioncounts all its members, soalembic mergecorrectly collapses a fork; the guard skips (loudly) a directory that is absent or holds no revisions, and fails closed when revision files are present but none parses. The private track's version-line is asserted the same way at its own branch point — that arm skips on public CI, which never checks the submodule out — anddeploy-devgreps the registration boot log, so a version-line that merely degrades rather than crash-loops cannot ship green. Fresh only at PR-event time (#2533): that gate runs on thepull_requestevent, and GitHub recomputesrefs/pull/N/mergewhen the base advances but does not re-trigger workflows — so a PR can read mergeable, all checks green against a base that no longer exists, which is how #2526's fork merged 75 minutes after its last green run.alembic-head-watch.ymlcloses that window for this class by re-running the same script, unmodified, over an in-memorygit merge-treeof every open migration PR against the livedevtip on each push todev(cron is only a dropped-run backstop, and fires solely frommain); it is advisory — never a required context — and covers the Alembic single-head class only, since every other check is equally stale. It evaluates through an unrelated conflict (#2828):--write-treewrites the merged tree on exit 1 too, and the guard reads only the two version directories, so a PR that conflicts withdevinlearnings.md— four of nine open PRs on 2026-09-15, two of them carrying a live fork — still gets a real verdict, with the unrelated paths named on the status and comment. Only a revision file edited on both sides isconflict, and that is published as a visibleerrorstatus, never silence — "not evaluated" reading like "nothing to say" is how two forks reached the train green. Both coexist during transition, so a schema change lands in bothmigrations.py(SQLite) and a new Alembic revision (Postgres) — both tracks CI-guarded by theschema-parityjob (SQLite parity pytest + thescripts/ci/check_alembic_parity.pycross-track guard that fails a DDL change missing its Alembic revision, #1342) — until SQLite end-of-support September 1, 2026 (#1278; guidedocs/migrations/SQLITE_TO_POSTGRES.md) — after which the goal (#746) istables.pyMetaData as single source with autogenerated revisions. Two-track (open-core): enterprise owns onlyenterprise_*tables via a separate runner (enterprise/backend/_migrations.py, tracked inenterprise_schema_migrations, never OSSschema_migrations); one file per migration (NNNN_slug.pywithNAME+upgrade(cursor, conn), filename order). Enterprise migrations may FK-into OSS tables but must never ALTER one — OSS enforcement goes through an OSS migration as an edition-agnostic primitive (e.g.users.suspended_at, #995). The enterprise runner runs fromregister_enterpriseafter OSSinit_database. -
Router Registration Order Matters — In
main.py, static routes like/api/agents/context-statsmust come before/{name}catch-all. New collection-level agent endpoints must be registered before parameterized routes. -
Agent Server Mirrors Backend (Subset) —
docker/base-image/agent_server/routers/has routers that mirror a subset of backend routers (chat, credentials, files, git, skills, dashboard). The backend proxies to the agent server. Changes to agent-internal APIs must update both sides. Byte-identical vendored mirrors (the agent server ships as its own image and structurally cannot importsrc/backend, so a shared policy is copied rather than imported, each with a parity test):credential_paths.py(#11),model_context.py(#1521),safe_yaml.py(#1965 — the ent#314 hardened YAML loader), andmcp_validator.py(#2007). A guard that walks only one of the two trees is not a guard: ent#314's AST scan had an empty allowlist over the whole backend and still missed six bareyaml.safe_loadcalls in the agent server on the same author-controlled documents (template.yaml, skill frontmatter,dashboard.yaml,.trinity/persistent-state.yaml), because it never looked there. It now walksdocker/base-image/agent_server/too — extend the scan, not just the fix, whenever a policy gains a second home. -
Frontend: Store = Domain, View = Page — Pinia stores (
stores/agents.js) are domain-scoped, not view-scoped. Views compose from multiple stores. Composables (composables/use*.js) extract reusable logic. API calls go through stores, not views directly. -
Single API Client (
api.js) — One Axios instance with auth interceptor. Stores callapi.get()/api.post(). No rawfetch()or duplicate Axios instances. -
Auth Pattern:
Depends(get_current_user)+AuthorizedAgent— Every authenticated endpoint uses FastAPIDepends()for auth. Agent-scoped endpoints useAuthorizedAgentorOwnedAgentByNamefor access control. Role-gated endpoints userequire_role("creator")orrequire_admin(ROLE-001).internal.pyis the only exception (no auth, for agent-to-backend calls). Enumeration-safety (self-uniform, #186): an agent-access handler must be self-uniform — it must never return an existence-404followed by an access-403in the same function (that differential lets any caller enumerate which agents exist). The four dependency helpers (get_authorized_agent/get_owned_agent/…_by_name,dependencies.py) now return a uniform 404 for both a non-existent AND an inaccessible/unowned agent — they evaluate existence and access before branching (equal query-count → equal timing) and run_enforce_connector_scopefirst. Access-first inline handlers (can_user_access/share→ 403 before any existence lookup) are already self-uniform and stay 403. When adding an agent endpoint: route through a dependency, or if checking inline, check access first — do not reintroduce a 404-then-403 split. The MCP third surface mirrors this (Invariant #13):chat.ts checkAgentAccessreturns one uniform reason and never discloses the owner username. Guarded bytests/unit/test_186_enumeration_uniformity.py. Shared imperative-guard family (#1310): the inline access/owner/admin checks are consolidated behind five leaf helpers independencies.py—assert_admin(403;_reject_connector_principal+reject_agent_principal+role != "admin"),assert_agent_access(403;_enforce_connector_scope+can_user_access_agent),assert_agent_owner(403;_enforce_connector_scope(owner_op=True)+can_user_share_agent),assert_owns_or_admin(403;id != owner AND role != "admin"),assert_owns(403;id != owner, no admin bypass). All raise 403 (access-first → self-uniform, above). An admin gate is never agent-callable (ent#293/ent#297):get_current_userresolves an agent-scoped MCP key to its owner carrying the owner's role, so on a default admin-owned install any non-ephemeral agent's injectedTRINITY_MCP_API_KEYsatisfied every admin gate. Five occurrences of that one class — trinity-ops-agent#232, #1644 (retention acknowledge), #1816 (system-agent restart), ent#236 and ent#293 (skills-library repointing) — were each closed by boltingreject_agent_principalonto one more endpoint, 18 of them against 114 admin-gated call sites. Five occurrences meant the gate was wrong, not the endpoints, sorequire_adminandassert_adminnow reject agent principals themselves (#1890): a route added tomorrow inherits the protection without anyone remembering to ask, and the per-endpointreject_agent_principalcalls are belt-and-braces rather than the mechanism. Safe by construction and verified, not assumed — the agent-key flows that must keep working (heartbeat, structured reports, the #1083 result callback) authorize oncurrent_user.agent_nameself-checks and never touch an admin gate, andUser.agent_nameis populated only forscope == "agent", sotrinity-systemstill passes. This is the grant-vs-use line: the endpoint that uses a capability may be agent-callable; the endpoint that grants one is human-only. Two admin-gated reads that were uses, not grants, were re-opened rather than left half-working —POST /api/monitoring/agents/{name}/checkdrops toget_current_user(the admin gate was redundant withAuthorizedAgentByName) andGET /api/subscriptionsis owner-scoped, not un-gated (un-gating would hand anyrole=usera fleet-wideowner_email+ agent-name enumeration oracle — the Invariant #8 disclosure class, reintroduced by a fix for a different disclosure). Never writerequire_role("admin")— it is a third spelling that rejects connector but not agent principals, andrequire_rolestays deliberately permissive because agent-spawned agent creation runs throughrequire_role("creator")(ent#69 Part 2), so a blanket rejection there would break ghost spawning. Userequire_admin(equivalent —adminis last inROLE_HIERARCHY— and it rejects agent principals); an AST guard intests/unit/test_293_admin_gate_rejects_agent_keys.pyfails the build if the spelling reappears. The trigger to revisit an existing gate is a change in what the endpoint does — escalating a handler's destructiveness silently re-prices every principal that could already reach it. Imperative vs path-dependency: agent name in the path → prefer the path-dependency (uniform-404); agent name derived from a resolved resource (session/notification/subscription/execution row) or a composite gate → use the imperative helper; both run_enforce_connector_scopefirst so the connector boundary is enforced identically.assert_agent_owner≠ delete-authorization — it wraps the owner-or-admincan_user_share_agent, NOT theis_system-guardedcan_user_delete_agent; delete paths keep the delete predicate. Permanent exceptions (own intentional-404 helpers, enumeration-safe by construction):nevermined._require_read_access/_require_write_access,reports.get_report,sessions._session_or_404(compound 404). The admin gate is an ALLOWLIST, not two named enemies (#2323). ent#293/#297 closed it againstagentandconnectorby name, which is a denylist over a free-text column:mcp_api_keys.scopecarries no CHECK constraint, and any scope setting neitheragent_namenorconnector_agentwalks both rejections and inherits the owner's role across ~163 admin-gated sites.models.User.mcp_scope's own docstring predicted it — "fail-closed against a sixth scope a future PR invents" — and #2323'sopsscope was that sixth. Both gates now additionally requiremcp_scope ∈ ADMIN_GATE_SCOPES = {None, "user", "system"}:Noneis the JWT human the role check was written for,userlegitimately drives admin endpoints today (ops dashboards,trinity-ops-agent) so removing it would be a behaviour change, andsystemistrinity-system. Zero behaviour change over the live domain exceptportal_delegate, which passed both named guards and was contained only by its route fence — it now has a second layer, unreachable in normal operation and firing only if that fence is holed. A principal object lackingmcp_scopeentirely fails closed via a dedicated sentinel — nevergetattr(..., None), which would make the absent attribute the privileged JWT value (the documented getattr-discriminator trap);models.Useralways declares the field, so the only objects this rejects are stand-ins that do not match the real principal.assert_admintakesallow_scopes=so an individual endpoint can opt a bounded scope in (allow_scopes={"ops"}on the ops reads) — the grant that makes a machine credential authorized by what it is rather than by who owns it, and which flips the failure direction so a new ops route is inaccessible until opted in rather than silently reachable.require_roleis deliberately not consulted (agent-spawned creation throughrequire_role("creator")is supported ent#69 behaviour). Static guard:tests/unit/test_1310_auth_wiring.py; behavioral proof:tests/unit/test_1310_auth_consolidation.py; allowlist + future-scope proof:tests/unit/test_293_admin_gate_rejects_agent_keys.py. -
Channel Adapter ABC — External messaging (Slack, Telegram, WhatsApp/Twilio) follows
adapters/base.py→ChannelAdapterABC withNormalizedMessageandChannelResponse. New channels must implement this interface. -
WebSocket Events for Real-Time — All real-time updates go through WebSocket broadcast (
agent_activity,agent_collaboration). Frontend subscribes viautils/websocket.js. Don't poll for state that should be pushed. Transport is the Redis Streams event bus inservices/event_bus.py(RELIABILITY-003, #306) —ConnectionManager/FilteredWebSocketManagerare thin shims thatXADDtotrinity:events; theStreamDispatcherruns oneXREAD BLOCKper backend process and fans out to registered clients. New broadcast sites should continue calling the existingmanager.broadcast(...)/filtered_manager.broadcast_filtered(...)API — do not bypass it to publish directly. -
Docker as Source of Truth — Agent container state comes from Docker labels (
trinity.*), not from an in-memory registry.docker_service.pyis the single point of Docker interaction. -
Credentials: File Injection, Never Stored in DB as Plaintext — Credentials use
.envfiles injected into containers (CRED-002). Encrypted exports use AES-256-GCM (.credentials.enc). Redis holds transient secrets. Exception with mandatory encryption: channel bot/auth tokens (Slack, Telegram, WhatsApp) and subscription/Nevermined OAuth tokens are persisted in SQLite because they drive long-lived background processes (webhook receivers, scheduled bots) that can't depend on container env vars. These MUST be wrapped in AES-256-GCM JSON envelopes viaservices/credential_encryption.py— plaintext persistence is forbidden. Tables under this rule:subscription_credentials.encrypted_credentials,nevermined_agent_config.encrypted_credentials,telegram_bindings.bot_token_encrypted,whatsapp_bindings.auth_token_encrypted,agent_git_config.github_pat_encrypted,users.github_pat_encrypted(per-user GitHub PAT — ent#162; resolved live by owner at agent creation viasettings_service.resolve_github_pat),slack_workspaces.bot_token(TEXT column, JSON-envelope content),slack_link_connections.slack_bot_token(TEXT column, JSON-envelope content — encrypted by #453, 2026-05-05),system_settings['elevenlabs_api_key_encrypted'](platform ElevenLabs key, JSON-envelope value — ent#117; runtime-resolved bysettings_service.get_elevenlabs_api_key()),system_settings['a2a_outbound_endpoints_encrypted'](the outbound A2A endpoint list — one envelope over a JSON document whose entries each carry a peer credential; #736. Chosen over a new table precisely because this location is already blessed: it is why #736 ships with no migration and no Alembic revision), and — since ent#435 — the six credential-bearing settingssystem_settings['{anthropic_api_key,github_pat,google_api_key,slack_app_token,slack_client_secret,slack_signing_secret}_encrypted']— plusresend_api_key_encryptedsince ent#582 (the email-provider key, env-only before, so no cleartext row predates it; membership isSECRET_SETTING_KEYS, which the rotation script derives from). ent#435 closes the invariant's own loophole: those six rows held live third-party credentials in CLEARTEXT while this list read as though everything was covered, so the platform's encryption-at-rest posture was contradicted by a singleSELECT(CWE-312) — every dump, backup, replica and snapshot carried usable tokens, readable withoutCREDENTIAL_ENCRYPTION_KEY. The fix is a policy leaf (services/secret_settings.py) plus a sink guard:db.set_settingraisesSecretSettingWriteError(→ 422) for any key inSECRET_SETTING_KEYSor merely credential-shaped (*_api_key/*_token/*_secret/*_pat/*_password/*_credentials), because the genericPUT /api/settings/{key}catch-all can address ANY key — the same door #506, #1609, ent#12, #1644, ent#14 and ent#346 each found open. Three properties are load-bearing. (1) The key NAME moves (<key>→<key>_encrypted, cleartext row DELETED), not just the value: a same-named key that may hold either form leaves "is this install encrypted?" unanswerable by inspection, which IS the reported defect; with the rename the reporter's own query (SELECT key … WHERE key IN (…)returning nothing) is the proof and the guard keeps it true. (2) The read path lazily migrates —settings_service._resolve_secret_settingresolves encrypted → legacy-cleartext-encrypted-and-deleted-on-sight → env →'', so a restored pre-fix backup or a direct DB write makes cleartext transient rather than merely absent right now; steady state pays nothing (the legacy key is never read while the encrypted row exists). It is fail-OPEN on read (an unreadable envelope degrades to env, never a 500 on the agent-start path) but deliberately does not fall back to a stale legacy row, which would resurrect a replaced credential; and fail-CLOSED on write. (3)slack_client_idis a documented exemption, not an omission — an OAuth client_id is emitted verbatim in the browser-visible authorize URL (thewhatsapp_bindings.account_sid"(public)" precedent) — recorded with its reason inPUBLIC_CREDENTIAL_SHAPED_KEYSso a later reader can tell reviewed from overlooked. Dual-track sweep (secret_settings_encryption+ Alembic0041) sharing ONE decision function (plan_migration) since the two drivers cannot share SQL; hard-fails on a missing key only when there is something to encrypt (the #453 choice, so a fresh install still boots);downgrade()is a deliberate no-op, because the honest inverse is "write these credentials back in cleartext".scripts/deploy/rotate-credential-key.pygains a row-keyedsystem_settingspass, which also closes the pre-existing gap that leftelevenlabs_api_key_encrypted/a2a_outbound_endpoints_encryptedout of every rotation (envelope-in-a-row is invisible to a column sweep). Encryption protects the DB going forward only — historical backups still hold the plaintext, so the affected tokens must be rotated: runbook docs/migrations/SECRET_SETTINGS_ENCRYPTION_2026-08.md. Guards:tests/unit/test_ent435_settings_sink_guard.py(AST — everysystem_settingswriter is gated or listed with a reason; OSS tree only, the private submodule owns its twin per the #1677 convention). -
MCP Server = Third Surface in Sync — The MCP server (
src/mcp-server/src/tools/*.ts) is a TypeScript proxy over the backend API. When adding a backend endpoint for external access, the MCP tool module needs updating too. Three surfaces must stay in sync: backend router, agent server (if internal), MCP tool (if external). Every router outside the by-design exclusions (internal,setup,auth,public,paid) declares its MCP surface in a first-line# mcp:header — a pointer to the covering tool module, or# mcp: none — <reason>for a deliberately unexposed surface (admin, grant-vs-use human-only, UI-only) — so/validate-architecturecan tell "unexposed on purpose" from "forgotten". -
Pydantic Models Centralized in
models.py— Request/response models live inmodels.py, not scattered across routers (#654). Keeps the API contract in one place. Scope: this invariant governs router models — aclass X(BaseModel)must not be defined underrouters/(enforced by the static guardtests/unit/test_models_centralized.py). Two model homes are intentionally separate and out of scope:db_models.py(DB-row / persistence models — a distinct layer) andadapters/base.py(the ChannelAdapter ABC'sNormalizedMessage/ChannelResponse). One documented exception, allowlisted in the guard:routers/canary.py::RunCycleRequestevaluatesINVARIANTS(from thecanarylibrary) in aField(description=…)at class-definition time, and thecanarylibrary importsTaskExecutionStatusback frommodels— relocating it would forcemodels.pytofrom canary import …, inverting the dependency direction of a module meant to be a low-level leaf everything imports from. -
API URL Nesting Convention — Agent-scoped resources nest under
/api/agents/{name}/.... Platform-wide resources get top-level prefixes (/api/executions,/api/operator-queue). -
Time-Window SQL uses
iso_cutoff(), notdatetime('now', ...)— Columns written viautc_now_iso()are ISO-Z strings (Tseparator,Zsuffix); SQLite'sdatetime('now', ...)emits a different format (space separator, no suffix), making lexicographic comparison silently incorrect (#476). For rolling-window filters on ISO-Z TEXT columns, compute the cutoff in Python viaiso_cutoff(hours)fromutils/helpers.pyand pass it as a bound parameter. Write-side/read-boundary cousin (#1474): the standalone scheduler is a separate package that can't importutils/helpers.py, so its execution/schedule timestamps (started_at/completed_at/validated_at/last_run_at/retry_scheduled_at+ process-schedule variants) now serialize via a vendored behavioural mirrorsrc/scheduler/utils.py(utc_now_iso/to_utc_iso, Z-suffixed) — functionally identical to the backend copy but textually divergent (to_utc_isouses an early return vs the backend'sif/else), so unlikefailure_classifier.pya source diff CANNOT verify it; the contract is agreement on output, enforced bytests/unit/test_1713_scheduler_utils_parity.py(#1713) and the property/edge suitestests/unit/test_1771b_timestamp_helpers_{properties,edges}.py(#1771) — previously naive strings that JSnew Date(...)mis-parsed as local time.parse_scheduler_tsreads tolerantly and returns naive UTC so the historical model type +aware − naiveduration math are preserved (write + read are one atomic change). The leaking backend read boundaries (db/schedules.pyget_agent_executions_summary/get_fleet_executions/get_agent_schedules_summarylast_run_at;db/activities.py_row_to_activity/_mapping_to_activity) normalize naive stored strings viaparse_iso_timestamp+to_utc_iso, fixing historical rows for all consumers; the 5 execution panels also parse viaparseUTCas render-layer defense (covers WS-pushed timestamps). Honest caveat:agent_schedules.next_run_atstays mixed-format across the scheduler (Z) and the backend's ownnext_run_at.isoformat()writers (db/schedules.py, tz-aware+00:00/offset) — safe becausenext_run_atis only ever parse-compared in Python, never lexicographically in SQL (Invariant #16's trap doesn't apply).main.pySchedulerStatus.last_check(health endpoint, not a DB row / not a relative-time surface) is out of scope. -
Non-root containers — every Trinity-built image MUST end with a
USERdirective switching to a non-root user; the backend additionally requiresgroup_add: ${DOCKER_GID:-999}in compose for Docker socket access on Linux. New Dockerfiles failing this are rejected at review (#874). CI guards in.github/workflows/container-security.yml(path-filtered ondocker/**,docker-compose*.yml,scripts/deploy/start.sh,src/mcp-server/Dockerfile, independent of theui-gated e2e workflow):verify-non-rootexecs the backend/scheduler/mcp-server containers, asserts UID 1000, and provesgroup_addworks by runningdocker.from_env().ping()from inside the backend (not a/api/agentsprobe —list_all_agents_fastswallows Docker errors, a false-positive trap);verify-prod-frontend-uidbuilds the prod frontend out-of-band and asserts UID 101 (nginxinc/nginx-unprivileged). Dev-onlydocker/frontend/Dockerfileis exempt. Upgrading deployments must re-own their data path andagent-configsvolume per docs/migrations/NON_ROOT_CONTAINERS_2026-05.md. -
Trigger boundaries accept
Idempotency-Key(RELIABILITY-006, #525) — every producer boundary that creates an execution accepts an optionalIdempotency-Keyheader and routes it throughservices/idempotency_service.py(begin/complete/fail) backed by theidempotency_keystable. The same(scope, key)within 24h yields one execution; duplicates short-circuit with the original result +X-Idempotent-Replay: true(in-flight duplicate → 409). Enforcement lives at the router layer, not solely inTaskExecutionService, because sync/chatruns an inline path and/api/webhooks/{token}creates no execution. Wired boundaries:/chat,/task,/api/internal/execute-task,/api/webhooks/{token}(auto-derives(token, body_hash)),/api/paid/{name}/chat(#1018 — always keys on(payment-signature ∥ message)viaderive_payment_key, a divergent client header never forks execution; a completed-unsettled snapshot re-drives settle thenupgrade_snapshots),/api/agents/{name}/fan-out, and the scheduler (Idempotency-Key: sched:{execution_id}) + MCPchat_with_agent/fan_out(deterministic key over call args). Any new trigger type must accept an idempotency key before merge — the dedup layer is fail-open (a key never blocks a real execution), so the cost of adding it is onebegin/complete/failtriple.
Two Docker bridge networks, by design — agents physically cannot route to Redis.
| Network | Subnet | Members |
|---|---|---|
trinity-platform-network |
172.29.0.0/16 | redis, scheduler, vector |
trinity-agent-network |
172.28.0.0/16 | agents, frontend |
Bridges (members of both networks): backend (primary HTTP API — Redis on platform side, agents on agent side), mcp-server (agents reach http://mcp-server:8080/mcp via Docker DNS), otel-collector (agents push metrics), cloudflared (prod only — proxies to backend and public agents).
Rule: agents are never on trinity-platform-network. Any new service that mounts the agent network must NOT connect to Redis — full stop. The three agent-container-create sites hard-code the network name trinity-agent-network (cited by function so the reference survives line drift): crud.py::_create_agent_container (#1484), lifecycle.py::_provision_folders_and_run_agent_container, and system_agent_service.py::SystemAgentService._create_system_agent.
Redis ACL users:
| User | Auth | Purpose |
|---|---|---|
default |
REDIS_PASSWORD |
Admin / recovery / ad-hoc ops; +@all |
backend |
REDIS_BACKEND_PASSWORD |
Backend runtime; data ops only, -@dangerous |
scheduler |
REDIS_BACKEND_PASSWORD |
Scheduler runtime; same access pattern |
backend/scheduler cannot run FLUSHALL, CONFIG, SHUTDOWN, DEBUG, MIGRATE, REPLICAOF, MONITOR, or other @dangerous categories. Both passwords are mandatory in .env; compose refuses to render without them, and src/backend/config.py / src/scheduler/config.py raise on import if REDIS_URL lacks credentials. Upgrade path: docs/migrations/REDIS_AUTH.md.
Local and production use the same ports. Local URLs, auth, and admin credentials: see CLAUDE.md / CLAUDE.local.md.
| Port | Service |
|---|---|
| 80 | Frontend (nginx/Vite) — prod: https://your-domain.com |
| 8000 | Backend (FastAPI) — /docs for OpenAPI |
| 8080 | MCP Server (/mcp) |
| 8686 | Vector health |
| 2222–2262 | Agent SSH |
- Bind mount (survives
docker-compose down -v):~/trinity-data/→/data— containstrinity.db(SQLite),agent-files/(FILES-001),agent-data-tmp/(transient export staging, #1169),agent-import-tmp/(transient copy-intent snapshot staging, ent#15 — free-space preflighted,AGENT_IMPORT_MAX_BYTES-capped, swept after 24h), andbackups/(#2216 — the automatic database recovery points:trinity-backup-YYYYMMDD.{db,dump}nightly +pre-migration-YYYYMMDD-HHMMSS.dbat boot; dir 0700; same-disk scope — protects against corruption/slips, not disk loss; retentionbackup_retention_days+MIN_KEEP=3). - Docker volumes:
redis-data(Redis AOF),agent-configs,audit-data,audit-logs, per-agentagent-{name}-workspace(the durable home volume — declareddata_pathsruntime data lives under/home/developer/datahere, #1169),agent-{name}-public(FILES-001), and shared-folder volumes.