Skip to content

Commit dc8d459

Browse files
authored
Merge pull request #112 from ScriptKittyOS/feat/authority-executor-split
feat: the authority decides, this node executes
2 parents 918bad6 + 3b9c0cb commit dc8d459

14 files changed

Lines changed: 637 additions & 63 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -350,10 +350,11 @@ somewhere outside the host, on any schedule, makes backdated forgery detectable
350350
without needing a transparency service. This is not built yet and is named here as the gap it is.
351351

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

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

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

381384
**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
382385
read tools return query-receipted results, its proposal tools return proposal ids rather than effects, and content

‎docs/adr/0008-authority-is-an-adapter.md‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ whether the thing you asked for is allowed to happen.
2020
behind `Trinity.MCP.Auth`. That answers who is calling and with what scopes. Whether an effect happens is
2121
answered by the gate, or by whichever `Trinity.Authority` implementation is in force. The auth layer is never
2222
described as an authority layer.
23-
5. When an external adapter is in force, Trinity keeps **no executor** for the effects that adapter governs. The
24-
absence is asserted by a census over the tree, not by intent.
23+
5. **Amended 2026-09-30, see below.** ~~When an external adapter is in force, Trinity keeps **no executor** for the
24+
effects that adapter governs. The absence is asserted by a census over the tree, not by intent.~~
25+
There is **no model-side executor**, and after a `decide/3` allow **this VM runs the tool**. The census over the
26+
tree still asserts the shape, and now asserts one executor rather than none.
2527

2628
## Consequences
2729
- Adapters can be written against a published behaviour by anyone, including for authority layers this project
@@ -64,3 +66,39 @@ record. That is deliberate and belongs in this ADR because it is a statement abo
6466
can and cannot deliver: after a partition, two devices hold two true accounts of what each did, and nothing
6567
available afterwards turns them into one account of what happened. A system that claimed otherwise would be
6668
inventing the part it could not know.
69+
70+
## Amended 2026-09-30 — point 5: one executor, here, not none
71+
72+
**Source: the owner, 2026-09-30.** Point 5 as accepted said that an external adapter meant Trinity kept no executor
73+
for the effects that adapter governed, and the census test asserted the absence by finding exactly one file able to
74+
call a tool's `execute/2` and noting that the effect never reached it under an adapter.
75+
76+
That is no longer the design. The split is:
77+
78+
> Any actor proposes. The authority in force authorizes. This node's Elixir executes Trinity tools after an allow.
79+
80+
The adapter **does not run the tool** and **does not change the arguments**. It answers `:allow` or `:deny` with a
81+
basis, and stops.
82+
83+
### Why the earlier shape was worse
84+
85+
It put the effect in the adapter's hands, in another repository, on the far side of the membrane. Every check the
86+
membrane makes, the effect class, the catalogue, the fingerprint, the idempotency key, the admission receipt, applied
87+
to something that then happened somewhere else. A second executor is a second place for the rules to be different.
88+
89+
It also made `stage/2` load-bearing in a way nothing checked: the membrane verified the fingerprint, handed the
90+
`Staged` to the authority, and used whatever came back. An adapter could change `args` after the fingerprint had been
91+
verified, or keep `args` and change `module`, which no fingerprint covers at all.
92+
93+
### What is true now, with where it is enforced
94+
95+
| claim | enforced at |
96+
| --- | --- |
97+
| 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` |
98+
| exactly one file calls a tool's `execute/2` on the effect path | `test/trinity/effects/census_test.exs` |
99+
| `stage/2` may set `staged_at` and `basis` and nothing else | `lib/trinity/effects.ex`, `check_stage_kept_the_subject/2` |
100+
| the fingerprint is re-derived after `stage/2` against the one the gate bound | `lib/trinity/effects.ex`, `check_fingerprint_against/2` |
101+
| an adapter's `execute/3` is on no path and must not run a tool | `lib/trinity/authority.ex`, `lib/trinity/authority/local.ex` |
102+
103+
Decisions 1 to 4 are unchanged. The behaviour still has four callbacks, because removing `execute/3` would silently
104+
accept an adapter written against the older contract, where returning a result from it was how the effect happened.

‎docs/authority-adapter.md‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<!--
2+
SPDX-FileCopyrightText: Sudo Apt Holdings LLC
3+
SPDX-License-Identifier: Apache-2.0
4+
-->
5+
# Writing an authority adapter
6+
7+
How to make an external authority layer the one that decides for this node.
8+
9+
**Every claim here is a path in this repository, or says NOT IN TREE.** A sentence you cannot follow
10+
to a file is a sentence you should not trust.
11+
12+
## The split
13+
14+
> Anything may propose. The authority in force authorizes. After an allow, this node's Elixir runs
15+
> the tool.
16+
17+
The adapter **decides**. It does not run the tool, and it does not change what it was asked about.
18+
19+
## One module, chosen once, at boot
20+
21+
`TRINITY_AUTHORITY` names one module. It is read once, by `Trinity.Authority.Selection`
22+
(`lib/trinity/authority/selection.ex`), as the first child of the application after the data
23+
directory lock, and the selection is kept in `:persistent_term`. No runtime path changes it.
24+
25+
- unset, `""`, or `local` selects `Trinity.Authority.Local`.
26+
- any other value is an Elixir module name, with or without the `Elixir.` prefix.
27+
- the module must be **loaded** and must export every callback, or the boot stops and the refusal
28+
names which: `{:not_loaded, name}` or `{:missing_callback, module, {name, arity}}`.
29+
- a name that is not an existing atom is reported as not loaded, and no atom is created from the
30+
environment's text.
31+
32+
Selection is **synchronous**: `Selection.start/0` completes before any later child starts, so there
33+
is no window in which the node is running with the selection still being made. Before that,
34+
`Trinity.Authority.impl/0` (`lib/trinity/authority.ex`) answers `Local` under `:default` and
35+
**raises** under `:regulated`, because a brief fallback to the local authority is still a fallback.
36+
37+
`Trinity.Authority.Local` (`lib/trinity/authority/local.ex`) is **the only implementation in this
38+
tree**. Everything else is NOT IN TREE: this repository holds no client, no envelope, no schema and
39+
no transport for any particular authority layer, and a test asserts that under `local` no adapter
40+
module is loaded (`test/trinity/authority/selection_test.exs`).
41+
42+
## The regulated profile refuses the local authority
43+
44+
Under `TRINITY_PROFILE=regulated` the node refuses to boot on `Trinity.Authority.Local`:
45+
46+
- the rule is `Trinity.Profile.check_authority/2` in `lib/trinity/profile.ex`, which answers
47+
`{:error, :regulated_refuses_local_authority}`.
48+
- it is asked at boot, before any child exists, by `verify_regulated_configuration!/0` in
49+
`lib/trinity/application.ex`.
50+
- an unset `TRINITY_AUTHORITY` resolves to `Local` and is therefore refused too.
51+
- a name that cannot be resolved is refused as `{:regulated_authority_unselectable, reason}`,
52+
carrying the selection's own reason, and `check_authority/2` is total, so a caller that loses the
53+
reason still gets `{:regulated_authority_unresolved, value}` rather than a crash.
54+
55+
The refusals are tested in `test/trinity/profile_test.exs`, and at an actual boot, in spawned OS
56+
processes, in `test/trinity/regulated_boot_node_test.exs`.
57+
58+
## The callbacks
59+
60+
Four are required. `Trinity.Authority.callbacks/0` is the list the selection checks against, in
61+
`lib/trinity/authority.ex`.
62+
63+
| callback | answer | what it is for |
64+
| --- | --- | --- |
65+
| `stage/2` | `{:ok, Staged.t()}` or `{:error, term()}` | record that an effect is about to happen |
66+
| `decide/3` | `{:ok, :allow \| :deny, basis}` or `{:error, term()}` | **the decision**, with a basis saying why |
67+
| `execute/3` | `{:error, :not_the_effect_path}` | reserved; see below |
68+
| `receipt/2` | `{:ok, term()}` or `{:error, term()}` | record a receipt of a kind |
69+
70+
And one optional:
71+
72+
| callback | answer | what it is for |
73+
| --- | --- | --- |
74+
| `forward_receipt/2` | `:ok` or `{:error, term()}` | acknowledge a queued receipt envelope |
75+
76+
### `decide/3` returns allow or deny, and never falls back
77+
78+
`:allow` or `:deny`. An adapter that cannot reach its far side returns `{:error, reason}` or
79+
`:deny`; it must **not** decide locally, and it must not answer as though the local gate had
80+
decided. The membrane treats `{:ok, :deny, basis}` as a denial with a receipt, and `{:error, _}` as
81+
a denial with a receipt. Both are safe. A fallback to `Local` is the one answer that is not.
82+
83+
### `execute/3` must not run a tool
84+
85+
It is **not** the place the effect happens, and nothing on the effect path calls it. After a
86+
`decide/3` allow and after the admission receipt is in the chain, `Trinity.Effects.run_tool/2`
87+
(`lib/trinity/effects.ex`) calls the tool's own `execute/2`, in this VM.
88+
89+
It stays in the behaviour so that an adapter written against the older contract, where returning a
90+
result from it was how the effect happened, is not silently accepted. Answer
91+
`{:error, :not_the_effect_path}`, which is what `Trinity.Authority.Local` answers.
92+
93+
### `stage/2` may not change the subject
94+
95+
Set `staged_at` and `basis`. Nothing else. Every other field of the `Trinity.Authority.Staged`
96+
(`lib/trinity/authority/staged.ex`) you return must be the one you were given.
97+
98+
After `stage/2` the membrane re-derives the fingerprint over the arguments you returned and compares
99+
it to the one the gate bound, not to the one your struct now carries, so rewriting `args` and
100+
`fingerprint` to agree with each other does not pass. It then compares every remaining field,
101+
because the fingerprint covers the session, the tool name, the arguments and the working directory,
102+
and covers `module` not at all: an adapter could otherwise leave every fingerprinted field alone and
103+
still change which code runs.
104+
105+
Both refusals are tested against adapters that actually attempt them, in
106+
`test/trinity/effects/membrane_test.exs` with `Trinity.TestAuthority.MutatesArgs` and
107+
`Trinity.TestAuthority.SwapsModule` (`test/support/authority/adapters.ex`), alongside a control
108+
adapter that changes nothing and whose effect runs.
109+
110+
## Receipts: Trinity writes them, the adapter acknowledges them
111+
112+
Trinity signs and appends its own receipts, locally, around the effect: an `admit` before the tool
113+
runs and a `done` after it, or a `denied` instead. That is `Trinity.Effects` and
114+
`Trinity.Receipts`. **The adapter does not write Trinity's receipts.**
115+
116+
What an adapter may do is receive them. `forward_receipt/2` is offered the exported envelope
117+
**byte for byte, exactly as it was signed**. An implementation must not re-sign it, rebuild it or
118+
alter a leaf: the point is that a verifier checks the signature over those bytes offline, which is
119+
what lets a queue wrap, delay and re-deliver an envelope without the far side knowing a queue
120+
exists. `:ok` acknowledges; any error leaves the entry pending to be offered again, oldest first.
121+
An implementation that does not export it is one this machine never forwards to, and the queue for
122+
its scopes simply does not drain. See `lib/trinity/receipts/queue.ex` and
123+
`test/trinity/receipts/queue_test.exs`.
124+
125+
## `act_hash` is opaque
126+
127+
An authority layer may bind its decision to an act identifier. **Trinity treats it as opaque.** It
128+
does not parse it, does not recompute it and does not depend on its shape; it carries it and stores
129+
it beside the decision.
130+
131+
Where an adapter records one, it should record the algorithm beside it, as `act_hash` and
132+
`act_hash_alg`, so a reader is never guessing which construction produced the digest. The value
133+
this contract expects is `sha256+rfc8785-v1`: SHA-256 over the [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)
134+
canonical JSON form. Trinity already uses RFC 8785 for its own approval fingerprints and holds a
135+
conformance vector for it (`test/support/rfc8785_input.json`, `test/support/rfc8785_expected.txt`),
136+
so the canonicalisation is not a new dependency here.
137+
138+
**`act_hash` is NOT IN TREE.** No field, column or function of that name exists in this repository.
139+
It is named here so an adapter and this node agree on the word, not because Trinity reads it.
140+
141+
## Packaging an adapter
142+
143+
The adapter lives **outside this repository**, and the release that contains both is built outside
144+
it too. This tree builds a Trinity that can be pointed at one; it does not build, vendor or depend
145+
on any. A wrapper release adds the adapter module to the code path and sets `TRINITY_AUTHORITY`.
146+
Anything more than that is NOT IN TREE.
147+
148+
## Checklist
149+
150+
- [ ] the module is loaded in the release, and `TRINITY_AUTHORITY` names it
151+
- [ ] `stage/2`, `decide/3`, `execute/3`, `receipt/2` are exported
152+
- [ ] `stage/2` changes nothing but `staged_at` and `basis`
153+
- [ ] `decide/3` answers `:allow` or `:deny`, and never falls back to a local decision
154+
- [ ] `execute/3` runs no tool
155+
- [ ] `forward_receipt/2` acknowledges bytes it did not alter, or is absent on purpose
156+
- [ ] under `TRINITY_PROFILE=regulated`, the node boots, which means it did not select `Local`

‎lib/trinity/application.ex‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,11 @@ defmodule Trinity.Application do
120120
if profile == :regulated do
121121
mcp_auth = Application.get_env(:trinity, :mcp_auth, []) |> Keyword.get(:profile, :local)
122122
llm = Application.get_env(:trinity, :llm, [])
123-
authority = authority_module()
124123

125124
[
126125
Trinity.Profile.check_mcp_auth(profile, mcp_auth),
127126
Trinity.Profile.check_embedded_as(profile, mcp_auth),
128-
Trinity.Profile.check_authority(profile, authority),
127+
check_authority(profile),
129128
Trinity.Profile.check_llm_endpoints(
130129
profile,
131130
Trinity.Profile.raw_endpoints(),
@@ -143,10 +142,19 @@ defmodule Trinity.Application do
143142

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

0 commit comments

Comments
 (0)