The ledger keeps what its comments promise - #620
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: WaylandYang <wayland0916@gmail.com>
WaylandYang
force-pushed
the
fix/the-ledger-keeps-what-its-comments-promise
branch
from
September 12, 2026 08:25
a1b752f to
57c9e49
Compare
WaylandYang
deleted the
fix/the-ledger-keeps-what-its-comments-promise
branch
September 12, 2026 08:40
This was referenced Sep 12, 2026
Closed
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four findings that came out of answering #564, all in the same place: the two contest tables keep less than their comments promise. Closes #612, #613, #618, #619.
A conflict that evaporated was not decided (#612, #613)
temporal::list_conflictsis a read, but before it selected anything it ran anUPDATEthat turned every open conflict with an invalidated side intostatus='resolved', resolution='stale', resolved_at=now(). The cleanup is right — a conflict whose side is gone has nothing left to adjudicate — but three things followed from where it lived:resolved_atrecorded when somebody opened the Review page.record_axisasks "was this conflict open at T" withstatus='open' OR resolved_at > T, so the answer depended on who had looked since.stalewas not in the value set the schema comments, andresolutionhad noCHECK, so the documented set and the real set had diverged.Now: a conflict that evaporated has its own status,
withdrawn;resolutionholds only the three human decisions and has aCHECK; and the retirement happens in a trigger onfacts.invalidated_at, soresolved_atis the invalidation time. Un-invalidating both sides reopens it. A trigger rather than the twelve call sites that setinvalidated_at(and the four that clear it): the invariant belongs where the data changes, and the thirteenth call site would have missed it.Existing rows are repaired rather than erased — a
stalerow'sresolved_atbecomes the earlier of its two facts'invalidated_at, which is when the conflict actually stopped applying and is already in the table.A cycle is keyed by the cycle (#618)
cycleswalks from every node inadj— aHashMap— andleft/rightwere the first and last fact of whichever traversal got there first. Measured: one three-edge cycle, the same input,check()called 200 times in one process gave three distinct(left, right, path)triples.Since
axiom_violationsis unique on(kb_id, kind, left_fact, right_fact), a rotation is a different row, and the consequence is not a duplicate — it is that a person's decision silently stops applying. A run deletes open rows it did not re-find and keeps resolved ones, so the same cycle comes back as a freshopenrow under the rotated key while the resolved row sits beside it pointing at a key nothing recomputes. The reopen branch matches on the key, so it never fires.The cycle is now rotated to start at its smallest fact before it is reported, which makes the key a function of the cycle. A regression test runs
checka hundred times and asserts one shape. The migration deletes open cycle rows (the next run recreates them canonically) and re-keys resolved ones from their storedpath, keeping the earliest when two rotations collide.A violation's evidence follows the run (#619)
Both writers used
ON CONFLICT ... DO NOTHING, and nothing else ever wrotepathordetail— not even the reopen branch. So a row that stays open carries the evidence of the first run that ever recorded its key, and a reopened row showsdetected_at = now()beside evidence from another run.DO UPDATEnow carries both forward. The insert and the follow-upSELECTfold into one statement;xmax = 0distinguishes a real insert so the report still counts what it says it counts.Checks
cargo test --workspaceagainst a database that is not the one a server is running on: 431 pass. The one failure isthe_embedding_gate_is_never_held_beyond_its_ceiling, which fails ondevwithout these changes as well — its budget is wall-clock over a span that includes the ledger writes, so a slow local Postgres trips it.The trigger, both migrations' data repair, and the cycle re-keying were each exercised against real rows in a rolled-back transaction before they were committed.
🤖 Generated with Claude Code