From 63734bdbd2af001748455a3f5b25efdcbe84e89f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sat, 26 Sep 2026 08:45:58 -0400 Subject: [PATCH] chore(s124): complete slice 124 (align the receipt with RFC 9943) The slice was specified as a rename of the signed payload's fields to RFC 9943, COSE and CWT names under a new scheme identifier. Writing the mapping table the same slice asked for is what showed the rename to be wrong, and amendment 1 replaced acceptance criteria 2, 3 and 4 before any code was written. RFC 9943 defines two objects where this tree has one word. A Signed Statement is issued by an Issuer. A Receipt is issued by a Transparency Service and MUST carry proof of inclusion in a Verifiable Data Structure. Trinity runs no Transparency Service, so it issues the first and never the second. Dressing its fields in iss, sub, kid and iat would tell any reader who knows those names that this is a SCITT object, and they would then expect a transparency service, a verifiable data structure and inclusion proofs. None of that exists here. That is a conformance claim made by appearance rather than by a row in the standards register, which is the precise failure that register exists to prevent; the slice's own Scope section had already refused the word "receipt" for that reason while its criteria asked for the same claim in field names. What landed instead: - docs/receipt-scheme-mapping.md: the terminology collision, the envelope Trinity signs with today (DSSE pre-authentication encoding, not COSE), the field by field mapping, and the three fields with no equivalent because they are the hash chain SCITT delegates to a service Trinity does not run. - An RFC 9943 row in docs/09-standards-register.md at `not claimed`. - A README paragraph saying Trinity issues Signed Statements and never SCITT Receipts, so nobody infers conformance from the house name. - Five checks asserting that nothing was renamed, that no receipt byte changed, and that the document cannot drift from the payload it describes. Two drafting errors were corrected against the tree before committing: the draft mapped `subject_ref` to CWT `sub`, but `subject_ref` and `meta` are row columns outside the signature and only `subject` is signed; and the draft never said what envelope Trinity signs with, while its own closing argument turns on replacing that envelope. The census checks were demonstrated against three planted violations rather than against agreement with today's tree: a renamed table row, an eleventh signed field with the document untouched, and a reformatted table that made the parser match nothing. The last of those is the guard against the check going vacuous. Nothing under lib/ changed; `git diff --stat lib/` is empty. Deferred to the owner: a COSE_Sign1 envelope with CWT claims, which is the change that would be real rather than cosmetic. Opened by a defect found while building this: slice 125. The slice 090 Logger redaction filter calls Enum.map/2 on the message body, OTP chardata is routinely an improper list whose tail is a binary, Enum.map/2 raises on one, and OTP removes a raising filter from the handler permanently. A db_connection disconnect is enough to trigger it, so the credential mask dies on the first connection wobble and stays dead for the life of the node while every test passes. Signed-off-by: Ayla Croft --- CHANGELOG.md | 26 ++++ README.md | 43 ++++++ docs/09-standards-register.md | 1 + docs/receipt-scheme-mapping.md | 82 ++++++++++++ test/trinity/receipts/scheme_mapping_test.exs | 123 ++++++++++++++++++ 5 files changed, 275 insertions(+) create mode 100644 docs/receipt-scheme-mapping.md create mode 100644 test/trinity/receipts/scheme_mapping_test.exs 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