Verification reports lag as tampering, and the three nodes disagree about the same repository #251
Evidence
Shipped in the release at 0bf2706, promoted 2026-08-27.
1 pushes receipt
- AtlantisPleb opened this issue 4d ago
-
A Author 4d ago Sweep verdict: stays open. This is a real design task, not a remainder, and it has no owner.
Nothing here is a small finish. The first acceptance criterion changes what
verify/2means: it has to take the applied-sequence marker as an input and split "this node has not replayed entry N yet" from "this node applied entry N and cannot produce what it says". That is a contract change to a verifierEXIT-002depends on, and it needs the design pass the issue's own analysis sets up rather than a patch.What blocks it: nobody is assigned, and it should not be started casually — a verifier that is wrong in the other direction (silently treating real divergence as lag) is worse than the false positives being reported now. The lag window is minutes and self-healing, so this is not urgent in the way a red build is.
One coupling worth recording so it is not discovered twice. #179's third acceptance criterion asks for a periodic verification pass whose findings reach the
EXIT-006status disclosure. As this issue shows, a pass built on today'sverify/2would publishserved_refs_divergedandobject_missingfor a healthy forge most of the time, on whichever node ran it — so the scheduled pass should not ship before this lands, or it ships pre-tuned-out. #179 is in flight; that ordering is the thing to carry across.No code changed in this sweep.
-
AtlantisPleb
closed this as completed in
6bef9224d ago -
A Author 4d ago Fixed on
mainin 6bef9222b8cc31fbe4a8ddf6419b342354cfea4f.Reproduced first. Three data directories against one WAL, which is the fleet's actual shape (shared object storage, per-node
/var/lib/openagents/forge/repos). A node one entry behind —applied_seq=1, headseq=2— returned{:error, ...}withserved_refs_diverged(recorded= the new sha,served= the old one) andobject_missing, byte for byte the shape a tampered projection produces. A node that had never replayed returned the same two codes, three times over. The issue does not overstate it.How lag and tampering are told apart now.
verify/2locates the projection on the log instead of comparing it to the head. The greatest sequence whose recorded post-state refs the repository serves exactly is its:position, andhead_seq - positionis:behind. A projection sitting at some sequence on the log carries no finding and reportsstatus: :behindwith{:ok, report}. A projection sitting at no sequence reportsserved_refs_divergedper ref andstatus: :diverged.The boundary is tight because lag runs one way only. A node that has not replayed an entry is missing what that entry introduced; it can never serve a ref the log has no record of, a ref at a value the log never recorded, or a value the log recorded before the state it is serving. Each of those is still a finding however far behind the node is, and
object_missingis bounded by the position rather than waived — every object an entry at or below it introduced must still be there.The applied-sequence marker bounds the search rather than answering it: only sequences at or above it are candidates. Rolling the marker forward to the head makes the check stricter, not quieter, because the head's refs still have to be present. Rolling it back to
-1only widens the search to older states the log itself records. A marker past the end of the log is a new finding,applied_seq_beyond_log— which is also how a WAL truncated at the tail surfaces, since0..n-1stays contiguous afterward and every other check misses it.Two limits are named rather than hidden. A projection rolled back to a state the log passed through, with its marker rolled back to match, is reported as behind: from the WAL and the repository alone that is the same observation as a node mid-replay. And a deleted cache is an empty projection at sequence
-1, which is what a node that has never replayed looks like — the report still says how far from the log it is, andREPOSITORY-003is what brings it back. The independence test that asserted a lost cache was divergence now asserts it is behind by the whole log, which is still not clean.The cluster answer.
verify_cluster/2asks every member and combines the answers into a verdict lag cannot move::verifiedwhen every member answered and every one is at the head;:convergingwhen nothing contradicts the log and some member is behind or silent;:divergedonly when a member contradicts the log, when a member cannot see a repository the others verified, or when two members report different chain links at the same sequence;:unavailablewhen nothing was checked. A replaying node moves the fleet between:verifiedand:converging, both of which clear themselves. Every report names its node, applied sequence, position, and distance from the head, and cluster findings carry the node that produced them. Passing:anchorthrough to the members is how you check they are reading one log rather than only each other.Acceptance criteria.
- An entry above the marker is lag, an entry at or below it that the projection cannot produce is a finding. Done, and made stricter: the marker is a claim that bounds the search, not a tolerance.
- A report says which node produced it and at what applied sequence:
:node,:applied_seq,:position,:behind,:head_seq. - #179's scheduled pass can publish findings without publishing the replay window — a behind node returns
{:ok, report}withfindings: [].
Tests.
test/openagents/forge/verification_test.exs, 15 tests over three real projections replayed from one real WAL of genuinereceive-packentries: a lagging node, a never-replayed node, a tampered current node, a node that is both behind and tampered, a smuggled ref on a lagging node, a missing object, a forged marker in each direction, and the fleet at three sequences, converging, verified, diverged, partly silent, and wholly silent.EXIT-002is amended in the same commit with the lag/divergence rule, the marker's role, both named limits, and the cluster verdict;docs/taxonomy.mdgains behind and diverged so the two words stop being interchangeable.
What happened
#180's rehearsal 2 was performed against the live forge on 2026-08-25, on revision
46cf8a5aea3791936c22e82c145a9a8dd734374d. Run on one node it reportsfindings: []for this repository, which is the result the rehearsal asks for. Run on all three nodes at the same time it reports three different answers.Two samples, minutes apart, each one a single sweep across
sarah-fleet-1,sarah-fleet-2, andsarah-fleet-3:OpenAgentsInc/openagents.comserved_refs_diverged,object_missing, 383OpenAgentsInc/openagentsserved_refs_diverged,object_missingserved_refs_diverged,object_missingOpenAgentsInc/openagents.comserved_refs_diverged,object_missing, 393OpenAgentsInc/openagentsserved_refs_diverged,object_missingserved_refs_diverged,object_missing,object_missingThese are the two findings that mean the served state disagrees with the record.
OpenAgents.Forge.Verification's own moduledoc callsobject_missing"the repository cannot produce an object the WAL says a push introduced", and #179 treats one of them as a corruption serious enough to file. On a healthy forge, under ordinary push traffic, every node reports them about some repository most of the time.Why the nodes disagree
The WAL is shared and the projection is not.
forge_wal_adapterisOpenAgents.Forge.WAL.Gcsagainst the bucketgs://sarah-forge-wal, so all three nodes read one log. The bare repositories live on each node's own disk —/var/lib/openagents/forge/reposis on/dev/sda1, not a shared mount — so a push accepted on one node reaches the other two only when they replay it.verify/2compares a shared log against a local projection and has no way to tell a node that has not replayed yet from a node whose projection was altered. Both produce the same two findings.The window is minutes rather than seconds. Entry 136 of
OpenAgentsInc/openagentswas written at 14:32:33Z;sarah-fleet-1still reported the divergence at 14:38:25Z and was clean by 14:42:35Z. It does converge on its own, and by 14:42 the node's WAL head and itsrefs/heads/mainon disk both read4773472f.Why it matters
#179's third acceptance criterion cannot be met as written. It asks for "a periodic verification pass over the configured repositories whose findings reach the status disclosure
EXIT-006publishes". A pass built today would publishserved_refs_divergedandobject_missingfor a forge with nothing wrong with it, on whichever node ran it, most of the time. The finding that would have caught #179 is the finding that fires constantly, so it would be tuned out.A verifier that cannot distinguish lag from divergence weakens
EXIT-002. The contract's value is that a reader can be told whether to trust what the forge serves. An answer that depends on which of three nodes replies, and that is wrong for several minutes after every push, is not that.It is also visible to clients, in principle. A clone is answered by whichever node the load balancer picks. Eight consecutive
git ls-remotecalls againstOpenAgentsInc/openagentsreturned the same sha, so no flapping was observed from outside, but nothing in the design prevents one node answering with refs another node has already advanced past.Reproduction
Copy it into the container on each of
sarah-fleet-1,sarah-fleet-2, andsarah-fleet-3and runbin/openagents rpc 'Code.eval_file("/tmp/probe.exs")', then read/tmp/probe.out. Under any push traffic the three answers differ.Acceptance criteria
verify/2distinguishes a projection that has not replayed an entry yet from one that disagrees with an entry it has applied. The applied-sequence markerOpenAgents.Forge.Sync.ensure_fresh/1already trusts is the obvious input: an entry above the marker is lag, and an entry at or below it that the projection cannot produce is a finding.Found by: #180's rehearsal 2,
docs/forge-exit-rehearsals.md.