cmd/corral/ui_ledger.go at b663803169, reviewed by codex:gpt-6-astra, verified by claude-code:claude-fable-5-1.
readUILedger verifies one directory snapshot, reads another, and pairs entries with checks solely by index: concurrent replacement or reordering can attach an earlier entry's successful signature check to different content, while newly appended entries receive an empty check without increasing Problems.
I tried to refute by showing the two reads are the same snapshot or that pairing is validated. Neither holds: VerifyLedgerDir (internal/auditpush/ledgerdir.go:564-567) calls ReadLedgerDir itself, and readLedgerRecord (cmd/corral/ledger_record.go:29-35) calls ReadLedgerDir again, so ui_ledger.go:90 and :94 are two independent directory reads. ui_ledger.go:105-109 pairs by index and falls back to a zero ChainCheck when i >= len(checks); it never compares c.Hash (which ChainCheck exposes at ledgerdir.go:545 precisely so callers can match) with e.Hash. The only narrowing I can offer is that both reads sort stably by Pushed (ledgerdir.go:1031) and the ledger is append-only, so the common race is an append that yields a trailing entry rendered as 'unsigned' with no Problem increment — which is the second half of the finding, not a refutation of it. No lock or hash cross-check exists in the scope.
confirmed by pdbethke — real, low not medium, checked by reading main b6638031: readUILedger reads the directory twice (VerifyLedgerDir, then readLedgerRecord) and pairs checks to entries by index (ui_ledger.go:105-109) without comparing ChainCheck.Hash to e.Hash. On an append-only ledger the realistic race is an entry appended between reads rendering as unsigned with no Problem counted; a stale SigOK on different bytes needs a rewrite in that window. Matching by hash closes both.
ref 786e8a0c675e76d241e5443fbd3e285042e79b3eb3f9d2e58e9b5efa590b6d12#R1
Replacing c.Note with the retraction message discards VerifyLedgerDir's warning that legacy signed entries have unauthenticated signer names, so a retracted pre-corral-ledger-4 entry can display 'verified' beside its self-reported keyid without the required caveat.
I tried to refute by showing the caveat survives somewhere else or that retracted entries do not render keyid/verified. It does not and they do: ledgerdir.go:656-657 appends the 'signer name is self-reported' caveat into c.Note for any signed entry whose format does not hash keyid; ui_ledger.go:115 copies c.Note into u.Note and then :122-123 assigns u.Note = "RETRACTED: " + r.Reason, discarding it (the adjudication branch at :125 does the same). Verified and KeyID are untouched, and cmd/corral/uiweb/index.html:178 renders chip('ok', 'verified · ' + e.keyid) whenever e.verified is true with no problem, while :180-181 shows only e.note. So a retracted pre-4 signed entry displays 'verified · <self-reported keyid>' with no caveat. The claim is exactly as narrow as stated.
confirmed by pdbethke — real, low, checked by reading main b6638031: ledgerdir.go:656-657 puts the 'signer name is self-reported' caveat into c.Note for pre-corral-ledger-4 signed entries; ui_ledger.go:123 (and :125) overwrite u.Note with the retraction text while uiweb/index.html:178 still renders 'verified · <keyid>', so the caveat is lost exactly on retracted legacy entries.
ref 786e8a0c675e76d241e5443fbd3e285042e79b3eb3f9d2e58e9b5efa590b6d12#R2
Recorded so that "found nothing here" can be told apart from "never looked here".
Finding 1 undermines the association between displayed entries and their verification results during concurrent ledger changes. Finding 2 drops a verification caveat from retracted entries. These are code-read findings; no code or tests were executed.
Both findings survive. R1 is correct as stated: readUILedger reads the directory twice (VerifyLedgerDir at line 90 via its own ReadLedgerDir, then readLedgerRecord at line 94 via a second ReadLedgerDir) and pairs checks to entries purely by index at lines 105-109, never comparing checks[i].Hash against e.Hash even though ChainCheck carries Hash for exactly that purpose. The practical window is narrow — the ledger is append-only, both reads sort by Pushed, and an append between reads only adds trailing entries that fall into the i >= len(checks) branch — but the reviewer's stated consequences hold: a trailing entry gets an empty ChainCheck (rendered as 'unsigned', Problems not incremented), and a rewrite/reorder in the window would carry a stale SigOK onto different bytes. R2 is also correct: VerifyLedgerDir appends the 'signer name is self-reported' caveat to c.Note at ledgerdir.go:656-657 for pre-corral-ledger-4 signed entries, ui_ledger.go:123 then overwrites u.Note wholesale with the retraction reason, and the page at cmd/corral/uiweb/index.html:178 still renders 'verified · <keyid>' from e.verified/e.keyid, so the caveat is the only thing lost. I could not refute either.