Settle the forge WAL anchor's publication surface

f3dfbfbc8b9d · AtlantisPleb · · parent adc413e98c70

Settle the forge WAL anchor's publication surface

`EXIT-005` chains every WAL entry to its predecessor, so a rewrite of an
accepted push is total rather than local, and one remembered link checks the
whole prefix before it. `git push` hands that link to the pusher. Nobody else
gets one, so a consistent rewrite — the log, the content-addressed entry keys,
the index, every chain link, and the derived `forge_pushes` rows edited
together — is still undetectable to a reader who kept nothing.

`docs/2026-08-23-forge-wal-anchoring.md` called the choice of publication
surface a cost and custody decision and left it open as stage 5. This settles
it, and settles it by naming the verifier first: not the pusher, who is already
served, but a stranger reading a public repository, who needs a commitment
reachable with no account, no credential, and no request to the operator.

ADR 0008 chooses one unsigned JSON anchor document at a stable well-known path,
published on an interval off the push path, each anchor naming the digest of
the anchor before it. The ADR states plainly what that proves on its own:
nothing. The operator serves it and can serve anything. What it buys is that a
commitment covering every public repository's whole log prefix becomes cheap
for a third party to keep a copy of, and a copy is the thing that contradicts a
rewrite; that one archived anchor pins every anchor before it, the way an entry
link pins every entry before it; and that a stopped publication becomes visible
instead of silent.

The head is not signed. A signature made with a key the operator holds, over a
document the operator serves, adds nothing against the operator and would make
the surface read as proof.

Four surfaces are rejected with what each would require, and none of them is
rejected as worthless. A GitHub mirror commit: no mirror is configured, and a
ref reached by `git push --mirror` is force-writable by the same credential, so
the anchor branch would not be append-only — what GitHub keeps that the
operator cannot rewrite is its push event log, which nothing here reads. A
public transparency log: the strongest routinely available option and the only
one that closes split views, needing an account, a submission key, and a client
this repository does not have. A Nostr relay set: real replication to hosts the
operator does not run, costing a protocol client, a key, and a retention
assumption nobody controls. A Bitcoin-anchored commitment: the only option
where no party at all is trusted, needing an OpenTimestamps client that does
not exist or a funded wallet, and proving existence and time but never
completeness.

Each rejected surface publishes the chosen document's digest, so adopting one
later adds a witness rather than replacing what readers fetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/2026-08-23-forge-wal-anchoring.md
  • modified docs/architecture.md
  • added docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md

Diff

3 files changed, +256 -13

docs/2026-08-23-forge-wal-anchoring.md modified +31 -13

@@ -4,7 +4,7 @@

4 4
**Commit measured:** `7e5f7b1` on `openagents/main`, the forge
5 5
**Question:** `EXIT-002` proves the served repository can be checked against the WAL without trusting the operator's database, and its own conclusion is that the check is tamper-evident and not tamper-proof. What would make a *consistent* rewrite of the log detectable, what does each option really cost, and what can no option here achieve?
6 6
**Method:** direct reading of every writer of a WAL entry (`lib/openagents/forge/pushes.ex`, `lib/openagents/forge/git_plane.ex`, `lib/openagents/repositories/importer.ex`, `lib/openagents/repositories/provisioner.ex`), the log itself (`lib/openagents/forge/wal.ex` and its two adapters), the reader that replays it (`lib/openagents/forge/sync.ex`), the verifier (`lib/openagents/forge/verification.ex`), the derived receipt (`lib/openagents/forge/push_receipt.ex`), and the invariants they are bound to (`INVARIANTS.md`, `REPOSITORY-003`, `EXIT-001` through `EXIT-004`). Claims this repository cannot settle are in section 6 with what would settle them.
7
**Status:** Stages 1, 2, and 4 shipped. Stages 3 and 5 are open.
7
**Status:** Stages 1, 2, 4, and 5 shipped. Stage 3 is open.
8 8
9 9
---
10 10

@@ -277,8 +277,14 @@ surface is one that multiple parties read.

277 277
**What a verifier needs.** The anchor's location and the ability to read it. No
278 278
key.
279 279
280
**Verdict.** Correct second step, and the cheapest useful version is the mirror
281
commit, precisely because it is already half-built. Staged as 3 and 5.
280
**Verdict.** Correct second step. The surface is settled by ADR 0008
281
(`docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md`,
282
stage 5): a self-served anchor document at a well-known path, unsigned, with
283
each anchor naming the digest of the anchor before it. The mirror commit is not
284
the interim it looked like here — no mirror is configured, and a ref reached by
285
`git push --mirror` is force-writable by the same credential, so the anchor
286
branch would not be append-only. Every rejected surface publishes *that
287
document's digest*, so escalation is additive. Staged as 3 and 5.
282 288
283 289
### 3.4 A client-side receipt returned to the pusher
284 290

@@ -466,23 +472,35 @@ entries the operator holds is the backfill section 7 rules out.

466 472
467 473
**Size:** small, but it is a migration, so `RELEASE-001` applies.
468 474
469
### Stage 5 — Decide the durable publication surface (open question, large)
475
### Stage 5 — Decide the durable publication surface (decision) — SETTLED
470 476
471
**Seam:** none in this repository yet.
477
**Seam:** none in this repository; the decision is
478
`docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md`.
472 479
473
Choosing between a self-run append-only log, a third-party timestamping service,
474
and a public chain is a cost and custody decision, not an implementation one.
475
Section 6 lists what would settle it. Until it is settled, stage 3's mirror
476
commit is the honest interim: witnessed, cheap, and not overstated.
480
Settled by naming the verifier first. The pusher is served by stage 2 already;
481
the verifier stage 3 is for is a stranger reading a public repository, who needs
482
a commitment reachable with no account, no credential, and no request to the
483
operator. The chosen surface is one unsigned JSON anchor document at a stable
484
well-known path, published on an interval off the push path, with each anchor
485
naming the digest of the anchor before it.
486
487
The ADR is explicit that the document proves nothing on its own — the operator
488
serves it — and that its value is making a commitment cheap for a third party to
489
keep a copy of, since a copy is what contradicts a rewrite. A GitHub mirror
490
commit, a public transparency log, a Nostr relay set, and a Bitcoin-anchored
491
commitment are all rejected *for now* with what each would require, and each of
492
them publishes this document's digest, so adopting one later adds a witness
493
rather than replacing the surface.
477 494
478 495
---
479 496
480 497
## 6. What this repository cannot settle
481 498
482
- **Where the anchor should live.** The options differ in cost, in who reads
483
  them, and in what happens when publication stops. Settling it needs a decision
484
  about who the verifier is expected to be — a pusher, a user, or an auditor —
485
  and only the first is served by stage 2 alone.
499
- **Where the anchor should live.** Settled by ADR 0008 after naming the
500
  verifier: a stranger reading a public repository, not the pusher stage 2
501
  already serves. What this repository still cannot settle is whether anybody
502
  outside it will ever *witness* the published anchor, which is the residue
503
  #151 carries.
486 504
- **Whether the operator will accept a custody split.** Signing is only worth
487 505
  building after that answer, and the answer is not a code change.
488 506
- **What the production logs actually contain.** Every claim here about existing
docs/architecture.md modified +1

@@ -312,6 +312,7 @@ decision and current operator procedure.

312 312
- [ADR 0005: Use Basecoat and one OpenAgents component system](decisions/0005-use-basecoat-and-one-component-system.md)
313 313
- [ADR 0006: Isolate web and distributed fleet staging](decisions/0006-isolate-web-and-distributed-fleet-staging.md)
314 314
- [ADR 0007: Cut over to forge-canonical source control only after proof](decisions/0007-cut-over-to-forge-canonical-source-control-after-proof.md)
315
- [ADR 0008: Publish the forge WAL anchor at a well-known path](decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md)
315 316
316 317
## Superseded narratives
317 318
docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md added +224

@@ -0,0 +1,224 @@

1
# ADR 0008: Publish the forge WAL anchor at a well-known path
2
3
Date: 2026-08-23
4
5
Status: Accepted
6
7
Issue: #170. Parent: #151. Implemented by #168.
8
9
## Context
10
11
`EXIT-002` checks what the forge serves against the WAL that accepted it, and
12
its own conclusion is that the check compares two things the operator holds.
13
`EXIT-005` chains every WAL entry to its predecessor, so a rewrite of an
14
accepted push can no longer be confined to one entry: changing entry *k*
15
changes the link of every entry after it. One link remembered outside the
16
operator's storage therefore checks the entire prefix of the log before it.
17
18
`git push` now prints that link, so a pusher can keep it. That serves exactly
19
one verifier — the person who pushed, for the one repository they pushed to,
20
up to the one sequence they wrote down. It is not publication. A reader of a
21
public repository, an auditor, or anyone arriving later has no commitment at
22
all, and an operator who rewrites the WAL, the content-addressed entry keys,
23
the index, every chain link, and the derived `forge_pushes` rows produces a
24
self-consistent forge that `verify/2` reports clean.
25
26
`docs/2026-08-23-forge-wal-anchoring.md` section 3.3 lists the publication
27
surfaces and calls the choice between them a cost and custody decision rather
28
than an implementation one. This ADR is that decision.
29
30
### Who the verifier is
31
32
The options only rank once this is settled, and it is settled by looking at
33
who reads the forge. Three classes:
34
35
1. **The pusher.** Already served, by the receipt `EXIT-005` prints. Needs
36
   nothing further.
37
2. **A stranger reading a public repository.** Served by nothing today. They
38
   read `/changelog`, `/<owner>/<repo>`, and a clone, and they have no way to
39
   tell whether the history they are reading is the history that was accepted.
40
3. **Someone reconstructing the past after an incident.** Served by nothing
41
   today, and worst placed of the three: they arrive after the fact and cannot
42
   retroactively obtain a commitment.
43
44
Classes 2 and 3 are the ones this decision is for. Both need a commitment they
45
can reach **without an account, without a credential, and without asking the
46
operator for anything**, and class 3 needs it to have been reachable *before*
47
the rewrite it is trying to detect.
48
49
## Decision
50
51
**Publish one JSON anchor document at a stable well-known path, on an
52
interval, from a job off the push path. Do not sign it. Chain each anchor to
53
the digest of the anchor before it. State on the document itself, and on
54
`/status`, that the document is published by the operator and witnessed by
55
nobody.**
56
57
The document carries, per repository the forge already publishes to anonymous
58
readers: the entry count, the head sequence, the head chain link, a digest of
59
the exportable ref map, and the first sequence the chain covers. It carries the
60
anchor's own sequence, its `published_at`, and the digest of the previously
61
published anchor document.
62
63
### What this proves
64
65
**On its own, nothing.** The operator serves the document and can serve any
66
document. This is the whole reason the decision is written down rather than
67
implied by the code: a publication surface that reads as proof while depending
68
on the operator is a worse failure than the gap it papers over.
69
70
What it does is make a commitment **cheap to keep a copy of**, and a copy is
71
the thing that contradicts a rewrite. Specifically:
72
73
- **It extends the pusher's receipt to everyone.** Any reader can now hold a
74
  commitment covering every public repository's whole log prefix, not only the
75
  pushes they made themselves.
76
- **One archived copy pins every anchor before it.** Each anchor names the
77
  digest of the previous anchor document, so the published sequence is itself a
78
  hash chain — the same trick `EXIT-005` plays on WAL entries, applied one level
79
  up. A reader who kept anchor 400 has a commitment to anchors 1 through 399,
80
  and through them to the WAL prefixes those anchored.
81
- **It makes stopping visible.** `published_at` advances every interval whether
82
  or not the log moved, so a reader can tell publication has halted. #168 is
83
  explicit that a halt is indistinguishable from an outage until somebody
84
  notices; this is what gives them something to notice.
85
- **It is the artifact every stronger option needs anyway.** A mirror commit,
86
  a transparency-log entry, and a timestamp proof all publish *this document's
87
  digest*. Escalating is additive rather than a rewrite of the surface.
88
89
A third-party web archiver that snapshots the URL holds a copy the operator
90
cannot edit. Whether anyone does that is not a claim this repository can make,
91
and nothing here asserts it.
92
93
### What it does not prove
94
95
- **Nothing is witnessed.** No party other than the operator attests that this
96
  document existed at this time with these contents. The status disclosure
97
  therefore publishes `anchor_published: true` and `anchor_witnessed: false`
98
  as two separate facts, and `degraded` stays true on the witness axis.
99
- **A reader who kept no copy holds nothing**, exactly as a pusher who kept no
100
  receipt holds nothing. The surface makes keeping a copy possible and cheap; it
101
  cannot make anyone do it.
102
- **Everything after the last anchor is unanchored.** The exposure window is
103
  the publication interval.
104
- **A split view is not closed.** One document served to one reader and another
105
  to another is caught only when readers compare. A single public surface with
106
  plural readers narrows it and does not shut it.
107
- **Withholding is untouched**, as it is by every option here. An operator who
108
  deletes a repository, serves stale state, or refuses a clone still holds all
109
  of those powers. Anchoring tells you the history you *can* read is the history
110
  that was accepted.
111
- **Completeness is untouched.** An anchor over a truncated log is a valid
112
  anchor over a truncated log.
113
114
### Why the head is not signed
115
116
`docs/2026-08-23-forge-wal-anchoring.md` section 3.2 settles this and the
117
decision keeps it. A signature over an operator-served document, made with a key
118
the operator holds, adds nothing against the operator — which is the threat this
119
whole lane exists for. The network attacker it would defend against is already
120
covered by TLS. Adding a signature would make the surface look like proof while
121
changing nothing about who has to be trusted, so the anchor is unsigned and says
122
why on its face.
123
124
Signing becomes worth building when the key lives somewhere the operator
125
genuinely cannot reach. That is an organizational change, not a code change.
126
127
## Options rejected, and what each would require
128
129
### A commit to the GitHub mirror
130
131
**Would prove:** GitHub records force pushes in its own event history and does
132
not answer to this operator, so a mirrored anchor commit is witnessed by a party
133
with no interest in this forge. A stranger can read it without an account.
134
135
**Rejected because the component does not exist.** `:forge_mirror_urls` is empty
136
in `config/config.exs` and set by no environment, so no mirror runs today, and
137
`OpenAgents.Forge.Pushes.mirror_now/1` is `git push --mirror` — a force push of
138
every ref. Shipping an anchor whose security story rests on a mirror nobody has
139
provisioned would be an assertion nothing can contradict.
140
141
It is also weaker than it first reads. A branch reached by `--mirror` is
142
force-writable by the same credential, so the anchor *branch* is not
143
append-only. What GitHub keeps that the operator cannot rewrite is the **push
144
event log**, a different artifact that nothing in this repository reads and that
145
GitHub exposes on its own terms.
146
147
**To adopt:** provision a mirror repository and a scoped credential, decide
148
whether the anchor goes on a ref the mirror force push can reach, and name what
149
reads GitHub's event history. Then publish this document's digest there.
150
151
### A public append-only transparency log
152
153
**Would prove:** the strongest routinely available property. A log with
154
independent monitors and witnesses gives inclusion proofs a stranger can check
155
and real split-view resistance, which is the one limit publication alone cannot
156
close.
157
158
**Rejected because it needs a relationship and a client that do not exist.**
159
There is no account with any log operator, no key registered with one, and no
160
Elixir client for Sigsum, Rekor, or a Certificate-Transparency-shaped log in
161
this repository. Verification would also depend on that log's own tooling, which
162
a reader would have to obtain separately.
163
164
**To adopt:** someone owns the account and the submission key, a client lands
165
with its failure modes bounded off the push path, and the reader-facing
166
verification path is documented end to end. This is the correct escalation and
167
the one that closes split views.
168
169
### A Nostr relay set
170
171
**Would prove:** replication to hosts the operator does not run. An event
172
published to a diverse relay set is hard to retract everywhere at once, and the
173
diversity is the property, not the signature.
174
175
**Rejected because it costs a protocol client, a key, and a retention
176
assumption.** This repository has no Nostr client; the workspace's
177
`nostr-effect` is another repository's TypeScript library and not a dependency
178
here. Relay retention is each relay's policy, so "published" and "still
179
retrievable next year" are different claims. The signing key is the operator's,
180
which the unsigned-head reasoning above already weighs.
181
182
**To adopt:** a client lands, a relay set is chosen with its diversity argued
183
rather than assumed, and someone states what retention is being relied on.
184
185
### A Bitcoin-anchored commitment
186
187
**Would prove:** the strongest ordering and time property available, and the
188
only one where no party at all has to be trusted. An OpenTimestamps proof
189
verifies against the chain itself, so even the calendar servers are not trusted.
190
191
**Rejected because the client does not exist and the variant that avoids it
192
costs custody.** There is no OpenTimestamps implementation in this repository
193
and no `ots` binary in the deployment; the OP_RETURN variant needs a funded
194
wallet and a spending key, which is money custody added to a transparency
195
problem. Verification also needs a Bitcoin node or a block explorer, and the
196
proof only upgrades hours after the anchor.
197
198
Worth stating even so: Bitcoin anchoring proves existence and time, never
199
completeness. An anchor over a truncated log is still a valid anchor.
200
201
**To adopt:** an OpenTimestamps client lands, stamping runs off the publication
202
path with its latency tolerated, and the reader-facing verification path is
203
documented. This is the right option if the forge ever needs its history to
204
survive an adversarial operator over years.
205
206
## Consequences
207
208
- `/status` and `GET /api/status` publish `anchor_published` and
209
  `anchor_witnessed` as two facts, not one, and the forge stays
210
  independence-degraded on the witness axis. `EXIT-006` derives both rather than
211
  restating them, so neither can drift.
212
- `EXIT-005` extends to cover publication. The invariant claims that the anchor
213
  is published and archivable, and states that it is not witnessed.
214
- #151's gap narrows rather than closing. Before this, a consistent rewrite was
215
  undetectable to anyone who kept nothing. After it, a consistent rewrite is
216
  undetectable to anyone who kept nothing *and* has to survive contact with
217
  every reader who kept an anchor. The operator still controls whether anyone
218
  ever gets a copy, so #151 stays open on the witness axis.
219
- The publication interval is the exposure window and is operator-owned
220
  configuration, which means the operator can lengthen it. That is disclosed by
221
  `published_at` rather than prevented.
222
- Escalation to any rejected option is additive: each one publishes this
223
  document's digest, so none of them requires changing the surface readers
224
  already fetch.

This page updates live while a promote is in flight · changelog