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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ standards register names the rows that ask for them.
| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | planned |
| 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | approved |
| 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved |
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | planned |
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | done |
| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | planned |
| 020 | Tool protocol + registry | 2 Tools | M | 012 | planned |
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned |
Expand Down
2 changes: 1 addition & 1 deletion VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ never pin a version hex marks as retired or vulnerable.
| `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. |
| `beam_mcp` | ~> 0.8 | 🔍 not yet a dependency | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.8.0 on hex.pm, standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. |
| `jido` | ~> 2.3 (pending ADR-0009) | 🔍 not yet a dependency | Actions, directives and the effect boundary, if the Slice 012 checkpoint adopts it. |
| `jido` | not used (ADR-0009, decided 2026-09-20) | 🔍 not a single package | Measured at the Slice 012 checkpoint and not adopted: the agent runtime duplicates PubSub, Oban and the gateways and adds a second tool executor; the action shape is written in-tree at Slice 020 with `jsv` for its schemas. The row stays so the decision is visible where a reader would look for the package. |
| `jason` | ~> 1.2 | ✅ in `mix.lock` | |
| `boundary` | ~> 0.10 | ✅ in `mix.lock` | Compile-time module dependency enforcement. Measured at Slice 000: it compiles and enforces on Elixir 1.20.4 / OTP 28, and it reports violations as **warnings**, so it enforces only while `--warnings-as-errors` is on the compile step. ⚠️ No release since 2024-09-25. |
| `nimble_options` | ~> 1.1 | ✅ in `mix.lock` | Config validation for behaviours. |
Expand Down
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import Config
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
# Slice 012: sessions hibernate and stop quickly in tests so AC8 is observable in seconds.
config :trinity, :sessions, idle_hibernate_ms: 200, idle_stop_ms: 60_000

# Slice 011: the registry in tests is the scripted fake plus a Mox mock; the live tests set
# their own entries from the environment at runtime.
config :trinity, :llm,
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ slice_id percent sha date
001 30.37 5a9c8f7 2026-09-06
010 44.88 45ba4f0 2026-09-20
011 51.57 ec5334a 2026-09-20
012 60.82 019d9cf 2026-09-20
14 changes: 8 additions & 6 deletions docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ Trinity.Application
├── 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}
├── Trinity.LLM.Supervisor # provider clients, rate limiters. Slice 011
├── Trinity.Sessions.Supervisor (DynamicSupervisor) # one Trinity.Sessions.Session per conversation. Slice 012
│ └── Trinity.Sessions.Session (gen_statem) # states: idle → thinking → tool_wait → approval_wait → compacting
│ └── Trinity.Sessions.TurnTaskSupervisor (Task.Supervisor, per session) # parallel tool calls
├── {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 # tool runtime (ports, browsers). Slice 020/022
├── Trinity.Permissions.Gate # approval requests + allowlist cache. Slice 021
├── Trinity.Receipts.Supervisor # Slice 024
Expand Down Expand Up @@ -126,7 +128,7 @@ Every state transition is persisted before it is broadcast. A crash between pers
- 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 (Slice 013).
- 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

Expand Down
4 changes: 3 additions & 1 deletion docs/02-tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ Versions live in `VERSIONS.md`. This file explains *why* each choice was made an
## Explicitly not chosen (and why)

- **Umbrella apps**: isolation is enforced by `boundary` without the build/config overhead.
- **Jido**: *revised 2026-09-05:* reconsidered rather than rejected. Whether it expresses the action, directive and
- **Jido**: *decided 2026-09-20, ADR-0009:* not used, on the Slice 012 checkpoint's measurements (the agent runtime
duplicates PubSub, Oban and the gateways and adds a second tool executor; the action shape is a few dozen lines
with `jsv` already present). The earlier text of this bullet follows as written. *Revised 2026-09-05:* reconsidered rather than rejected. Whether it expresses the action, directive and
effect layer better than plain OTP is ADR-0009, decided by measurement at the Slice 012 checkpoint.
- **Mnesia**: split-brain and schema-management sharp edges; SQLite/CubDB are simpler for single-node.
- **Ortex**: stalled since Nov 2024. Bumblebee/EXLA instead.
Expand Down
5 changes: 4 additions & 1 deletion docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ with adapter-specific `execute/1` guarded by `repo().__adapter__()`.
| tool_call_id | string, nullable | |
| usage | map, nullable | prompt/completion tokens, cost |
| provider_meta | map | model, finish reason, latency |
Append-only. Editing is a new message with `parts.supersedes`.
Append-only. Editing is a new message with `parts.supersedes`. One edit is allowed and named (slice 012): an
assistant row written as a draft during a turn (`parts.draft = true`, content updated every 500 ms or 2 KB) becomes
final at the end of the turn (`draft = false`, plus `tool_calls`, `usage`, and `interrupted`, `error` or `cap` when the
turn ended that way); the role, seq and session never change.

### messages_fts (Slice 031): SQLite `fts5(content, session_id UNINDEXED, message_id UNINDEXED)`; on Postgres a
`tsvector` generated column on `messages`.
Expand Down
2 changes: 1 addition & 1 deletion docs/09-standards-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status

| Control | Where Trinity satisfies it | Evidence path | Status | Decider |
|---|---|---|---|---|
| Independence from other systems that share the Jido library | shared library, not shared runtime; not on the path a finding takes | ADR-0009 appended decision | `tree property` once 012 lands with the boundary tests | owner |
| Independence from other systems that share the Jido library | not applicable: Trinity uses no Jido package (ADR-0009, decision appended 2026-09-20) | `mix deps.tree` shows no jido package | `tree property` | owner |
| Standalone operation with no authority plane and no outbound connection | `TRINITY_AUTHORITY=local`, the standalone assertion | slice 024 AC2 | `:unknown` until 024 lands | owner |
| Nothing fails open | signing unavailable denies; unknown tool denies; unknown effect denies; adapter unresolvable refuses to start | slice 024 AC5, ADR-0010 | `:unknown` until 024 lands | owner |

Expand Down
22 changes: 21 additions & 1 deletion docs/adr/0009-jido-for-the-action-effect-layer.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ADR-0009 — Jido 2.0 for actions, directives and the effect boundary; decided by the Slice 012 design checkpoint
Status: accepted · Date: 2026-09-05 · Owner decision on the version line recorded 2026-09-20
Status: superseded by the decision appended 2026-09-20 (no Jido) · Date: 2026-09-05

## Context
ADR/tech-stack v1 said "Jido not chosen". Reconsidered: Jido has a worked vocabulary for actions, directives and
Expand Down Expand Up @@ -34,3 +34,23 @@ may still return "actions and directives only".
One consequence for the standards register rather than for a slice: another system in the same platform family
runs on the same Jido line. That is a shared library, not a shared runtime, and it is not on the path a finding
takes; the register carries the row and the argument, and no slice here does.

## Decision, appended 2026-09-20 (later): no Jido at all

The Slice 012 checkpoint ran on 2026-09-20 against `jido` 2.3.3, `jido_action` 2.3.2 and `jido_signal` 2.2.0,
read from their Hex tarballs (the measurements and the commands are in `slices/012-*/NOTES.md`). Its findings:
(a) M4 is assertable against `Jido.Action` and M3 only with a census, because `Jido.Exec.run/4` takes caps as
call-site options; (b) `Jido.Agent` adds sensors, a scheduler, signal routing and worker pools the plan assigns
elsewhere, and an `AgentServer` that executes tools through `Jido.Exec`, a second path beside
`Trinity.Effects`; (c) `jido` is 29,820 lines with ten runtime dependencies, `jido_action` six. The one piece
with value, `Jido.Action` as the shape a tool is written in, is a few dozen lines to write and the tree already
carries `jsv` for JSON Schema validation through req_llm.

**Owner decision, 2026-09-20, on those measurements: Trinity uses no Jido package.** This supersedes the
provisional decision above and the earlier appended line recording "the runtime is Jido v2"; that line stands
as written and this one is its correction. Consequences: slice 020 writes `Trinity.Tools.Tool` as Trinity's own
behaviour (a module with a name, a JSON Schema for its parameters validated with `jsv`, `execute/2`, and the
effect and risk declarations the security model needs), around the permission gate and the effect catalog
rather than around a library's executor; the `jido` row leaves VERSIONS.md; the standards register's row on a
shared library with a sister system is closed as not applicable; nothing in slice 012 changes, because it
used none of it.
3 changes: 3 additions & 0 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ defmodule Trinity.Application do
{Phoenix.PubSub, name: Trinity.PubSub},
# Slice 011: streams to a pid run under this supervisor, never as bare tasks.
{Task.Supervisor, name: Trinity.LLM.TaskSupervisor},
# Slice 012: one session process per conversation, found by id.
{Registry, keys: :unique, name: Trinity.Registry},
Trinity.Sessions.Supervisor,
# Start to serve requests, typically the last entry
TrinityWeb.Endpoint
] ++ Trinity.Smoke.children(Trinity.Smoke.argv())
Expand Down
35 changes: 35 additions & 0 deletions lib/trinity/core_policy.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.CorePolicy do
@moduledoc """
A digest over the object code of the modules that decide what the agent may do. Slice 012
introduces it because AC9 (kill and reseed) asserts it; slice 024 extends the list and writes it
into the boot receipt. A reseeded Session is born from this hash and nothing else in process
state.
"""

@modules [
Trinity.Sessions.Session,
Trinity.Sessions.Caps,
Trinity.Sessions.ToolRunner,
Trinity.Sessions.ToolRunner.Stub,
Trinity.Sessions.Sentinel
]

@doc "The modules the hash covers, in order."
@spec modules() :: [module()]
def modules, do: @modules

@doc "SHA-256, hex, over the concatenated object code of `modules/0`."
@spec hash() :: String.t()
def hash do
@modules
|> Enum.map(fn mod ->
{^mod, binary, _path} = :code.get_object_code(mod)
binary
end)
|> IO.iodata_to_binary()
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
end
end
71 changes: 64 additions & 7 deletions lib/trinity/sessions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ defmodule Trinity.Sessions do
exports this module alone; `Trinity.Sessions.Store` and the schemas stay inside. Slice 012
adds the session process on top of this API and changes nothing here.
"""
use Boundary, deps: [Trinity], exports: []
# Slice 012: Sessions reaches the LLM (docs/01: Sessions depends on LLM, Repo, PubSub).
use Boundary,
deps: [Trinity, Trinity.LLM],
exports: [Events, Message, Persona, SessionRow, Session, Caps]

alias Trinity.Sessions.{Message, Persona, Session, Store}
alias Trinity.Sessions.{Message, Persona, SessionRow, Store}

@type session_id :: String.t()

Expand All @@ -23,15 +26,15 @@ defmodule Trinity.Sessions do
def get_persona_by_name(name), do: Store.get_persona_by_name(name)

@doc "Creates a session. `persona_id` is required; `origin` and `status` come from a closed vocabulary."
@spec create_session(map()) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()}
@spec create_session(map()) :: {:ok, SessionRow.t()} | {:error, Ecto.Changeset.t()}
def create_session(attrs), do: Store.insert_session(attrs)

@doc "The session with this id, or nil."
@spec get_session(session_id()) :: Session.t() | nil
@spec get_session(session_id()) :: SessionRow.t() | nil
def get_session(id), do: Store.get_session(id)

@doc "Sessions, most recently active first. Options: `status:`, `limit:` (default 50)."
@spec list_sessions(keyword()) :: [Session.t()]
@spec list_sessions(keyword()) :: [SessionRow.t()]
def list_sessions(opts \\ []), do: Store.list_sessions(opts)

@doc """
Expand All @@ -56,8 +59,8 @@ defmodule Trinity.Sessions do
def history(session_id, opts \\ []), do: Store.history(session_id, opts)

@doc "Marks a session archived."
@spec archive(Session.t()) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()}
def archive(%Session{} = session), do: Store.update_session(session, %{status: "archived"})
@spec archive(SessionRow.t()) :: {:ok, SessionRow.t()} | {:error, Ecto.Changeset.t()}
def archive(%SessionRow{} = session), do: Store.update_session(session, %{status: "archived"})

@doc "The number of messages in a session."
@spec message_count(session_id()) :: non_neg_integer()
Expand All @@ -66,4 +69,58 @@ defmodule Trinity.Sessions do
@doc "Every `seq` in a session, ascending. The stress test's population."
@spec seqs(session_id()) :: [pos_integer()]
def seqs(session_id), do: Store.seqs(session_id)

## The process (slice 012)

alias Trinity.Sessions.{Events, Session, Supervisor}

@doc "Starts the session's process, or returns the running one. The row must exist."
@spec start_session(session_id()) :: {:ok, pid()} | {:error, term()}
def start_session(session_id), do: Supervisor.start_session(session_id)

@doc "Idempotent: the running pid, or a fresh process rehydrated from the database."
@spec ensure_started(session_id()) :: {:ok, pid()} | {:error, term()}
def ensure_started(session_id) do
case whereis(session_id) do
nil -> start_session(session_id)
pid -> {:ok, pid}
end
end

@doc "The session's pid, if its process is running."
@spec whereis(session_id()) :: pid() | nil
def whereis(session_id) do
case Registry.lookup(Trinity.Registry, session_id) do
[{pid, _}] -> pid
[] -> nil
end
end

@doc "Persists the user's message and starts a turn; refuses while a turn is in flight."
@spec send_user_message(session_id(), String.t()) :: {:ok, Message.t()} | {:error, term()}
def send_user_message(session_id, content) do
with {:ok, pid} <- ensure_started(session_id), do: Session.send_user_message(pid, content)
end

@doc "Stops the turn in flight, persisting what arrived as interrupted."
@spec cancel_turn(session_id()) :: :ok | {:error, term()}
def cancel_turn(session_id) do
case whereis(session_id) do
nil -> {:error, :not_running}
pid -> Session.cancel_turn(pid)
end
end

@doc "The state name and a redacted view of the process's data."
@spec state(session_id()) :: map() | {:error, :not_running}
def state(session_id) do
case whereis(session_id) do
nil -> {:error, :not_running}
pid -> Session.state(pid)
end
end

@doc "Subscribes the caller to the session's events on `session:<id>`."
@spec subscribe(session_id()) :: :ok | {:error, term()}
def subscribe(session_id), do: Events.subscribe(session_id)
end
42 changes: 42 additions & 0 deletions lib/trinity/sessions/caps.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Sessions.Caps do
@moduledoc """
Code-owned caps on the agent loop (M3). Slice 012. Module attributes, not configuration: no
config key can raise them, and the loop function that consults them takes no cap argument.
Reaching a cap is a recorded outcome and a normal return to `idle`, never a crash.
"""

@max_turns_per_message 8
@max_tokens_per_message 200_000
@max_wall_ms 600_000

@type reason :: :max_turns | :max_tokens | :max_wall_ms

@doc "The caps, for the record a cap outcome writes and for tests."
@spec limits() :: %{
max_turns: pos_integer(),
max_tokens: pos_integer(),
max_wall_ms: pos_integer()
}
def limits,
do: %{
max_turns: @max_turns_per_message,
max_tokens: @max_tokens_per_message,
max_wall_ms: @max_wall_ms
}

@doc """
`:ok` when the turn may continue, or the first cap it has reached. Takes the turn record only:
there is no argument through which a caller could pass a looser limit.
"""
@spec check(Trinity.Sessions.State.turn()) :: :ok | {:cap, reason()}
def check(%{turns: turns, tokens: tokens, started_at: started_at}) do
cond do
turns >= @max_turns_per_message -> {:cap, :max_turns}
tokens >= @max_tokens_per_message -> {:cap, :max_tokens}
System.monotonic_time(:millisecond) - started_at >= @max_wall_ms -> {:cap, :max_wall_ms}
true -> :ok
end
end
end
Loading
Loading