Skip to content

Latest commit

 

History

History
211 lines (183 loc) · 24.5 KB

01-architecture.md

File metadata and controls

211 lines (183 loc) · 24.5 KB

01: Architecture

Shape

A single Mix/Phoenix application (trinity) organised into bounded contexts and enforced with the boundary library. Not an umbrella (adds friction without isolation benefits here); not a monolith of free-floating modules (boundary violations become compile errors). ADR-0001.

Two top-level namespaces: Trinity (core, no web deps) and TrinityWeb (Phoenix). TrinityWeb may depend on Trinity; Trinity must never depend on TrinityWeb.

Supervision tree (target state; slices build it incrementally)

Trinity.Application
├── Trinity.TLS.Finch                             # the Finch instance Req's defaults name while the
│                                                 # runtime's ssl carries CVE-2026-89422; first, so it
│                                                 # runs before anything can make a request (ADR-0014)
├── Trinity.Repo                                  # Ecto (SQLite primary). Slice 010
├── {Phoenix.PubSub, name: Trinity.PubSub}        # all fan-out. Slice 010
├── Trinity.Telemetry                             # metrics + cost ledger. Slice 090
├── {Registry, keys: :unique, name: Trinity.Registry}   # Slice 012, as built
├── {Task.Supervisor, name: Trinity.LLM.TaskSupervisor}  # Slice 011, as built: stream_to/3 runs here.
│                                                 # Trinity.LLM.Supervisor (rate limiters) is not built:
│                                                 # nothing needs a process yet (011 NOTES, follow-up)
├── Trinity.Sessions.Supervisor (DynamicSupervisor) # one Trinity.Sessions.Session per conversation. Slice 012, as built
│     └── Trinity.Sessions.Session (gen_statem)   # states: idle → thinking → tool_wait → approval_wait → compacting → error
│           └── Task.Supervisor (started by the Session, linked, unnamed) # the model call and the tool calls of one turn
├── Trinity.Tools.Supervisor                      # Slice 020, as built: Trinity.Tools.TaskSupervisor (every tool
│     │                                         # call of a turn runs under it) and Trinity.Tools.Registry
│     │                                         # (GenServer over ETS). 022 adds the stateful runtimes beside them
├── Trinity.Permissions.Gate                      # Slice 021, as built: approval requests (rows, then broadcasts),
│                                                 # decisions, expiries; pending rows reloaded with their timers
├── Trinity.Authority.Selection                   # Slice 024, as built: a transient Task right after the data
│                                                 # directory lock; reads TRINITY_AUTHORITY once, refuses the boot
│                                                 # by name (ADR-0010). Placed early in the list, before the Repo.
├── Trinity.Repo.Receipts                         # Slice 024, as built: the receipts chain's own SQLite file,
│                                                 # synchronous full (ADR-0013), its own migrations
├── Trinity.Receipts.Supervisor                   # Slice 024, as built: KeyCustody.boot!/1 in its init (the
│     │                                         # signer and its key, once), then
│     └── Trinity.Receipts.WriterSupervisor (DynamicSupervisor)
│           └── Trinity.Receipts.ChainWriter (one per chain_scope, :unique in Trinity.Registry, temporary; ADR-0013)
│                 # serialises append per scope. prev_hash -> receipt_hash is a read-then-write, so
│                 # concurrent sessions would otherwise race: SQLite's single writer serialises the
│                 # INSERT but does not guarantee each row read the correct predecessor.
├── Trinity.Effects.Boot                          # Slice 024, as built: a transient Task writing the boot receipt
│                                                 # once the signer and the authority are known
├── Trinity.Memory.Supervisor                     # Slice 032, as built: Trinity.Memory.TaskSupervisor (the
│   │                                             # observer's task after each turn, the page's model download)
│   └── Nx.Serving (Trinity.Memory.Embedding)     # the local embedder, started only when the embedder is
│                                                 # :local and the model is in the cache; absent otherwise, the
│                                                 # tier off and the reason logged once. ensure_embedding/0
│                                                 # starts it after a download.
├── Trinity.Skills.Registry                       # Slice 040, as built: a GenServer over an ETS table; scans the
│                                                 # roots at boot and on change (file_system watchers, linked,
│                                                 # a dead one dropped), writes the index rows on first read
├── Oban                                        # cron + durable jobs. Slice 050
├── Trinity.MCP.Supervisor                        # MCP clients (one per server, lib per 059) + server. Slice 059-062
├── Trinity.Gateways.Router                       # one road from a channel to a session. Slice 070
├── Trinity.Gateways.Console                      # the in-process adapter; platforms at 071, 072
├── Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080
├── Trinity.Sandbox.Supervisor                    # Luerl workers. Slice 110
├── Trinity.Desktop                               # ex_tauri bridge (tray, notifications). Slice 100
│                                                 # As built: `Trinity.Desktop.Shell`, after the sessions and
│                                                 # before Oban; the tray, the notifications, the tray's
│                                                 # actions, on the `Trinity.Desktop` implementation the
│                                                 # process selected (`Tauri` when the shell launched it,
│                                                 # `Noop` otherwise). `ExTauri.ShutdownManager` (the
│                                                 # channel and heartbeat) is first in the tree outside :test
└── TrinityWeb.Endpoint                           # LiveView. Slice 013

Trinity.Effects is a module rather than a process: it is the membrane every effectful call passes through, and holds no state of its own. Trinity.Authority is a behaviour with the selection resolved once at boot by the Selection child and must not be re-resolvable afterwards; as built at slice 024 the selected module is read from :persistent_term by Trinity.Authority.impl/0.

Restart strategies: Trinity.Sessions.Supervisor is :one_for_one with max_restarts: 10, max_seconds: 60 per session; a Session that crashes rehydrates from the DB (Trinity.Sessions.rehydrate/1) and re-enters idle. Tool tasks are linked to their Session but trapped; a tool crash is a tool error, not a session crash.

Bounded contexts and dependency rules (enforced by boundary)

boundary enforces this table only under --warnings-as-errors. Measured at slice 000: it reports a violation as a warning, so mix compile --force with a planted Trinity → TrinityWeb call exits 0. Every rule below is advisory unless the gate's compile step carries that flag, which test/gate_alias_test.exs asserts. Drop the flag and this table stops being enforced without anything failing.

Context (module) Owns May depend on
Trinity.Sessions Session process, turn loop, message log; the persona row and its store (since 010; Trinity.Personas is the context over them, as built at 030) LLM, Tools, Effects, Permissions, Memory, Skills, Repo, PubSub, Receipts (as built at 030: the prompt truncation receipt), Context (as built at 033: AGENTS.md every turn)
Trinity.Context What the project tells the prompt: AgentsMd (033), SkillsIndex (040) Skills (as built at 040)
Trinity.LLM Provider behaviour, req_llm adapter, model registry, streaming, usage Repo (usage), Telemetry
Trinity.Tools Tool behaviour, registry, execution runtime, core tools, and (as built at 024) the compile-time effect catalog Trinity.Tools.Catalog, because the registry reads it and Effects depends on Tools Permissions, Sandbox, Repo, Memory (as built at 031: session_search reads the index; Memory never depends on Tools)
Trinity.Permissions Policy, tier/1 (name-only), fingerprint-bound approvals, override adjudication Repo, PubSub
Trinity.Effects The membrane; the runner in force (Effects.Runner, the executor Tools.Runner takes as a function); decision and query receipts; the boot receipt Tools, Permissions, Authority, Receipts, Repo
Trinity.Authority Behaviour; Local implementation (the one caller of execute/2 for effectful tools); selection at boot; Staged Receipts, Repo
Trinity.Receipts Local chain (one supervised writer per scope, ADR-0013), the signer seam (Ed25519, P-384, ML-DSA-87), key custody and the registry, checkpoints, the verifier, the alarm Repo (Repo.Receipts)
Trinity.Memory Always-on tiers with their budget and consolidator (030), search (031), semantic store and retrieval (032), compaction (023) LLM (summaries/embeddings), Repo
Trinity.Skills SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools; at 041: staging, promotion, manager, scanner, diff, learn, skill_manage and learn) Repo, Permissions, Effects, Receipts, Sandbox (as built at 041: Tools, Memory, Permissions, Receipts and LLM; Effects is not a dependency: the promotion is not a tool call, it writes its own effect receipt; Tools never depends on Skills)
Trinity.Scheduler Oban workers for agent tasks, delivery (as built at 050: the tasks and task_runs rows, next_run_at computed from a cron expression or a one-shot datetime, Workers.Tick (the one Cron plugin entry, every minute, enqueuing Workers.RunTask for what is due), RunTask (one turn in a fresh origin: "cron" session, the summary, the delivery), Delivery with Delivery.Desktop, Parse.human/2 (a phrase to cron through the model, checked by Oban's parser); Oban itself on the app's repo, Lite on SQLite and Basic on Postgres, queues agent_tasks, memory, maintenance; the observer as Trinity.Memory.ObserverWorker and the curator as Trinity.Memory.Curator live in Memory) Sessions, LLM, Repo (Gateways at 070)
Trinity.MCP Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: Servers and ServerConfig (the rows), Supervisor and Boot (one Client per enabled row), Client with its Wire (the outbound request, the headers, the core's decoder and validator) and two transports (Transport.Stdio, a child on a Port; Transport.HTTP, one POST per request), Bridge (the one tool module every MCP tool runs through) and Client.Auth (062's seam, a static token at 060); at 061: Server (the module above the core, @behaviour BeamMCP.Server, handed to both transports through :server; tools/call answered through the membrane, everything else the core's), Server.Catalog (the exported tools, sorted), Server.Exports (the configured names, :catalog refused), Server.Session (the origin: "mcp" session with its own persona), Server.Envelope and Server.Replay (the sealed requestState and the nonce table), Server.Auth.Local (the static bearer, 062's seam on the server side), Server.Plug (the POST /mcp endpoint, mounted in TrinityWeb.Endpoint ahead of the parsers) and Server.Stdio with mix trinity.mcp.stdio; at 062: Trinity.MCP.Auth, a boundary of its own under Trinity.MCP with deps: [] (the behaviour authorize/2 answering a Principal, never the token; Config, Token, JWKS, Discovery, Scopes; the profiles Local, External and Embedded with Embedded.Keys; the client role Client with Client.Store), and AuthHost in Trinity.MCP (the configuration from the tree's paths and the authority selection, the personal profile's boot, the refusal receipts, the client role's flow for the page); Server.Auth.Local is gone, Server.Plug authorizes ahead of the transport and answers 401 with a challenge, Server checks scope before the gate) Tools, Effects, Permissions, Memory (as built at 059: a top-level boundary, like Trinity.Smoke, with deps: [Trinity, BeamMCP.JSON]; the boundary compiler checks every call into the beam_mcp application and this boundary alone lists its modules; at 060 the deps are [Trinity, BeamMCP.JSON, BeamMCP.Schema], Trinity's tools, permissions and receipts reached through Trinity's exports, and Trinity.Application and TrinityWeb list Trinity.MCP; at 061 the core's Server, Catalog, ToolSpec and both transports are listed too, and Trinity exports Tools.Registry and Effects.Runner to it)
Trinity.Gateways Adapter behaviour, router, allowlists, pairing (as built at 070: Adapter (the behaviour: child_spec/1, capabilities/0, deliver/2, format/2, render_approval/2, plus name/1 deriving the row's adapter name from the module), Console (the in-process adapter the tests and mix trinity.console talk to), Format (chunking to the channel's limit, re-opening a split code fence, markdown reduced to text where a channel renders none, the default approval rendering), Identity and Identities (the gateway_identities rows, the pairing code and the allowlist), Router (one process: admission, then the token bucket, then commands, then the session; it subscribes to session:<id> and approvals:<id> per conversation and coalesces deltas into an edited message), Commands (the slash registry, including /attach, /approve and /deny), Cap (the channel trust cap) and Receipts (the capped-decision receipt)) Sessions, Permissions, PubSub
Trinity.Subagents Delegation, result collection (as built at 080: delegate/3 and delegate_many/3 creating child sessions with origin: "subagent" and parent_id set, one turn each under a monitor, the awaiting shape reused from slice 050's RunTask including its drain of the start :idle; budgets of turns, tokens and wall clock; restarts defaulting to 0, because replaying a turn replays arbitrary tool calls and a :session or :always approval would cover the replay silently; cancel_subtree/1 depth first; running?/1 asked of the process rather than of SessionRow.status, which is the session lifecycle and stays active after a subagent finishes; short_id/1 taking the tail of a UUIDv7, whose head is shared between children created in the same second) Sessions
Trinity.Sandbox Luerl runners, resource limits none
Trinity.Desktop ex_tauri bridge (as built at 100: the behaviour with Noop and Tauri; Shell, the process that keeps the tray and decides notifications; commands to the Rust window over ex_tauri's channel, events back; Trinity.Secrets, Trinity.Settings and Trinity.Setup sit in Trinity itself because providers, tools and receipts read them) PubSub (as built at 100: Trinity, Gateways, Sessions, Permissions, Scheduler: it counts approvals and busy sessions, creates a session, pauses the gateways, and notifies on a finished task)
Trinity.Telemetry The event catalogue and the only module that names an event (as built at 090: span/3 and emit/3 routing everything through one place; emitters for the LLM, tool, approval, session, gateway and budget events listed in docs/telemetry.md, which was written before them because an event name is an interface and a handler attached to a name that no longer fires simply never runs; trace/2 giving a turn one trace id so its events nest, with no tracing library and no dependency; Telemetry.Costs reading usage_events schemalessly, because Trinity.LLM depends on Trinity and naming its schema would be a cycle; Telemetry.Activity, a bounded buffer whose handler does one send because a telemetry handler runs in the emitting process; Telemetry.Redaction, a primary Logger filter that masks shapes rather than names and never drops a line) Repo
TrinityWeb LiveViews, components, API all Trinity.* public APIs

The six rows in bold were corrected on 2026-09-05. As written, the table forbade the effect path drawn in the "Core data flow" section below: Trinity.Effects could not call Trinity.Tools, and Trinity.Sessions could not reach Trinity.Effects at all. boundary enforces this at compile time and the gate treats its warnings as errors, so each row was a slice that would not have compiled. Slice 024 hits it first.

Public API of each context is the context module (Trinity.Sessions, Trinity.Tools, …). Cross-context calls go through those modules only. boundary exports: lists enforce this.

Extension points (behaviours)

Behaviour Callbacks (sketch) Registered via
Trinity.LLM.Provider stream/3, generate/3, embed/2, models/0, capabilities/1 config :providers list
Trinity.Tools.Tool name/0, description/0, schema/0, risk/0, execute/2 config :tools list + MCP dynamic registration
Trinity.Gateways.Adapter child_spec/1, deliver/2, capabilities/0 config :gateways list
Trinity.Memory.VectorStore upsert/3, search/3, delete/1, count/1 (as built at 032: the scope filter is search/3's required argument) the database adapter, at compile time (Brute on SQLite, Pgvector on Postgres; Hnswlib when a slice measures past 10^5 rows)
Trinity.Memory.Embedder embed/1, dim/0, model_id/0, availability/0 config :trinity, :memory, embedder: (:local default, :hosted opt-in only, :fake in the suite)
Trinity.Skills.Loader load/1, validate/1 config :skill_loaders (not built at 040: the three roots are fixed and Trinity.Skills.Sources scans them; a loader behaviour waits for a fourth kind of source)
Trinity.Permissions.Policy decide/3 → `:allow :deny

Adding any of these = new module + config entry. No core edits. A test in Slice 020 asserts this ("a tool module in test/support appears in the registry with zero core changes").

Core data flow: one turn

UI/Gateway ──user_message──▶ Session(gen_statem)
  Session: append message → build prompt (persona + always-on memory + skills index + history[compacted])
          → LLM.stream/3 ──chunks──▶ PubSub "session:<id>" ──▶ LiveView + gateways
          → on tool_call: Permissions.decide → (ask → approval_wait) → Tools.execute in Task → result appended
          → loop until final text → persist → Memory.Observer.observe(turn) (a Task under Trinity.Memory.TaskSupervisor, as built at 032) → idle

Every state transition is persisted before it is broadcast. A crash between persist and broadcast is safe (rehydrate re-broadcasts the last state).

Tool calls (Slice 020): at {:done, :tool_calls} the Session hands the turn's calls to Trinity.Sessions.ToolRunner.run_all/2, the seam whose implementation in force is Trinity.Tools.Runner (config, so a test can put the stub back). The runner runs every call at once under Trinity.Tools.TaskSupervisor, each with its tool's timeout: lookup, jsv validation of the arguments (refused, never repaired), Trinity.Permissions.decide/3 once, execute/2, the result cap. A crash, a timeout and an unknown name are error results the model reads; the Session writes one tool row per answer with the tool's definition digest. Each turn's request carries the declared surface (Trinity.Tools.to_llm_tools/0) and the assistant row records it (provider_meta.tool_surface); Trinity.Tools.surface_diff/1 over a history names the calls a turn made outside it. Sessions depends on Tools; Tools depends on Permissions and never on Sessions (the runner implements the seam's functions without naming the behaviour, which would close a cycle).

Compaction (Slice 023): before a model call the Session estimates the request (Trinity.Memory.Tokens, bytes over three plus four per message, calibrated high) against the model's window (context_tokens on the registry entry, 32,768 when absent); over the soft threshold (70 %) it enters compacting, runs Trinity.Memory.Compactor in a Task (the structured call, with a plain-text fallback when the provider answers no object) and writes the compaction row itself; over the hard threshold (90 %) after that it forks: a child session with parent_id, the compaction first, the user's message second, the child's turn started, the parent closed with a row naming the child and {:forked, child_id} broadcast. Memory depends on LLM and the core, never on Sessions.

Effect path (Slice 024, as built): Session → ToolRunner seam → Effects.Runner (executor) → Tools.Runner.decide (the gate, once) → decision receipt → Effects.execute (the membrane: decision, effect class and catalog, fingerprint re-derived, idempotency by session and call id) → Authority.stage → decide → admission receipt → Authority.Local.execute → tool.execute/2 → outcome receipt. Authority.Local is the only caller of execute/2 for effectful tools; Tools.Runner.call_tool/3 runs effect: :none tools directly and refuses the rest by name; a census over git ls-files with a planted bypass holds both. Reads emit query receipts, chained unsigned and checkpointed. A decision that cannot be receipted (no signer) refuses the call, reads included.

The page (Slice 013): TrinityWeb.SessionLive.Show subscribes to session:<id> on mount, calls Trinity.Sessions.ensure_started/1, loads the history from the database into a LiveView stream and the turn in flight from Trinity.Sessions.state/1 (the state name and the draft text), and drops the delta broadcasts that were already queued when that reply arrived, because the reply's text contains them. Completed messages render once through TrinityWeb.Markdown (mdex); the in-progress text is one assign replaced per coalesced delta. Tool rows are written without an event of their own, so the page reads what the database has past its last seen seq whenever a final or interrupted message arrives. A user message's send goes through Trinity.Sessions, never to the process directly. The Trinity.LLM.Providers.Fake provider lives in lib/ and runs the chat in development under TRINITY_FAKE_PROVIDER=1.

Storage

  • Primary: SQLite via ecto_sqlite3, single file under the OS data dir, WAL mode, one writer (the Repo pool is size 1 for writes; reads may use a second pool). FTS5 virtual table for message search. sqlite_vec for vectors.
  • Secondary: Postgres + pgvector, selected by TRINITY_DB=postgres. Enables Oban Pro Workflows later. Kept compiling and tested in CI (matrix), not default.
  • Durable settings/secrets: OS keychain via Trinity.Secrets (Slice 100); before that, env vars. As built at 100: secrets in the keychain (through the shell binary's --keychain mode), the environment as the fallback; non-secret desktop settings (hotkey, notifications, launch at login, chosen folders, the setup's default model) in <data dir>/settings.json (Trinity.Settings), mode 0600.

Concurrency and state rules

  • Session state = %Session.State{} struct, rebuilt from DB on init; in-memory only for the active turn.
  • Never block a Session on I/O: LLM streaming, tool execution, embedding happen in Tasks; Session receives messages.
  • PubSub topics: session:<id> (turn events), approvals:<id>, gateway:<adapter>, system.
  • Backpressure: stream chunks are coalesced to ≤ 20 broadcasts/sec per session (built at Slice 012: a 50 ms timer in the Session, so 013 receives coalesced deltas).

Directory layout

lib/trinity/                 core contexts (one dir per context)
lib/trinity_web/             Phoenix: live/session_live (the chat, 013), components/chat_components.ex
                             (the component vocabulary), markdown.ex (the one renderer), plugs/ (the CSP)
priv/repo/migrations/
priv/skills/               bundled skills (SKILL.md)
priv/personas/             default SOUL.md
test/support/              Mox definitions, factories, fake tools/providers
docs/                      architecture, data model, security model, standards, ADRs
docs/                      this plan's docs and ADRs
tauri/                     desktop shell (Slice 001+; as built, `src-tauri/`, the generator's name: main.rs
                           and keychain.rs, slice 100)