JuliusBrussee/caveman · critical

proposalrun: seq prev_hash does not link to the prior row

Error message

proposalrun: seq %d prev_hash does not link to the prior row

What it means

VerifyChain checks that each row's PrevHash equals the previous row's RowHash (empty for seq 1). This error means the hash link between two adjacent rows is broken: the row's prev_hash was edited, rows were reordered or transplanted from another chain, or a row was inserted without updating descendants' prev_hash values.

Solutions

  1. Locate the first broken link (the reported seq) and recompute the chain from that point using the library's Append/RowHash functions.
  2. Restore affected rows from a backup taken before the manual edit.
  3. Rebuild the whole proposal's chain from the original events if the source of truth is available.
  4. Prevent direct UPDATE/DELETE on the table (revoke privileges, triggers) so links can only change via the append API.

Example fix

// before
db.Exec("UPDATE proposal_runs SET prev_hash = $1 WHERE seq = $2", someHash, seq)
// after
run := buildNextRun(prevRow, action, detail, cost) // Append computes prev_hash from prevRow.RowHash
Defensive patterns

Strategy: try-catch

Validate before calling

func linksIntact(runs []proposalrun.Run) bool {
	sorted := append([]proposalrun.Run(nil), runs...)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].Seq < sorted[j].Seq })
	prev := ""
	for _, r := range sorted {
		if r.PrevHash != prev { return false }
		prev = r.RowHash
	}
	return true
}

Try / catch

if err := proposalrun.VerifyChain(runs); err != nil {
	if strings.Contains(err.Error(), "prev_hash does not link") {
		// rebuild chain from that seq via the append API
	}
	return err
}

Prevention

When it happens

Trigger: Calling VerifyChain(runs) where sorted[i].PrevHash != sorted[i-1].RowHash — e.g. prev_hash manually updated, a row copied from another proposal, or a row appended without chaining to the prior hash.

Common situations: Manual SQL updates to repair 'wrong-looking' hashes, importing rows from a different run set, bugs in custom writers that bypass the Append helper, forked chains after a restore.

Understand the failure class

Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/4e1df9686206b521. Report an issue: GitHub.

Appendix: source

Thrown at shared/platform/proposalrun/proposalrun.go:357

//   - each row's row_hash recomputes from its own stored fields (no tampering).
//
// It returns nil for an empty slice (a proposal with no runs yet is consistent).
func VerifyChain(runs []Run) error {
	if len(runs) == 0 {
		return nil
	}
	sorted := make([]Run, len(runs))
	copy(sorted, runs)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].Seq < sorted[j].Seq })

	prevHash := ""
	for i, r := range sorted {
		wantSeq := int64(i + 1)
		if r.Seq != wantSeq {
			return fmt.Errorf("proposalrun: seq %d out of order (expected %d)", r.Seq, wantSeq)
		}
		if r.PrevHash != prevHash {
			return fmt.Errorf("proposalrun: seq %d prev_hash does not link to the prior row", r.Seq)
		}
		got, err := RowHash(r.PrevHash, r.Seq, r.Action, r.Detail, r.CostUSD, r.CreatedAt)
		if err != nil {
			return fmt.Errorf("proposalrun: seq %d recompute: %w", r.Seq, err)
		}
		if got != r.RowHash {
			return fmt.Errorf("proposalrun: seq %d row_hash mismatch (tampered)", r.Seq)
		}
		prevHash = r.RowHash
	}
	return nil
}

View on GitHub (pinned to 3ee70a1026)