{"record":{"id":"4e1df9686206b521","repo":"JuliusBrussee/caveman","slug":"proposalrun-seq-d-prev-hash-does-not-link-to-the","errorCode":null,"errorMessage":"proposalrun: seq %d prev_hash does not link to the prior row","messagePattern":"proposalrun: seq (.+?) prev_hash does not link to the prior row","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"shared/platform/proposalrun/proposalrun.go","lineNumber":357,"sourceCode":"//   - each row's row_hash recomputes from its own stored fields (no tampering).\n//\n// It returns nil for an empty slice (a proposal with no runs yet is consistent).\nfunc VerifyChain(runs []Run) error {\n\tif len(runs) == 0 {\n\t\treturn nil\n\t}\n\tsorted := make([]Run, len(runs))\n\tcopy(sorted, runs)\n\tsort.Slice(sorted, func(i, j int) bool { return sorted[i].Seq < sorted[j].Seq })\n\n\tprevHash := \"\"\n\tfor i, r := range sorted {\n\t\twantSeq := int64(i + 1)\n\t\tif r.Seq != wantSeq {\n\t\t\treturn fmt.Errorf(\"proposalrun: seq %d out of order (expected %d)\", r.Seq, wantSeq)\n\t\t}\n\t\tif r.PrevHash != prevHash {\n\t\t\treturn fmt.Errorf(\"proposalrun: seq %d prev_hash does not link to the prior row\", r.Seq)\n\t\t}\n\t\tgot, err := RowHash(r.PrevHash, r.Seq, r.Action, r.Detail, r.CostUSD, r.CreatedAt)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"proposalrun: seq %d recompute: %w\", r.Seq, err)\n\t\t}\n\t\tif got != r.RowHash {\n\t\t\treturn fmt.Errorf(\"proposalrun: seq %d row_hash mismatch (tampered)\", r.Seq)\n\t\t}\n\t\tprevHash = r.RowHash\n\t}\n\treturn nil\n}\n","sourceCodeStart":339,"sourceCodeEnd":370,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/27d5a3981a347890211bb1bf2439e5c821a63bc9/shared/platform/proposalrun/proposalrun.go#L339-L370","documentation":"Each Run row stores PrevHash = the previous row's RowHash (empty string for seq 1). After sorting and confirming contiguity, VerifyChain compares each row's stored PrevHash against the RowHash of the row before it. A mismatch means the linkage field itself is broken: the row was inserted with the wrong predecessor hash (e.g. read a stale chain head under a race), was edited in place, or was spliced in from another chain.","triggerScenarios":"Two writers append concurrently: both read the same tail RowHash and one overwrites/interleaves, so a row links to a hash that is not its true predecessor. Or an UPDATE statement touches prev_hash. Or rows from two organizations/chains were merged into one ListRuns result.","commonSituations":"Missing unique constraint on (org, seq) letting concurrent appends race; an operator 'fixing' a row with SQL UPDATE; copying rows between environments; test fixtures hand-built with fabricated hashes.","solutions":["Serialize appends: allocate Seq and read the chain tail inside one transaction (SELECT ... FOR UPDATE on the tail row or an advisory lock), so PrevHash is always the committed predecessor.","Repair by recomputing PrevHash and RowHash forward from the first broken row (re-chain) — but treat the gap as a tamper event and investigate first.","Add a UNIQUE(org_id, seq) constraint and a trigger rejecting UPDATEs to prev_hash/row_hash so drift cannot recur."],"exampleFix":"// before\nhead := store.GetTailHash(orgID)            // outside the write transaction\nrow := buildRun(action, detail, head)       // racy: another writer may append in between\nstore.Insert(row)\n\n// after\ntx := db.Begin()\ntx.Exec(`SELECT row_hash FROM proposal_runs WHERE org_id=$1 ORDER BY seq DESC LIMIT 1 FOR UPDATE`, orgID)\nhead := /* that row_hash */\nrow := buildRun(action, detail, head)\ntx.Insert(row)\ntx.Commit()","handlingStrategy":"validation","validationCode":"// Detect a broken link before the verifier: compare stored PrevHash to prior RowHash.\nfunc linksConsistent(sorted []proposalrun.Run) bool {\n    prev := \"\"\n    for _, r := range sorted {\n        if r.PrevHash != prev { return false }\n        prev = r.RowHash\n    }\n    return true\n}","typeGuard":null,"tryCatchPattern":"if err := proposalrun.VerifyChain(runs); err != nil {\n    if strings.Contains(err.Error(), \"prev_hash does not link\") {\n        // stop writes, diff against a replica/backup, then re-chain from the broken row\n    }\n}","preventionTips":["Allocate Seq and read the tail RowHash inside one transaction (FOR UPDATE on the tail or an advisory lock).","Make the table append-only for the app role; forbid UPDATE on prev_hash/row_hash.","Alert on this error: it indicates either a writer race or tampering, both needing investigation."],"tags":["audit","hash-chain","concurrency","tamper-detection"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}