Skip to content

The ledger keeps what its comments promise - #620

Merged
WaylandYang merged 3 commits into
devfrom
fix/the-ledger-keeps-what-its-comments-promise
Sep 12, 2026
Merged

WaylandYang merged 3 commits into
devfrom
fix/the-ledger-keeps-what-its-comments-promise

Conversation

@WaylandYang

Copy link
Copy Markdown
Contributor

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_conflicts is a read, but before it selected anything it ran an UPDATE that turned every open conflict with an invalidated side into status='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_at recorded when somebody opened the Review page. record_axis asks "was this conflict open at T" with status='open' OR resolved_at > T, so the answer depended on who had looked since.
  • stale was not in the value set the schema comments, and resolution had no CHECK, so the documented set and the real set had diverged.
  • A base whose Review page is never opened kept stale conflicts open forever.

Now: a conflict that evaporated has its own status, withdrawn; resolution holds only the three human decisions and has a CHECK; and the retirement happens in a trigger on facts.invalidated_at, so resolved_at is the invalidation time. Un-invalidating both sides reopens it. A trigger rather than the twelve call sites that set invalidated_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 stale row's resolved_at becomes 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)

cycles walks from every node in adj — a HashMap — and left/right were 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_violations is 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 fresh open row 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 check a 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 stored path, 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 wrote path or detail — 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 shows detected_at = now() beside evidence from another run. DO UPDATE now carries both forward. The insert and the follow-up SELECT fold into one statement; xmax = 0 distinguishes a real insert so the report still counts what it says it counts.

Checks

cargo test --workspace against a database that is not the one a server is running on: 431 pass. The one failure is the_embedding_gate_is_never_held_beyond_its_ceiling, which fails on dev without 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

WaylandYang and others added 2 commits September 12, 2026 16:25
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
WaylandYang force-pushed the fix/the-ledger-keeps-what-its-comments-promise branch from a1b752f to 57c9e49 Compare September 12, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A conflict list resolves conflicts while reading them

1 participant