Skip to content

Commit 63734bd

Browse files
committed
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 <aylacroft@proton.me>
1 parent 04c921a commit 63734bd

5 files changed

Lines changed: 275 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,32 @@ evidence for each increment is retained by the maintainers and summarised here.
2222
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
2323
the public record of delivered work.
2424

25+
## 2026-09-26
26+
27+
### `slice/124`: What the receipts are, in the standard's words
28+
29+
IETF RFC 9943 (SCITT) standardises the class of object Trinity's receipts belong to, and it splits
30+
that class in two. A *Signed Statement* is issued by whoever made the statement. A *Receipt* is issued
31+
by a Transparency Service and must carry proof of inclusion in a verifiable log. Trinity runs no
32+
transparency service, so in that vocabulary Trinity issues Signed Statements and never Receipts.
33+
34+
This increment says so, in `docs/receipt-scheme-mapping.md`: the terminology, the envelope Trinity
35+
actually signs with, a field by field mapping to COSE, CWT and in-toto constructs, and the three
36+
fields that have no equivalent because they are the hash chain SCITT delegates to a service Trinity
37+
does not run.
38+
39+
The increment was planned as a rename of those fields to the standard's names. It deliberately does
40+
not do that. An object wearing `iss`, `sub`, `kid` and `iat` reads as conformant to anyone who knows
41+
those names, and they would then reasonably expect a transparency service and inclusion proofs behind
42+
it. That is a claim made by appearance rather than by a row in the standards register, so the register
43+
carries an RFC 9943 row at `not claimed` instead, and the tests assert that **nothing** was renamed
44+
and that no receipt byte changed. Two further tests exist only to stop the mapping document drifting
45+
from the code it describes, and were demonstrated against three planted violations rather than against
46+
agreement with the tree as it stands.
47+
48+
Nothing under `lib/` changed. Verified: `git diff --stat lib/` empty; gate green at exit 0; 821 tests
49+
passing; coverage 80.31%, level with the previous increment.
50+
2551
## 2026-09-24
2652

2753
### `slice/042` — Rules your decisions imply

‎README.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,49 @@ do over the next year, and why the order is what it is.
307307
| `ci/fips/` | The container the FIPS leg builds its toolchain in |
308308
| `coverage.tsv` | Test coverage per increment, appended at each close |
309309

310+
## What the receipts prove, and to whom
311+
312+
Three different claims, sold separately, because blurring them is how audit evidence gets
313+
overstated.
314+
315+
**Standalone (`TRINITY_AUTHORITY=local`) proves what happened, to someone who trusts the machine.**
316+
The permission gate decides, the effect runs here, and the chain is signed here. That gives four
317+
things worth having: the set of code paths that can cause an effect is closed and held by a census
318+
with a planted bypass, so nothing slipped past the membrane; an approval is bound to the exact call
319+
by a fingerprint re-derived from the arguments actually passed, so an approved call cannot be
320+
swapped; the chain is hash-linked, signed and checkpointed, so nothing was altered afterwards; and
321+
the verifier runs without the application, so an auditor need not trust the program that wrote the
322+
records.
323+
324+
**What it does not prove, stated plainly rather than left to be discovered.** A key in a file proves
325+
the records were not altered by anything lacking read access to that file. It does not prove custody
326+
of the key, and `docs/10-assurance-case.md` C6 says so. Nor is there any separation of duties:
327+
standalone, Trinity is judge, actor and scribe, so a party who does not already trust the host has
328+
only the host's word for it.
329+
330+
**A witness proves it to someone who does not trust the machine.** Publishing the chain head
331+
somewhere outside the host, on any schedule, makes backdated forgery detectable by a third party
332+
without needing a transparency service. This is not built yet and is named here as the gap it is.
333+
334+
**An external authority plane proves the decision was never the machine's to make.** Point
335+
`TRINITY_AUTHORITY` at an adapter and Trinity keeps no executor for the effects that adapter
336+
governs, which is asserted by a census over the tree rather than by intent. The decision is made
337+
elsewhere by something with its own key, so relying on it does not require trusting this host at
338+
all.
339+
340+
The honest one-line version: **standalone proves what happened to someone who trusts the machine, a
341+
witness proves it to someone who does not, and an external authority proves the decision was never
342+
the machine's to make.**
343+
344+
**On the word "receipt".** IETF RFC 9943 (SCITT) standardises this class of object and splits it in
345+
two: a *Signed Statement* is issued by whoever made the statement, and a *Receipt* is issued by a
346+
Transparency Service and must carry proof of inclusion in a verifiable log. Trinity runs no
347+
transparency service, so in that vocabulary Trinity issues Signed Statements and never Receipts. The
348+
house name is not changing, because "receipt" is the right word for what it is to the person who owns
349+
the machine, but nobody should infer SCITT conformance from it. The field by field mapping, including
350+
the three fields that have no equivalent, is in
351+
[`docs/receipt-scheme-mapping.md`](docs/receipt-scheme-mapping.md).
352+
310353
## Connecting Trinity to the platform
311354

312355
Trinity runs standalone, and it is also one component of **Sanction OS**, the platform formed by Requisition and

‎docs/09-standards-register.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status
3333
| 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 |
3434
| 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 |
3535
| Trusted time for receipts | none in software; a deployment supplies a trusted time source | slice 026 risk line | `real-world dependency` | deployment |
36+
| 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 |
3637

3738
## Data at rest and keys
3839

‎docs/receipt-scheme-mapping.md‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC -->
2+
<!-- SPDX-License-Identifier: Apache-2.0 -->
3+
# Trinity's receipts and RFC 9943
4+
5+
RFC 9943, *An Architecture for Trustworthy and Transparent Digital Supply Chains* (IETF SCITT,
6+
Standards Track, June 2026), standardises the class of object Trinity's receipts belong to. This page
7+
says exactly where Trinity sits relative to it, including the part that does not fit.
8+
9+
## The terminology, first, because it is a collision
10+
11+
SCITT uses two words that Trinity uses one of.
12+
13+
| SCITT | Issued by | What it asserts |
14+
|---|---|---|
15+
| **Signed Statement** | the Issuer | an identifiable, non-repudiable statement about an artifact |
16+
| **Receipt** | a **Transparency Service** | a cryptographic proof that a Signed Statement is included in a Verifiable Data Structure |
17+
18+
A SCITT Receipt **MUST** carry inclusion proofs. Trinity operates no Transparency Service, maintains
19+
no Verifiable Data Structure, and issues no inclusion proofs: it keeps a local, single-writer hash
20+
chain with signed checkpoints.
21+
22+
**So what Trinity calls a receipt is, in SCITT's vocabulary, a Signed Statement.** Nothing Trinity
23+
emits is a SCITT Receipt, and this project makes no such claim. The name predates the RFC in this
24+
tree and is not being changed, because "receipt" is the right word for what it is to the person who
25+
owns the machine; but a reader who knows SCITT should not have to work this out for themselves.
26+
27+
## The envelope Trinity signs with today
28+
29+
The signature is not over the canonical JSON directly. It is over DSSE's pre-authentication encoding
30+
of a payload type and that JSON, where the payload type is `trinity/receipt/<scheme>`
31+
(`Trinity.Receipts.Envelope.receipt_type/1`). So the envelope is already a published construct, just
32+
a different one from SCITT's: DSSE rather than COSE. That matters for the last section.
33+
34+
## The field mapping
35+
36+
Trinity's signed payload is RFC 8785 canonical JSON of exactly these ten keys
37+
(`Trinity.Receipts.ChainWriter`, the `body` map):
38+
39+
`scheme`, `seq`, `chain_scope`, `prev_hash`, `kind`, `subject`, `decision`, `fingerprint`, `at`,
40+
`key_id`.
41+
42+
`subject_ref` and `meta` are columns on the row but are **not** in the signed payload, so they are
43+
not covered by the signature and are not mapped below.
44+
45+
| Trinity | SCITT / COSE / CWT | Note |
46+
|---|---|---|
47+
| `scheme` | COSE `alg` (label 1) | Trinity's scheme names the signature and digest algorithms together; COSE separates them. |
48+
| `key_id` | COSE `kid` (label 4) | Same role: which key, without saying which algorithm. |
49+
| `at` | CWT `iat` | RFC 3339 here, epoch seconds there. |
50+
| `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. |
51+
| `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. |
52+
| *(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. |
53+
| `decision`, `fingerprint` | the statement payload | Domain content. SCITT does not constrain it. |
54+
| `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. |
55+
56+
## Why the field names are not being renamed
57+
58+
The obvious next step looks like renaming Trinity's fields to their COSE and CWT equivalents so a
59+
receipt "looks standard". That would be worse than leaving it, for one reason: **the object would
60+
then read as conformant while not being conformant.** A reader who recognises `iss`, `sub`, `kid`
61+
and `iat` has been told, by the shape of the thing, that this is a SCITT object, and will reasonably
62+
expect the rest of SCITT: a Transparency Service, a verifiable data structure, inclusion proofs.
63+
None of that exists here.
64+
65+
That is a claim made by appearance rather than by a row in `docs/09-standards-register.md`, which is
66+
the specific failure this project's standards register exists to prevent.
67+
68+
**What would be real, rather than cosmetic**, is emitting the statement as a `COSE_Sign1` object with
69+
CWT claims `iss` and `sub` in its protected header, in place of the DSSE pre-authentication encoding
70+
described above. That makes a Trinity statement genuinely readable by a SCITT-aware verifier rather
71+
than merely familiar-looking. It is a change of signing envelope, with a scheme bump and a
72+
dual-reading verifier behind it, and it is a decision for the owner rather than a rename anybody can
73+
do.
74+
75+
## What this page lets a reader conclude
76+
77+
- Trinity's receipts are **Signed Statements** in SCITT's sense, not Receipts.
78+
- Their integrity comes from a local hash chain with signed checkpoints, not from a transparency log.
79+
- Every element of the signed payload maps to a published construct, and the three that do not map
80+
(`seq`, `prev_hash`, `chain_scope`) are the ones SCITT delegates to a service Trinity does not run.
81+
- `subject_ref` and `meta` are unsigned row columns. Nothing should be inferred from them.
82+
- No conformance to RFC 9943 is claimed, and none should be inferred.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Receipts.SchemeMappingTest do
4+
@moduledoc """
5+
Slice 124 amendment 1, ACs 2 and 3: the receipt bytes did not change, and `docs/receipt-scheme-mapping.md`
6+
cannot drift from the payload it describes.
7+
8+
A document that maps a field set to a standard is worth exactly as much as its agreement with the
9+
code, and that agreement decays silently: the next slice to add a signed field leaves the document
10+
describing a payload that no longer exists, and nothing anywhere fails. So the document's own
11+
mapping table is the population here, checked against the payload in both directions.
12+
"""
13+
use Trinity.DataCase, async: false
14+
15+
alias Trinity.Receipts
16+
alias Trinity.Receipts.Receipt
17+
18+
@doc_path Path.expand("../../../docs/receipt-scheme-mapping.md", __DIR__)
19+
@external_resource @doc_path
20+
21+
# Slice 024's field set, written out because "these fields did not change" is the criterion: a list
22+
# derived from the code would agree with the code by construction and could prove nothing.
23+
@slice_024_keys ~w(scheme seq chain_scope prev_hash kind subject decision fingerprint at key_id)
24+
25+
defp payload_keys do
26+
scope = "test:" <> Trinity.UUID.generate()
27+
on_exit(fn -> Receipts.stop_writer(scope) end)
28+
29+
{:ok, row} =
30+
Receipts.append(scope, %{
31+
kind: "decision",
32+
subject: %{"n" => 1},
33+
decision: %{"outcome" => "allow"},
34+
fingerprint: "ab01"
35+
})
36+
37+
row.signed_payload |> JSON.decode!() |> Map.keys() |> Enum.sort()
38+
end
39+
40+
defp doc, do: File.read!(@doc_path)
41+
42+
# The first column of every row of the mapping table, minus the header and the *(absent)* row,
43+
# unwrapped from its backticks and split on ", " so a cell naming two fields yields both.
44+
defp documented_fields do
45+
doc()
46+
|> String.split("\n")
47+
|> Enum.filter(&String.starts_with?(&1, "| `"))
48+
|> Enum.map(fn row -> row |> String.split("|") |> Enum.at(1) |> String.trim() end)
49+
|> Enum.flat_map(&String.split(&1, ", "))
50+
|> Enum.map(&String.trim(&1, "`"))
51+
|> Enum.sort()
52+
end
53+
54+
test "AC2: the signed payload's keys are exactly the ten slice 024 established" do
55+
assert payload_keys() == Enum.sort(@slice_024_keys)
56+
end
57+
58+
test "AC3: every signed field has a row in the mapping table, and every row names a signed field" do
59+
documented = documented_fields()
60+
61+
assert documented != [], """
62+
no rows were parsed out of the mapping table in #{@doc_path}. The table's rows are expected to \
63+
start with "| `", so either the document was reformatted or this parser is now vacuous. A check \
64+
that silently matches nothing is worse than no check.
65+
"""
66+
67+
keys = payload_keys()
68+
69+
assert Enum.sort(documented) == keys, """
70+
#{@doc_path} and the signed payload disagree.
71+
72+
In the payload, missing a row in the table: #{inspect(keys -- documented)}
73+
In the table, absent from the payload: #{inspect(documented -- keys)}
74+
75+
A field in the payload with no row is an unmapped field in a document whose purpose is the \
76+
mapping. A row naming a field the payload does not carry is a claim about bytes that do not \
77+
exist. `subject_ref` and `meta` are row columns rather than signed fields and belong in neither.
78+
"""
79+
end
80+
81+
test "AC3: the kinds the document names are exactly Receipt.kinds/0" do
82+
assert [kind_row] = Regex.run(~r/^\| `kind` \|.*$/m, doc()),
83+
"the document has no `kind` row; this check has gone vacuous"
84+
85+
named =
86+
Regex.scan(~r/`([a-z]+)`/, kind_row)
87+
|> Enum.map(fn [_, k] -> k end)
88+
|> Enum.reject(&(&1 == "kind"))
89+
|> Enum.sort()
90+
91+
assert named == Enum.sort(Receipt.kinds()), """
92+
the document's `kind` row names #{inspect(named)}, Receipt.kinds/0 is \
93+
#{inspect(Enum.sort(Receipt.kinds()))}.
94+
"""
95+
end
96+
97+
test "AC3: the count the document states in prose matches the payload" do
98+
words = %{
99+
"eight" => 8,
100+
"nine" => 9,
101+
"ten" => 10,
102+
"eleven" => 11,
103+
"twelve" => 12,
104+
"thirteen" => 13
105+
}
106+
107+
assert [_, word] = Regex.run(~r/exactly these (\w+) keys/, doc()),
108+
"the document no longer states the field count in prose; this check has gone vacuous"
109+
110+
assert Map.get(words, word) == length(payload_keys()),
111+
"the document says #{word} keys, the payload has #{length(payload_keys())}"
112+
end
113+
114+
test "AC4: the standards register carries an RFC 9943 row that claims no conformance" do
115+
register = Path.expand("../../../docs/09-standards-register.md", __DIR__) |> File.read!()
116+
117+
assert [row] = Regex.run(~r/^\|.*RFC 9943.*$/m, register),
118+
"docs/09-standards-register.md carries no RFC 9943 row"
119+
120+
assert row =~ "receipt-scheme-mapping.md", "the row names no evidence path in the tree"
121+
assert row =~ "not claimed", "the row does not say conformance is not claimed"
122+
end
123+
end

0 commit comments

Comments
 (0)