diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4aed6..11f4072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a991c85..9e80d4a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/09-standards-register.md b/docs/09-standards-register.md index a182661..b7578b3 100644 --- a/docs/09-standards-register.md +++ b/docs/09-standards-register.md @@ -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 diff --git a/docs/receipt-scheme-mapping.md b/docs/receipt-scheme-mapping.md new file mode 100644 index 0000000..d390e0d --- /dev/null +++ b/docs/receipt-scheme-mapping.md @@ -0,0 +1,82 @@ + + +# 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/` +(`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. diff --git a/test/trinity/receipts/scheme_mapping_test.exs b/test/trinity/receipts/scheme_mapping_test.exs new file mode 100644 index 0000000..7e5861d --- /dev/null +++ b/test/trinity/receipts/scheme_mapping_test.exs @@ -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