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 @@ -22,6 +22,32 @@ evidence for each increment is retained by the maintainers and summarised here.
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
the public record of delivered work.

## 2026-09-26

### `slice/124`: What the receipts are, in the standard's words

IETF RFC 9943 (SCITT) standardises the class of object Trinity's receipts belong to, and it splits
that class in two. A *Signed Statement* is issued by whoever made the statement. A *Receipt* is issued
by a Transparency Service and must carry proof of inclusion in a verifiable log. Trinity runs no
transparency service, so in that vocabulary Trinity issues Signed Statements and never Receipts.

This increment says so, in `docs/receipt-scheme-mapping.md`: the terminology, the envelope Trinity
actually signs with, a field by field mapping to COSE, CWT and in-toto constructs, and the three
fields that have no equivalent because they are the hash chain SCITT delegates to a service Trinity
does not run.

The increment was planned as a rename of those fields to the standard's names. It deliberately does
not do that. An object wearing `iss`, `sub`, `kid` and `iat` reads as conformant to anyone who knows
those names, and they would then reasonably expect a transparency service and inclusion proofs behind
it. That is a claim made by appearance rather than by a row in the standards register, so the register
carries an RFC 9943 row at `not claimed` instead, and the tests assert that **nothing** was renamed
and that no receipt byte changed. Two further tests exist only to stop the mapping document drifting
from the code it describes, and were demonstrated against three planted violations rather than against
agreement with the tree as it stands.

Nothing under `lib/` changed. Verified: `git diff --stat lib/` empty; gate green at exit 0; 821 tests
passing; coverage 80.31%, level with the previous increment.

## 2026-09-24

### `slice/042` — Rules your decisions imply
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,49 @@ do over the next year, and why the order is what it is.
| `ci/fips/` | The container the FIPS leg builds its toolchain in |
| `coverage.tsv` | Test coverage per increment, appended at each close |

## What the receipts prove, and to whom

Three different claims, sold separately, because blurring them is how audit evidence gets
overstated.

**Standalone (`TRINITY_AUTHORITY=local`) proves what happened, to someone who trusts the machine.**
The permission gate decides, the effect runs here, and the chain is signed here. That gives four
things worth having: the set of code paths that can cause an effect is closed and held by a census
with a planted bypass, so nothing slipped past the membrane; an approval is bound to the exact call
by a fingerprint re-derived from the arguments actually passed, so an approved call cannot be
swapped; the chain is hash-linked, signed and checkpointed, so nothing was altered afterwards; and
the verifier runs without the application, so an auditor need not trust the program that wrote the
records.

**What it does not prove, stated plainly rather than left to be discovered.** A key in a file proves
the records were not altered by anything lacking read access to that file. It does not prove custody
of the key, and `docs/10-assurance-case.md` C6 says so. Nor is there any separation of duties:
standalone, Trinity is judge, actor and scribe, so a party who does not already trust the host has
only the host's word for it.

**A witness proves it to someone who does not trust the machine.** Publishing the chain head
somewhere outside the host, on any schedule, makes backdated forgery detectable by a third party
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.

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
the machine's to make.**

**On the word "receipt".** IETF RFC 9943 (SCITT) standardises this class of object and splits it in
two: a *Signed Statement* is issued by whoever made the statement, and a *Receipt* is issued by a
Transparency Service and must carry proof of inclusion in a verifiable log. Trinity runs no
transparency service, so in that vocabulary Trinity issues Signed Statements and never Receipts. The
house name is not changing, because "receipt" is the right word for what it is to the person who owns
the machine, but nobody should infer SCITT conformance from it. The field by field mapping, including
the three fields that have no equivalent, is in
[`docs/receipt-scheme-mapping.md`](docs/receipt-scheme-mapping.md).

## Connecting Trinity to the platform

Trinity runs standalone, and it is also one component of **Sanction OS**, the platform formed by Requisition and
Expand Down
1 change: 1 addition & 0 deletions docs/09-standards-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status
| Audit record durability of the last committed receipt | receipts file may run `synchronous: :full` in its own file | slice 010 (file slot), slice 024 | `:unknown` | owner |
| Disconnected operation without loss of audit | store-and-forward receipts, hybrid logical clocks, Merkle merge | slice 026 | `not claimed`; 026 is blocked on an external answer | owner |
| Trusted time for receipts | none in software; a deployment supplies a trusted time source | slice 026 risk line | `real-world dependency` | deployment |
| IETF RFC 9943 (SCITT) alignment of the receipt object | nothing to satisfy: Trinity issues SCITT **Signed Statements**, never SCITT **Receipts**, which MUST carry inclusion proofs from a Transparency Service Trinity does not run. The signed field set maps element by element to COSE, CWT and in-toto constructs; the three fields that do not map (`seq`, `prev_hash`, `chain_scope`) are the ones SCITT delegates to that service | docs/receipt-scheme-mapping.md; slice 124 amendment 1; `Trinity.Receipts.SchemeMappingTest` | `not claimed`: alignment is documented, conformance is not claimed and none should be inferred. Field names are deliberately **not** renamed to COSE/CWT names, because an object that reads as conformant while running no transparency service is a claim made by appearance, which is what this register exists to prevent | owner; a conformance claim would need a `COSE_Sign1` envelope and a Transparency Service, both outside slice 124 |

## Data at rest and keys

Expand Down
82 changes: 82 additions & 0 deletions docs/receipt-scheme-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
# Trinity's receipts and RFC 9943

RFC 9943, *An Architecture for Trustworthy and Transparent Digital Supply Chains* (IETF SCITT,
Standards Track, June 2026), standardises the class of object Trinity's receipts belong to. This page
says exactly where Trinity sits relative to it, including the part that does not fit.

## The terminology, first, because it is a collision

SCITT uses two words that Trinity uses one of.

| SCITT | Issued by | What it asserts |
|---|---|---|
| **Signed Statement** | the Issuer | an identifiable, non-repudiable statement about an artifact |
| **Receipt** | a **Transparency Service** | a cryptographic proof that a Signed Statement is included in a Verifiable Data Structure |

A SCITT Receipt **MUST** carry inclusion proofs. Trinity operates no Transparency Service, maintains
no Verifiable Data Structure, and issues no inclusion proofs: it keeps a local, single-writer hash
chain with signed checkpoints.

**So what Trinity calls a receipt is, in SCITT's vocabulary, a Signed Statement.** Nothing Trinity
emits is a SCITT Receipt, and this project makes no such claim. The name predates the RFC in this
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.

## The envelope Trinity signs with today

The signature is not over the canonical JSON directly. It is over DSSE's pre-authentication encoding
of a payload type and that JSON, where the payload type is `trinity/receipt/<scheme>`
(`Trinity.Receipts.Envelope.receipt_type/1`). So the envelope is already a published construct, just
a different one from SCITT's: DSSE rather than COSE. That matters for the last section.

## The field mapping

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

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

`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.

| Trinity | SCITT / COSE / CWT | Note |
|---|---|---|
| `scheme` | COSE `alg` (label 1) | Trinity's scheme names the signature and digest algorithms together; COSE separates them. |
| `key_id` | COSE `kid` (label 4) | Same role: which key, without saying which algorithm. |
| `at` | CWT `iat` | RFC 3339 here, epoch seconds there. |
| `kind` | COSE `content_type` (label 3) | Trinity's is a closed vocabulary of five (`Trinity.Receipts.Receipt.kinds/0`: `decision`, `effect`, `query`, `boot`, `cap`); COSE's is a media type. |
| `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. |
| `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

The obvious next step looks like renaming Trinity's fields to their COSE and CWT equivalents so a
receipt "looks standard". That would be worse than leaving it, for one reason: **the object would
then read as conformant while not being conformant.** A reader who recognises `iss`, `sub`, `kid`
and `iat` has been told, by the shape of the thing, that this is a SCITT object, and will reasonably
expect the rest of SCITT: a Transparency Service, a verifiable data structure, inclusion proofs.
None of that exists here.

That is a claim made by appearance rather than by a row in `docs/09-standards-register.md`, which is
the specific failure this project's standards register exists to prevent.

**What would be real, rather than cosmetic**, is emitting the statement as a `COSE_Sign1` object with
CWT claims `iss` and `sub` in its protected header, in place of the DSSE pre-authentication encoding
described above. That makes a Trinity statement genuinely readable by a SCITT-aware verifier rather
than merely familiar-looking. It is a change of signing envelope, with a scheme bump and a
dual-reading verifier behind it, and it is a decision for the owner rather than a rename anybody can
do.

## What this page lets a reader conclude

- 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.
- `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.
123 changes: 123 additions & 0 deletions test/trinity/receipts/scheme_mapping_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Receipts.SchemeMappingTest do
@moduledoc """
Slice 124 amendment 1, ACs 2 and 3: the receipt bytes did not change, and `docs/receipt-scheme-mapping.md`
cannot drift from the payload it describes.

A document that maps a field set to a standard is worth exactly as much as its agreement with the
code, and that agreement decays silently: the next slice to add a signed field leaves the document
describing a payload that no longer exists, and nothing anywhere fails. So the document's own
mapping table is the population here, checked against the payload in both directions.
"""
use Trinity.DataCase, async: false

alias Trinity.Receipts
alias Trinity.Receipts.Receipt

@doc_path Path.expand("../../../docs/receipt-scheme-mapping.md", __DIR__)
@external_resource @doc_path

# Slice 024's field set, written out because "these fields did not change" is the criterion: a list
# derived from the code would agree with the code by construction and could prove nothing.
@slice_024_keys ~w(scheme seq chain_scope prev_hash kind subject decision fingerprint at key_id)

defp payload_keys do
scope = "test:" <> Trinity.UUID.generate()
on_exit(fn -> Receipts.stop_writer(scope) end)

{:ok, row} =
Receipts.append(scope, %{
kind: "decision",
subject: %{"n" => 1},
decision: %{"outcome" => "allow"},
fingerprint: "ab01"
})

row.signed_payload |> JSON.decode!() |> Map.keys() |> Enum.sort()
end

defp doc, do: File.read!(@doc_path)

# The first column of every row of the mapping table, minus the header and the *(absent)* row,
# unwrapped from its backticks and split on ", " so a cell naming two fields yields both.
defp documented_fields do
doc()
|> String.split("\n")
|> Enum.filter(&String.starts_with?(&1, "| `"))
|> Enum.map(fn row -> row |> String.split("|") |> Enum.at(1) |> String.trim() end)
|> Enum.flat_map(&String.split(&1, ", "))
|> Enum.map(&String.trim(&1, "`"))
|> Enum.sort()
end

test "AC2: the signed payload's keys are exactly the ten slice 024 established" do
assert payload_keys() == Enum.sort(@slice_024_keys)
end

test "AC3: every signed field has a row in the mapping table, and every row names a signed field" do
documented = documented_fields()

assert documented != [], """
no rows were parsed out of the mapping table in #{@doc_path}. The table's rows are expected to \
start with "| `", so either the document was reformatted or this parser is now vacuous. A check \
that silently matches nothing is worse than no check.
"""

keys = payload_keys()

assert Enum.sort(documented) == keys, """
#{@doc_path} and the signed payload disagree.

In the payload, missing a row in the table: #{inspect(keys -- documented)}
In the table, absent from the payload: #{inspect(documented -- keys)}

A field in the payload with no row is an unmapped field in a document whose purpose is the \
mapping. A row naming a field the payload does not carry is a claim about bytes that do not \
exist. `subject_ref` and `meta` are row columns rather than signed fields and belong in neither.
"""
end

test "AC3: the kinds the document names are exactly Receipt.kinds/0" do
assert [kind_row] = Regex.run(~r/^\| `kind` \|.*$/m, doc()),
"the document has no `kind` row; this check has gone vacuous"

named =
Regex.scan(~r/`([a-z]+)`/, kind_row)
|> Enum.map(fn [_, k] -> k end)
|> Enum.reject(&(&1 == "kind"))
|> Enum.sort()

assert named == Enum.sort(Receipt.kinds()), """
the document's `kind` row names #{inspect(named)}, Receipt.kinds/0 is \
#{inspect(Enum.sort(Receipt.kinds()))}.
"""
end

test "AC3: the count the document states in prose matches the payload" do
words = %{
"eight" => 8,
"nine" => 9,
"ten" => 10,
"eleven" => 11,
"twelve" => 12,
"thirteen" => 13
}

assert [_, word] = Regex.run(~r/exactly these (\w+) keys/, doc()),
"the document no longer states the field count in prose; this check has gone vacuous"

assert Map.get(words, word) == length(payload_keys()),
"the document says #{word} keys, the payload has #{length(payload_keys())}"
end

test "AC4: the standards register carries an RFC 9943 row that claims no conformance" do
register = Path.expand("../../../docs/09-standards-register.md", __DIR__) |> File.read!()

assert [row] = Regex.run(~r/^\|.*RFC 9943.*$/m, register),
"docs/09-standards-register.md carries no RFC 9943 row"

assert row =~ "receipt-scheme-mapping.md", "the row names no evidence path in the tree"
assert row =~ "not claimed", "the row does not say conformance is not claimed"
end
end
Loading