|
1
|
+ |
defmodule OpenAgents.Forge.Anchor do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
The published commitment to the forge's WAL (`EXIT-005`, ADR 0008).
|
|
4
|
+ |
|
|
5
|
+ |
`EXIT-002` compares two things the operator holds, and `EXIT-005` makes a
|
|
6
|
+ |
rewrite of either one total rather than local: change any accepted entry and
|
|
7
|
+ |
every link after it changes, so one link remembered outside the operator's
|
|
8
|
+ |
storage checks the whole prefix before it. `git push` hands that link to the
|
|
9
|
+ |
pusher. This module hands it to everyone else.
|
|
10
|
+ |
|
|
11
|
+ |
Every interval, a document naming each public repository's entry count, head
|
|
12
|
+ |
sequence, head chain link, and ref-map digest is written to
|
|
13
|
+ |
`forge_wal_anchors` and served verbatim at
|
|
14
|
+ |
`/.well-known/openagents-forge-anchor.json`.
|
|
15
|
+ |
|
|
16
|
+ |
## What this proves
|
|
17
|
+ |
|
|
18
|
+ |
On its own, nothing. The operator serves the document and could serve any
|
|
19
|
+ |
document. Saying so on the document itself, in this moduledoc, and on
|
|
20
|
+ |
`/status` is deliberate: a publication surface that reads as proof while
|
|
21
|
+ |
depending on the operator is worse than the gap it papers over.
|
|
22
|
+ |
|
|
23
|
+ |
What it buys is that a commitment becomes cheap for a third party to keep a
|
|
24
|
+ |
copy of, and a copy is the thing that contradicts a rewrite:
|
|
25
|
+ |
|
|
26
|
+ |
* A reader now holds a commitment covering every public repository's whole
|
|
27
|
+ |
log prefix, not only the pushes they made themselves.
|
|
28
|
+ |
* Each anchor names `previous_digest`, the digest of the anchor before it, so
|
|
29
|
+ |
the published sequence is itself a hash chain. One archived anchor pins
|
|
30
|
+ |
every anchor before it, the way an entry link pins every entry before it.
|
|
31
|
+ |
* `published_at` advances every interval whether or not the log moved, so a
|
|
32
|
+ |
reader can tell publication has stopped. A halt is otherwise
|
|
33
|
+ |
indistinguishable from an outage.
|
|
34
|
+ |
|
|
35
|
+ |
## What this does not prove
|
|
36
|
+ |
|
|
37
|
+ |
Nothing is *witnessed*: no party other than the operator attests that this
|
|
38
|
+ |
document existed at this time with these contents, which is why
|
|
39
|
+ |
`OpenAgents.Forge.Independence` publishes `anchor_published` and
|
|
40
|
+ |
`anchor_witnessed` as two separate facts and stays degraded on the second.
|
|
41
|
+ |
A reader who kept no copy holds nothing. Everything after the last anchor is
|
|
42
|
+ |
unanchored, so the exposure window is the interval. A split view is narrowed
|
|
43
|
+ |
and not closed. Withholding is untouched: an operator who serves nothing,
|
|
44
|
+ |
serves stale state, or refuses a clone is not detected by any of this.
|
|
45
|
+ |
Completeness is untouched: an anchor over a truncated log is a valid anchor
|
|
46
|
+ |
over a truncated log.
|
|
47
|
+ |
|
|
48
|
+ |
The head is not signed. A signature made with a key the operator holds, over
|
|
49
|
+ |
a document the operator serves, adds nothing against the operator — see
|
|
50
|
+ |
`docs/2026-08-23-forge-wal-anchoring.md` section 3.2 and ADR 0008.
|
|
51
|
+ |
|
|
52
|
+ |
## Bounds
|
|
53
|
+ |
|
|
54
|
+ |
The population is the repositories an anonymous reader can already see —
|
|
55
|
+ |
`OpenAgents.Repositories.readable_by/2` with no user — because publishing a
|
|
56
|
+ |
private repository's name and push count would contradict `TRANSPARENCY-001`,
|
|
57
|
+ |
where an unpublished repository is indistinguishable from one that does not
|
|
58
|
+ |
exist. A private repository's log is therefore anchored for nobody, and its
|
|
59
|
+ |
pusher's own receipt stays the only commitment to it.
|
|
60
|
+ |
|
|
61
|
+ |
Nothing here runs on the push path and nothing here can fail a push. The
|
|
62
|
+ |
publisher is a scheduled job, it only reads the WAL, and a repository whose
|
|
63
|
+ |
index it cannot read is reported as unreadable in the document rather than
|
|
64
|
+ |
omitted from it.
|
|
65
|
+ |
"""
|
|
66
|
+ |
|
|
67
|
+ |
import Ecto.Query, warn: false
|
|
68
|
+ |
|
|
69
|
+ |
alias OpenAgents.Forge.{Verification, WAL, WALAnchor}
|
|
70
|
+ |
alias OpenAgents.Repo
|
|
71
|
+ |
alias OpenAgents.Repositories
|
|
72
|
+ |
alias OpenAgents.Repositories.Repository
|
|
73
|
+ |
|
|
74
|
+ |
@schema "openagents.forge_wal_anchor.v1"
|
|
75
|
+ |
@path "/.well-known/openagents-forge-anchor.json"
|
|
76
|
+ |
@decision "docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md"
|
|
77
|
+ |
|
|
78
|
+ |
@trust "This document is published by the forge operator and witnessed by nobody. " <>
|
|
79
|
+ |
"It proves nothing on its own: an operator who rewrote the log would serve " <>
|
|
80
|
+ |
"the rewritten head here too. Its value is that keeping a copy is cheap, and " <>
|
|
81
|
+ |
"a copy you kept is what contradicts a later rewrite."
|
|
82
|
+ |
|
|
83
|
+ |
@verify "Keep this file. Later, run OpenAgents.Forge.Verification.verify/2 against the " <>
|
|
84
|
+ |
"forge's WAL with anchor: %{seq: head_seq, link: head_link} for the repository " <>
|
|
85
|
+ |
"you care about; a log rewritten at or before that sequence reports " <>
|
|
86
|
+ |
"anchor_mismatch. Check previous_digest against the sha256 of the anchor file " <>
|
|
87
|
+ |
"you kept before this one."
|
|
88
|
+ |
|
|
89
|
+ |
@doc "The well-known path the anchor document is served at."
|
|
90
|
+ |
@spec path() :: String.t()
|
|
91
|
+ |
def path, do: @path
|
|
92
|
+ |
|
|
93
|
+ |
@doc "The document schema identifier."
|
|
94
|
+ |
@spec schema() :: String.t()
|
|
95
|
+ |
def schema, do: @schema
|
|
96
|
+ |
|
|
97
|
+ |
@doc """
|
|
98
|
+ |
The most recently published anchor, or `nil` when none has been published.
|
|
99
|
+ |
"""
|
|
100
|
+ |
@spec latest() :: WALAnchor.t() | nil
|
|
101
|
+ |
def latest do
|
|
102
|
+ |
Repo.one(from anchor in WALAnchor, order_by: [desc: anchor.anchor_seq], limit: 1)
|
|
103
|
+ |
end
|
|
104
|
+ |
|
|
105
|
+ |
@doc """
|
|
106
|
+ |
Whether any anchor has been published.
|
|
107
|
+ |
|
|
108
|
+ |
A read that fails answers `false`. The failure direction is deliberate: the
|
|
109
|
+ |
disclosure this feeds claims less than reality rather than more.
|
|
110
|
+ |
"""
|
|
111
|
+ |
@spec published?() :: boolean()
|
|
112
|
+ |
def published? do
|
|
113
|
+ |
Repo.exists?(WALAnchor)
|
|
114
|
+ |
rescue
|
|
115
|
+ |
_database_unavailable -> false
|
|
116
|
+ |
catch
|
|
117
|
+ |
_kind, _reason -> false
|
|
118
|
+ |
end
|
|
119
|
+ |
|
|
120
|
+ |
@doc """
|
|
121
|
+ |
Build, store, and serve the next anchor.
|
|
122
|
+ |
|
|
123
|
+ |
Returns `{:ok, anchor}`, or `{:error, reason}` when the row could not be
|
|
124
|
+ |
written. Two nodes publishing in the same interval race on the unique
|
|
125
|
+ |
`anchor_seq`; the loser reports `:anchor_seq_taken` and retries next tick,
|
|
126
|
+ |
because a published sequence with two different documents behind it would
|
|
127
|
+ |
break the chain a reader walks.
|
|
128
|
+ |
"""
|
|
129
|
+ |
@spec publish(DateTime.t()) :: {:ok, WALAnchor.t()} | {:error, term()}
|
|
130
|
+ |
def publish(now \\ DateTime.utc_now()) do
|
|
131
|
+ |
previous = latest()
|
|
132
|
+ |
seq = if previous, do: previous.anchor_seq + 1, else: 0
|
|
133
|
+ |
previous_digest = previous && previous.digest
|
|
134
|
+ |
|
|
135
|
+ |
body =
|
|
136
|
+ |
seq
|
|
137
|
+ |
|> document(previous_digest, now)
|
|
138
|
+ |
|> Jason.encode!(pretty: true)
|
|
139
|
+ |
|
|
140
|
+ |
%WALAnchor{}
|
|
141
|
+ |
|> WALAnchor.changeset(%{
|
|
142
|
+ |
anchor_seq: seq,
|
|
143
|
+ |
digest: digest(body),
|
|
144
|
+ |
previous_digest: previous_digest,
|
|
145
|
+ |
body: body,
|
|
146
|
+ |
published_at: now
|
|
147
|
+ |
})
|
|
148
|
+ |
|> Repo.insert()
|
|
149
|
+ |
|> case do
|
|
150
|
+ |
{:ok, anchor} -> {:ok, anchor}
|
|
151
|
+ |
{:error, _changeset} -> {:error, :anchor_seq_taken}
|
|
152
|
+ |
end
|
|
153
|
+ |
end
|
|
154
|
+ |
|
|
155
|
+ |
@doc """
|
|
156
|
+ |
The anchor document for sequence `seq`, before encoding.
|
|
157
|
+ |
|
|
158
|
+ |
Public so a test can compare the served bytes against the log rather than
|
|
159
|
+ |
against a fixture.
|
|
160
|
+ |
"""
|
|
161
|
+ |
@spec document(non_neg_integer(), String.t() | nil, DateTime.t()) :: map()
|
|
162
|
+ |
def document(seq, previous_digest, now) do
|
|
163
|
+ |
%{
|
|
164
|
+ |
"schema" => @schema,
|
|
165
|
+ |
"anchor_seq" => seq,
|
|
166
|
+ |
"published_at" => DateTime.to_iso8601(now),
|
|
167
|
+ |
"previous_digest" => previous_digest,
|
|
168
|
+ |
"repositories" => Enum.map(published_repositories(), &repository_anchor/1),
|
|
169
|
+ |
"signed" => false,
|
|
170
|
+ |
"witnessed" => false,
|
|
171
|
+ |
"trust" => @trust,
|
|
172
|
+ |
"verify" => @verify,
|
|
173
|
+ |
"decision" => @decision
|
|
174
|
+ |
}
|
|
175
|
+ |
end
|
|
176
|
+ |
|
|
177
|
+ |
@doc "`sha256:<hex>` over the exact bytes a reader fetches."
|
|
178
|
+ |
@spec digest(binary()) :: String.t()
|
|
179
|
+ |
def digest(body) when is_binary(body) do
|
|
180
|
+ |
"sha256:" <> (:sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower))
|
|
181
|
+ |
end
|
|
182
|
+ |
|
|
183
|
+ |
@doc """
|
|
184
|
+ |
The repositories an anonymous reader can already see, oldest name first.
|
|
185
|
+ |
|
|
186
|
+ |
This is the population, and it is read through the same predicate every
|
|
187
|
+ |
anonymous surface reads, so a repository that stops being public stops being
|
|
188
|
+ |
anchored in the same commit.
|
|
189
|
+ |
"""
|
|
190
|
+ |
@spec published_repositories() :: [Repository.t()]
|
|
191
|
+ |
def published_repositories do
|
|
192
|
+ |
Repository
|
|
193
|
+ |
|> Repositories.readable_by(nil)
|
|
194
|
+ |
|> order_by([repository], asc: repository.owner_key, asc: repository.name_key)
|
|
195
|
+ |
|> Repo.all()
|
|
196
|
+ |
end
|
|
197
|
+ |
|
|
198
|
+ |
defp repository_anchor(%Repository{} = repository) do
|
|
199
|
+ |
base = %{"repo" => "#{repository.owner}/#{repository.name}"}
|
|
200
|
+ |
|
|
201
|
+ |
case WAL.read_index(repository.storage_key) do
|
|
202
|
+ |
{:ok, _generation, index} ->
|
|
203
|
+ |
entries = WAL.entries(index)
|
|
204
|
+ |
|
|
205
|
+ |
Map.merge(base, %{
|
|
206
|
+ |
"entries" => length(entries),
|
|
207
|
+ |
"head_seq" => head(entries)[:seq],
|
|
208
|
+ |
"head_link" => head(entries)[:link],
|
|
209
|
+ |
"chained_from" => chained_from(entries),
|
|
210
|
+ |
"refs_digest" => refs_digest(index)
|
|
211
|
+ |
})
|
|
212
|
+ |
|
|
213
|
+ |
{:error, :not_found} ->
|
|
214
|
+ |
# A repository nobody has pushed to has no index. That is an empty
|
|
215
|
+ |
# record, not an unreadable one.
|
|
216
|
+ |
Map.merge(base, %{
|
|
217
|
+ |
"entries" => 0,
|
|
218
|
+ |
"head_seq" => nil,
|
|
219
|
+ |
"head_link" => nil,
|
|
220
|
+ |
"chained_from" => nil,
|
|
221
|
+ |
"refs_digest" => nil
|
|
222
|
+ |
})
|
|
223
|
+ |
|
|
224
|
+ |
{:error, _reason} ->
|
|
225
|
+ |
# Reported rather than dropped: a repository silently missing from the
|
|
226
|
+ |
# anchor is exactly what an operator hiding one would look like.
|
|
227
|
+ |
Map.merge(base, %{"unreadable" => true})
|
|
228
|
+ |
end
|
|
229
|
+ |
end
|
|
230
|
+ |
|
|
231
|
+ |
defp head(entries) do
|
|
232
|
+ |
case List.last(entries) do
|
|
233
|
+ |
nil -> %{}
|
|
234
|
+ |
entry -> %{seq: entry["seq"], link: WAL.entry_link(entry)}
|
|
235
|
+ |
end
|
|
236
|
+ |
end
|
|
237
|
+ |
|
|
238
|
+ |
defp chained_from(entries) do
|
|
239
|
+ |
case Enum.find(entries, &(WAL.entry_link(&1) != nil)) do
|
|
240
|
+ |
nil -> nil
|
|
241
|
+ |
entry -> entry["seq"]
|
|
242
|
+ |
end
|
|
243
|
+ |
end
|
|
244
|
+ |
|
|
245
|
+ |
# The refs a clone actually receives, so a reader can check what they cloned
|
|
246
|
+ |
# against what was anchored. Length-delimited for the same reason the chain
|
|
247
|
+ |
# encoding is: no two distinct ref maps may encode alike.
|
|
248
|
+ |
defp refs_digest(index) do
|
|
249
|
+ |
index
|
|
250
|
+ |
|> WAL.refs()
|
|
251
|
+ |
|> Verification.exportable_refs()
|
|
252
|
+ |
|> Enum.sort()
|
|
253
|
+ |
|> Enum.map_join(fn {name, sha} ->
|
|
254
|
+ |
"#{byte_size(name)}:#{name}#{byte_size(sha)}:#{sha}"
|
|
255
|
+ |
end)
|
|
256
|
+ |
|> digest()
|
|
257
|
+ |
end
|
|
258
|
+ |
end
|