Skip to content

feat: the authority decides, this node executes - #112

Merged
HackTuah merged 1 commit into
mainfrom
feat/authority-executor-split
Sep 30, 2026
Merged

HackTuah merged 1 commit into
mainfrom
feat/authority-executor-split

Conversation

@HackTuah

Copy link
Copy Markdown
Member

Owner ruling, 2026-09-30:

Anything may propose. The authority in force authorizes. After a decide/3 allow, this node's Elixir runs the tool.

The adapter decides. It does not run the tool, and it does not change the arguments it was asked about. No adapter is added here, and none is implemented.

1. The effect path

Trinity.Effects handed the effect to authority.execute/3 and treated 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.

before after
result = authority.execute(staged, :allow, ctx) result = run_tool(restaged, ctx)
executor was lib/trinity/authority/local.ex executor is lib/trinity/effects.ex
census asserted no executor under an adapter census asserts one executor, wherever the authority lives

Trinity.Authority.Local.execute/3 now answers {:error, :not_the_effect_path}. The callback stays in the behaviour so an adapter written against the older contract, where returning a result from it was how the effect happened, is not silently accepted.

2. stage/2 could still 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:

  1. 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 with each other does not pass;
  2. 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. Nothing else.

Tested against adapters that actually attempt it, plus a control that changes nothing and whose effect runs, so the two denials cannot be satisfied by a membrane that simply refuses everything under any adapter:

Trinity.TestAuthority.MutatesArgs  -> {:denied, {:fingerprint_mismatch_after_stage, bound, derived}}
Trinity.TestAuthority.SwapsModule  -> {:denied, {:stage_changed_the_subject, [:module]}}
Trinity.TestAuthority.Allows       -> {:ok, %{content: "wrote 2 bytes..."}}, receipts admit then done

The module-swap adapter first swapped to Trinity.TestTools.WriteNote, which is what write_note already resolves to in the test registry. That is the identity, and it passed while proving nothing. It swaps to Trinity.TestTools.Echo.

3. Two boot failures under :regulated

An unloadable TRINITY_AUTHORITY crashed instead of refusing by name. Reproduced before fixing:

select/1 -> {:error, {:not_loaded, "NoSuchAdapterModule"}}
authority_module/0 would yield -> nil
RAISED FunctionClauseError: no function clause matching in Trinity.Profile.check_authority/2

Trinity.Application flattened every selection refusal to nil, and nil matched no clause. The caller now reports the selection's own reason as {:regulated_authority_unselectable, reason}, and check_authority/2 is total.

Selection exposed Local while it was still in flight. It ran as {Task, :start_link, [fn -> boot!() end]}, 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 now 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.

4. Documents

  • ADR-0008 point 5 amended by append, old text struck rather than deleted, with a table naming where each new claim is enforced.
  • README executor sentences say the same thing.
  • docs/authority-adapter.md, new: four callbacks plus forward_receipt/2; decide/3 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, carried with act_hash_alg of sha256+rfc8785-v1, and NOT IN TREE; the wrapper release lives outside this repository. Every claim in it is a path in this tree or says NOT IN TREE.

The one test that proves Local is refused under :regulated is untouched and still passes, in test/trinity/profile_test.exs and at a real boot in test/trinity/regulated_boot_node_test.exs.

Evidence

mix gate
prod_check: PASS
3812 mods/funs, found no issues.
No vulnerabilities found.
trinity.names: OK over 674 tracked files
Result: 948/949 passed (20/20 properties, 928/929 tests), 18 excluded
trinity.coverage: 042 80.31% vs 005 80.31%: OK

plan_check: PASS

The one failure is Trinity.Skills.RegistryTest "AC3 ... within 2 s", which is load-sensitive by construction and fails on this machine with these changes entirely excluded. It is green on CI.

No new slice id, no ROADMAP row, no adapter. Mentions of the authority layer by name in the README go from three to two; none is added anywhere in the tree.

Not merged.

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 <aylacroft@proton.me>
@HackTuah
HackTuah merged commit dc8d459 into main Sep 30, 2026
11 of 12 checks passed
@HackTuah
HackTuah deleted the feat/authority-executor-split branch September 30, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant