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
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,11 @@ somewhere outside the host, on any schedule, makes backdated forgery detectable
without needing a transparency service. This is not built yet and is named here as the gap it is.

**An external authority plane proves the decision was never the machine's to make.** Point
`TRINITY_AUTHORITY` at an adapter and Trinity keeps no executor for the effects that adapter
governs, which is asserted by a census over the tree rather than by intent. The decision is made
elsewhere by something with its own key, so relying on it does not require trusting this host at
all.
`TRINITY_AUTHORITY` at an adapter and the decision is made elsewhere, by something with its own
key, so relying on it does not require trusting this host to have decided honestly. There is no
model-side executor: after the adapter allows, the tool runs in this VM, behind the same membrane
and the same admission receipt as always, and a census over the tree asserts that there is exactly
one place it can run.

The honest one-line version: **standalone proves what happened to someone who trusts the machine, a
witness proves it to someone who does not, and an external authority proves the decision was never
Expand All @@ -373,10 +374,12 @@ the three fields that have no equivalent, is in
Trinity runs standalone, and it is also one component of **Sanction OS**, the platform formed by Requisition and
Ultraviolet.

**Requisition** is the authority layer. Point `TRINITY_AUTHORITY` at the adapter module and Trinity delegates every
catalogued effect to it: Trinity proposes, Requisition decides, and Trinity keeps no executor for those effects.
Trinity refuses to start if the module is absent or does not implement `Trinity.Authority`. See
`docs/adr/0008-authority-is-an-adapter.md` and `docs/adr/0010-authority-selection-at-boot.md`.
**Requisition** is the authority layer. Point `TRINITY_AUTHORITY` at the adapter module and every catalogued effect
is put to it for a decision: anything may propose, the authority layer authorizes, and after an allow this node's
Elixir runs the tool. The adapter does not run it and may not change the arguments it was asked about. Trinity
refuses to start if the module is absent or does not implement `Trinity.Authority`. See
`docs/authority-adapter.md` for the contract, and `docs/adr/0008-authority-is-an-adapter.md` and
`docs/adr/0010-authority-selection-at-boot.md` for the decisions behind it.

**Ultraviolet** is the purple-team tool. Add it on the `/mcp` page like any other MCP server (since slice 060 a server is a row, not a config entry). Its
read tools return query-receipted results, its proposal tools return proposal ids rather than effects, and content
Expand Down
42 changes: 40 additions & 2 deletions docs/adr/0008-authority-is-an-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ whether the thing you asked for is allowed to happen.
behind `Trinity.MCP.Auth`. That answers who is calling and with what scopes. Whether an effect happens is
answered by the gate, or by whichever `Trinity.Authority` implementation is in force. The auth layer is never
described as an authority layer.
5. When an external adapter is in force, Trinity keeps **no executor** for the effects that adapter governs. The
absence is asserted by a census over the tree, not by intent.
5. **Amended 2026-09-30, see below.** ~~When an external adapter is in force, Trinity keeps **no executor** for the
effects that adapter governs. The absence is asserted by a census over the tree, not by intent.~~
There is **no model-side executor**, and after a `decide/3` allow **this VM runs the tool**. The census over the
tree still asserts the shape, and now asserts one executor rather than none.

## Consequences
- Adapters can be written against a published behaviour by anyone, including for authority layers this project
Expand Down Expand Up @@ -64,3 +66,39 @@ record. That is deliberate and belongs in this ADR because it is a statement abo
can and cannot deliver: after a partition, two devices hold two true accounts of what each did, and nothing
available afterwards turns them into one account of what happened. A system that claimed otherwise would be
inventing the part it could not know.

## Amended 2026-09-30 — point 5: one executor, here, not none

**Source: the owner, 2026-09-30.** Point 5 as accepted said that an external adapter meant Trinity kept no executor
for the effects that adapter governed, and the census test asserted the absence by finding exactly one file able to
call a tool's `execute/2` and noting that the effect never reached it under an adapter.

That is no longer the design. The split is:

> Any actor proposes. The authority in force authorizes. This node's Elixir executes Trinity tools after an allow.

The adapter **does not run the tool** and **does not change the arguments**. It answers `:allow` or `:deny` with a
basis, and stops.

### Why the earlier shape was worse

It put the effect in the adapter's hands, in another repository, on the far side of the membrane. Every check the
membrane makes, the effect class, the catalogue, the fingerprint, the idempotency key, the admission receipt, applied
to something that then happened somewhere else. A second executor is a second place for the rules to be different.

It also made `stage/2` load-bearing in a way nothing checked: the membrane verified the fingerprint, handed the
`Staged` to the authority, and used whatever came back. An adapter could change `args` after the fingerprint had been
verified, or keep `args` and change `module`, which no fingerprint covers at all.

### What is true now, with where it is enforced

| claim | enforced at |
| --- | --- |
| the tool runs in this VM, after `decide/3` allow and after the admission receipt is in the chain | `lib/trinity/effects.ex`, `run_tool/2` |
| exactly one file calls a tool's `execute/2` on the effect path | `test/trinity/effects/census_test.exs` |
| `stage/2` may set `staged_at` and `basis` and nothing else | `lib/trinity/effects.ex`, `check_stage_kept_the_subject/2` |
| the fingerprint is re-derived after `stage/2` against the one the gate bound | `lib/trinity/effects.ex`, `check_fingerprint_against/2` |
| an adapter's `execute/3` is on no path and must not run a tool | `lib/trinity/authority.ex`, `lib/trinity/authority/local.ex` |

Decisions 1 to 4 are unchanged. The behaviour still has four callbacks, because removing `execute/3` would silently
accept an adapter written against the older contract, where returning a result from it was how the effect happened.
156 changes: 156 additions & 0 deletions docs/authority-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<!--
SPDX-FileCopyrightText: Sudo Apt Holdings LLC
SPDX-License-Identifier: Apache-2.0
-->
# Writing an authority adapter

How to make an external authority layer the one that decides for this node.

**Every claim here is a path in this repository, or says NOT IN TREE.** A sentence you cannot follow
to a file is a sentence you should not trust.

## The split

> Anything may propose. The authority in force authorizes. After an allow, this node's Elixir runs
> the tool.

The adapter **decides**. It does not run the tool, and it does not change what it was asked about.

## One module, chosen once, at boot

`TRINITY_AUTHORITY` names one module. It is read once, by `Trinity.Authority.Selection`
(`lib/trinity/authority/selection.ex`), as the first child of the application after the data
directory lock, and the selection is kept in `:persistent_term`. No runtime path changes it.

- unset, `""`, or `local` selects `Trinity.Authority.Local`.
- any other value is an Elixir module name, with or without the `Elixir.` prefix.
- the module must be **loaded** and must export every callback, or the boot stops and the refusal
names which: `{:not_loaded, name}` or `{:missing_callback, module, {name, arity}}`.
- a name that is not an existing atom is reported as not loaded, and no atom is created from the
environment's text.

Selection is **synchronous**: `Selection.start/0` completes before any later child starts, so there
is no window in which the node is running with the selection still being made. Before that,
`Trinity.Authority.impl/0` (`lib/trinity/authority.ex`) answers `Local` under `:default` and
**raises** under `:regulated`, because a brief fallback to the local authority is still a fallback.

`Trinity.Authority.Local` (`lib/trinity/authority/local.ex`) is **the only implementation in this
tree**. Everything else is NOT IN TREE: this repository holds no client, no envelope, no schema and
no transport for any particular authority layer, and a test asserts that under `local` no adapter
module is loaded (`test/trinity/authority/selection_test.exs`).

## The regulated profile refuses the local authority

Under `TRINITY_PROFILE=regulated` the node refuses to boot on `Trinity.Authority.Local`:

- the rule is `Trinity.Profile.check_authority/2` in `lib/trinity/profile.ex`, which answers
`{:error, :regulated_refuses_local_authority}`.
- it is asked at boot, before any child exists, by `verify_regulated_configuration!/0` in
`lib/trinity/application.ex`.
- an unset `TRINITY_AUTHORITY` resolves to `Local` and is therefore refused too.
- a name that cannot be resolved is refused as `{:regulated_authority_unselectable, reason}`,
carrying the selection's own reason, and `check_authority/2` is total, so a caller that loses the
reason still gets `{:regulated_authority_unresolved, value}` rather than a crash.

The refusals are tested in `test/trinity/profile_test.exs`, and at an actual boot, in spawned OS
processes, in `test/trinity/regulated_boot_node_test.exs`.

## The callbacks

Four are required. `Trinity.Authority.callbacks/0` is the list the selection checks against, in
`lib/trinity/authority.ex`.

| callback | answer | what it is for |
| --- | --- | --- |
| `stage/2` | `{:ok, Staged.t()}` or `{:error, term()}` | record that an effect is about to happen |
| `decide/3` | `{:ok, :allow \| :deny, basis}` or `{:error, term()}` | **the decision**, with a basis saying why |
| `execute/3` | `{:error, :not_the_effect_path}` | reserved; see below |
| `receipt/2` | `{:ok, term()}` or `{:error, term()}` | record a receipt of a kind |

And one optional:

| callback | answer | what it is for |
| --- | --- | --- |
| `forward_receipt/2` | `:ok` or `{:error, term()}` | acknowledge a queued receipt envelope |

### `decide/3` returns allow or deny, and never falls back

`:allow` or `:deny`. An adapter that cannot reach its far side returns `{:error, reason}` or
`:deny`; it must **not** decide locally, and it must not answer as though the local gate had
decided. The membrane treats `{:ok, :deny, basis}` as a denial with a receipt, and `{:error, _}` as
a denial with a receipt. Both are safe. A fallback to `Local` is the one answer that is not.

### `execute/3` must not run a tool

It is **not** the place the effect happens, and nothing on the effect path calls it. After a
`decide/3` allow and after the admission receipt is in the chain, `Trinity.Effects.run_tool/2`
(`lib/trinity/effects.ex`) calls the tool's own `execute/2`, in this VM.

It stays in the behaviour so that an adapter written against the older contract, where returning a
result from it was how the effect happened, is not silently accepted. Answer
`{:error, :not_the_effect_path}`, which is what `Trinity.Authority.Local` answers.

### `stage/2` may not change the subject

Set `staged_at` and `basis`. Nothing else. Every other field of the `Trinity.Authority.Staged`
(`lib/trinity/authority/staged.ex`) you return must be the one you were given.

After `stage/2` the membrane re-derives the fingerprint over the arguments you returned and compares
it to the one the gate bound, not to the one your struct now carries, so rewriting `args` and
`fingerprint` to agree with each other does not pass. It then compares every remaining field,
because the fingerprint covers the session, the tool name, the arguments and the working directory,
and covers `module` not at all: an adapter could otherwise leave every fingerprinted field alone and
still change which code runs.

Both refusals are tested against adapters that actually attempt them, in
`test/trinity/effects/membrane_test.exs` with `Trinity.TestAuthority.MutatesArgs` and
`Trinity.TestAuthority.SwapsModule` (`test/support/authority/adapters.ex`), alongside a control
adapter that changes nothing and whose effect runs.

## Receipts: Trinity writes them, the adapter acknowledges them

Trinity signs and appends its own receipts, locally, around the effect: an `admit` before the tool
runs and a `done` after it, or a `denied` instead. That is `Trinity.Effects` and
`Trinity.Receipts`. **The adapter does not write Trinity's receipts.**

What an adapter may do is receive them. `forward_receipt/2` is offered the exported envelope
**byte for byte, exactly as it was signed**. An implementation must not re-sign it, rebuild it or
alter a leaf: the point is that a verifier checks the signature over those bytes offline, which is
what lets a queue wrap, delay and re-deliver an envelope without the far side knowing a queue
exists. `:ok` acknowledges; any error leaves the entry pending to be offered again, oldest first.
An implementation that does not export it is one this machine never forwards to, and the queue for
its scopes simply does not drain. See `lib/trinity/receipts/queue.ex` and
`test/trinity/receipts/queue_test.exs`.

## `act_hash` is opaque

An authority layer may bind its decision to an act identifier. **Trinity treats it as opaque.** It
does not parse it, does not recompute it and does not depend on its shape; it carries it and stores
it beside the decision.

Where an adapter records one, it should record the algorithm beside it, as `act_hash` and
`act_hash_alg`, so a reader is never guessing which construction produced the digest. The value
this contract expects is `sha256+rfc8785-v1`: SHA-256 over the [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)
canonical JSON form. Trinity already uses RFC 8785 for its own approval fingerprints and holds a
conformance vector for it (`test/support/rfc8785_input.json`, `test/support/rfc8785_expected.txt`),
so the canonicalisation is not a new dependency here.

**`act_hash` is NOT IN TREE.** No field, column or function of that name exists in this repository.
It is named here so an adapter and this node agree on the word, not because Trinity reads it.

## Packaging an adapter

The adapter lives **outside this repository**, and the release that contains both is built outside
it too. This tree builds a Trinity that can be pointed at one; it does not build, vendor or depend
on any. A wrapper release adds the adapter module to the code path and sets `TRINITY_AUTHORITY`.
Anything more than that is NOT IN TREE.

## Checklist

- [ ] the module is loaded in the release, and `TRINITY_AUTHORITY` names it
- [ ] `stage/2`, `decide/3`, `execute/3`, `receipt/2` are exported
- [ ] `stage/2` changes nothing but `staged_at` and `basis`
- [ ] `decide/3` answers `:allow` or `:deny`, and never falls back to a local decision
- [ ] `execute/3` runs no tool
- [ ] `forward_receipt/2` acknowledges bytes it did not alter, or is absent on purpose
- [ ] under `TRINITY_PROFILE=regulated`, the node boots, which means it did not select `Local`
18 changes: 13 additions & 5 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,11 @@ defmodule Trinity.Application do
if profile == :regulated do
mcp_auth = Application.get_env(:trinity, :mcp_auth, []) |> Keyword.get(:profile, :local)
llm = Application.get_env(:trinity, :llm, [])
authority = authority_module()

[
Trinity.Profile.check_mcp_auth(profile, mcp_auth),
Trinity.Profile.check_embedded_as(profile, mcp_auth),
Trinity.Profile.check_authority(profile, authority),
check_authority(profile),
Trinity.Profile.check_llm_endpoints(
profile,
Trinity.Profile.raw_endpoints(),
Expand All @@ -143,10 +142,19 @@ defmodule Trinity.Application do

# AC5's input. `Trinity.Authority.Selection.select/1` is the same pure rule the selection child
# uses, asked here before that child exists rather than duplicated.
defp authority_module do
#
# Owner ruling 2026-09-30: a refusal is reported with the selection's own reason. This used to
# answer `nil` for every refusal, and `nil` reached `check_authority/2` as an argument no clause
# matched, so `TRINITY_AUTHORITY=SomethingNotLoaded` under `:regulated` stopped the boot with a
# FunctionClauseError in `Trinity.Profile` rather than with the name of the module that could
# not be loaded.
defp check_authority(profile) do
case Trinity.Authority.Selection.select(System.get_env("TRINITY_AUTHORITY")) do
{:ok, module} -> module
_ -> nil
{:ok, module} ->
Trinity.Profile.check_authority(profile, module)

{:error, reason} ->
{:error, {:regulated_authority_unselectable, reason}}
end
end

Expand Down
Loading
Loading