Publish the WAL chain head where a stranger can read it

939208aa475c · AtlantisPleb · · parent f3dfbfbc8b9d

Publish the WAL chain head where a stranger can read it

`EXIT-005` chains every WAL entry to its predecessor, so one link remembered
outside the operator's storage checks the whole prefix before it, and `git
push` hands that link to the pusher. Everyone else had nothing. A consistent
rewrite — the entry payloads, their content-addressed keys, the index, every
chain link, and the derived `forge_pushes` rows edited together — produced a
forge that `Verification.verify/2` reported clean, and no reader who had not
pushed could contradict it.

Now `OpenAgents.Forge.AnchorPublisher` writes one document per interval and
`/.well-known/openagents-forge-anchor.json` serves it with no credential. Per
repository an anonymous reader can already see: the entry count, the head
sequence, the head chain link, the ref-map digest, and the sequence the chain
starts at. Per anchor: its own sequence, its `published_at`, and the `sha256`
of the anchor before it. A stranger who keeps one of those files can later
report a rewrite at or before that head, which is what the proof does — it
takes the head out of the *served bytes*, performs the rewrite that defeats
`EXIT-002`, and asserts both halves: clean with no anchor, `anchor_mismatch`
with the one the stranger kept.

What a consistent rewrite could do before: go entirely undetected by anyone who
had not pushed to the repository. What it costs now: it must also survive
contact with every reader who kept an anchor, and because the anchors chain to
each other, one archived anchor pins every anchor before it.

The operator still serves the document, and nothing here pretends otherwise.
ADR 0008 chose this surface with that stated on its face, and the artifact
carries it: the document publishes `witnessed: false`, `signed: false`, and a
`trust` line saying an operator who rewrote the log would serve the rewritten
head here too. `OpenAgents.Forge.Independence` publishes `anchor_published` and
`anchor_witnessed` as two facts rather than one, counts the first from the
anchors that exist rather than from a config flag, and keeps `/status` degraded
on the second under #151. The head is unsigned: a signature made with a key the
operator holds, over a document the operator serves, adds nothing against the
operator.

The population is `Repositories.readable_by/2` with no user, so the anchor names
exactly what an anonymous reader already reaches. A private repository is
anchored for nobody — naming it would publish its existence and its push count
on a surface `TRANSPARENCY-001` keeps a dark repository off entirely — and its
pusher's own receipt stays the only commitment to it. That makes
`OpenAgents.Forge.Anchor` the sixth composer `REPOSITORY-001` enumerates.

No push can fail on this. Publication is a scheduled job beside
`OpenAgents.Forge.MirrorWatch`, not on the push path at all, not even on the
derived side of the acknowledgment barrier. A failed publish logs an atom code
and retries next tick; an unpublished anchor is a condition `/status` already
reports. The forge serves the stored bytes verbatim, because a reader hashes
what they fetched and the next anchor names that digest.

`STATUS-001` moves with the publication: `independence.verification.anchor`,
`.anchor_published`, and `.anchor_witnessed` join the enumerated key set.

Mutations checked, each confirmed red on the named assertion and reverted.
Publishing a `nil` head link fails "a stranger who kept the document reports a
consistent rewrite". Dropping the `readable_by/2` gate fails "a private
repository is anchored for nobody". Never setting `previous_digest` fails "the
served bytes are what the digest and the next anchor name". Re-encoding the
document in the controller instead of serving stored bytes fails the verbatim
assertion. Claiming `anchor_published` unconditionally fails two disclosure
tests. Dropping `anchor_witnessed` from the projection fails the STATUS-001 key
set as a stale declared key. Removing the route's authority clause leaves it
`:unclassified` in the route inventory.

One mutation did not bite as written. Removing the witness axis from
`Independence.degraded?/0`'s disjunction changed no assertion, because two of
the three axes are constants today — no export is encrypted, so `degraded` is
true regardless of what the anchor axis says. Rather than report a green that
was not earned, `degraded?/3` is public and the proof varies one axis at a time
through it; the same mutation is now red on
`Independence.degraded?(clean_export, published_unwitnessed, encrypted)`.

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 INVARIANTS.md
  • modified config/config.exs
  • modified config/test.exs
  • modified docs/2026-08-23-forge-wal-anchoring.md
  • modified docs/forge-operator-independence.md
  • modified docs/taxonomy.md
  • added lib/openagents/forge/anchor.ex
  • added lib/openagents/forge/anchor_publisher.ex
  • modified lib/openagents/forge/independence.ex
  • modified lib/openagents/forge/supervisor.ex
  • added lib/openagents/forge/wal_anchor.ex
  • added lib/openagents_web/controllers/forge_anchor_controller.ex
  • modified lib/openagents_web/live/network_status_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824035934_create_forge_wal_anchors.exs
  • modified test/openagents/forge/independence_disclosure_test.exs
  • modified test/openagents/forge/independence_test.exs
  • modified test/openagents/network_status_test.exs
  • modified test/openagents/repositories/visibility_join_test.exs
  • added test/openagents_web/controllers/forge_anchor_controller_test.exs

Diff

22 files changed, +943 -83

INVARIANTS.md modified +78 -22

@@ -3054,6 +3054,12 @@ that becomes a gap republishes all three, and the enumeration fails until this

3054 3054
contract readmits them — which is the decision being asked for, not an
3055 3055
accident.
3056 3056
3057
The independence disclosure also carries the anchor's own address
3058
(`/.well-known/openagents-forge-anchor.json`), which is a fixed path and not
3059
instance data: it is where a reader goes to hold the forge to `EXIT-005`, and a
3060
disclosure that named a gap without naming the surface that closes it would be
3061
asking the reader to go looking.
3062
3057 3063
What the projection carries beside counts is the bounded public SCV activity
3058 3064
band (`scvs`: a derived public id, a label, a status, a weight, and one
3059 3065
bounded activity line), the forge deploy lane (short shas, statuses, timings,

@@ -3216,8 +3222,10 @@ model, each named below.

3216 3222
  the anonymous one.
3217 3223
- **Listing composes the predicate.** The modules that compose `readable_by/2`
3218 3224
  are `OpenAgents.Repositories`, `OpenAgents.Issues`, `OpenAgents.Projects`,
3219
  `OpenAgents.Notifications`, and `OpenAgents.DataRights.AccountExport` — five,
3220
  not the four the amendment above named.
3225
  `OpenAgents.Notifications`, `OpenAgents.DataRights.AccountExport`, and
3226
  `OpenAgents.Forge.Anchor` — six, not the four the amendment above named. The
3227
  last is the published WAL anchor (`EXIT-005`), which is anonymous and must
3228
  name exactly the repositories an anonymous reader already reaches.
3221 3229
- **The predicate's terms live in one file, plus four stated exceptions.**
3222 3230
  Every site in `lib/` naming a repository's `visibility` or `lifecycle_state`
3223 3231
  against `"public"` or `"ready"` is classified, and the four that decide reach

@@ -3867,16 +3875,51 @@ values from the WAL afterwards. A pusher who keeps that line holds a value the

3867 3875
operator cannot retroactively change, and `verify/2` with it as `:anchor`
3868 3876
reports `anchor_mismatch` against a log rewritten at or before that sequence.
3869 3877
3870
What that is worth is bounded and the bounds are the point. It covers one
3871
repository's log, up to one sequence, for the one party who wrote it down. It
3872
is not publication: nobody else can check it, a pusher who keeps nothing holds
3873
nothing, and re-fetching the link returns the forge's current answer rather than
3874
independent evidence. The route is a convenience for a lost terminal, not a
3875
second source.
3876
3877
No push may fail on this. The link is derived from data already in hand with no
3878
I/O, by an encoder that is total by construction, and the derivation is wrapped
3879
so a link that cannot be produced is omitted rather than raised. The entry then
3878
That covers one repository's log, up to one sequence, for the one party who
3879
wrote it down. **The link also leaves the forge on a schedule, for everyone
3880
else.** `OpenAgents.Forge.AnchorPublisher` writes one document per interval,
3881
served verbatim at `/.well-known/openagents-forge-anchor.json` with no
3882
credential: per repository an anonymous reader can already see, the entry
3883
count, the head sequence, the head chain link, the ref-map digest, and the
3884
sequence the chain starts at. The proof takes the head out of the *served
3885
bytes*, performs the rewrite that defeats `EXIT-002`, and asserts both halves —
3886
clean with no anchor, `anchor_mismatch` with the one a stranger kept.
3887
3888
The publication is chained the way the entries are: each anchor names the
3889
`sha256` of the anchor document before it, so one archived anchor pins every
3890
anchor before it, and the served bytes are stored and returned verbatim because
3891
a reader hashes what they fetched. `published_at` advances every interval
3892
whether or not the log moved, which is what lets a reader see that publication
3893
has stopped.
3894
3895
**Publishing an anchor is not having one witnessed, and the two are published
3896
as two facts.** The operator serves this document and could serve any document,
3897
so it proves nothing on its own; its value is that keeping a copy is cheap and a
3898
copy is what contradicts a later rewrite. `OpenAgents.Forge.Independence`
3899
reports `anchor_published` from the anchors that exist and `anchor_witnessed`
3900
as `false`, and `EXIT-006` stays degraded on the second axis. The head is not
3901
signed: a signature made with a key the operator holds, over a document the
3902
operator serves, adds nothing against the operator. ADR 0008 records the
3903
surfaces rejected — a mirror commit, a public transparency log, a Nostr relay
3904
set, a Bitcoin-anchored commitment — with what each would require, and each of
3905
them would publish this document's digest rather than replace it.
3906
3907
The bounds stay the point. A reader who keeps nothing holds nothing. Everything
3908
pushed after the last anchor is unanchored, so the exposure window is the
3909
interval. A split view is narrowed and not closed. A private repository is
3910
anchored for nobody, because naming it would publish its existence on an
3911
anonymous surface `TRANSPARENCY-001` keeps a dark repository off entirely, and
3912
its pusher's own receipt stays the only commitment to it. Re-fetching either
3913
the anchor or the receipt route returns the forge's current answer rather than
3914
independent evidence.
3915
3916
No push may fail on this. Publication is a scheduled job beside
3917
`OpenAgents.Forge.MirrorWatch` and is not on the push path at all — not even on
3918
the derived side of the acknowledgment barrier — because a slow or failing
3919
anchor must never be able to refuse a push. The link is derived from data
3920
already in hand with no I/O, by an encoder that is total by construction, and
3921
the derivation is wrapped so a link that cannot be produced is omitted rather
3922
than raised. The entry then
3880 3923
enters the log unchained and the verifier reports `chain_link_missing`, which is
3881 3924
something to find out about rather than a reason to refuse a push the forge can
3882 3925
accept. The side-band line inherits the same discipline: it is formatted after

@@ -3900,11 +3943,15 @@ serves stale state, or refuses a clone holds every one of those powers still.

3900 3943
3901 3944
Evidence: `OpenAgents.Forge.WAL`, `OpenAgents.Forge.Verification`,
3902 3945
`OpenAgents.Forge.GitHTTP`, `OpenAgentsWeb.PushReceiptController`,
3946
`OpenAgents.Forge.Anchor`, `OpenAgents.Forge.AnchorPublisher`,
3947
`OpenAgentsWeb.ForgeAnchorController`,
3903 3948
`test/openagents/forge/wal_test.exs`,
3904 3949
`test/openagents/forge/git_http_test.exs`,
3905 3950
`test/openagents/forge/independence_test.exs`,
3906
`test/openagents_web/controllers/push_receipt_controller_test.exs`, and
3907
`docs/2026-08-23-forge-wal-anchoring.md`.
3951
`test/openagents_web/controllers/push_receipt_controller_test.exs`,
3952
`test/openagents_web/controllers/forge_anchor_controller_test.exs`,
3953
`docs/2026-08-23-forge-wal-anchoring.md`, and
3954
`docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md`.
3908 3955
3909 3956
### EXIT-006 — The status surface discloses every gap the ledger records
3910 3957

@@ -3924,12 +3971,20 @@ flattering direction. The export section is counted from

3924 3971
`OpenAgents.DataRights.ExportInventory`: a family that regresses to `partial`
3925 3972
or `blocked` appears on the status page without anyone editing the disclosure,
3926 3973
and a gap closed elsewhere disappears from it in the same commit. The
3927
verification section reads the configured anchor source and reports
3928
`anchor_published` as `false` while none is configured, so the difference
3929
between `EXIT-005`'s tamper-*evident* chain and a tamper-*proof* log is
3930
published rather than blurred; issue #168 publishes the anchor. `degraded` is
3931
the disjunction of the three axes, so nothing waits on a person deciding when
3932
to say so, and it is expected to be true today.
3974
verification section counts the anchors `OpenAgents.Forge.Anchor` has actually
3975
published rather than reading a config flag, so a publisher that has stopped
3976
reports `false` without anyone editing the disclosure. It publishes
3977
`anchor_published` and `anchor_witnessed` as two facts, because they are two
3978
facts: the operator serves the anchor document, so publishing one leaves a
3979
consistent rewrite undetectable to anyone who kept no copy of it, and
3980
`anchor_witnessed` is `false` while no party outside the operator attests to
3981
it (ADR 0008; issue #151 carries the witness). The difference between
3982
`EXIT-005`'s tamper-*evident* chain and a tamper-*proof* log is therefore
3983
published rather than blurred. `degraded` is the disjunction of the axes, so
3984
nothing waits on a person deciding when to say so, and it is expected to be
3985
true today. Two of the axes are constants, so `degraded?/0` would stay true
3986
even if one were dropped from the disjunction; `degraded?/3` is public for
3987
exactly that reason and the proof varies one axis at a time through it.
3933 3988
3934 3989
One claim is stated rather than derived and it says so: no export is encrypted
3935 3990
to a key the recipient holds and no Ecto column in this repository is encrypted

@@ -3947,7 +4002,8 @@ that cannot assemble it renders the page without it rather than failing.

3947 4002
Four mutations were confirmed to fail the proof and reverted: publishing an
3948 4003
empty gap list while the ledger records gaps; making `degraded` constant;
3949 4004
adding the forge's repository name to the projection; and claiming exports are
3950
encrypted.
4005
encrypted. A fifth was added with the anchor: dropping the witness axis from
4006
the disjunction, which `degraded?/0` cannot detect and `degraded?/3` does.
3951 4007
3952 4008
Evidence: `OpenAgents.Forge.Independence`, `OpenAgents.NetworkStatus`,
3953 4009
`OpenAgentsWeb.NetworkStatusLive`,

@@ -4556,7 +4612,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4556 4612
| EXIT-002 | `test/openagents/forge/independence_test.exs` |
4557 4613
| EXIT-003 | `test/openagents/forge/independence_test.exs` |
4558 4614
| EXIT-004 | `test/openagents/forge/independence_test.exs` |
4559
| EXIT-005 | `test/openagents/forge/independence_test.exs`, `test/openagents/forge/wal_test.exs`, `test/openagents/forge/git_http_test.exs`, `test/openagents_web/controllers/push_receipt_controller_test.exs` |
4615
| EXIT-005 | `test/openagents/forge/independence_test.exs`, `test/openagents/forge/wal_test.exs`, `test/openagents/forge/git_http_test.exs`, `test/openagents_web/controllers/push_receipt_controller_test.exs`, `test/openagents_web/controllers/forge_anchor_controller_test.exs` |
4560 4616
| EXIT-006 | `test/openagents/forge/independence_disclosure_test.exs` |
4561 4617
| STACK-001 | `ops/ci/stack-contracts.sh`, `test/openagents/stacks_test.exs` |
4562 4618
| ISSUE-001 | `test/openagents/forge/commit_references_test.exs`, `test/openagents/issues/closing_references_test.exs`, `test/openagents/forge/push_closes_issues_test.exs` |
config/config.exs modified +5

@@ -296,6 +296,11 @@ config :openagents,

296 296
  forge_operator_token: nil,
297 297
  forge_mirror_urls: %{},
298 298
  forge_wal_adapter: OpenAgents.Forge.WAL.Local,
299
  # The published WAL anchor (EXIT-005, ADR 0008). The interval is also the
300
  # anchor's exposure window: everything pushed after the last anchor is
301
  # unanchored until the next one.
302
  forge_wal_anchor_enabled: true,
303
  forge_wal_anchor_interval_ms: 3_600_000,
299 304
  forge_wal_dir: nil,
300 305
  forge_wal_bucket: nil,
301 306
  forge_gcs_token_provider: nil,
config/test.exs modified +4

@@ -94,6 +94,10 @@ config :openagents,

94 94
  forge_repos: ["openagents.com", "demo"],
95 95
  forge_operator_token: "forge_test_operator_token_0123456789",
96 96
  forge_deploy_lane_enabled: false,
97
  # The anchor publisher is off by default: the tests that exercise it drive
98
  # `OpenAgents.Forge.Anchor.publish/1` themselves, so no timer writes anchor
99
  # rows underneath a test that is asserting there are none.
100
  forge_wal_anchor_enabled: false,
97 101
  forge_repo_owners: %{"openagents.com" => "OpenAgentsInc", "demo" => "OpenAgentsInc"}
98 102
99 103
config :openagents, :migrate_on_boot, false
docs/2026-08-23-forge-wal-anchoring.md modified +35 -16

@@ -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, 4, and 5 shipped. Stage 3 is open.
7
**Status:** All five stages shipped. What remains is a witness, which no stage here can supply.
8 8
9 9
---
10 10

@@ -433,20 +433,37 @@ is a convenience for a lost terminal.

433 433
rejected push, plus direct unit tests of the shapes the annotator must refuse
434 434
to touch in `test/openagents/forge/git_http_test.exs`.
435 435
436
### Stage 3 — Publish the head where the operator does not solely control it (this repository, medium)
437
438
**Seam:** a scheduled job beside `OpenAgents.Forge.MirrorWatch`, never on the
439
push path.
440
441
Publish `{repo, entries, head link, ref-map digest, published_at}` on an
442
interval. The cheapest surface with a real third party is a commit to the GitHub
443
mirror, which keeps GitHub as a witness and not as authority; `:forge_mirror_urls`
444
is empty today (`config/config.exs:297`), so this stage has to configure a
445
mirror before it can use one. A second surface with independent readers is
446
strictly better and is the stage 5 question.
447
448
**Size:** medium, mostly operational. **What it settles:** everything at or
449
before the last published head becomes checkable by a stranger.
436
### Stage 3 — Publish the head where a stranger can read it (this repository, medium) — SHIPPED
437
438
**Seam:** `OpenAgents.Forge.AnchorPublisher`, a scheduled job beside
439
`OpenAgents.Forge.MirrorWatch` and not on the push path at all.
440
441
Every interval, `OpenAgents.Forge.Anchor` writes one document and
442
`OpenAgentsWeb.ForgeAnchorController` serves its stored bytes verbatim at
443
`/.well-known/openagents-forge-anchor.json`, with no credential. Per repository
444
an anonymous reader can already see: the entry count, the head sequence, the
445
head chain link, the ref-map digest, and the sequence the chain starts at. Per
446
anchor: its own sequence, its `published_at`, and the `sha256` of the anchor
447
before it, so the published sequence is a chain and one archived anchor pins
448
every anchor before it.
449
450
The population is `OpenAgents.Repositories.readable_by/2` with no user, because
451
naming a private repository would publish its existence and its push count on
452
an anonymous surface `TRANSPARENCY-001` keeps a dark repository off entirely. A
453
private repository is therefore anchored for nobody and its pusher's receipt
454
stays the only commitment to it.
455
456
The title of this stage said "where the operator does not solely control it",
457
and this surface does not do that, which is stated rather than glossed. The
458
operator serves the document. What changed is that a commitment covering every
459
public repository's whole log prefix is now cheap for anybody to keep a copy
460
of, and a copy is the thing that contradicts a rewrite. `/status` publishes
461
`anchor_published` and `anchor_witnessed` separately for exactly that reason.
462
463
**Size:** medium. **What it settles:** everything at or before the last
464
published head becomes checkable by a stranger *who kept the document*.
465
**What it does not:** a stranger who kept nothing, a rewrite of the anchor
466
history itself for a reader who kept nothing, withholding, or a split view.
450 467
451 468
### Stage 4 — Store the link beside the derived receipt (this repository, small) — SHIPPED
452 469

@@ -555,7 +572,9 @@ and not a property.

555 572
| Invariant | Change |
556 573
| --- | --- |
557 574
| `EXIT-002` | Amended. The caveat now names the chain, says what it does and does not add, and points here for the publication that would close it. |
558
| `EXIT-005` | New in stage 1, amended by stage 2. Every WAL entry commits to the entry before it, the chain is checkable against an externally held link, and the link now leaves the forge at acknowledgment for the pusher to hold. |
575
| `EXIT-005` | New in stage 1, amended by stages 2 and 3. Every WAL entry commits to the entry before it, the chain is checkable against an externally held link, the link leaves the forge at acknowledgment for the pusher to hold, and a periodic anchor document publishes the head of every public repository to anyone, chained to the anchor before it and stated to be unwitnessed. |
559 576
| `REPOSITORY-003` | Unchanged, and load-bearing. Replay reads entries one at a time against the ref state each recorded, which is what makes an entry an individually meaningful unit worth chaining. |
560 577
| `EXIT-003` | Amended by stage 4. The receipt now carries the entry's link, still derived from the WAL in both directions, and the proof shows a rewritten stored link changing no verification outcome. |
561 578
| `EXIT-001` | Amended by stage 2. `push_receipt` moves from `blocked` to `portable`, and its probe calls the published route rather than the route inventory. |
579
| `EXIT-006` | Amended by stage 3. The verification section counts published anchors instead of reading a config flag, and reports `anchor_witnessed` as a separate axis that keeps the forge degraded. |
580
| `STATUS-001` | Amended by stage 3. The published key set gains `independence.verification.anchor`, `.anchor_published`, and `.anchor_witnessed`. |
docs/forge-operator-independence.md modified +26 -15

@@ -215,17 +215,27 @@ The verifier recomputes the chain, reports a broken or missing link, and

215 215
accepts an anchor — a sequence and link obtained anywhere other than this log —
216 216
against which it reports a disagreement.
217 217
218
The limit is worth naming as plainly as the capability, and the chain moves it
219
rather than removing it. Nothing here publishes a link outside the operator's
220
own storage yet, so an operator who rewrites an entry, its key, the index, and
221
every link after it produces a self-consistent log that verifies clean.
222
Content addressing and the chain make tampering *evident*, not *impossible*.
223
What the chain buys is that one link remembered elsewhere now covers a whole
224
prefix of the log, which is why the next step is publication and not
225
cryptography. `docs/2026-08-23-forge-wal-anchoring.md` weighs the options —
226
a client-side receipt to the pusher, a periodic external anchor, and signing —
227
with their true costs, and stages them. It also states what none of them can
228
do: an anchor detects rewriting, never withholding.
218
That link now leaves the operator's storage twice. A pusher gets it in their
219
own `git push` output. Everyone else gets it from
220
`/.well-known/openagents-forge-anchor.json`, which
221
`OpenAgents.Forge.AnchorPublisher` rewrites on an interval with each public
222
repository's head, and which needs no credential to read. A stranger who keeps
223
one of those documents can later report the rewrite that defeats content
224
addressing on its own.
225
226
The limit is worth naming as plainly as the capability, and publication moves
227
it rather than removing it. **The operator serves the anchor**, so the document
228
proves nothing on its own; its value is that keeping a copy is cheap and a copy
229
is what contradicts a later rewrite. Nobody outside the operator witnesses it,
230
which is why `/status` reports `anchor_published` and `anchor_witnessed` as two
231
separate facts and stays degraded on the second. Content addressing and the
232
chain make tampering *evident*, not *impossible*.
233
`docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md`
234
records why this surface and not a mirror commit, a public transparency log, a
235
relay set, or a chain anchor, with what each of those would require.
236
`docs/2026-08-23-forge-wal-anchoring.md` weighs the options with their true
237
costs and stages them. Both state what none of them can do: an anchor detects
238
rewriting, never withholding.
229 239
230 240
## Mirror recovery
231 241

@@ -292,9 +302,10 @@ The gaps below are not only recorded here. `OpenAgents.Forge.Independence`

292 302
publishes them in `OpenAgents.NetworkStatus`, so `/status` and
293 303
`GET /api/status` report the forge as independence-degraded while any of them
294 304
stands, under `EXIT-006`. The export counts are read from the ledger rather
295
than restated, and the verification section reports `anchor_published` as
296
`false` while no anchor is configured, so the difference between a
297
tamper-evident log and a tamper-proof one is published rather than blurred.
305
than restated, and the verification section counts the anchors actually
306
published and reports `anchor_witnessed` separately, so the difference between
307
a tamper-evident log, a published anchor, and a witnessed one is published
308
rather than blurred.
298 309
299 310
A forge that records its limits in a document and reports itself healthy on its
300 311
status page has hidden them.

@@ -315,6 +326,6 @@ the one people clone from.

315 326
| Gap | Issue |
316 327
| --- | --- |
317 328
| The live forge cannot serve a full clone of its own repository | #179 |
318
| No commitment to the WAL is published outside operator storage, so a consistent rewrite still verifies clean | #151 |
329
| The published WAL anchor is served by the operator and witnessed by nobody, so a consistent rewrite is caught only by a reader who kept a copy | #151 |
319 330
| No export is encrypted to a key the recipient holds, and no column is encrypted at rest | #178 |
320 331
| Five of six exit rehearsals have never been performed | #180 |
docs/taxonomy.md modified +15 -1

@@ -63,6 +63,16 @@ refs. A push is a receipt, not a deployment.

63 63
forge to GitHub. GitHub is a mirror only; nothing on GitHub can affect what
64 64
the forge serves.
65 65
66
**Anchor** — the periodic published commitment to the WAL
67
(`OpenAgents.Forge.Anchor`, ADR 0008): one document per interval at
68
`/.well-known/openagents-forge-anchor.json` naming each public repository's
69
head chain link, chained to the anchor before it. An anchor is **not a
70
receipt** — it is evidence held by whoever kept a copy, never a record of what
71
happened to one party, and never ref authority. Say *published* when the
72
operator served it and *witnessed* when somebody outside the operator attests
73
to it; today the anchor is published and unwitnessed, and the two words are not
74
interchangeable.
75
66 76
**Push to the forge, never to GitHub:**
67 77
68 78
```sh

@@ -726,7 +736,8 @@ is exactly one component system; adding a second is forbidden.

726 736
   **Where `machine` stays, and why**. `machine` in `contribution.machine`
727 737
   means machine-readable and is a different word.
728 738
5. **Name the receipt.** Turn, push, build, deployment, consent, outcome.
729
   When checkpoints exist, they are a receipt family, not a Git branch.
739
   When checkpoints exist, they are a receipt family, not a Git branch. An
740
   anchor is not a receipt.
730 741
6. **Module means two things.** Elixir module or module artifact — say which.
731 742
7. **An invariant is not true until its proof runs green.**
732 743
8. **Agent work is a thread.** A thread is not Sarah's one conversation

@@ -748,6 +759,9 @@ is exactly one component system; adding a second is forbidden.

748 759
    `apps/openagents.com`.
749 760
14. **Proposed means unclaimed.** An italic term in this document has no owner
750 761
    and no issue. Do not cite one as a plan.
762
15. **Published is not witnessed.** A surface the operator serves is published.
763
    Witnessed means a party outside the operator attests to it. Never write
764
    "anchored" where only the first holds.
751 765
752 766
## Proof
753 767
lib/openagents/forge/anchor.ex added +258

@@ -0,0 +1,258 @@

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
lib/openagents/forge/anchor_publisher.ex added +74

@@ -0,0 +1,74 @@

1
defmodule OpenAgents.Forge.AnchorPublisher do
2
  @moduledoc """
3
  The scheduled job that publishes the WAL anchor (`EXIT-005`, ADR 0008).
4
5
  It sits beside `OpenAgents.Forge.MirrorWatch` and never on the push path.
6
  `OpenAgents.Forge.Pushes` acknowledges a push only after the WAL persists it,
7
  and everything after that barrier is derived and unable to fail the push;
8
  this job is not even on that side of the barrier, because a slow or failing
9
  anchor must not be able to refuse a push.
10
11
  It publishes every tick whether or not the log moved. An anchor whose
12
  repository section is unchanged still carries a fresh `published_at`, which
13
  is the only thing that tells a reader publication has not stopped — and #168
14
  is explicit that a stopped publication is indistinguishable from an outage
15
  until somebody notices.
16
17
  Failure is logged and retried next tick. There is nothing to escalate: an
18
  unpublished anchor is the condition `OpenAgents.Forge.Independence` already
19
  reports on `/status`.
20
  """
21
22
  use GenServer
23
24
  require Logger
25
26
  alias OpenAgents.Forge.Anchor
27
28
  @default_interval_ms 60 * 60 * 1000
29
30
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
31
32
  @impl true
33
  def init(_opts) do
34
    send(self(), :tick)
35
    {:ok, %{}}
36
  end
37
38
  @impl true
39
  def handle_info(:tick, state) do
40
    publish()
41
    schedule()
42
    {:noreply, state}
43
  end
44
45
  def handle_info(_message, state), do: {:noreply, state}
46
47
  @doc "One publication pass. Public so a test can drive it without the timer."
48
  def publish do
49
    case Anchor.publish() do
50
      {:ok, anchor} ->
51
        {:ok, anchor}
52
53
      # The code is the atom itself, never an inspected payload:
54
      # `OpenAgents.LogSafety` keeps failure bodies out of operational lines.
55
      {:error, reason} ->
56
        Logger.warning("forge_wal_anchor_publish_failed code=#{reason}")
57
        {:error, reason}
58
    end
59
  rescue
60
    error ->
61
      Logger.warning(
62
        "forge_wal_anchor_publish_failed code=#{OpenAgents.OperationalLog.code(error)}"
63
      )
64
65
      {:error, :publish_failed}
66
  end
67
68
  @doc "The publication interval, which is also the anchor's exposure window."
69
  def interval_ms do
70
    Application.get_env(:openagents, :forge_wal_anchor_interval_ms, @default_interval_ms)
71
  end
72
73
  defp schedule, do: Process.send_after(self(), :tick, interval_ms())
74
end
lib/openagents/forge/independence.ex modified +38 -13

@@ -18,10 +18,16 @@ defmodule OpenAgents.Forge.Independence do

18 18
    `:partial` or `:blocked` appears here without anyone editing this module,
19 19
    and a gap closed elsewhere disappears from here in the same commit.
20 20
  * **Verification.** `EXIT-002` and `EXIT-005` make a rewrite of the WAL
21
    evident and total, not impossible. That distinction survives only while an
22
    anchor exists somewhere the operator does not solely control, so
23
    `anchor_published` reads the configured anchor source and reports `false`
24
    while none is configured. Issue #168 publishes one.
21
    evident and total, not impossible. That distinction survives only while a
22
    commitment exists somewhere the operator does not solely control, and
23
    publishing one and having one witnessed are two different facts, so both
24
    are published. `anchor_published` counts the anchors
25
    `OpenAgents.Forge.Anchor` has actually written rather than a config flag,
26
    so a publisher that has stopped reports `false`. `anchor_witnessed` is
27
    `false` because no party other than the operator attests to the document:
28
    the operator serves it and could serve any document, and its value is that
29
    a third party can cheaply keep a copy, not that anyone has. Issue #151
30
    carries the witness.
25 31
  * **Private data.** No export is encrypted to a key the recipient holds, and
26 32
    no Ecto column in this repository is encrypted at rest. Issue #178 carries
27 33
    that decision. This one is stated rather than derived, because there is no

@@ -38,10 +44,12 @@ defmodule OpenAgents.Forge.Independence do

38 44
  """
39 45
40 46
  alias OpenAgents.DataRights.ExportInventory
47
  alias OpenAgents.Forge.Anchor
41 48
42 49
  @schema "openagents.forge_independence.v1"
43 50
44 51
  @anchor_issue 168
52
  @witness_issue 151
45 53
  @encryption_issue 178
46 54
  @document "docs/forge-operator-independence.md"
47 55

@@ -68,9 +76,19 @@ defmodule OpenAgents.Forge.Independence do

68 76
  def degraded?,
69 77
    do: degraded?(export_section(), verification_section(), private_data_section())
70 78
71
  defp degraded?(export, verification, private_data) do
79
  @doc """
80
  Whether three given sections fall short on any axis.
81
82
  Public because two of the three axes are constants today: no export is
83
  encrypted and the anchor is witnessed by nobody, so `degraded?/0` would
84
  report `true` even if an axis were dropped from the disjunction entirely.
85
  Varying one section at a time is the only way a proof can show that each
86
  axis is actually load-bearing.
87
  """
88
  @spec degraded?(map(), map(), map()) :: boolean()
89
  def degraded?(export, verification, private_data) do
72 90
    export["gaps"] != [] or not verification["anchor_published"] or
73
      not private_data["exports_encrypted"]
91
      not verification["anchor_witnessed"] or not private_data["exports_encrypted"]
74 92
  end
75 93
76 94
  # Counted from the ledger, so this section cannot claim an export gap is

@@ -99,18 +117,25 @@ defmodule OpenAgents.Forge.Independence do

99 117
100 118
  defp count(entries, status), do: Enum.count(entries, &(&1.status == status))
101 119
102
  # An anchor the operator also holds is not an anchor. Until one is published
103
  # somewhere the operator does not solely control, the honest word for what
104
  # verification buys is "evident".
120
  # Publishing an anchor and having one witnessed are two facts, and collapsing
121
  # them into one boolean is how a surface starts claiming more than it can
122
  # show. The operator serves the anchor document, so publication alone leaves
123
  # a consistent rewrite undetectable to anyone who kept no copy of it.
124
  #
125
  # The count comes from the anchors actually written, not from a config flag,
126
  # so a publisher that has stopped reports `false` without anyone editing
127
  # this module. A failed read answers `false` too: the failure direction
128
  # claims less than reality rather than more.
105 129
  defp verification_section do
106
    anchor = safely(fn -> Application.get_env(:openagents, :forge_wal_anchor) end)
107
    published? = anchor not in [nil, false, ""]
130
    published? = safely(fn -> Anchor.published?() end) || false
108 131
109 132
    %{
110
      "property" => if(published?, do: "tamper_evident_with_anchor", else: "tamper_evident"),
133
      "property" => if(published?, do: "tamper_evident_published", else: "tamper_evident"),
111 134
      "chained" => true,
112 135
      "anchor_published" => published?,
113
      "issue" => if(published?, do: nil, else: @anchor_issue)
136
      "anchor_witnessed" => false,
137
      "anchor" => Anchor.path(),
138
      "issue" => if(published?, do: @witness_issue, else: @anchor_issue)
114 139
    }
115 140
  end
116 141
lib/openagents/forge/supervisor.ex modified +11 -1

@@ -13,11 +13,21 @@ defmodule OpenAgents.Forge.Supervisor do

13 13
      [
14 14
        {Task.Supervisor, name: OpenAgents.Forge.TaskSupervisor},
15 15
        {OpenAgents.Forge.MirrorWatch, []}
16
      ] ++ repository_children() ++ deploy_lane_children()
16
      ] ++ anchor_children() ++ repository_children() ++ deploy_lane_children()
17 17
18 18
    Supervisor.init(children, strategy: :one_for_one)
19 19
  end
20 20
21
  # The WAL anchor publisher (EXIT-005, ADR 0008). Off the push path by
22
  # construction: a slow or failing anchor must never be able to refuse a push.
23
  defp anchor_children do
24
    if Application.get_env(:openagents, :forge_wal_anchor_enabled, true) do
25
      [{OpenAgents.Forge.AnchorPublisher, []}]
26
    else
27
      []
28
    end
29
  end
30
21 31
  defp repository_children do
22 32
    if Application.get_env(:openagents, :repository_provisioner_enabled, true) do
23 33
      [
lib/openagents/forge/wal_anchor.ex added +45

@@ -0,0 +1,45 @@

1
defmodule OpenAgents.Forge.WALAnchor do
2
  @moduledoc """
3
  One published anchor document (`forge_wal_anchors`).
4
5
  The row holds the exact bytes that were served, not the fields they were
6
  built from, because the digest a reader computes is a digest of the bytes
7
  they fetched. Re-rendering a document from columns would let key order drift
8
  between releases and turn every archived copy into apparent tampering, which
9
  is the same reason `OpenAgents.Forge.WAL`'s chain encoding is not JSON.
10
11
  `previous_digest` names the digest of the anchor published before this one,
12
  so the published sequence is itself a hash chain: one archived anchor pins
13
  every anchor before it, the way an entry link pins every entry before it.
14
15
  This table is a publication record, never authority. Nothing reads it to
16
  decide what the forge serves, and `OpenAgents.Forge.Verification` reaches no
17
  database at all — a verifier consulting PostgreSQL would be asking the
18
  operator to confirm the operator.
19
  """
20
21
  use Ecto.Schema
22
  import Ecto.Changeset
23
24
  @primary_key {:id, :binary_id, autogenerate: true}
25
  @timestamps_opts [type: :utc_datetime_usec]
26
27
  @type t :: %__MODULE__{}
28
29
  schema "forge_wal_anchors" do
30
    field :anchor_seq, :integer
31
    field :digest, :string
32
    field :previous_digest, :string
33
    field :body, :string
34
    field :published_at, :utc_datetime_usec
35
    timestamps(updated_at: false)
36
  end
37
38
  def changeset(anchor, attrs) do
39
    anchor
40
    |> cast(attrs, [:anchor_seq, :digest, :previous_digest, :body, :published_at])
41
    |> validate_required([:anchor_seq, :digest, :body, :published_at])
42
    |> validate_number(:anchor_seq, greater_than_or_equal_to: 0)
43
    |> unique_constraint(:anchor_seq)
44
  end
45
end
lib/openagents_web/controllers/forge_anchor_controller.ex added +46

@@ -0,0 +1,46 @@

1
defmodule OpenAgentsWeb.ForgeAnchorController do
2
  @moduledoc """
3
  Serve the published WAL anchor (`EXIT-005`, ADR 0008).
4
5
  The stored bytes are served verbatim, because the digest a reader computes is
6
  a digest of the bytes they fetched and the next anchor names that digest as
7
  its `previous_digest`. Re-encoding the document from columns would let key
8
  order drift between releases and turn every archived copy into apparent
9
  tampering.
10
11
  Anonymous by construction: an anchor a reader has to authenticate for is an
12
  anchor the operator can withhold from the reader who would check it. There is
13
  nothing here to withhold anyway — the document names only repositories an
14
  anonymous reader can already see.
15
16
  What re-fetching settles is nothing, and the document says so on its face.
17
  The evidence is the copy a reader *kept*: an operator who rewrote the log
18
  would serve the rewritten head here too.
19
  """
20
21
  use OpenAgentsWeb, :controller
22
23
  alias OpenAgents.Forge.Anchor
24
25
  def show(conn, _params) do
26
    case Anchor.latest() do
27
      %{body: body} ->
28
        conn
29
        |> put_resp_content_type("application/json")
30
        |> send_resp(200, body)
31
32
      nil ->
33
        # Not an error, and not a silence either. A reader who fetches this
34
        # path deserves to be told that nothing is anchored yet rather than to
35
        # read a 404 as "wrong URL".
36
        conn
37
        |> put_status(:not_found)
38
        |> json(%{
39
          "schema" => Anchor.schema(),
40
          "published" => false,
41
          "reason" => "no anchor has been published yet",
42
          "decision" => "docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md"
43
        })
44
    end
45
  end
46
end
lib/openagents_web/live/network_status_live.ex modified +17

@@ -581,6 +581,23 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

581 581
                    @projection["independence"]["verification"]["issue"]
582 582
                  )}
583 583
                </span>
584
                <span :if={@projection["independence"]["verification"]["anchor_published"]}>
585
                  · anchor at
586
                  <.link href={@projection["independence"]["verification"]["anchor"]}>
587
                    <code>{@projection["independence"]["verification"]["anchor"]}</code>
588
                  </.link>
589
                </span>
590
                <span
591
                  :if={
592
                    @projection["independence"]["verification"]["anchor_published"] and
593
                      not @projection["independence"]["verification"]["anchor_witnessed"]
594
                  }
595
                  id="status-independence-anchor-witness"
596
                >
597
                  · published by the operator, witnessed by nobody{issue_text(
598
                    @projection["independence"]["verification"]["issue"]
599
                  )}
600
                </span>
584 601
              </li>
585 602
              <li id="status-independence-private-data">
586 603
                private data: access controlled, not encrypted{issue_text(
lib/openagents_web/route_authority.ex modified +7

@@ -226,6 +226,13 @@ defmodule OpenAgentsWeb.RouteAuthority do

226 226
  defp policy(%{path: "/api/status", verb: verb}) when verb in [:get, :head],
227 227
    do: declaration(:public_read, "anonymous", "published:status", false)
228 228
229
  # The published WAL anchor (EXIT-005, ADR 0008). Anonymous read: a
230
  # commitment a stranger cannot fetch without a credential is a commitment
231
  # the operator decides who may check.
232
  defp policy(%{path: "/.well-known/openagents-forge-anchor.json", verb: verb})
233
       when verb in [:get, :head],
234
       do: declaration(:public_read, "anonymous", "published:forge-wal-anchor", false)
235
229 236
  # The agent front door. Public by construction: it describes how to ask and
230 237
  # carries no instance data, so there is nothing in it to withhold.
231 238
  defp policy(%{path: path, verb: verb})
lib/openagents_web/router.ex modified +5

@@ -383,6 +383,11 @@ defmodule OpenAgentsWeb.Router do

383 383
    get "/health", HealthController, :show
384 384
    get "/healthz", HealthController, :show
385 385
    get "/api/status", NetworkStatusController, :show
386
387
    # The published WAL anchor (EXIT-005, ADR 0008). Anonymous by
388
    # construction: an anchor a reader must authenticate for is an anchor the
389
    # operator can withhold from the reader who would check it.
390
    get "/.well-known/openagents-forge-anchor.json", ForgeAnchorController, :show
386 391
    get "/api/changelog", ChangelogController, :show
387 392
    get "/api/contracts/repositories-v1.json", ApiContractController, :repositories_v1
388 393
    get "/api/contracts/do-not-build-v1.json", ApiContractController, :do_not_build_v1
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -285,7 +285,8 @@

285 285
    20260824024146,
286 286
    20260824031651,
287 287
    20260824032138,
288
    20260824032226
288
    20260824032226,
289
    20260824035934
289 290
  ],
290 291
  "required_tables": [
291 292
    "users",
priv/repo/migrations/20260824035934_create_forge_wal_anchors.exs added +30

@@ -0,0 +1,30 @@

1
defmodule OpenAgents.Repo.Migrations.CreateForgeWalAnchors do
2
  use Ecto.Migration
3
4
  # The published WAL anchor (`EXIT-005`, ADR 0008). One row per published
5
  # anchor document, holding the exact bytes that were served rather than the
6
  # fields they were built from: the digest a reader computes is a digest of
7
  # the bytes they fetched, and the next anchor names that digest.
8
  #
9
  # `anchor_seq` is unique because a published sequence with two different
10
  # documents behind it would break the chain a reader walks back. Two nodes
11
  # publishing in the same interval race here, and the loser retries next tick.
12
  #
13
  # This table is a publication record, never authority. Nothing reads it to
14
  # decide what the forge serves, and `OpenAgents.Forge.Verification` reaches
15
  # no database at all.
16
  def change do
17
    create table(:forge_wal_anchors, primary_key: false) do
18
      add :id, :binary_id, primary_key: true
19
      add :anchor_seq, :bigint, null: false
20
      add :digest, :string, null: false
21
      add :previous_digest, :string
22
      add :body, :text, null: false
23
      add :published_at, :utc_datetime_usec, null: false
24
25
      timestamps(type: :utc_datetime_usec, updated_at: false)
26
    end
27
28
    create unique_index(:forge_wal_anchors, [:anchor_seq])
29
  end
30
end
test/openagents/forge/independence_disclosure_test.exs modified +51 -12

@@ -15,6 +15,7 @@ defmodule OpenAgents.Forge.IndependenceDisclosureTest do

15 15
  import Phoenix.LiveViewTest
16 16
17 17
  alias OpenAgents.DataRights.ExportInventory
18
  alias OpenAgents.Forge.Anchor
18 19
  alias OpenAgents.Forge.Independence
19 20
20 21
  # Every string the disclosure may contain. A repository path, an account id,

@@ -25,10 +26,20 @@ defmodule OpenAgents.Forge.IndependenceDisclosureTest do

25 26
    docs/forge-operator-independence.md
26 27
    single_operator
27 28
    tamper_evident
28
    tamper_evident_with_anchor
29
    tamper_evident_published
30
    /.well-known/openagents-forge-anchor.json
29 31
    portable partial blocked not_user_data
30 32
  )
31 33
34
  # The projection is briefly cached so anonymous traffic cannot become an rpc
35
  # storm. A test that publishes an anchor would otherwise leave that state in
36
  # the cache for whichever test runs next.
37
  setup do
38
    :persistent_term.erase({OpenAgents.NetworkStatus, :cache})
39
    on_exit(fn -> :persistent_term.erase({OpenAgents.NetworkStatus, :cache}) end)
40
    :ok
41
  end
42
32 43
  describe "the disclosure derives from the ledger" do
33 44
    test "every gap the export ledger records is published, and no other" do
34 45
      recorded =

@@ -65,22 +76,32 @@ defmodule OpenAgents.Forge.IndependenceDisclosureTest do

65 76
      assert Independence.degraded?()
66 77
    end
67 78
79
    # The publication state is read from the anchors that exist, not from a
80
    # flag someone can set, so this test publishes a real one.
68 81
    test "publishing an anchor changes the verification claim" do
69
      previous = Application.get_env(:openagents, :forge_wal_anchor)
70
      Application.put_env(:openagents, :forge_wal_anchor, %{seq: 1, link: "abc"})
71
72
      on_exit(fn ->
73
        case previous do
74
          nil -> Application.delete_env(:openagents, :forge_wal_anchor)
75
          value -> Application.put_env(:openagents, :forge_wal_anchor, value)
76
        end
77
      end)
82
      {:ok, _anchor} = Anchor.publish()
78 83
79 84
      verification = Independence.projection()["verification"]
80 85
81 86
      assert verification["anchor_published"]
82
      assert verification["property"] == "tamper_evident_with_anchor"
83
      assert verification["issue"] == nil
87
      assert verification["property"] == "tamper_evident_published"
88
      assert verification["anchor"] == Anchor.path()
89
    end
90
91
    # The whole point of ADR 0008: the operator serves the anchor, so
92
    # publishing it does not make it evidence against the operator. A
93
    # disclosure that closed the verification axis on publication alone would
94
    # be claiming exactly what the anchor cannot show.
95
    test "a published anchor is still not witnessed, and still reports degraded" do
96
      {:ok, _anchor} = Anchor.publish()
97
98
      projection = Independence.projection()
99
100
      assert projection["verification"]["anchor_published"]
101
      refute projection["verification"]["anchor_witnessed"]
102
      assert projection["verification"]["issue"] == 151
103
      assert projection["degraded"]
104
      assert Independence.degraded?()
84 105
    end
85 106
86 107
    test "an unencrypted private export is disclosed rather than softened" do

@@ -124,6 +145,7 @@ defmodule OpenAgents.Forge.IndependenceDisclosureTest do

124 145
      assert view |> element("#status-independence-summary") |> render() =~ "degraded"
125 146
      assert has_element?(view, "#status-independence-verification")
126 147
      assert has_element?(view, "#status-independence-private-data")
148
      refute has_element?(view, "#status-independence-anchor-witness")
127 149
128 150
      rendered = render(view)
129 151

@@ -131,6 +153,23 @@ defmodule OpenAgents.Forge.IndependenceDisclosureTest do

131 153
        assert rendered =~ Atom.to_string(entry.family)
132 154
      end
133 155
    end
156
157
    test "a published anchor is named on the page and still called unwitnessed",
158
         %{conn: conn} do
159
      Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
160
      {:ok, _anchor} = Anchor.publish()
161
      :persistent_term.erase({OpenAgents.NetworkStatus, :cache})
162
163
      conn = put_req_header(conn, "accept", "text/html")
164
      {:ok, view, _html} = live(conn, ~p"/status")
165
166
      assert view |> element("#status-independence-verification") |> render() =~ Anchor.path()
167
168
      assert view |> element("#status-independence-anchor-witness") |> render() =~
169
               "witnessed by nobody"
170
171
      assert view |> element("#status-independence-summary") |> render() =~ "degraded"
172
    end
134 173
  end
135 174
136 175
  defp strings(value) when is_map(value), do: value |> Map.values() |> Enum.flat_map(&strings/1)
test/openagents/forge/independence_test.exs modified +133 -1

@@ -11,7 +11,17 @@ defmodule OpenAgents.Forge.IndependenceTest do

11 11
12 12
  use OpenAgents.DataCase, async: false
13 13
14
  alias OpenAgents.Forge.{PushReceipt, Pushes, Repos, Sync, Verification, WAL}
14
  alias OpenAgents.Forge.{
15
    Anchor,
16
    Independence,
17
    PushReceipt,
18
    Pushes,
19
    Repos,
20
    Sync,
21
    Verification,
22
    WAL
23
  }
24
15 25
  alias OpenAgents.Repo
16 26
17 27
  defmodule TestPipeline do

@@ -73,6 +83,7 @@ defmodule OpenAgents.Forge.IndependenceTest do

73 83
    %{
74 84
      base: base,
75 85
      repo: repository.storage_key,
86
      repository: repository,
76 87
      url: "http://x:#{plaintext}@127.0.0.1:#{port}/exit-owner/demo.git"
77 88
    }
78 89
  end

@@ -360,6 +371,99 @@ defmodule OpenAgents.Forge.IndependenceTest do

360 371
    end
361 372
  end
362 373
374
  ## ── EXIT-005: the anchor published to a stranger ───────────────────────
375
376
  describe "the anchor published at a well-known path" do
377
    test "a stranger who kept the document reports a consistent rewrite", context do
378
      publish_repository!(context)
379
      seed_history!(context)
380
381
      {:ok, published} = Anchor.publish()
382
383
      # The anchor a stranger holds is the bytes they fetched, so the head
384
      # comes out of the served document and not out of the log it commits to.
385
      head = document_head!(published, context)
386
387
      rewrite_first_entry_consistently!(context, "a payload the pusher never sent")
388
389
      # Nothing inside the operator's storage disagrees with anything else in
390
      # it. This is the case `EXIT-002` cannot catch, and #167's receipt only
391
      # catches for the one person who pushed.
392
      assert {:ok, %{findings: []}} = Verification.verify(context.repo)
393
394
      assert {:error, %{findings: findings}} = Verification.verify(context.repo, anchor: head)
395
      assert %{"seq" => _seq} = detail(findings, "anchor_mismatch")
396
    end
397
398
    test "the served bytes are what the digest and the next anchor name", context do
399
      publish_repository!(context)
400
      seed_history!(context)
401
402
      {:ok, first} = Anchor.publish()
403
      {:ok, second} = Anchor.publish()
404
405
      # A reader hashes the file they fetched, so the stored digest has to be
406
      # a digest of the stored bytes and nothing re-rendered from columns.
407
      assert Anchor.digest(first.body) == first.digest
408
      assert Anchor.digest(second.body) == second.digest
409
410
      # The published sequence is itself a chain: one archived anchor pins
411
      # every anchor before it, the way an entry link pins every entry.
412
      assert first.anchor_seq == 0
413
      assert first.previous_digest == nil
414
      assert second.anchor_seq == 1
415
      assert second.previous_digest == first.digest
416
    end
417
418
    test "a private repository is anchored for nobody", context do
419
      seed_history!(context)
420
421
      # The repository is private in this setup, and the anchor is anonymous.
422
      # Naming it here would publish its existence and its push count on a
423
      # surface `TRANSPARENCY-001` keeps a dark repository off entirely.
424
      {:ok, private_anchor} = Anchor.publish()
425
      assert repository_section(private_anchor, context) == nil
426
427
      publish_repository!(context)
428
      {:ok, public_anchor} = Anchor.publish()
429
      assert %{"entries" => entries} = repository_section(public_anchor, context)
430
      assert entries > 0
431
    end
432
433
    test "the document states that nobody witnesses it", context do
434
      publish_repository!(context)
435
      seed_history!(context)
436
437
      {:ok, published} = Anchor.publish()
438
      document = Jason.decode!(published.body)
439
440
      # ADR 0008: the operator serves this, so it proves nothing on its own.
441
      # The claim has to travel with the artifact, not only with the docs.
442
      refute document["witnessed"]
443
      refute document["signed"]
444
      assert document["trust"] =~ "witnessed by nobody"
445
      assert document["decision"] =~ "0008"
446
    end
447
448
    test "each axis of the disclosure decides degraded on its own", _context do
449
      clean_export = %{"gaps" => []}
450
      encrypted = %{"exports_encrypted" => true}
451
      unencrypted = %{"exports_encrypted" => false}
452
      published_unwitnessed = %{"anchor_published" => true, "anchor_witnessed" => false}
453
      witnessed = %{"anchor_published" => true, "anchor_witnessed" => true}
454
      unpublished = %{"anchor_published" => false, "anchor_witnessed" => false}
455
456
      # Publishing the anchor is not witnessing it, and the disclosure has to
457
      # keep reporting degraded on the second. Every other axis is clean here,
458
      # so this asserts the witness disjunct rather than riding on a constant.
459
      assert Independence.degraded?(clean_export, published_unwitnessed, encrypted)
460
      assert Independence.degraded?(clean_export, unpublished, encrypted)
461
      assert Independence.degraded?(clean_export, witnessed, unencrypted)
462
      assert Independence.degraded?(%{"gaps" => [%{}]}, witnessed, encrypted)
463
      refute Independence.degraded?(clean_export, witnessed, encrypted)
464
    end
465
  end
466
363 467
  ## ── EXIT-003: recovery from the WAL, never from the mirror ─────────────
364 468
365 469
  describe "recovery" do

@@ -654,6 +758,34 @@ defmodule OpenAgents.Forge.IndependenceTest do

654 758
655 759
  defp work_dir(context), do: Path.join(context.base, "work")
656 760
761
  # The setup's repository is private, which is what a repository is by
762
  # default. The anchor's population is what an anonymous reader can already
763
  # see, so a test about publication has to publish first.
764
  defp publish_repository!(context) do
765
    context.repository
766
    |> Ecto.Changeset.change(visibility: "public")
767
    |> Repo.update!()
768
  end
769
770
  defp repository_section(anchor, context) do
771
    path = "#{context.repository.owner}/#{context.repository.name}"
772
773
    anchor.body
774
    |> Jason.decode!()
775
    |> Map.fetch!("repositories")
776
    |> Enum.find(&(&1["repo"] == path))
777
  end
778
779
  defp document_head!(anchor, context) do
780
    case repository_section(anchor, context) do
781
      %{"head_seq" => seq, "head_link" => link} when is_integer(seq) and is_binary(link) ->
782
        %{seq: seq, link: link}
783
784
      other ->
785
        flunk("the published anchor carried no head for the repository: #{inspect(other)}")
786
    end
787
  end
788
657 789
  # What an operator with write access to their own object storage can do:
658 790
  # replace an accepted entry, re-derive its content-addressed key, and
659 791
  # recompute every link after it so the log agrees with itself.
test/openagents/network_status_test.exs modified +5

@@ -117,7 +117,12 @@ defmodule OpenAgents.NetworkStatusTest do

117 117
    "independence.private_data.issue",
118 118
    "independence.schema",
119 119
    "independence.verification",
120
    # The anchor's address, its publication state, and whether anybody outside
121
    # the operator witnesses it. Three keys, because ADR 0008 turns on
122
    # publication and witnessing being different facts.
123
    "independence.verification.anchor",
120 124
    "independence.verification.anchor_published",
125
    "independence.verification.anchor_witnessed",
121 126
    "independence.verification.chained",
122 127
    "independence.verification.issue",
123 128
    "independence.verification.property",
test/openagents/repositories/visibility_join_test.exs modified +4 -1

@@ -81,7 +81,10 @@ defmodule OpenAgents.Repositories.VisibilityJoinTest do

81 81
    OpenAgents.Projects => "the workspace-wide project list and the project-item reads",
82 82
    OpenAgents.Notifications => "the inbox read and the fan-out gate",
83 83
    OpenAgents.DataRights.AccountExport =>
84
      "the pull requests, stacks, and issue dependencies an export may carry"
84
      "the pull requests, stacks, and issue dependencies an export may carry",
85
    OpenAgents.Forge.Anchor =>
86
      "the repositories the published WAL anchor names, which is an anonymous " <>
87
        "surface and must reach exactly what an anonymous reader already reaches"
85 88
  }
86 89
87 90
  # Every site in `lib/` that names the predicate's own terms — a repository's
test/openagents_web/controllers/forge_anchor_controller_test.exs added +54

@@ -0,0 +1,54 @@

1
defmodule OpenAgentsWeb.ForgeAnchorControllerTest do
2
  @moduledoc """
3
  EXIT-005, ADR 0008. The anchor is only worth publishing if a stranger can
4
  fetch it with no credential and hash exactly what they fetched.
5
  """
6
7
  use OpenAgentsWeb.ConnCase, async: false
8
9
  alias OpenAgents.Forge.Anchor
10
11
  test "an anonymous reader fetches the stored bytes verbatim", %{conn: conn} do
12
    {:ok, published} = Anchor.publish()
13
14
    conn = get(conn, Anchor.path())
15
16
    assert conn.status == 200
17
    assert get_resp_header(conn, "content-type") |> hd() =~ "application/json"
18
19
    # Byte-for-byte, because the digest a reader computes is a digest of what
20
    # they fetched and the next anchor names that digest as its
21
    # `previous_digest`. Re-encoding here would break every archived copy.
22
    assert conn.resp_body == published.body
23
    assert Anchor.digest(conn.resp_body) == published.digest
24
  end
25
26
  test "the head a reader takes from the response is the head the log carries", %{conn: conn} do
27
    {:ok, _published} = Anchor.publish()
28
29
    body = conn |> get(Anchor.path()) |> response(200) |> Jason.decode!()
30
31
    assert body["schema"] == Anchor.schema()
32
    assert is_integer(body["anchor_seq"])
33
    assert is_list(body["repositories"])
34
    refute body["witnessed"]
35
    refute body["signed"]
36
  end
37
38
  test "an unpublished anchor says so rather than going silent", %{conn: conn} do
39
    body = conn |> get(Anchor.path()) |> json_response(404)
40
41
    assert body["published"] == false
42
    assert body["reason"] =~ "no anchor"
43
  end
44
45
  test "the latest anchor is the one served", %{conn: conn} do
46
    {:ok, _first} = Anchor.publish()
47
    {:ok, second} = Anchor.publish()
48
49
    body = conn |> get(Anchor.path()) |> response(200) |> Jason.decode!()
50
51
    assert body["anchor_seq"] == second.anchor_seq
52
    assert body["previous_digest"] == second.previous_digest
53
  end
54
end

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