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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ evidence for each increment is retained by the maintainers and summarised here.

## 2026-09-26

### `slice/026`: Trinity keeps working when the link goes down

A site that loses its connection keeps operating, and the record of what it did survives the gap intact.

Every receipt now carries a clock that cannot run backwards, even if the machine's own clock does, so two
machines that worked separately can be lined up afterwards. The clock is part of what gets signed, which matters:
comparing two records by something anyone could edit afterwards would prove nothing. What the clock is worth is
written down plainly alongside it. It is the machine's own reading of the time and it is not trustworthy on its
own; a deployment that needs the time itself to be trustworthy supplies a trusted source, and the standards
register records that as something no version of this software can supply for you.

Receipts that cannot be sent wait in a durable queue and are delivered in order when the link returns, exactly as
they were signed, never rebuilt. The queue has a limit, and reaching it stops Trinity accepting new work rather
than letting it act with no way to confirm anything. That refusal is itself recorded.

When two machines reconnect, their records are compared by a single value rather than message by message. Where
they disagree, each disagreement is recorded and signed. **Neither record is ever rewritten.** Two machines that
both acted while unable to see each other produced two true accounts of what each did, and nothing available
afterwards turns those into one account of what happened; claiming otherwise would mean inventing the part that
cannot be known.

Records written before this increment remain valid and verifiable, and a test confirms it by verifying one built
under the previous format.

Verified: gate green at exit 0; 904 tests passing; coverage 80.31%, level with the previous increment.

### `slice/125`: the credential filter cannot be switched off by a log line

Trinity masks anything credential-shaped on its way into a log. That filter could be killed by
Expand Down
11 changes: 9 additions & 2 deletions bin/verify_receipt.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# (slice 024, AC7). Run it from any directory against a file `mix trinity.receipts.export`
# wrote; the registry travels inside the file.
#
# elixir verify_receipt.exs receipts.json [--schemes receipt_v2_ed25519,receipt_v2_p384]
# elixir verify_receipt.exs receipts.json [--schemes receipt_v3_ed25519,receipt_v2_ed25519]
#
# Exit codes, the vocabulary an operator learns once: 0 verified, 1 invalid, 2 usage,
# 5 trust not established (a key id the registry does not know), 6 compromised key.
Expand All @@ -22,10 +22,17 @@
# file and the in-app verifier agree on the same inputs.

defmodule VerifyReceipt do
# Every scheme version this script can read. `v2` is slice 024's; `v3` is slice 026's and carries
# a hybrid logical clock in the signed payload. Both are listed because a chain that spans the
# bump has rows of each, and dropping the older one would mean a verifier that refuses bytes it
# signed itself last week.
@families %{
"receipt_v2_ed25519" => {"ed25519", :eddsa, :none, :ed25519},
"receipt_v2_p384" => {"p384", :ecdsa, :sha384, :secp384r1},
"receipt_v2_mldsa87" => {"mldsa87", :mldsa87, :none, nil}
"receipt_v2_mldsa87" => {"mldsa87", :mldsa87, :none, nil},
"receipt_v3_ed25519" => {"ed25519", :eddsa, :none, :ed25519},
"receipt_v3_p384" => {"p384", :ecdsa, :sha384, :secp384r1},
"receipt_v3_mldsa87" => {"mldsa87", :mldsa87, :none, nil}
}
@signed_kinds ~w(decision effect boot cap)

Expand Down
7 changes: 6 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,12 @@ config :phoenix,

# Slice 024: the receipt signing key and registry for the suite live under the project's
# ignored tmp/, never in the data directory of the machine running the tests.
config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__)
# Slice 026: the forwarder's timer is off in the suite. A background drain racing an assertion
# about queue depth is a flaky test about nothing; `Trinity.Receipts.Forwarder.drain/0` is called
# explicitly where a test wants a pass to happen.
config :trinity, :receipts,
keys_dir: Path.expand("../tmp/test_keys", __DIR__),
forward_interval_ms: 0

# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no
# model is needed.
Expand Down
57 changes: 57 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,63 @@ The receipt names the fields that changed and never their values, and the
`[:trinity, :tool, :surface_drift]` event carries the same. A description is content, and the point
of holding the tool is that its new content has not been read by anyone entitled to approve it.

## Disconnected operation: the clock, the queue and the merge (Slice 026, as built)

A field or clinical site loses its link and keeps working. Three mechanisms make that safe to do, and each has a
limit worth stating before its benefit.

### The clock, and what it is not worth

Every receipt written under `receipt_v3_*` carries a hybrid logical clock (Kulkarni et al., 2014):
`{wall, counter, node}`, in the **signed** payload. It is there because a hash chain orders its own rows
perfectly and says nothing about a chain that ran elsewhere at the same time, and a merge needs an order that
survives a partition.

**A signed receipt with an untrusted clock is weaker evidence than a signed receipt with a trusted one, and this
clock is untrusted.** It is the host's own reading. A host that lies about the time produces receipts that are
internally consistent and wrong about when; the signature attests that *this host said so*, not that it was true.
A deployment that needs the *when* to be trustworthy supplies a trusted time source, and
`docs/09-standards-register.md` carries that as a real-world dependency rather than a tree property, because no
version of this tree can close it.

What the clock does guarantee:

- **It never goes backwards within a chain.** `Clock.next/2` is monotone even when the host's wall clock steps
back, and the writer refuses a row whose clock does not follow the tail. The refusal is itself receipted, with
both clocks named.
- **It is signed**, so the ordering claim cannot be edited by anything that cannot sign. This is why the clock is
in the signed payload rather than in `meta`, against R21's general rule that platform-set values go in `meta`: a
merge that ordered two chains by an unsigned field would order them by something any writer could rewrite, and
the merge would then be evidence of nothing.
- **It names the device**, so two chains are distinguishable and ties break deterministically.

### The queue, and why it is bounded

Receipts awaiting acknowledgement sit in a durable per-scope queue. The envelope is stored **byte for byte** and
never rebuilt at send time, so a retry hands the far side exactly the bytes that were signed; that is the
condition under which store-and-forward is invisible to a verifier checking a signature offline.

Acknowledgement is in order, and a drain stops a scope at its first refusal rather than skipping past it. A far
side holding row 7 but not row 5 holds a gap it cannot see.

**Past the bound, effects are denied rather than performed and left unacknowledged.** An unbounded queue turns a
long partition into an unbounded liability: the machine keeps acting, nothing can be confirmed, and the operator
finds out when the disk fills. The queue-full refusal is receipted and is deliberately not itself queued, because
a full queue that cannot record that it is full is a deadlock dressed as a safety property.

### The merge, and what it refuses to do

On reconnect, two chains are compared by Merkle tree head (RFC 6962 hashing: distinct leaf and node prefixes,
odd nodes promoted rather than duplicated, so two distinct chains cannot share a head). Equal heads mean every
row matches, at the cost of one comparison rather than one message per row.

**A merge never rewrites either side.** It does not produce one reconciled chain. Both chains stay exactly as
they were and what the merge produces is a signed record of where they disagree, one conflict receipt per
differing position. This is the honest outcome rather than a limitation: two devices that both acted while unable
to see each other produced two true accounts of what each did, and nothing available afterwards turns those into
one account of what happened. A remote export that does not verify is refused before anything is compared, so
conflict receipts cannot be written into the local chain by handing the merge invented rows.

## Data at rest

- SQLite file under the OS data dir with 0600 perms. Optional at-rest encryption is a later slice (SQLCipher via exqlite build flag), noted rather than planned.
35 changes: 35 additions & 0 deletions docs/adr/0008-authority-is-an-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,38 @@ whether the thing you asked for is allowed to happen.
- Slice 024 builds the behaviour, `Local`, the effect catalog and local receipts. No slice in this tree builds an
adapter; that is downstream work in a downstream repository.
- Slice 062 builds the MCP resource-server profile. It is scoped as identity and never as authority.

## Amendment, 2026-09-26 (slice 026): a fifth callback, optional, for store-and-forward

The Decision above names four callbacks. Store-and-forward adds a fifth, `forward_receipt/2`, and it is
**optional**, which is the part that matters for this ADR's posture: an adapter written against the four
published callbacks stays valid and unchanged, and an adapter that does not export the fifth is simply one this
machine never forwards to.

The question this slice was blocked on was put to the external authority plane's maintainers on 2026-09-20 and
answered the same day, against their tree at `8bc693ee`. Their answer, and it shapes the contract:

> store-and-forward does not change their side as long as the acknowledgement carries the envelope the plane
> signed, unmodified. Their verifiers check the plane's signature over the envelope bytes offline, so a queue can
> wrap, delay or re-deliver an envelope and cannot re-sign it or alter a leaf.

So `forward_receipt/2` receives the exported receipt **byte for byte**. An implementation may not re-sign it,
rebuild it or alter a leaf. This tree holds up its end by storing the envelope at queue time rather than
reconstructing it at send time, and a test asserts the stored bytes equal the export; a queue that rebuilt the
envelope would break offline verification with nothing here failing.

Two further consequences for the posture in point 5 above, that Trinity keeps no executor for effects an adapter
governs:

- **The queue is bounded and the bound is a refusal.** Past it, effects are denied rather than performed and left
unacknowledged. A machine acting unboundedly while unable to confirm anything is exactly the state this ADR's
separation exists to avoid, and an unbounded queue would create it quietly.
- **A reconciliation row on the plane's side is wanted and was offered as small work on request.** It is not a
dependency: `AC1` to `AC4` run under `Local`, which acknowledges immediately, so the mode is proven in this
tree without any adapter. The request is made at this slice's G1, and its absence blocks nothing here.

The merge this slice adds never rewrites either chain. It records where two chains disagree and signs that
record. That is deliberate and belongs in this ADR because it is a statement about what an authority boundary
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.
24 changes: 20 additions & 4 deletions docs/receipt-scheme-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ emits is a SCITT Receipt, and this project makes no such claim. The name predate
tree and is not being changed, because "receipt" is the right word for what it is to the person who
owns the machine; but a reader who knows SCITT should not have to work this out for themselves.

## Two scheme versions, since slice 026

`receipt_v2_<family>` is slice 024's and carries ten keys. `receipt_v3_<family>` is slice 026's and
carries eleven: the ten plus `clock`, a hybrid logical clock for relating two chains that ran during
a partition. New rows are written under `v3`; `v2` rows are still verified under `v2`, row by row,
under each row's own scheme.

That is a scheme bump rather than an edit: no byte signed under `v2` changed, and
`Trinity.Receipts.Signer.accepted_schemes/0` is what the verifier allows by default, so bumping the
version did not make previously signed rows unverifiable. The field list below is `v3`.

## The envelope Trinity signs with today

The signature is not over the canonical JSON directly. It is over DSSE's pre-authentication encoding
Expand All @@ -33,11 +44,13 @@ a different one from SCITT's: DSSE rather than COSE. That matters for the last s

## The field mapping

Trinity's signed payload is RFC 8785 canonical JSON of exactly these ten keys
Trinity's signed payload is RFC 8785 canonical JSON of exactly these eleven keys
(`Trinity.Receipts.ChainWriter`, the `body` map):

`scheme`, `seq`, `chain_scope`, `prev_hash`, `kind`, `subject`, `decision`, `fingerprint`, `at`,
`key_id`.
`key_id`, `clock`.

Under `receipt_v2_*` the same list without `clock`.

`subject_ref` and `meta` are columns on the row but are **not** in the signed payload, so they are
not covered by the signature and are not mapped below.
Expand All @@ -51,6 +64,7 @@ not covered by the signature and are not mapped below.
| `subject` | CWT `sub` (claim 2) | SCITT requires a single `tstr`; Trinity's signed `subject` is a map of references. Mapping it needs a canonical string form chosen, which is a decision, not a rename. |
| *(absent)* | CWT `iss` (claim 1), **required** | Trinity has no issuer field. A single-operator machine has one issuer and it is implied by the key, which is why it was never needed and is exactly what a standards-track reader would look for first. |
| `decision`, `fingerprint` | the statement payload | Domain content. SCITT does not constrain it. |
| `clock` | **no equivalent** | A hybrid logical clock (slice 026): `wall`, `counter`, `node`. SCITT has no notion of one because a transparency service supplies the ordering its receipts need. Trinity has no such service, so ordering across a partition has to travel in the statement, and it is signed for the reason the mapping document gives below: a merge that orders two chains by an unsigned field orders them by something any writer could rewrite. |
| `seq`, `chain_scope`, `prev_hash` | **no equivalent** | These are the hash chain. In SCITT the equivalent guarantee comes from a Transparency Service's Verifiable Data Structure and is carried in a Receipt's proofs, not in the statement. Trinity's chain is the whole of its tamper-evidence, and it is local. |

## Why the field names are not being renamed
Expand All @@ -76,7 +90,9 @@ do.

- Trinity's receipts are **Signed Statements** in SCITT's sense, not Receipts.
- Their integrity comes from a local hash chain with signed checkpoints, not from a transparency log.
- Every element of the signed payload maps to a published construct, and the three that do not map
(`seq`, `prev_hash`, `chain_scope`) are the ones SCITT delegates to a service Trinity does not run.
- Every element of the signed payload maps to a published construct except four: `seq`, `prev_hash`
and `chain_scope`, which are the ones SCITT delegates to a service Trinity does not run, and
`clock`, which exists for the same reason, since ordering across a partition is what a
transparency service would otherwise provide.
- `subject_ref` and `meta` are unsigned row columns. Nothing should be inferred from them.
- No conformance to RFC 9943 is claimed, and none should be inferred.
17 changes: 17 additions & 0 deletions lib/trinity/authority.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ defmodule Trinity.Authority do
@doc "Records a receipt of a kind with attributes; the adapter may forward it."
@callback receipt(kind :: String.t(), attrs :: map()) :: {:ok, term()} | {:error, term()}

@doc """
Hands a queued receipt envelope to the authority, byte for byte (slice 026).

Optional. An implementation that does not export it is one this machine never forwards to, and
the queue for its scopes simply does not drain.

The envelope is the exported receipt exactly as it was signed. An implementation **must not**
re-sign it, rebuild it or alter a leaf: the external authority plane's verifiers check the
signature over these bytes offline, which is the property that lets a queue wrap, delay and
re-deliver an envelope without the far side having to know a queue exists at all.

`:ok` acknowledges. Any error leaves the entry pending, to be offered again.
"""
@callback forward_receipt(envelope :: map(), meta :: map()) :: :ok | {:error, term()}

@optional_callbacks forward_receipt: 2

@callbacks [stage: 2, decide: 3, execute: 3, receipt: 2]

@doc "The callbacks every implementation must export, as `{name, arity}`."
Expand Down
11 changes: 11 additions & 0 deletions lib/trinity/authority/local.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,15 @@ defmodule Trinity.Authority.Local do
scope = Map.fetch!(attrs, :scope)
Trinity.Receipts.append(scope, Map.put(attrs, :kind, kind))
end

@doc """
Acknowledges a queued envelope (slice 026).

Standalone, there is no far side and nothing to send to, so this acknowledges immediately. It is
not a stub: it is what makes the local authority exercise the same queue-and-acknowledge path an
adapter does, so the mode is proven without one. A receipt still passes through the queue, is
still acknowledged in order, and the bound still applies.
"""
@impl true
def forward_receipt(envelope, _meta) when is_map(envelope), do: :ok
end
Loading
Loading