Skip to content

Commit d515d2d

Browse files
authored
Merge pull request #66 from ScriptKittyOS/slice/070-gateway-core
Slice 070: gateway core (adapter behaviour, routing, PubSub fan-out, pairing, the channel trust cap). Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents 8b31ee5 + c571639 commit d515d2d

37 files changed

Lines changed: 3152 additions & 16 deletions

‎README.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ MCP authorization): 24 slices, each merged with a merge commit and tagged `slice
5757
protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains.
5858
Trinity issues no production authority: the personal issuer refuses to start under an
5959
external authority adapter, by construction (`docs/07-security-model.md`).
60+
- **Reachable from elsewhere.** A gateway is a channel Trinity answers from: the adapter carries
61+
text, and everything else (the session, the gate, the receipts) is the same machinery the
62+
desktop uses. A sender Trinity does not know gets a pairing code shown on the `/gateways` page
63+
and nothing else, no session and no model; once paired they get slash commands, the answer
64+
streaming back as the turn runs, and any approval the turn raises rendered into the same
65+
conversation. What a channel may approve is capped below what the desktop may: a `write` at
66+
most by default, so an `exec` or a `destructive` request is decided at the machine, and the
67+
refusal is receipted. The console adapter ships (`mix trinity.console`); the platforms are next.
6068
- **Runs on a schedule.** Tasks on the `/tasks` page: a prompt, a persona, the skills to hint,
6169
and when (a cron expression, a one-shot time, or a phrase like "every weekday at 9am" the
6270
model turns into cron). Each run is a fresh conversation you can open, its result waits on
@@ -65,7 +73,7 @@ MCP authorization): 24 slices, each merged with a merge commit and tagged `slice
6573
curator that marks old memories stale and archives the untouched ones (never deleting)
6674
run on the same queues; `/oban` shows the jobs.
6775

68-
Not there yet: messaging gateways and subagents (M5b),
76+
Not there yet: the messaging platforms themselves and subagents (M5b),
6977
the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every
7078
slice, and the [Milestones](#milestones) section below explains how to read it.
7179

@@ -107,7 +115,7 @@ model registry is `config/llm.exs`; keys come from the environment (`.env` is gi
107115
pages: `/` conversations, `/s/:id` a conversation and `/s/:id/receipts` its receipts, `/search`
108116
full-text search, `/personas` and `/memory` the persona and its memory (with the semantic tab and
109117
the embedding model's download), `/skills` the skills, the changes waiting for your decision and
110-
the learn form, `/mcp` the MCP servers you connect to (their health and the tools they
118+
the learn form, `/gateways` the channels you can reach Trinity from, `/mcp` the MCP servers you connect to (their health and the tools they
111119
contribute), `/permissions` the rules and pending approvals (a server's question to you, when
112120
one of its tools asks for input mid-call, is answered there too), `/tasks` the scheduled tasks and
113121
their results, `/oban` the jobs (in development, or when configured), `/settings` the export. `mix trinity.export` and `mix trinity.import` do what `/settings` does from a terminal;

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ standards register names the rows that ask for them.
5656
| 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved |
5757
| 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved |
5858
| 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | approved |
59-
| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned |
59+
| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | done |
6060
| 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned |
6161
| 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned |
6262
| 080 | Subagents + delegation | 8 Orchestration | M | 020, 023 | planned |

‎config/config.exs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,7 @@ config :phoenix, :json_library, Jason
214214
# Import environment specific config. This must remain at the bottom
215215
# of this file so it overrides the configuration defined above.
216216
import_config "#{config_env()}.exs"
217+
218+
# Slice 070: a scheduled run can be delivered to a gateway conversation; `deliver_to`'s "kind"
219+
# names the implementation (slice 050's `Trinity.Scheduler.Delivery.for/1`).
220+
config :trinity, :deliveries, %{"gateway" => Trinity.Scheduler.Delivery.Gateway}

‎docs/01-architecture.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ Trinity.Application
5353
│ # a dead one dropped), writes the index rows on first read
5454
├── Oban # cron + durable jobs. Slice 050
5555
├── Trinity.MCP.Supervisor # MCP clients (one per server, lib per 059) + server. Slice 059-062
56-
├── Trinity.Gateways.Supervisor # adapters (Telegram, Discord…). Slice 070+
56+
├── Trinity.Gateways.Router # one road from a channel to a session. Slice 070
57+
├── Trinity.Gateways.Console # the in-process adapter; platforms at 071, 072
5758
├── Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080
5859
├── Trinity.Sandbox.Supervisor # Luerl workers. Slice 110
5960
├── Trinity.Desktop # ex_tauri bridge (tray, notifications). Slice 100
@@ -91,7 +92,7 @@ without anything failing.
9192
| `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) |
9293
| `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) |
9394
| `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) |
94-
| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub |
95+
| `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 |
9596
| `Trinity.Subagents` | Delegation, result collection | Sessions, Tools |
9697
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
9798
| `Trinity.Desktop` | ex_tauri bridge | PubSub |

‎docs/05-data-model.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ One row per request, the audit trail this slice owns; slice 024 reads it for dec
173173
| prompt | text | the user message of the run's turn |
174174
| persona_id | fk personas, nullable | the default persona when unset |
175175
| skill_names | {array, string} | hinted to the model in the prompt |
176-
| deliver_to | map | `%{"kind" => "desktop"}` at 050; gateways name their kind at 070 |
176+
| deliver_to | map | `%{"kind" => "desktop"}` at 050; at 070 `%{"kind" => "gateway", "adapter" => "console", "conversation" => "c-1"}` names a channel |
177177
| enabled | boolean | the tick enqueues enabled tasks only; a `once` task is disabled once enqueued |
178178
| timeout_ms | integer | one run's bound (600 000 by default) |
179179
| last_run_at, next_run_at | timestamp | `next_run_at` is computed by `Trinity.Scheduler` from the schedule, not mirrored from Oban: the tick (one Cron plugin entry a minute) enqueues what is due and advances it |
@@ -194,6 +194,26 @@ Oban's own `oban_jobs` (and `oban_peers`) carry the jobs; the pruner keeps a wee
194194
The curator (slice 050) adds `stale_at` and `archived_at` to `memories` (docs above): stale is still
195195
recalled and shown as such; archived leaves recall and stays in the row.
196196

197+
### gateway_identities (Slice 070, as built)
198+
Who outside this machine may talk to Trinity. The key is the pair, because an external id means
199+
nothing outside the platform that issued it.
200+
201+
| column | type | notes |
202+
|---|---|---|
203+
| adapter | string | the adapter's own name (`Trinity.Gateways.Adapter.name/1`: "console"), half the key |
204+
| external_user_id | string | the platform's id for the person; unique with `adapter` |
205+
| display_name | string, nullable | whatever the platform offered, for the page |
206+
| state | string | "pending" (a code was shown) \| "paired" \| "revoked"; a revoked row is kept, so turning someone away is recorded rather than forgotten |
207+
| code | string, nullable | the six-character pairing code, shown on `/gateways` and nowhere else; cleared once spent |
208+
| code_expires_at | timestamp, nullable | ten minutes; a pending sender who comes back later is shown a new code rather than locked out |
209+
| paired_at, revoked_at | timestamp, nullable | |
210+
| last_conversation | string, nullable | where the pairing was asked from, so the answer goes back there |
211+
212+
A gateway session is an ordinary session with `origin` = the adapter's name and `origin_ref`
213+
`%{"adapter", "conversation", "external_user_id"}` (010's columns; this slice adds no column to
214+
`sessions`). A scheduled task delivers to one with
215+
`deliver_to: %{"kind" => "gateway", "adapter" => …, "conversation" => …}`.
216+
197217
### usage_events (Slice 011; the ledger and budgets that read it are Slice 090)
198218
One row per completed call, as built at slice 011:
199219

‎docs/07-security-model.md‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -282,17 +282,39 @@ regulated effect.
282282
- Never in DB, never in logs, never in prompts. `mix gate` runs a regex secret scan on the diff.
283283
- Provider keys are read at call time by the provider module; not held in Session state.
284284

285-
## Gateways
286-
287-
- Allowlist of external user ids; DM pairing code flow (Slice 070). Unknown senders get a pairing prompt, nothing else.
288-
- Rate limits per external user.
285+
## Gateways (Slice 070, as built)
286+
287+
- **An unknown sender gets a pairing prompt and nothing else.** No session is created, no model is
288+
called and nothing of the message is read but the code it might be: `Trinity.Gateways.Router`
289+
asks `Identities.admit/3` before anything else happens. The code is six characters from an
290+
alphabet without the pairs a person misreads, lives ten minutes, and is compared in constant
291+
time. It is shown on `/gateways` and nowhere else, which is the whole of the proof: a sender
292+
who can read it is at the owner's desktop. A configured allowlist
293+
(`config :trinity, :gateways, allowlist: [{adapter, id}]`) pairs a known id at first sight.
294+
- **An identity is `(adapter, external_user_id)`**, because an id means nothing outside the
295+
platform that issued it, and a revoked row is kept rather than deleted: turning someone away is
296+
a thing the page records rather than a thing it forgets.
297+
- **Rate limits per external user**: a token bucket in the router's own state, above the session,
298+
so a flood costs a row lookup and nothing more. A throttled sender is told, not dropped.
299+
- **An adapter carries text and nothing else.** It never calls the LLM, never touches a session
300+
and never decides an approval; that rule is what lets a platform be added as one module and a
301+
configuration entry. A command that raises costs its own message: the router holds every
302+
conversation's binding and rescues rather than dying with them.
289303
- **Channel trust cap.** The allowlist, pairing and rate limits control *who may talk to the bot*. They do not
290304
control *what an approval arriving from that channel may authorise*, and an approval surface is exactly as
291305
trustworthy as the account behind it. So each channel carries a cap on the risk tier it can approve, and the
292306
cap is applied after the gate's own decision, never instead of it. Default: `:read`, `:network` and `:write`
293-
are approvable from any paired gateway; `:exec` and `:destructive` are **desktop or console only**. The cap is
294-
configurable and the default is the safe one. A capped request is not silently dropped: the requester is told
295-
the decision must be made on the desktop, and the refusal is receipted like any other.
307+
are approvable from any paired gateway; `:exec` and `:destructive` are **desktop only**. The cap is
308+
configurable per adapter (`config :trinity, :gateways, caps: %{"console" => :write}`) and the default is the
309+
safe one. A capped request is not silently dropped: the requester is told the decision must be made on the
310+
desktop, and the refusal is receipted like any other (`Trinity.Gateways.Receipts`, basis `channel_cap`, on the
311+
session's own chain).
312+
313+
**Amended at 070**: this paragraph read "desktop or console only" when it was written, before there was a
314+
console adapter. There is one now, and it is capped like every other channel rather than exempted: an
315+
in-process adapter is still a channel, and making it the one that may approve a destructive effect would be a
316+
hole shaped exactly like the thing the cap exists for. `Trinity.Gateways.Cap` applies the ceiling to every
317+
adapter, `Console` included, and an unknown tier is refused rather than waved through.
296318

297319
## Effects and receipts (Slice 024, as built)
298320

‎lib/mix/tasks/trinity.console.ex‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Mix.Tasks.Trinity.Console do
4+
@shortdoc "Talks to Trinity from this terminal through the console gateway"
5+
@moduledoc """
6+
Slice 070. Starts the application and talks to it through `Trinity.Gateways.Console`, the
7+
in-process adapter, so the gateway layer can be used and watched without a platform account:
8+
9+
mix trinity.console
10+
11+
It is a real channel and not a back door. The first message is answered with a pairing code,
12+
as any unknown sender's is; the code is shown on the desktop's `/gateways` page, and typing it
13+
here pairs this terminal. Every slash command works (`/help` lists them), an approval raised by
14+
a turn appears here, and the channel's tier ceiling applies to `/approve` exactly as it would
15+
on a messaging platform: an `exec` or `destructive` request is decided on the desktop.
16+
17+
Two things to know, as with `mix trinity.mcp.stdio`: this is a whole Trinity and the data
18+
directory admits one at a time (`Trinity.DataDir.Lock`), so run it when the desktop does not,
19+
or point it at its own directory with `TRINITY_DATA_DIR`. Ctrl-C twice leaves.
20+
"""
21+
use Boundary, classify_to: Trinity.Gateways
22+
use Mix.Task
23+
24+
alias Trinity.Gateways.{Console, Router}
25+
26+
@conversation "terminal"
27+
28+
@impl Mix.Task
29+
def run(argv) do
30+
Mix.Task.run("app.start")
31+
user = Enum.at(argv, 0, System.get_env("USER") || "owner")
32+
{:ok, _} = ensure_started(Console)
33+
{:ok, _} = ensure_started(Router)
34+
35+
IO.puts("Trinity console. The first message will be answered with a pairing code.")
36+
IO.puts("Say /help for the commands, or Ctrl-C twice to leave.\n")
37+
loop(user)
38+
end
39+
40+
defp ensure_started(module) do
41+
case module.start_link([]) do
42+
{:ok, pid} -> {:ok, pid}
43+
{:error, {:already_started, pid}} -> {:ok, pid}
44+
other -> other
45+
end
46+
end
47+
48+
defp loop(user) do
49+
case IO.gets("> ") do
50+
:eof ->
51+
:ok
52+
53+
{:error, reason} ->
54+
Mix.shell().error("console: #{inspect(reason)}")
55+
56+
line ->
57+
line |> String.trim() |> send_line(user)
58+
loop(user)
59+
end
60+
end
61+
62+
defp send_line("", _user), do: :ok
63+
64+
defp send_line(text, user) do
65+
before = length(Console.delivered(@conversation))
66+
_ = Router.inbound(Console, @conversation, user, text)
67+
# The reply streams, so the terminal waits a moment for what the channel was shown rather
68+
# than printing nothing and looking broken.
69+
print_new(before, 0)
70+
end
71+
72+
defp print_new(before, waited) when waited < 30_000 do
73+
delivered = Console.delivered(@conversation)
74+
75+
if length(delivered) > before do
76+
Process.sleep(200)
77+
Console.text(@conversation) |> Enum.drop(visible_before(before)) |> Enum.each(&IO.puts/1)
78+
else
79+
Process.sleep(100)
80+
print_new(before, waited + 100)
81+
end
82+
end
83+
84+
defp print_new(_before, _waited), do: IO.puts("(no answer yet)")
85+
86+
# How many visible messages the log held before this turn: an edit replaces a message rather
87+
# than adding one, so counting the log is not counting what a person has seen.
88+
defp visible_before(before) do
89+
@conversation
90+
|> Console.delivered()
91+
|> Enum.take(before)
92+
|> Enum.count(&match?({:message, _}, &1))
93+
end
94+
end

0 commit comments

Comments
 (0)