Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ them: 23 slices, each merged with a merge commit and tagged `slice/NNN` (`git ta
the assistant's own, and one that needs your approval waits for it on the permissions page
while the client carries a sealed state it can retry with. A headless release runs the same
tree as a server, in a container or under systemd (`docs/mcp-server.md`).
- **Knows who is calling.** Trinity's MCP server is an OAuth 2.1 resource server for the
authorization server you already run (`production`: a valid audience-bound token or `401`
with the metadata a client needs to find that server; every refusal and every call is
receipted with the caller's issuer, subject and scope, and the token itself never reaches a
tool, a message or a receipt) or, for your own clients on your own machine, a small
authorization server of its own (`personal`: a consent page in your browser, short-lived
tokens that no production deployment accepts). A token's scope says what it may ask for;
the permission gate still decides whether it happens. As a client, Trinity answers a
protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains.
Trinity issues no production authority: the personal issuer refuses to start under an
external authority adapter, by construction (`docs/07-security-model.md`).
- **Runs on a schedule.** Tasks on the `/tasks` page: a prompt, a persona, the skills to hint,
and when (a cron expression, a one-shot time, or a phrase like "every weekday at 9am" the
model turns into cron). Each run is a fresh conversation you can open, its result waits on
Expand All @@ -54,7 +65,7 @@ them: 23 slices, each merged with a merge commit and tagged `slice/NNN` (`git ta
curator that marks old memories stale and archives the untouched ones (never deleting)
run on the same queues; `/oban` shows the jobs.

Not there yet: MCP's authorization roles (M5a), messaging gateways and subagents (M5b),
Not there yet: messaging gateways and subagents (M5b),
the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every
slice, and the [Milestones](#milestones) section below explains how to read it.

Expand Down Expand Up @@ -203,8 +214,9 @@ pinned at 0.8.0 and reached only through the `Trinity.MCP` boundary; slice 059's
measures what it ships against the 2026-07-28 checklist, and the MCP phase (milestone M5a) builds
Trinity's client driver (slice 060: a thin driver over the core's decoder and validator, stdio
and Streamable HTTP, 2026-07-28 preferred and 2025-11-25 as the fallback, a server's mid-call
question answered as an approval), the approval loop and authorization above it. The client side
and the authorization server are Trinity's own work. `docs/adr/0007-mcp-2026-07-28-target-and-library.md`
question answered as an approval), the approval loop and authorization above it (slice 062: the
resource server, the client role and the personal profile's authorization server, all above the
core). The client side and the authorization roles are Trinity's own work. `docs/adr/0007-mcp-2026-07-28-target-and-library.md`
records the protocol target and the layering.

## License
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ standards register names the rows that ask for them.
| 059 | MCP capability gap against beam_mcp, and the server seam probe | 6 MCP | S/M | 020 | approved |
| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved |
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved |
| 062 | MCP authorization: OAuth client role, RS, embedded AS, Enterprise Managed Authorization (ID-JAG) | 6 MCP | L | 061 | planned |
| 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | done |
| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned |
| 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned |
| 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned |
Expand Down Expand Up @@ -123,3 +123,4 @@ After 012, multiple branches are independent. If running more than one coding ag
| 2026-09-06 | Plan corrections, round 2. **Supersedes the slice count in the entry above:** that entry derived **38** on 2026-09-05, before slices 082, 083 and 084 were withdrawn and slice 034 was added. Re-derived this date, not adjusted by arithmetic: `find slices -name SLICE.md | wc -l` → **36**. The entry above is not rewritten. `scripts/plan_check.sh` now enforces this count, the acceptance-criteria numbering, the Definition-of-Done ranges, ROADMAP/SLICE.md agreement, and the absence of references to paths not in `git ls-files`. |
| 2026-09-20 | The MCP phase replanned under owner decisions of 2026-09-08, recorded in ADR-0007 decisions 5 to 8: beam_mcp 0.8.0 is the server core; 059 measures the capability gap and probes the `:server` seam (S/M); 060 is Trinity's thin driver (L, the M/L condition decided); the OAuth client role moves from 060 to 062; 061 carries a named blocker on the MRTR wrapper; the four `M5 Always-on` headers on 059 to 062 read M5a Automates, as this file has since 2026-09-08. R14 re-scoped, R15 closed. Re-derived this date: `find slices -name SLICE.md | wc -l` → **36**. |
| 2026-09-20 | Four slices added under the accepted 2026-09-20 plan: 002 (supply chain, early: SBOM, provenance, TLS floor; counted in M9), 003 (FIPS build leg from source; counted in M2 because it runs 024's FIPS properties), 025 (encryption at rest and the key-custody seam) and 026 (store-and-forward receipts; blocked on the external plane's answer). Re-derived this date: `find slices -name SLICE.md | wc -l` → **40**. |
| 2026-09-22 | Slice 062 amended under the owner's decision: the production profile is an OAuth client and resource server against an external authorization server, the embedded authorization server is the non-default personal profile refused under an external authority adapter, and Enterprise Managed Authorization (ID-JAG redemption at Trinity's own server) leaves the slice as a deferral with a lift condition (slice 062 NOTES, "Amendment"). AC3 and AC6 removed; the row's title follows. |
1 change: 1 addition & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ never pin a version hex marks as retired or vulnerable.
| `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. |
| `pgvector` | ~> 0.4.1 | ✅ in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. |
| `oban` | ~> 2.24 | ✅ in `mix.lock` | Uses `Oban.Engines.Lite` on SQLite and `Oban.Engines.Basic` on Postgres (the adapter is compile time, ADR-0002, so the engine is too). ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050 (2.24.1 measured on hex.pm 2026-09-22, Apache-2.0). |
| `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. |
| `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. |
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
Expand Down
51 changes: 51 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,57 @@ if config_env() != :test do
config :trinity, :web, search_provider: Trinity.Tools.Web.SearchProvider.Brave
end

# Slice 062: the MCP authorization profile. `TRINITY_MCP_AUTH_PROFILE` is `local` (the default:
# 061's static bearer on the loopback, no JWT anywhere), `production` (an OAuth 2.1 resource
# server against the external authorization server `TRINITY_MCP_AUTH_ISSUER` names, for this
# server's identifier `TRINITY_MCP_AUTH_RESOURCE`, its `/mcp` URL; Trinity issues nothing) or
# `personal` (the same resource server plus the embedded authorization server on the owner's
# machine; refused at boot under an external authority adapter). `TRINITY_MCP_AUTH_INTROSPECTION=1`
# asks the issuer about opaque tokens (RFC 7662) instead of validating JWTs, with
# `TRINITY_MCP_AUTH_INTROSPECTION_CLIENT` and `_SECRET` as the resource server's credentials when
# the issuer wants them; `TRINITY_MCP_AUTH_DCR=1` enables RFC 7591 registration (the embedded
# server's endpoint; the client role's registration when an issuer offers it);
# `TRINITY_MCP_AUTH_CLIENT_ID` is the client role's pre-registered identity at the enterprise
# authorization server, `TRINITY_MCP_AUTH_CLIENT_METADATA_URL` its Client ID Metadata Document.
# The suite sets `config :trinity, :mcp_auth` itself (docs/07-security-model.md).
auth_profile = System.get_env("TRINITY_MCP_AUTH_PROFILE")

if config_env() != :test and auth_profile not in [nil, ""] do
present = fn name ->
case System.get_env(name) do
nil -> nil
"" -> nil
value -> value
end
end

introspection_credentials =
case {present.("TRINITY_MCP_AUTH_INTROSPECTION_CLIENT"),
present.("TRINITY_MCP_AUTH_INTROSPECTION_SECRET")} do
{id, secret} when is_binary(id) and is_binary(secret) -> {id, secret}
_ -> nil
end

profile_atom =
case auth_profile do
"local" -> :local
"production" -> :production
"personal" -> :personal
other -> raise "TRINITY_MCP_AUTH_PROFILE is not local, production or personal: #{other}"
end

config :trinity, :mcp_auth,
profile: profile_atom,
issuer: present.("TRINITY_MCP_AUTH_ISSUER"),
resource: present.("TRINITY_MCP_AUTH_RESOURCE"),
audience: present.("TRINITY_MCP_AUTH_AUDIENCE"),
introspection: System.get_env("TRINITY_MCP_AUTH_INTROSPECTION") in ["1", "true"],
introspection_credentials: introspection_credentials,
dcr: System.get_env("TRINITY_MCP_AUTH_DCR") in ["1", "true"],
client_id: present.("TRINITY_MCP_AUTH_CLIENT_ID"),
client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL")
end

# Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider:
# the registry becomes the fake's two entries and a fresh stream answers with its markdown
# demo, so the UI can be exercised and screenshotted with no key and no egress. Development
Expand Down
2 changes: 1 addition & 1 deletion docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ without anything failing.
| `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`) | 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.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 | Sessions, **Permissions**, PubSub |
| `Trinity.Subagents` | Delegation, result collection | Sessions, Tools |
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
Expand Down
2 changes: 1 addition & 1 deletion docs/06-risk-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@
| R22 | Single maintainer; Growth needs two unaffiliated production users and commits from two orgs | High | Med | 6-month Sandbox checkpoint | Sandbox tolerates it; GOVERNANCE.md documents intent to grow; recruit co-maintainers via the Elixir community once public | 120, 122 |
| R24 | A native NIF in the desktop bundle: `mdex_native` (the markdown renderer's Rust core, added at 013) is the first, and slice 001 proved the packaging chain with a pure-BEAM release | Med | Med | The `package` workflow fails on one of its three operating systems after 013, or 100's cross-target build finds no artifact for a target | `rustler_precompiled` ships artifacts for every target the chain builds for (013 NOTES.md, measured against the checksum file); the `package` run on 013's `mix.lock` change is the first proof on three runners; lift condition: that run green on all three, cited in 013's PROOF.md; a fallback is `MDEX_NATIVE_BUILD=1` with the pinned Rust toolchain already in the tree | 013, 100 |
| R24 | Fired 2026-09-20, in slice 013: neither precompiled `mdex_native` artifact loads in Burrito's musl ERTS (both need glibc's `libgcc_s`; `_dl_find_object: symbol not found`). Lifted the same day by the owner's decision: the linux package builds the NIF from source for musl with Zig as the linker (`scripts/zig-cc-musl`, config/config.exs, `rustler` at build time), measured on a fresh install of the packaged binary rendering the real provider's answer; the `--smoke` path now prints `TRINITY_SMOKE_MARKDOWN=ok` and exits 3 otherwise, and the `package` workflow reads that line on every target, so a dead NIF cannot pass again. Residual: macOS and Windows use the precompiled artifact, and the `package` workflow has yet to run green there since the slice/011 tag (an older failure at "Serves HTTP 200", now diagnosed by the step's log). The row above stands as written | none | none | none | none | 013 |
| R23 | EMA / ID-JAG is beta everywhere (vendors label it so); IETF draft still moving | Med | Med | Spec revision breaks the exchange | Keep the AS small and behaviour-driven; pin the draft version in the ADR; conformance tests against two IdPs' documented flows | 062 |
| R23 | EMA / ID-JAG is beta everywhere (vendors label it so); IETF draft still moving | Low (re-scoped 2026-09-22) | Med | Spec revision breaks the exchange at the external authorization server, which is where it happens: Trinity redeems no assertion since the owner's decision of 2026-09-22 (slice 062 NOTES) | The resource server validates whatever audience-bound token that server issues, so a draft revision is that server's upgrade, not Trinity's; the row stays until the deferral lifts | 062 |
Loading
Loading