Skip to content

Commit f3a702b

Browse files
authored
Add conformance page reconciliation test and off-page vector sets (#957)
* docs(conformance): harden interop page and reconcile ADRs for IETF AUDIT review Precision pass over the conformance page and the claims around it ahead of external standards-reviewer scrutiny. Conformance page + counts: - Add a Unicode-normalisation canon vector (string_no_unicode_normalization): NFD "e + U+0301" must canonicalise verbatim (65 CC 81), not fold to NFC U+00E9. Confirmed all three SDKs preserve it (no bug). \u escapes keep the fixture bytes ASCII so the source cannot itself be NFC-folded. Canon set 43 -> 44. - Wire the page counts so they cannot silently drift: new CI-run reconciliation test (cross-sdk-tests/conformance_page_test.go) execs count.py and asserts every matrix cell and the exact prose figures equal its output. count.py gains an on_page flag; the page copy now cites the reconciliation test. - Tighten "independent implementations" language: state the real property (three languages, no shared code) and its limit (catches serialisation drift, not spec-interpretation ambiguity; single author). No claim of independent authorship/interpretation remains. - did:key row was all dashes (no SDK implements resolution). Removed it from the matrix, kept the fixtures counted (on_page=False), noted out-of-scope in the honesty callout, and filed #956 for the real wiring. ADR hygiene: - ADR-0015 header was stale (Rotate()/anchor sink "not implemented", Phase B "deferred"/"unstarted"). Updated Status + implementation-status to match the shipped state (offline rotation, anchor-first writes, Phase B checkpoint anchoring end-to-end) and ADR-0019; index status updated to agree. - Pin checkpoint anchoring to ADR-0015 Phase B (the mechanism) instead of ADR-0008, in the daemon flag help, threat-model.md, and daemon emission comments. ADR-0008 retained for the truncation gap, the anchoring freeze, and the --against-anchor verifier check. * docs(conformance): address code-review findings on the interop hardening Follow-up to the IETF-audit hardening pass, fixing 8 review findings: - Page copy no longer claims counts "cannot silently drift" absolutely: the reconciliation test runs in cross-sdk-tests.yml, which triggers on the vector corpus / spec (not site/** or scripts/**), so the copy now states what is actually enforced (a corpus edit that outdates a number turns CI red). Test header comment records the same trigger-scope caveat. - Retract the "independent implementations" overclaim in the two docs the page links to (README.md, cross-sdk-tests/README.md); state the real property (separate, no-shared-code, single author; serialisation-drift detector, not spec-ambiguity detector). - ADR-0015: the dated 2026-05-12 snapshot's "Phase B ... deferred" line now forward-points to the update where Phase B checkpoint anchoring landed, so it no longer contradicts the header. - Reconciliation test: scope the matrix regex to the "## Results matrix" section and the prose regexes to the honesty callout, so an unrelated table or stray figure elsewhere on the page can't be misread; surface count.py's stderr on failure; load count.py output + page once and share across subtests (was exec'd/read twice). - Remove the unpinned "2-3 pinned receipt/chain vectors per spec version" prose figure, pointing to the (pinned) matrix instead. - Pin the verifier-side checkpoint reference to "ADR-0008 §3 out-of-band check over ADR-0015 Phase B checkpoints" (was bare "ADR-0008 follow-through"). * docs(conformance): address remaining Copilot review findings - Reword the md renderer's "paste-ready" claim in count.py and test_count.py: the published matrix splits its combined Consumers column into separate Go/Py/TS columns, so it's a reference table, not a drop-in paste. - Widen cross-sdk-tests.yml's path filter to include the conformance page and scripts/conformance_matrix/**, so a direct edit to either can no longer bypass the reconciliation gate. Update the test's header comment and the page copy to match.
1 parent bed09a7 commit f3a702b

18 files changed

Lines changed: 335 additions & 34 deletions

File tree

.github/workflows/cross-sdk-tests.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ on:
1010
# The CLI<->WASM conformance gate compiles the web-verifier module, so a
1111
# change there must re-run the gate.
1212
- "web-verifier/**"
13+
# The conformance page reconciliation test (conformance_page_test.go)
14+
# binds the page's matrix and the count.py generator to each other, so
15+
# either side changing must re-run the gate.
16+
- "site/src/content/docs/conformance.mdx"
17+
- "scripts/conformance_matrix/**"
1318
pull_request:
1419
branches: [main]
1520
paths:
1621
- "cross-sdk-tests/**"
1722
- "sdk/go/**"
1823
- "spec/**"
1924
- "web-verifier/**"
25+
- "site/src/content/docs/conformance.mdx"
26+
- "scripts/conformance_matrix/**"
2027

2128
permissions:
2229
contents: read

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949

5050
## Conformance
5151

52-
Anyone can implement the protocol — so interoperability is the property that matters. The three SDKs (Go, Python, TypeScript) are independent implementations that **verify one another's receipts**: every conformance vector is signed once with a shared Ed25519 keypair, and each SDK re-verifies the signatures, canonical JSON, and hashes the other two produced — across a positive corpus, a MUST-reject corpus, and four pinned spec versions. This cross-language verification is enforced in CI.
52+
Anyone can implement the protocol — so interoperability is the property that matters. The three SDKs (Go, Python, TypeScript) are separate, no-shared-code implementations that **verify one another's receipts**: every conformance vector is signed once with a shared Ed25519 keypair, and each SDK re-verifies the signatures, canonical JSON, and hashes the other two produced — across a positive corpus, a MUST-reject corpus, and four pinned spec versions. This cross-language verification is enforced in CI (it catches serialisation drift between the languages, not spec-interpretation ambiguity — the SDKs share one author).
5353

5454
The **[Conformance page](https://agentreceipts.ai/conformance/)** is the citeable summary: the interop claim, a results matrix with per-set vector counts, permanent links to every frozen vector set, and how to reproduce the checks. The shared corpus lives in [`cross-sdk-tests/`](cross-sdk-tests/).
5555

cross-sdk-tests/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Cross-SDK Tests
22

3-
Three independent implementations of the Agent Receipt Protocol — the Go, Python, and TypeScript SDKs — verify one another's receipts. The vectors in this directory are the shared corpus that makes that claim checkable: each is signed once with a single shared Ed25519 keypair, and every SDK re-verifies the signatures, canonical JSON (RFC 8785), and receipt hashes the other two produced. Verification runs in both directions, over a **positive corpus** every implementation must accept and a **MUST-reject corpus** every implementation must refuse, across four pinned spec versions (v0.2.0–v0.5.0).
3+
Three separate implementations of the Agent Receipt Protocol — the Go, Python, and TypeScript SDKs, one per language with no shared code — verify one another's receipts. The vectors in this directory are the shared corpus that makes that claim checkable: each is signed once with a single shared Ed25519 keypair, and every SDK re-verifies the signatures, canonical JSON (RFC 8785), and receipt hashes the other two produced. Verification runs in both directions, over a **positive corpus** every implementation must accept and a **MUST-reject corpus** every implementation must refuse, across four pinned spec versions (v0.2.0–v0.5.0). This catches cross-language serialisation drift; because the SDKs share a single author, it does not catch a spec-interpretation ambiguity encoded identically into all three (see the [Conformance page](https://agentreceipts.ai/conformance/) for the full statement of what this does and does not prove).
44

55
For the published, citeable summary of this suite — the interop claim, the results matrix with per-set vector counts, and CI-enforcement status — see the [Conformance page](https://agentreceipts.ai/conformance/).
66

cross-sdk-tests/canonicalization_vectors.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@
202202
"input": "\ud83c\udf89",
203203
"canonical": "\"\ud83c\udf89\""
204204
},
205+
{
206+
"name": "string_no_unicode_normalization",
207+
"rule": "rule4_strings",
208+
"description": "RFC 8785 does NOT apply Unicode normalization (\u00a73, building on RFC 8259). Input is the NFD sequence U+0065 (LATIN SMALL LETTER E) + U+0301 (COMBINING ACUTE ACCENT), which is canonically equivalent to \u2014 but must NOT be folded to \u2014 the precomposed NFC form U+00E9 (LATIN SMALL LETTER E WITH ACUTE). A canonicaliser that silently normalises to NFC would emit \"\u00e9\" (2 UTF-8 bytes C3 A9) and fail this vector; the required output preserves both code points verbatim (3 UTF-8 bytes 65 CC 81). The input/canonical strings use \\u escapes so the source file's bytes stay ASCII and cannot themselves be NFC-folded by an editor.",
209+
"input": "e\u0301",
210+
"canonical": "\"e\u0301\""
211+
},
205212
{
206213
"name": "bool_and_null",
207214
"rule": "baseline",
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
//go:build integration
2+
3+
package crosssdk_test
4+
5+
// Reconciliation gate for the published Conformance page
6+
// (site/src/content/docs/conformance.mdx).
7+
//
8+
// The page's results matrix carries a vector count per row, and the page
9+
// claims those counts are "generated, not asserted". scripts/conformance_matrix/count.py
10+
// is the generator — it reads the frozen vector files and emits the counts.
11+
// Nothing, though, forced the page's numbers to equal count.py's output: the
12+
// table cells and the prose figures were hand-typed, so a change to a vector
13+
// file could silently desync the two. On a page that is cited to a standards
14+
// body as evidence, a stale number is worse than none.
15+
//
16+
// This test closes that gap. It runs count.py, parses the page, and asserts:
17+
// - every on-page vector set from count.py appears as a matrix row with the
18+
// exact count count.py computes (and no matrix row exists that count.py does
19+
// not know about), and
20+
// - the two exact prose figures in the honesty callout (the canonicalization
21+
// total and the MUST-reject case count) match count.py.
22+
//
23+
// It lives here, in cross-sdk-tests/, because cross-sdk-tests.yml re-runs on
24+
// every change to the vector corpora (cross-sdk-tests/** and spec/**), to this
25+
// page, and to count.py (scripts/conformance_matrix/**) — every input that can
26+
// move or desync these counts. count.py is invoked as the single source of
27+
// truth rather than re-deriving counts here, so the two cannot drift.
28+
29+
import (
30+
"bytes"
31+
"encoding/json"
32+
"os"
33+
"os/exec"
34+
"regexp"
35+
"strconv"
36+
"strings"
37+
"testing"
38+
)
39+
40+
const (
41+
countScriptPath = "../scripts/conformance_matrix/count.py"
42+
conformancePage = "../site/src/content/docs/conformance.mdx"
43+
)
44+
45+
type countSet struct {
46+
Name string `json:"name"`
47+
Count int `json:"count"`
48+
OnPage bool `json:"onPage"`
49+
}
50+
51+
type countPayload struct {
52+
Sets []countSet `json:"sets"`
53+
Total int `json:"total"`
54+
}
55+
56+
// reconcileInputs bundles the once-loaded count.py output and page text so the
57+
// subtests share a single python3 invocation and a single page read.
58+
type reconcileInputs struct {
59+
payload countPayload
60+
counts map[string]int // set name -> count, for O(1) lookup
61+
page string
62+
}
63+
64+
// loadReconcileInputs runs count.py --format json and reads the page. If python3
65+
// is not on PATH (e.g. a bare local `go test` without Python), the test is
66+
// skipped rather than failed — CI runners have python3, so the gate still fires
67+
// where it matters.
68+
func loadReconcileInputs(t *testing.T) reconcileInputs {
69+
t.Helper()
70+
python, err := exec.LookPath("python3")
71+
if err != nil {
72+
t.Skipf("python3 not available, skipping conformance-page reconciliation: %v", err)
73+
}
74+
cmd := exec.Command(python, countScriptPath, "--format", "json")
75+
var stderr bytes.Buffer
76+
cmd.Stderr = &stderr
77+
out, err := cmd.Output()
78+
if err != nil {
79+
// count.py prints "error: could not count vectors: <detail>" to stderr;
80+
// surface it so a broken vector file is diagnosable from the CI log.
81+
t.Fatalf("run count.py: %v\nstderr: %s", err, strings.TrimSpace(stderr.String()))
82+
}
83+
var payload countPayload
84+
if err := json.Unmarshal(out, &payload); err != nil {
85+
t.Fatalf("parse count.py JSON: %v", err)
86+
}
87+
if len(payload.Sets) == 0 {
88+
t.Fatal("count.py returned no vector sets")
89+
}
90+
counts := make(map[string]int, len(payload.Sets))
91+
for _, s := range payload.Sets {
92+
counts[s.Name] = s.Count
93+
}
94+
pageBytes, err := os.ReadFile(conformancePage)
95+
if err != nil {
96+
t.Fatalf("read conformance page: %v", err)
97+
}
98+
return reconcileInputs{payload: payload, counts: counts, page: string(pageBytes)}
99+
}
100+
101+
// section returns the slice of the page between the first line starting with
102+
// startPrefix (exclusive) and the next line starting with endPrefix (exclusive).
103+
// Scoping the regex scans to the intended block means an unrelated table or a
104+
// stray prose figure elsewhere on the page cannot be misread as a matrix row or
105+
// as a pinned count.
106+
func section(t *testing.T, page, startPrefix, endPrefix string) string {
107+
t.Helper()
108+
lines := strings.Split(page, "\n")
109+
start := -1
110+
for i, line := range lines {
111+
if strings.HasPrefix(line, startPrefix) {
112+
start = i + 1
113+
break
114+
}
115+
}
116+
if start == -1 {
117+
t.Fatalf("section start %q not found on the page", startPrefix)
118+
}
119+
end := len(lines)
120+
for i := start; i < len(lines); i++ {
121+
if strings.HasPrefix(lines[i], endPrefix) {
122+
end = i
123+
break
124+
}
125+
}
126+
if end <= start {
127+
t.Fatalf("section %q..%q is empty", startPrefix, endPrefix)
128+
}
129+
return strings.Join(lines[start:end], "\n")
130+
}
131+
132+
// matrixRow matches a results-matrix data row: a table row whose first cell is a
133+
// markdown link, e.g. `| [canonicalization](https://…) | … | 44 |`. The link
134+
// text is the vector-set name; the final numeric cell is the count.
135+
var matrixRowRe = regexp.MustCompile(`^\|\s*\[([^\]]+)\]\([^)]*\)\s*\|.*\|\s*([0-9]+)\s*\|\s*$`)
136+
137+
// parseMatrix extracts {name: count} for every matrix row in the given section.
138+
func parseMatrix(t *testing.T, src string) map[string]int {
139+
t.Helper()
140+
rows := map[string]int{}
141+
for _, line := range strings.Split(src, "\n") {
142+
m := matrixRowRe.FindStringSubmatch(line)
143+
if m == nil {
144+
continue
145+
}
146+
n, err := strconv.Atoi(m[2])
147+
if err != nil {
148+
t.Fatalf("row %q: non-numeric count %q", m[1], m[2])
149+
}
150+
if _, dup := rows[m[1]]; dup {
151+
t.Fatalf("duplicate matrix row for %q", m[1])
152+
}
153+
rows[m[1]] = n
154+
}
155+
if len(rows) == 0 {
156+
t.Fatal("no matrix rows parsed from the results-matrix section")
157+
}
158+
return rows
159+
}
160+
161+
var (
162+
proseCanonRe = regexp.MustCompile(`(\d+) canonicalization/receipt-hash vectors`)
163+
proseMalformedRe = regexp.MustCompile(`(\d+)-case MUST-reject corpus`)
164+
)
165+
166+
func TestConformancePageReconciles(t *testing.T) {
167+
in := loadReconcileInputs(t)
168+
169+
// The published matrix lives under "## Results matrix" and ends at the
170+
// "Each linked vector set…" sentence right after the table.
171+
t.Run("matrix", func(t *testing.T) {
172+
matrixSrc := section(t, in.page, "## Results matrix", "Each linked vector set")
173+
pageRows := parseMatrix(t, matrixSrc)
174+
175+
expected := map[string]int{}
176+
for _, s := range in.payload.Sets {
177+
if !s.OnPage {
178+
// Off-page sets (reference fixtures) must NOT appear as matrix rows.
179+
if _, present := pageRows[s.Name]; present {
180+
t.Errorf("set %q is on_page=False in count.py but appears in the page matrix", s.Name)
181+
}
182+
continue
183+
}
184+
expected[s.Name] = s.Count
185+
got, present := pageRows[s.Name]
186+
if !present {
187+
t.Errorf("count.py set %q is missing from the page matrix", s.Name)
188+
continue
189+
}
190+
if got != s.Count {
191+
t.Errorf("count mismatch for %q: page=%d, count.py=%d", s.Name, got, s.Count)
192+
}
193+
}
194+
// No matrix row may exist that count.py does not account for.
195+
for name := range pageRows {
196+
if _, ok := expected[name]; !ok {
197+
t.Errorf("page matrix row %q has no corresponding on-page set in count.py", name)
198+
}
199+
}
200+
})
201+
202+
// The two exact numeric claims in the honesty callout are pinned to count.py
203+
// too, scoped to that Aside so an earlier figure elsewhere cannot shadow them.
204+
t.Run("prose", func(t *testing.T) {
205+
callout := section(t, in.page, `<Aside type="caution" title="Honest coverage`, "</Aside>")
206+
checks := []struct {
207+
label string
208+
re *regexp.Regexp
209+
setName string
210+
}{
211+
{"canonicalization total", proseCanonRe, "canonicalization"},
212+
{"MUST-reject case count", proseMalformedRe, "malformed (MUST-reject)"},
213+
}
214+
for _, c := range checks {
215+
m := c.re.FindStringSubmatch(callout)
216+
if m == nil {
217+
t.Errorf("%s: prose figure not found in the honesty callout (pattern %q)", c.label, c.re)
218+
continue
219+
}
220+
got, err := strconv.Atoi(m[1])
221+
if err != nil {
222+
t.Fatalf("%s: non-numeric prose figure %q", c.label, m[1])
223+
}
224+
want, ok := in.counts[c.setName]
225+
if !ok {
226+
t.Fatalf("count.py has no set named %q", c.setName)
227+
}
228+
if got != want {
229+
t.Errorf("%s: prose says %d, count.py says %d", c.label, got, want)
230+
}
231+
}
232+
})
233+
}

daemon/cmd/obsigna-daemon/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ func resolveConfig(args []string, getenv func(string) string, errOut io.Writer)
325325
fs.BoolVar(&cfg.UnsafeSocketPath, "unsafe-socket-path", cfg.UnsafeSocketPath, "Permit a --socket/AGENTRECEIPTS_SOCKET path outside the per-platform safe set (logs a warning; does not override TCP rejection) (env: AGENTRECEIPTS_UNSAFE_SOCKET_PATH)")
326326
fs.StringVar(&cfg.RedactPatternsPath, "redact-patterns", cfg.RedactPatternsPath, "Path to a YAML file of additional redaction patterns (merged with built-in defaults) (env: AGENTRECEIPTS_REDACT_PATTERNS)")
327327
fs.DurationVar(&cfg.ShutdownDeadline, "shutdown-deadline", cfg.ShutdownDeadline, "Best-effort time budget for emitting interrupted-chain terminators on SIGTERM/SIGINT (cannot preempt in-progress SQLite I/O)")
328-
checkpointAnchor := fs.String("checkpoint-anchor", strings.Join(cfg.CheckpointAnchors, ","), "Comma-separated out-of-band checkpoint anchor sinks (ADR-0008 truncation anchor): file:<path>, git:<dir>, syslog:<tag>, or a bare path (=file:). Empty disables checkpointing. (env: AGENTRECEIPTS_CHECKPOINT_ANCHOR)")
328+
checkpointAnchor := fs.String("checkpoint-anchor", strings.Join(cfg.CheckpointAnchors, ","), "Comma-separated out-of-band checkpoint anchor sinks (ADR-0015 Phase B checkpoint anchoring; closes the ADR-0008 §2 truncation gap): file:<path>, git:<dir>, syslog:<tag>, or a bare path (=file:). Empty disables checkpointing. (env: AGENTRECEIPTS_CHECKPOINT_ANCHOR)")
329329
fs.IntVar(&cfg.CheckpointCadence, "checkpoint-cadence", cfg.CheckpointCadence, "Receipts between checkpoint emissions per chain; 0/1 = every receipt (default for spike testability). Truncation-window trade-off: cadence N means up to N tail receipts can be dropped between the last checkpoint and the store HEAD without a checkpoint-ahead signal from --against-anchor. Higher values reduce anchor I/O at the cost of a wider undetectable truncation window. A graceful shutdown always forces a final checkpoint regardless of cadence. (env: AGENTRECEIPTS_CHECKPOINT_CADENCE)")
330330
fs.IntVar(&cfg.MaxErrorLen, "max-error-len", cfg.MaxErrorLen, "Rune cap on the inline outcome.error string; oversized values are truncated, not rejected. 0 = default (256); negative disables truncation. (env: AGENTRECEIPTS_MAX_ERROR_LEN)")
331331
fs.IntVar(&cfg.MaxPromptPreviewLen, "max-prompt-preview-len", cfg.MaxPromptPreviewLen, "Rune cap on the inline intent.prompt_preview string; oversized values are truncated and prompt_preview_truncated is set. 0 = default (256); negative disables truncation. (env: AGENTRECEIPTS_MAX_PROMPT_PREVIEW_LEN)")

daemon/daemon.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ type Config struct {
118118
ShutdownDeadline time.Duration
119119

120120
// CheckpointAnchors is the fan-out list of out-of-band sinks the daemon
121-
// writes signed chain-HEAD checkpoints to (ADR-0008 follow-through, the
122-
// truncation anchor). Each entry is a backend spec — "file:<path>",
121+
// writes signed chain-HEAD checkpoints to (ADR-0015 Phase B checkpoint
122+
// anchoring, the truncation anchor). Each entry is a backend spec — "file:<path>",
123123
// "git:<dir>", or "syslog:<tag>" (a bare path means file). Empty disables
124124
// checkpointing entirely: the daemon's behaviour is then byte-identical to
125125
// a build without this feature, and `verify` without --against-anchor is
@@ -637,7 +637,7 @@ func Run(ctx context.Context, cfg Config) error {
637637
}
638638
pp.Redactor = pipeline.NewRedactor(customPatterns)
639639

640-
// Wire the out-of-band checkpoint anchor (ADR-0008 follow-through) when one
640+
// Wire the out-of-band checkpoint anchor (ADR-0015 Phase B) when one
641641
// or more sinks are configured. Empty CheckpointAnchors leaves pp.Checkpointer
642642
// nil, so the commit path and shutdown are byte-identical to a build without
643643
// the feature. A sink that fails to OPEN is fatal (the operator asked for an

daemon/internal/anchor/anchor.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import (
2626
const EventTypeRotation = "rotation"
2727

2828
// EventTypeCheckpoint is the event type for signed chain-HEAD checkpoint
29-
// records (the truncation anchor; ADR-0008 follow-through). A sink carries
30-
// both rotation and checkpoint events; readers filter on EventType.
29+
// records (the truncation anchor; ADR-0015 Phase B checkpoint anchoring). A
30+
// sink carries both rotation and checkpoint events; readers filter on EventType.
3131
const EventTypeCheckpoint = "checkpoint"
3232

3333
// Sink is an append-only external witness for daemon events.

daemon/internal/checkpoint/checkpoint.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import (
1818

1919
// Portable checkpoint types, re-exported from the SDK so the daemon's emitter,
2020
// reader, and callers keep one `checkpoint.*` surface. See
21-
// obsigna.dev/sdk/go/checkpoint for the design constraints (ADR-0008).
21+
// obsigna.dev/sdk/go/checkpoint for the design constraints (ADR-0015 External
22+
// anchor write contract; ADR-0008 anchoring freeze).
2223
type (
2324
Checkpoint = sdk.Checkpoint
2425
Signed = sdk.Signed

daemon/internal/pipeline/build.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ type Pipeline struct {
229229

230230
// Checkpointer, when set, receives the chain HEAD after every committed
231231
// receipt and emits an out-of-band signed checkpoint per its cadence
232-
// (ADR-0008 follow-through, truncation anchor). Nil = checkpointing
232+
// (ADR-0015 Phase B checkpoint anchoring, truncation anchor). Nil = checkpointing
233233
// disabled, which is the default and keeps the commit path byte-identical
234234
// to a build without the feature.
235235
//

0 commit comments

Comments
 (0)