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 @@ -37,7 +37,7 @@ standards register names the rows that ask for them.
| 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 | approved |
| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | approved |
| 020 | Tool protocol + registry | 2 Tools | M | 012 | planned |
| 020 | Tool protocol + registry | 2 Tools | M | 012 | done |
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned |
| 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | planned |
| 023 | Context compaction + session lineage | 2 Tools | M | 012 | planned |
Expand Down
1 change: 1 addition & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ never pin a version hex marks as retired or vulnerable.
| `telegex` | **not pinned** | 🔍 not a single package | Telegram. ⚠️ The latest release on hex is a release candidate, roughly two years old, and this file's own rule forbids pinning an `-rc`. Alternative: ex_gram. Slice 071 decides with the measurement. |
| `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | 🔍 not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. |
| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target. |
| `jsv` | ~> 0.23 | ✅ in `mix.lock` | JSON Schema (2020-12) validation of tool arguments in `Trinity.Tools.Schema`, with `cast: false` so a malformed call is refused and never repaired (docs/07). Was transitive through req_llm; direct since Slice 020 because a module of ours calls it (ADR-0009: Trinity's own tool behaviour, jsv for its schemas). |
| `rustler` | ~> 0.38 | ✅ in `mix.lock` | Build time only (`runtime: false`): what `rustler_precompiled` needs to compile `mdex_native` from source when `MDEX_NATIVE_BUILD=1`, which the linux package sets (owner decision 2026-09-20, Slice 013 NOTES finding 14). Nothing in the tree calls it. |

### Dev and quality
Expand Down
12 changes: 12 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ 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 020: the test tools, and the toolset the core ones belong to. AC1's one config line
# is the `modules:` entry; a tool module in test/support plus this line is the whole change.
config :trinity, :tools,
modules: [
Trinity.TestTools.Echo,
Trinity.TestTools.Sleep,
Trinity.TestTools.Crash,
Trinity.TestTools.Big
],
toolsets: %{core: ["echo", "sleep", "crash", "big"]},
timeout_ms: 2_000

# 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

Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ slice_id percent sha date
011 51.57 ec5334a 2026-09-20
012 60.82 019d9cf 2026-09-20
013 64.41 080c543 2026-09-20
020 67.18 8a5b7ae 2026-09-20
15 changes: 14 additions & 1 deletion docs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ Trinity.Application
├── 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.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 # approval requests + allowlist cache. Slice 021
├── Trinity.Receipts.Supervisor # Slice 024
│ └── Trinity.Receipts.ChainWriter (one per chain_scope, :unique in Trinity.Registry; ADR-0013)
Expand Down Expand Up @@ -113,6 +115,17 @@ UI/Gateway ──user_message──▶ Session(gen_statem)
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).

**Effect path (Slice 024):** `Session → Permissions.decide → Effects.execute → Authority → tool.execute/2 (local) or a proposal (external adapter) → Receipts.append`. `Effects` is the only caller of `execute/2` for effectful tools; a census test enforces it. Reads emit query receipts.

**The page (Slice 013):** `TrinityWeb.SessionLive.Show` subscribes to `session:<id>` on mount, calls
Expand Down
15 changes: 15 additions & 0 deletions docs/03-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@
previous slice fails until a NOTES.md justification names the reason. The rule as originally written stored no
baseline, so nothing could check it, which is the pattern CLAUDE.md §8 forbids.

## Tools (slice 020)

- A tool is a module implementing `Trinity.Tools.Tool` (`name/0`, `description/0`, `schema/0` as a JSON Schema
map with string keys, `risk/0`, `effect/0`, `execute/2`, optional `timeout/0` and `format_result/1`) plus one
line in `config :trinity, :tools` (`modules:`; `toolsets:` groups names). No core module changes; the
registry test asserts `git grep TestTools lib/` finds nothing.
- Tool modules are stateless. A runtime with state (a shell, a browser) is a child of `Trinity.Tools.Supervisor`
the tool looks up.
- `effect/0` is `:none` (a read), `:artifact` (a local write) or `:catalog` (an external effect). A `:catalog`
tool is listed in `Trinity.Effects.Catalog`'s module attribute or it does not start; nothing registered at
runtime may claim it. A runtime tool's name is namespaced (`mcp:<server>:<tool>`, `skill:<name>`); core
names are reserved; the tier map in `Trinity.Permissions` is code and lists core names only.
- A tool returns `{:ok, %Trinity.Tools.Result{}}` or `{:error, reason}`; the runner caps the content at
`result_cap_bytes` (64 KB) with a marker. Arguments arrive validated; a tool never repairs them either.

## UI (decided at slice 013)

- Tokens live in `assets/css/app.css` and nothing else names a colour, a radius or a font: two daisyUI themes
Expand Down
2 changes: 1 addition & 1 deletion docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ with adapter-specific `execute/1` guarded by `repo().__adapter__()`.
| role | string | "system" \| "user" \| "assistant" \| "tool" |
| content | text | plain text or JSON for tool payloads |
| parts | map | structured content parts (text, tool_call, tool_result, image ref). Every part carries `origin`, `source_ref`, `digest` and `taint ∈ {trusted, untrusted, blocked}`; summaries inherit the maximum taint of their inputs (Slice 022) |
| tool_call_id | string, nullable | |
| tool_call_id | string, nullable | the assistant row's call id a `tool` row answers (Slice 012); its `parts` carry `tool`, `ok`, `tool_result` (`content`, `truncated`, `meta`, `artifacts`, or `error`) and `tool_definition_digest`, and the assistant row's `provider_meta.tool_surface` maps every declared tool name to its digest (Slice 020) |
| usage | map, nullable | prompt/completion tokens, cost |
| provider_meta | map | model, finish reason, latency |
Append-only. Editing is a new message with `parts.supersedes`. One edit is allowed and named (slice 012): an
Expand Down
18 changes: 16 additions & 2 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,25 @@ defmodule Trinity do
# application locks the data directory before the Repo starts), Repo and UUID (the contexts
# use them), and the Sessions sub-boundary: a context TrinityWeb may call (docs/01). Slice
# 013 exports the schemas the chat renders, `Sessions.Message` and `Sessions.SessionRow`,
# which the Sessions boundary exports itself; Store stays inside.
# which the Sessions boundary exports itself; Store stays inside. Slice 020 exports the
# Tools and Permissions sub-boundaries and `Effects.Catalog`, a plain module of this
# boundary the tool registry reads (Effects becomes its own boundary at 024).
use Boundary,
deps: [],
exports:
[Paths, Repo, UUID, Config, Sessions, Sessions.Message, Sessions.SessionRow, LLM] ++
[
Paths,
Repo,
UUID,
Config,
Sessions,
Sessions.Message,
Sessions.SessionRow,
LLM,
Tools,
Permissions,
Effects.Catalog
] ++
if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: [])

@moduledoc """
Expand Down
3 changes: 3 additions & 0 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ defmodule Trinity.Application do
{Task.Supervisor, name: Trinity.LLM.TaskSupervisor},
# Slice 012: one session process per conversation, found by id.
{Registry, keys: :unique, name: Trinity.Registry},
# Slice 020: the tool registry and the task supervisor tool calls run under, before
# the sessions that call them.
Trinity.Tools.Supervisor,
Trinity.Sessions.Supervisor,
# Start to serve requests, typically the last entry
TrinityWeb.Endpoint
Expand Down
24 changes: 24 additions & 0 deletions lib/trinity/effects/catalog.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Effects.Catalog do
@moduledoc """
The effect catalog, resolved at compile time (docs/07, M4). Slice 020 opens it empty.

Every tool whose `effect/0` is `:catalog` (an external effect: send, spend, a provider
mutation) is listed here by name with its risk tier, in a module attribute and nowhere
else. `Trinity.Tools.Registry` admits a core `:catalog` tool only if its name is in this
list and refuses a runtime registration claiming `:catalog` outright; the census test
(slice 020 AC8) walks the tree and asserts no other path admits one. Slice 024 makes the
membrane read it.
"""

@catalog []

@doc "Every catalog tool as `{name, tier}`."
@spec all() :: [{String.t(), atom()}]
def all, do: @catalog

@doc "The names alone."
@spec names() :: [String.t()]
def names, do: Enum.map(@catalog, &elem(&1, 0))
end
55 changes: 55 additions & 0 deletions lib/trinity/permissions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Permissions do
@moduledoc """
The permission gate's door. Slice 020 ships the shape; slice 021 the policy, the grants, the
approvals and the `approval_wait` path.

`tier/1` is a function of the tool name alone (docs/07), read from a module attribute this
module owns: the core tool names 022 adds carry their tiers here, and any other name, a
namespaced dynamic tool included, is `:ask`. The map is code, not config, and it reads no
registry, so nothing a caller passes and nothing registered at runtime can move a tier.
`decide/3` consults the policy implementation in force (`config :trinity,
:permissions_policy`, default `Trinity.Permissions.Policy.Default`, which allows everything
until 021), and the runner calls it exactly once per tool call.
"""
use Boundary, deps: [Trinity], exports: [Policy]

# Core tool names and their tiers. Empty at slice 020: no core tool lives in lib/ yet; 022
# adds fs_read, fs_write, web_fetch, shell and the rest here, beside their modules.
@tiers %{}

@type tier :: :read | :write | :exec | :network | :destructive | :ask
@type decision :: :allow | :deny | :ask

defmodule Policy do
@moduledoc "What decides a call. Slice 021 implements the layered policy; the default allows."
@callback decide(session_id :: String.t() | nil, tool :: String.t(), args :: map()) ::
Trinity.Permissions.decision()

defmodule Default do
@moduledoc false
@behaviour Trinity.Permissions.Policy
@impl true
def decide(_session_id, _tool, _args), do: :allow
end
end

@doc "The risk tier for a name: a mapped core name's tier, else `:ask`."
@spec tier(String.t()) :: tier()
def tier(name) when is_binary(name), do: Map.get(tiers(), name, :ask)

@doc "The mapped core names, for the census."
@spec mapped_names() :: [String.t()]
def mapped_names, do: Map.keys(tiers())

# Through a function so an empty map at this slice reads as a map to the type checker,
# not as a constant it can fold to :ask.
defp tiers, do: Map.new(@tiers)

@doc "The decision for one call, from the policy in force."
@spec decide(String.t() | nil, String.t(), map()) :: decision()
def decide(session_id, tool, args), do: impl().decide(session_id, tool, args)

defp impl, do: Application.get_env(:trinity, :permissions_policy, Policy.Default)
end
3 changes: 2 additions & 1 deletion lib/trinity/sessions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ defmodule Trinity.Sessions do
adds the session process on top of this API and changes nothing here.
"""
# Slice 012: Sessions reaches the LLM (docs/01: Sessions depends on LLM, Repo, PubSub).
# Slice 020: and the tool runtime, for the declared surface and the runner in force.
use Boundary,
deps: [Trinity, Trinity.LLM],
deps: [Trinity, Trinity.LLM, Trinity.Tools],
exports: [Events, Message, Persona, SessionRow, Session, Caps]

alias Trinity.Sessions.{Message, Persona, SessionRow, Store}
Expand Down
8 changes: 4 additions & 4 deletions lib/trinity/sessions/prompt.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ defmodule Trinity.Sessions.Prompt do
alias Trinity.LLM.Request
alias Trinity.Sessions.{Message, Persona, SessionRow}

@doc "The request for the next model call."
@spec build(SessionRow.t(), Persona.t() | nil, [Message.t()]) :: Request.t()
def build(%SessionRow{} = session, persona, history) do
@doc "The request for the next model call; `tools` is the declared surface (slice 020), none by default."
@spec build(SessionRow.t(), Persona.t() | nil, [Message.t()], [Request.tool()]) :: Request.t()
def build(%SessionRow{} = session, persona, history, tools \\ []) do
Request.new!(%{
system: system(persona),
messages: Enum.map(history, &message/1),
tools: [],
tools: tools,
model: session.model || (persona && persona.model),
params: %{}
})
Expand Down
Loading
Loading