|
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.
|