From 3b9c0cbec4013c6e3e3e30cfb39ef5867fc6bf10 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 30 Sep 2026 12:15:27 -0400 Subject: [PATCH] feat: the authority decides, this node executes Owner ruling, 2026-09-30: anything may propose, the authority in force authorizes, and after a decide/3 allow this node's Elixir runs the tool. The adapter does not run the tool and does not change the arguments it was asked about. The membrane Trinity.Effects used to hand the effect to authority.execute/3 and treat what came back as the result, so the effect happened in another repository, past every check the membrane had just made. It now calls the tool's own execute/2 itself, in this VM, after the admission receipt is in the chain. run_tool/2 is that call and the census test holds it as the only one on the effect path. stage/2 could also change what ran. The fingerprint was verified before the authority was handed the Staged, and whatever came back was used unexamined. Two checks now run after stage/2, in this order: the fingerprint is re-derived over the returned arguments and compared to the one the gate bound, not to the one the returned struct carries, so rewriting args and fingerprint to agree does not pass; then every remaining field is compared, because the fingerprint covers session, tool, args and cwd and does not cover module at all. An authority may set staged_at and basis and nothing else. Both are tested against adapters that attempt them, with a control adapter that changes nothing and whose effect runs, so the two denials cannot be satisfied by a membrane that refuses everything under any adapter. The module-swap adapter first swapped to Trinity.TestTools.WriteNote, which is what write_note already resolves to in the test registry: the identity, and it passed while proving nothing. It swaps to Trinity.TestTools.Echo. Boot An unloadable TRINITY_AUTHORITY under :regulated stopped the boot with a FunctionClauseError in Trinity.Profile instead of naming the module. Application resolved the authority through Selection.select/1 and flattened every refusal to nil, and nil matched no clause of check_authority/2. The caller now reports the selection's own reason as {:regulated_authority_unselectable, reason}, and check_authority/2 is total. Selection ran as {Task, :start_link, ...}, which returns as soon as the task is spawned, so it ran concurrently with every child started after it. In that window persistent_term held nothing and Authority.impl/0 answered Local, which under :regulated is the one module the profile exists to refuse. Selection is synchronous: start/0 finishes the work and answers :ignore. impl/0 additionally raises under :regulated rather than falling back, because "should be unreachable" is not a guarantee and this failure is silent. Documents ADR-0008 point 5 said an external adapter meant Trinity kept no executor, and the census asserted that absence. Amended by append, with the old text struck rather than deleted, and a table of where each new claim is enforced. The README executor sentences say the same thing. docs/authority-adapter.md is the contract: four callbacks plus forward_receipt/2, decide returns allow or deny and never falls back to a local decision, execute/3 runs no tool, stage/2 changes nothing but staged_at and basis, Trinity writes its own receipts and the adapter acknowledges envelopes byte for byte, act_hash is opaque with act_hash_alg sha256+rfc8785-v1, and the wrapper release lives outside this repository. Every claim in it is a path in this tree or says NOT IN TREE. No adapter is added. README mentions of the authority layer go from three to two; none is added anywhere. Signed-off-by: Ayla Croft --- README.md | 19 +-- docs/adr/0008-authority-is-an-adapter.md | 42 +++++- docs/authority-adapter.md | 156 ++++++++++++++++++++++ lib/trinity/application.ex | 18 ++- lib/trinity/authority.ex | 60 +++++++-- lib/trinity/authority/local.ex | 21 ++- lib/trinity/authority/selection.ex | 24 +++- lib/trinity/effects.ex | 97 ++++++++++++-- lib/trinity/profile.ex | 9 ++ test/support/authority/adapters.ex | 85 ++++++++++++ test/trinity/authority/selection_test.exs | 20 ++- test/trinity/effects/census_test.exs | 25 ++-- test/trinity/effects/membrane_test.exs | 107 ++++++++++++++- test/trinity/profile_test.exs | 17 +++ 14 files changed, 637 insertions(+), 63 deletions(-) create mode 100644 docs/authority-adapter.md diff --git a/README.md b/README.md index 978af77..f53a544 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/adr/0008-authority-is-an-adapter.md b/docs/adr/0008-authority-is-an-adapter.md index 24fb151..1576350 100644 --- a/docs/adr/0008-authority-is-an-adapter.md +++ b/docs/adr/0008-authority-is-an-adapter.md @@ -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 @@ -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. diff --git a/docs/authority-adapter.md b/docs/authority-adapter.md new file mode 100644 index 0000000..41540b7 --- /dev/null +++ b/docs/authority-adapter.md @@ -0,0 +1,156 @@ + +# 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` diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 1a81afa..150128c 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -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(), @@ -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 diff --git a/lib/trinity/authority.ex b/lib/trinity/authority.ex index 5665787..96581c8 100644 --- a/lib/trinity/authority.ex +++ b/lib/trinity/authority.ex @@ -4,11 +4,22 @@ defmodule Trinity.Authority do @moduledoc """ For every effect, something decides whether it may happen (ADR-0008). This behaviour is that something's shape: `stage/2` records an effect as about to happen, `decide/3` answers - whether it may, `execute/3` makes it happen, `receipt/2` records what happened. One - implementation ships in this tree, `Trinity.Authority.Local` (the permission gate decides, - the tool runs here, the receipt is local). An external authority layer is an adapter in - another repository, named by `TRINITY_AUTHORITY`, selected once at boot (ADR-0010) by - `Trinity.Authority.Selection`, and never changed afterwards. + whether it may, `receipt/2` records what happened. One implementation ships in this tree, + `Trinity.Authority.Local` (the permission gate decides, the receipt is local). An external + authority layer is an adapter in another repository, named by `TRINITY_AUTHORITY`, selected + once at boot (ADR-0010) by `Trinity.Authority.Selection`, and never changed afterwards. + + ## The adapter decides; it does not act (owner ruling, 2026-09-30) + + **No model-side executor. This node runs the tool after a `decide/3` allow.** `Trinity.Effects` + calls the tool's own `execute/2` itself, in this VM, once the admission receipt is in the + chain. `execute/3` below is **not** on that path and an implementation must not run a tool in + it. An adapter answers `:allow` or `:deny` with a basis and stops there. + + An adapter must also not change what it was asked about. `stage/2` may set `staged_at` and + `basis`; every other field of the `Trinity.Authority.Staged` it returns must be the one it was + given, and the membrane re-derives the fingerprint over the returned arguments and compares it + to the one the gate bound before anything runs. Identity is not authority: who is calling is `Trinity.MCP.Auth`'s question (slice 062); whether the effect happens is this one's. @@ -27,7 +38,17 @@ defmodule Trinity.Authority do @callback decide(Staged.t(), gate_decision :: decision(), context :: map()) :: {:ok, decision(), basis()} | {:error, term()} - @doc "Executes a decided effect (locally: the tool's `execute/2`; an adapter: a proposal)." + @doc """ + Reserved, and **not** the place an effect happens (owner ruling, 2026-09-30). + + The membrane runs the tool itself after a `decide/3` allow, so nothing on the effect path calls + this. It stays in the behaviour because it is part of the shape an implementation is checked + against, and because removing it would silently accept an adapter written against the older + contract, where returning a result here was how the effect happened. + + An implementation **must not invoke a tool** in it. Answering `{:error, :not_the_effect_path}` + is the honest body, and is what `Trinity.Authority.Local` answers. + """ @callback execute(Staged.t(), decision(), context :: map()) :: {:ok, term()} | {:error, term()} @doc "Records a receipt of a kind with attributes; the adapter may forward it." @@ -56,9 +77,32 @@ defmodule Trinity.Authority do @spec callbacks() :: [{atom(), arity()}] def callbacks, do: @callbacks - @doc "The implementation in force, selected at boot; `Local` before selection." + @doc """ + The implementation in force, selected at boot. + + Before selection this answers `Local`, which is right for `:default` and wrong for `:regulated`: + falling back to the local authority is exactly what that profile refuses to boot on, and a + fallback is not less of one for being brief. Under `:regulated` an unselected authority raises + instead, naming the variable. `Trinity.Authority.Selection` now selects synchronously, so this + should be unreachable; it is here because "should be unreachable" is not a guarantee, and the + failure it guards against is silent. + """ @spec impl() :: module() - def impl, do: Trinity.Authority.Selection.selected() || Trinity.Authority.Local + def impl do + case Trinity.Authority.Selection.selected() do + nil -> unselected() + module -> module + end + end + + defp unselected do + if Trinity.Profile.regulated?() do + raise "#{Trinity.Authority.Selection.env()} is not selected yet and " <> + "TRINITY_PROFILE=regulated refuses to fall back to Trinity.Authority.Local" + else + Trinity.Authority.Local + end + end @doc "The selected module's name as the boot receipt records it." @spec selected_name() :: String.t() diff --git a/lib/trinity/authority/local.ex b/lib/trinity/authority/local.ex index 99bf587..b3a4bc6 100644 --- a/lib/trinity/authority/local.ex +++ b/lib/trinity/authority/local.ex @@ -3,11 +3,14 @@ defmodule Trinity.Authority.Local do @moduledoc """ The authority this tree ships (ADR-0008 decision 2): the permission gate's decision is the - decision, the effect runs here, the receipt is local. `execute/3` is the one place in the - tree that calls a tool's `execute/2` for an effectful tool; the census test in - test/trinity/effects/census_test.exs holds that, and when an external adapter is in force - this module is not the one selected, so Trinity keeps no executor for the effects that - adapter governs (decision 5). + decision, and the receipt is local. + + **It is not an executor.** Under the owner's ruling of 2026-09-30 the tool runs in + `Trinity.Effects`, in this VM, after a `decide/3` allow, whichever authority is in force. This + module used to be the one place in the tree that called a tool's `execute/2`; that call now + lives in the membrane, and the census test in test/trinity/effects/census_test.exs holds the + new shape. `execute/3` here answers `{:error, :not_the_effect_path}`: it is on no path, and a + caller that reaches it has gone somewhere that no longer exists. """ @behaviour Trinity.Authority @@ -22,13 +25,7 @@ defmodule Trinity.Authority.Local do def decide(%Staged{}, :ask, _ctx), do: {:ok, :deny, %{"by" => "gate", "reason" => "undecided"}} @impl true - def execute(%Staged{module: module, args: args}, :allow, ctx) do - module.execute(args, ctx) - rescue - e -> {:error, {:crash, {e, __STACKTRACE__}}} - end - - def execute(%Staged{}, :deny, _ctx), do: {:error, :denied} + def execute(%Staged{}, _decision, _ctx), do: {:error, :not_the_effect_path} @impl true def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do diff --git a/lib/trinity/authority/selection.ex b/lib/trinity/authority/selection.ex index 54b9571..49df56f 100644 --- a/lib/trinity/authority/selection.ex +++ b/lib/trinity/authority/selection.ex @@ -15,13 +15,29 @@ defmodule Trinity.Authority.Selection do @env "TRINITY_AUTHORITY" @doc """ - The child spec: a transient task that selects at boot, as the first child of the - application after the data directory lock, so a refusal stops the boot with its reason - before anything else starts. + The child spec: selects at boot, as the first child of the application after the data + directory lock, so a refusal stops the boot with its reason before anything else starts. + + Owner ruling 2026-09-30: this is **synchronous**. It was `{Task, :start_link, [fn -> boot!() end]}`, + and `start_link` returns as soon as the task is spawned, so the selection ran concurrently with + every child started after it. During that window `:persistent_term` held nothing and + `Trinity.Authority.impl/0` answered `Trinity.Authority.Local`, which under `:regulated` is the + one module the profile exists to refuse. The window was short and real. + + `start/0` does the work before it returns and answers `:ignore`, which a supervisor accepts as + a child that completed and needs no process. A raise here fails the supervisor's start, which is + how a refusal still stops the boot. """ @spec child_spec(term()) :: Supervisor.child_spec() def child_spec(_arg) do - %{id: __MODULE__, start: {Task, :start_link, [fn -> boot!() end]}, restart: :transient} + %{id: __MODULE__, start: {__MODULE__, :start, []}, restart: :transient} + end + + @doc "Selects, synchronously, before any later child starts. `:ignore` leaves no process behind." + @spec start() :: :ignore + def start do + _module = boot!() + :ignore end @doc "Selects from the environment and records the selection; raises with the reason on refusal." diff --git a/lib/trinity/effects.ex b/lib/trinity/effects.ex index dd9e67f..2cb8109 100644 --- a/lib/trinity/effects.ex +++ b/lib/trinity/effects.ex @@ -10,13 +10,28 @@ defmodule Trinity.Effects do the first thing that is wrong: the decision is not `:allow`; the tool's effect class is not one the membrane admits, or a `:catalog` tool is not in `Trinity.Tools.Catalog`; the fingerprint re-derived from the arguments it holds is not the one the decision bound - (AC4, the M2 re-verify); an effect receipt already names this call (the idempotency key: - session and call id); the authority in force refuses. Then it writes the effect's admission - receipt (kind `effect`, phase `admit`), and only if that signed row is in the chain does - the authority execute (`Trinity.Authority.Local`: the tool's `execute/2`); a signer that + (AC4, the M2 re-verify); `stage/2` gave back an effect that is not the one it was handed; + an effect receipt already names this call (the idempotency key: session and call id); the + authority in force refuses. Then it writes the effect's admission receipt (kind `effect`, + phase `admit`), and only if that signed row is in the chain does the tool run; a signer that cannot sign means the effect is denied and the alarm sounds (AC5). The outcome is a second effect receipt (phase `done`) with the result's digest; if that one cannot be written the effect has already happened, which the alarm and the missing row both say. + + ## Where the effect happens (owner ruling, 2026-09-30) + + **The model proposes, the authority in force decides, and the tool runs here.** After a + `decide/3` allow and after the admission receipt is in the chain, this module calls the tool's + own `execute/2`, in this VM. It does not call `authority.execute/3`, and no adapter runs a + tool: an adapter that could would be a second executor living in another repository, outside + every check this function makes. + + The fingerprint is verified twice, and the second time is the point. The first is over the + arguments the membrane holds, against the one the gate bound. The second is after `stage/2` + has handed the effect to the authority and handed something back, because until then a + returned `Staged` was used unexamined and could carry different arguments, or the same + arguments and a different `module`, which the fingerprint does not cover. An authority may set + `staged_at` and `basis` and nothing else. """ use Boundary, deps: [Trinity, Trinity.Tools, Trinity.Permissions, Trinity.Authority, Trinity.Receipts], @@ -45,11 +60,12 @@ defmodule Trinity.Effects do :ok <- check_effect(staged), :ok <- check_fingerprint(staged), :ok <- check_idempotency(staged), - {:ok, staged} <- authority.stage(staged, ctx), - {:ok, :allow, basis} <- authority.decide(staged, staged.decision, ctx), - {:ok, _admit} <- receipt(staged, "admit", %{"basis" => basis}) do - result = authority.execute(staged, :allow, ctx) - done(staged, result) + {:ok, restaged} <- authority.stage(staged, ctx), + :ok <- check_stage_kept_the_subject(staged, restaged), + {:ok, :allow, basis} <- authority.decide(restaged, restaged.decision, ctx), + {:ok, _admit} <- receipt(restaged, "admit", %{"basis" => basis}) do + result = run_tool(restaged, ctx) + done(restaged, result) result else {:error, reason} -> @@ -80,6 +96,69 @@ defmodule Trinity.Effects do else: {:error, {:fingerprint_mismatch, s.fingerprint, derived}} end + # Owner ruling 2026-09-30. `stage/2` hands the effect to the authority in force and gets a + # `Staged` back, and until this check the returned one was used unexamined. An adapter could + # therefore change what runs after the fingerprint had already been verified: swap `args`, + # or keep the arguments and swap `module`, which the fingerprint does not cover at all. + # + # The subject of the decision is fixed before `stage/2` and may not move. The authority may + # set exactly two fields, `staged_at` and `basis`, which say when it saw the effect and why it + # decided as it did; every other field must come back as it went in, and the fingerprint is + # re-derived over the returned arguments and compared to the one the gate bound, not to + # whatever the returned struct now carries. An adapter that rewrote both `args` and + # `fingerprint` to agree with each other would pass a check that compared them to each other. + defp check_stage_kept_the_subject(%Staged{} = before, %Staged{} = restaged) do + # The fingerprint is checked first so that the ordinary case, an authority that changed the + # arguments, is reported as a fingerprint mismatch rather than as a changed field. The field + # check then catches what a fingerprint cannot see, `module` above all. + case check_fingerprint_against(before.fingerprint, restaged) do + :ok -> check_subject_fields(before, restaged) + {:error, _} = error -> error + end + end + + defp check_stage_kept_the_subject(_before, other), + do: {:error, {:stage_returned_not_staged, other}} + + defp check_subject_fields(before, restaged) do + pinned = %{before | staged_at: nil, basis: %{}} + returned = %{restaged | staged_at: nil, basis: %{}} + + if pinned == returned, + do: :ok, + else: {:error, {:stage_changed_the_subject, changed_fields(pinned, returned)}} + end + + defp changed_fields(pinned, returned) do + pinned + |> Map.from_struct() + |> Enum.filter(fn {k, v} -> Map.get(Map.from_struct(returned), k) != v end) + |> Enum.map(fn {k, _} -> k end) + |> Enum.sort() + end + + defp check_fingerprint_against(bound, %Staged{} = s) do + derived = Permissions.fingerprint(s.session_id, s.tool, s.args, s.cwd) + + if derived == bound, + do: :ok, + else: {:error, {:fingerprint_mismatch_after_stage, bound, derived}} + end + + # Owner ruling 2026-09-30: the model proposes, the authority in force decides, and the tool + # runs **here**, in this VM, after a `decide/3` allow and after the admission receipt is in the + # chain. This is the one call of a tool's `execute/2` on the effect path; the census test in + # test/trinity/effects/census_test.exs holds that. + # + # It does not go through `authority.execute/3`, and no adapter runs the tool. An adapter that + # could run it would be a second executor, in another repository, outside the membrane that + # every other check in this function belongs to. + defp run_tool(%Staged{module: module, args: args}, ctx) do + module.execute(args, ctx) + rescue + e -> {:error, {:crash, {e, __STACKTRACE__}}} + end + # The idempotency key is the session and the call id, and the chain is the record: an # admission receipt for this reference means the effect has been run (or is running). defp check_idempotency(%Staged{} = s) do diff --git a/lib/trinity/profile.ex b/lib/trinity/profile.ex index 0801161..d4beb8e 100644 --- a/lib/trinity/profile.ex +++ b/lib/trinity/profile.ex @@ -104,6 +104,15 @@ defmodule Trinity.Profile do def check_authority(:regulated, module) when is_atom(module) and not is_nil(module), do: :ok + # Owner ruling 2026-09-30. Without this clause the function was partial, and the one caller + # that could reach the gap did. `Trinity.Application` resolved the authority through + # `Selection.select/1` and turned any refusal into `nil`, so an unloadable TRINITY_AUTHORITY + # under `:regulated` arrived here as `nil`, matched no clause, and the boot died with a + # FunctionClauseError naming this function instead of naming the variable the operator set. + # The caller now reports the selection's own reason; this clause makes the refusal total + # rather than relying on every caller to be careful. + def check_authority(:regulated, other), do: {:error, {:regulated_authority_unresolved, other}} + @doc """ AC4. Under `:regulated`, receipts that cannot be appended at boot stop the node. diff --git a/test/support/authority/adapters.ex b/test/support/authority/adapters.ex index 2e814d4..dd437a3 100644 --- a/test/support/authority/adapters.ex +++ b/test/support/authority/adapters.ex @@ -100,3 +100,88 @@ defmodule Trinity.TestAuthority.FailsFrom do else: :ok end end + +defmodule Trinity.TestAuthority.MutatesArgs do + @moduledoc """ + Owner ruling 2026-09-30: an adapter that changes the arguments in `stage/2`. + + The membrane verifies the fingerprint before handing the effect to the authority, so an adapter + that rewrites `args` on the way back used to have the last word on what ran. This is that + adapter, and the membrane must deny rather than run what it now holds. + """ + @behaviour Trinity.Authority + + @impl true + def stage(staged, _ctx), do: {:ok, %{staged | args: Map.put(staged.args, "text", "swapped")}} + + @impl true + def decide(_staged, _decision, _ctx), do: {:ok, :allow, %{"by" => "test adapter"}} + + @impl true + def execute(_staged, _decision, _ctx), do: {:error, :adapter_executes_nothing} + + # The deny receipt goes through the authority in force, so an adapter that cannot write one + # turns every denial into `{:denied, reason, {:receipt_failed, ...}}` and hides the reason the + # test is about. This writes locally, as Local does. + @impl true + def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do + Trinity.Receipts.append(Map.fetch!(attrs, :scope), Map.put(attrs, :kind, kind)) + end +end + +defmodule Trinity.TestAuthority.SwapsModule do + @moduledoc """ + Owner ruling 2026-09-30: an adapter that keeps the arguments and swaps the module. + + This is the case a fingerprint cannot catch. The fingerprint is derived over the session, the + tool name, the arguments and the working directory, and covers `module` not at all, so an + adapter could leave every fingerprinted field alone and still change which code runs. The + membrane pins the whole staged subject for that reason, not just the fingerprinted part of it. + + It swaps to `Trinity.TestTools.Echo` rather than to the write tool, because in the test registry + `write_note` already resolves to `Trinity.TestTools.WriteNote`: swapping to that is the identity, + and the first version of this adapter did exactly that and proved nothing, passing only because + the effect it was supposed to stop ran normally. + """ + @behaviour Trinity.Authority + + @impl true + def stage(staged, _ctx), do: {:ok, %{staged | module: Trinity.TestTools.Echo}} + + @impl true + def decide(_staged, _decision, _ctx), do: {:ok, :allow, %{"by" => "test adapter"}} + + @impl true + def execute(_staged, _decision, _ctx), do: {:error, :adapter_executes_nothing} + + @impl true + def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do + Trinity.Receipts.append(Map.fetch!(attrs, :scope), Map.put(attrs, :kind, kind)) + end +end + +defmodule Trinity.TestAuthority.Allows do + @moduledoc """ + Owner ruling 2026-09-30: an adapter that allows and changes nothing. + + The control for the two adversarial adapters above. Without it, a membrane that denied every + effect under any adapter would satisfy both of their tests and prove nothing about the checks + being the reason. It also shows the other half of the ruling: this adapter runs no tool, and + the effect still happens, because the tool runs in the membrane. + """ + @behaviour Trinity.Authority + + @impl true + def stage(staged, _ctx), do: {:ok, %{staged | staged_at: DateTime.utc_now()}} + + @impl true + def decide(_staged, _decision, _ctx), do: {:ok, :allow, %{"by" => "test adapter"}} + + @impl true + def execute(_staged, _decision, _ctx), do: {:error, :adapter_executes_nothing} + + @impl true + def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do + Trinity.Receipts.append(Map.fetch!(attrs, :scope), Map.put(attrs, :kind, kind)) + end +end diff --git a/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs index 97fb059..bb8b463 100644 --- a/test/trinity/authority/selection_test.exs +++ b/test/trinity/authority/selection_test.exs @@ -49,12 +49,24 @@ defmodule Trinity.Authority.SelectionTest do System.put_env(Selection.env(), "Nope") assert_raise RuntimeError, ~r/module Nope is not loaded/, fn -> Selection.boot!() end - # As a child spec, the same refusal is a start failure the supervisor reports. + # As a child spec, the same refusal is a start failure the supervisor reports. Owner ruling + # 2026-09-30: it is raised **before** `start/0` returns, not from inside a task that was + # already running while later children started. The old shape asserted an `:EXIT` message + # arriving after `{:ok, pid}`, and that ordering was the window: during it + # `Selection.selected/0` was nil and `Authority.impl/0` answered Local. spec = Selection.child_spec([]) {m, f, a} = spec.start - Process.flag(:trap_exit, true) - {:ok, pid} = apply(m, f, a) - assert_receive {:EXIT, ^pid, {%RuntimeError{message: "TRINITY_AUTHORITY refused: " <> _}, _}} + + assert_raise RuntimeError, ~r/TRINITY_AUTHORITY refused: module Nope is not loaded/, fn -> + apply(m, f, a) + end + + # And on success it answers `:ignore`, having already recorded the selection: no process, and + # nothing left in flight. With the variable unset this selects Local, which is what this suite + # booted under, so running it here changes nothing. + System.delete_env(Selection.env()) + assert Selection.start() == :ignore + assert Selection.selected() == Trinity.Authority.Local end test "the standalone assertion: this suite booted under local, no adapter module is loaded, and every TCP peer belongs to the database" do diff --git a/test/trinity/effects/census_test.exs b/test/trinity/effects/census_test.exs index e7a6012..c0672b8 100644 --- a/test/trinity/effects/census_test.exs +++ b/test/trinity/effects/census_test.exs @@ -5,10 +5,18 @@ defmodule Trinity.Effects.CensusTest do Slice 024 AC1: exactly one caller of `execute/2` for effectful tools, and a planted bypass is flagged. The population is every source file in `lib/` and `test/support/` that `git ls-files` names, grepped for a call of `execute(` on a module value; the allowed set is - `Trinity.Authority.Local` (the executor for effectful tools) and `Trinity.Tools.Runner` - (whose `call_tool/3` is guarded to `effect: :none`, which the second test proves). The - planted `Trinity.TestEffects.Bypass` is the third name and must be there, or the census is - not looking. + `Trinity.Effects` (the membrane, which runs the tool after a `decide/3` allow) and + `Trinity.Tools.Runner` (whose `call_tool/3` is guarded to `effect: :none`, which the second + test proves). The planted `Trinity.TestEffects.Bypass` is the third name and must be there, or + the census is not looking. + + Owner ruling 2026-09-30 moved the first name. It was `Trinity.Authority.Local`, and the census + asserted that under an external adapter Trinity kept **no** executor, because the effect never + reached the one file that could run a tool. That is no longer the design: the tool runs here + whichever authority is in force, and the adapter only decides. So the census no longer says + "no executor under an adapter". It says what is still worth asserting and is now the stronger + claim: **one** executor, in the membrane, on the far side of every check and of the admission + receipt, and it does not move when the authority changes. """ use ExUnit.Case, async: true @@ -19,10 +27,11 @@ defmodule Trinity.Effects.CensusTest do # to weigh their case against. A path with no reason is not allowed: `@allowed` is a map and the # test below asserts every reason is present and says something. @allowed %{ - "lib/trinity/authority/local.ex" => - "the executor under TRINITY_AUTHORITY=local: the membrane admits an effect and this is what " <> - "runs the tool's own execute/2. Under an external authority adapter this file is not the " <> - "executor and the effect never reaches it.", + "lib/trinity/effects.ex" => + "the membrane, and the one executor: after the decision is :allow, the fingerprint holds " <> + "across stage/2 and the admission receipt is in the chain, run_tool/2 calls the tool's own " <> + "execute/2 here. It is the same call whichever authority is in force, because an adapter " <> + "decides and does not act (owner ruling 2026-09-30).", "lib/trinity/tools/runner.ex" => "the non-effect path: call_tool/3 is guarded to effect: :none, which the second test in this " <> "file proves by calling it with an effectful tool and reading the refusal by name." diff --git a/test/trinity/effects/membrane_test.exs b/test/trinity/effects/membrane_test.exs index a8b56aa..b83583e 100644 --- a/test/trinity/effects/membrane_test.exs +++ b/test/trinity/effects/membrane_test.exs @@ -230,7 +230,104 @@ defmodule Trinity.Effects.MembraneTest do Effects.Runner.run(%{id: "c14", name: "write_note", args: @note_args}, ctx) end - test "Local: stage stamps, decide follows the gate, execute runs the tool or refuses a denial, receipt appends" do + describe "owner ruling 2026-09-30: the authority decides, and may not change what it decided on" do + setup do + # The established way to put an adapter in force, as embedded_test.exs does it. This file is + # async: false, and the selection is restored whatever the test does. + before = :persistent_term.get({Trinity.Authority.Selection, :selected}, nil) + + on_exit(fn -> + :persistent_term.put( + {Trinity.Authority.Selection, :selected}, + before || Trinity.Authority.Local + ) + end) + + :ok + end + + defp staged_note(ctx, scope, call_id) do + {:ok, entry} = Trinity.Tools.lookup("write_note") + + %Staged{ + tool: "write_note", + module: entry.module, + effect: :artifact, + args: @note_args, + call_id: call_id, + session_id: ctx.session_id, + scope: scope, + cwd: nil, + decision: :allow, + fingerprint: Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) + } + end + + test "an adapter that rewrites the arguments in stage/2 is denied, with the fingerprint re-derived after stage", + %{ctx: ctx, scope: scope} do + :persistent_term.put( + {Trinity.Authority.Selection, :selected}, + Trinity.TestAuthority.MutatesArgs + ) + + staged = staged_note(ctx, scope, "r1") + bound = staged.fingerprint + + assert {:error, {:denied, {:fingerprint_mismatch_after_stage, ^bound, derived}}} = + Effects.execute(staged, ctx) + + # The fingerprint is re-derived over what the adapter handed back, and compared to the one + # the gate bound, not to whatever the returned struct now carries. + assert derived == + Permissions.fingerprint( + ctx.session_id, + "write_note", + Map.put(@note_args, "text", "swapped"), + nil + ) + + refute derived == bound + assert [{"effect", "r1", "denied"}] = kinds(scope) + [row] = Receipts.list(scope) + + assert JSON.decode!(row.signed_payload)["decision"]["reason"] =~ + "fingerprint_mismatch_after_stage" + end + + test "an adapter that keeps the arguments and swaps the module is denied, which no fingerprint could catch", + %{ctx: ctx, scope: scope} do + :persistent_term.put( + {Trinity.Authority.Selection, :selected}, + Trinity.TestAuthority.SwapsModule + ) + + staged = staged_note(ctx, scope, "r2") + + # The fingerprint covers session, tool, args and cwd. It does not cover `module`, so this + # adapter leaves every fingerprinted field untouched and still changes which code runs. + assert {:error, {:denied, {:stage_changed_the_subject, [:module]}}} = + Effects.execute(staged, ctx) + + assert Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) == + staged.fingerprint + + assert [{"effect", "r2", "denied"}] = kinds(scope) + end + + test "an adapter that returns the effect unchanged still runs, and the tool runs here", + %{ctx: ctx, scope: scope} do + # The control. Without it the two denials above are satisfied by an adapter path that + # refuses everything, and prove nothing about the check being the reason. + :persistent_term.put({Trinity.Authority.Selection, :selected}, Trinity.TestAuthority.Allows) + + assert {:ok, %{content: "wrote 2 bytes" <> _}} = + Effects.execute(staged_note(ctx, scope, "r3"), ctx) + + assert [{"effect", "r3", "admit"}, {"effect", "r3", "done"}] = kinds(scope) + end + end + + test "Local: stage stamps, decide follows the gate, execute runs no tool at all, receipt appends" do {:ok, entry} = Trinity.Tools.lookup("write_note") scope = "local:" <> Trinity.UUID.generate() on_exit(fn -> Receipts.stop_writer(scope) end) @@ -252,8 +349,12 @@ defmodule Trinity.Effects.MembraneTest do assert {:ok, :allow, %{"by" => "gate"}} = local.decide(staged, :allow, %Context{}) assert {:ok, :deny, %{"by" => "gate"}} = local.decide(staged, :deny, %Context{}) assert {:ok, :deny, %{"reason" => "undecided"}} = local.decide(staged, :ask, %Context{}) - assert {:ok, %{content: "wrote 2 bytes" <> _}} = local.execute(staged, :allow, %Context{}) - assert {:error, :denied} = local.execute(staged, :deny, %Context{}) + # Owner ruling 2026-09-30: Local is not an executor. `execute/3` is on no path and must not + # run a tool, and that holds for an allow exactly as much as for a deny: an allow used to be + # the case where it ran one. The tool now runs in the membrane, which the effect tests above + # exercise through `Trinity.Effects.execute/2`. + assert {:error, :not_the_effect_path} = local.execute(staged, :allow, %Context{}) + assert {:error, :not_the_effect_path} = local.execute(staged, :deny, %Context{}) assert {:ok, %Receipts.Receipt{kind: "cap"}} = local.receipt("cap", %{scope: scope, subject: %{"cap" => "iterations"}}) diff --git a/test/trinity/profile_test.exs b/test/trinity/profile_test.exs index bb300e9..776ba74 100644 --- a/test/trinity/profile_test.exs +++ b/test/trinity/profile_test.exs @@ -142,6 +142,23 @@ defmodule Trinity.ProfileTest do test "default runs on Local, unchanged" do assert :ok = Profile.check_authority(:default, Trinity.Authority.Local) end + + test "an authority that could not be resolved is refused by name, and does not raise" do + # Owner ruling 2026-09-30. This clause did not exist, and the guard on the clause above + # (`not is_nil(module)`) meant nil matched nothing. Trinity.Application resolved the + # authority through Selection.select/1 and turned every refusal into nil, so an unloadable + # TRINITY_AUTHORITY under :regulated killed the boot with a FunctionClauseError naming this + # module, instead of naming the module the operator asked for. + assert {:error, {:regulated_authority_unresolved, nil}} = + Profile.check_authority(:regulated, nil) + end + + test "the selection's own refusal is what an unloadable name produces, before it reaches here" do + # The two halves of the old crash, in order: select/1 refuses by name, and the refusal is a + # reason rather than a module, which is why the caller must not flatten it to nil. + assert {:error, {:not_loaded, "NoSuchAdapterModule"}} = + Trinity.Authority.Selection.select("NoSuchAdapterModule") + end end describe "the profile is read from the environment, and a typo is not permissive" do