{"record":{"id":"68aeeea1cf3fc5a0","repo":"gastownhall/beads","slug":"cannot-demote-issue-s-with-retained-snapshots","errorCode":null,"errorMessage":"cannot demote issue %s with retained snapshots","messagePattern":"cannot demote issue (.+?) with retained snapshots","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/persistence.go","lineNumber":148,"sourceCode":"\t}\n\treturn result, nil\n}\n\nfunc persistenceIssueTable(wisp bool) string {\n\tif wisp {\n\t\treturn \"wisps\"\n\t}\n\treturn \"issues\"\n}\n\nfunc rejectPersistenceDemotion(ctx context.Context, tx DBTX, id string) error {\n\tfor _, table := range []string{\"issue_snapshots\", \"compaction_snapshots\"} {\n\t\tvar count int\n\t\tif err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE issue_id = ?`, table), id).Scan(&count); err != nil {\n\t\t\treturn fmt.Errorf(\"check retained snapshots in %s: %w\", table, err)\n\t\t}\n\t\tif count > 0 {\n\t\t\treturn fmt.Errorf(\"cannot demote issue %s with retained snapshots\", id)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc copyPersistenceAuxiliary(ctx context.Context, tx DBTX, id string, sourceWisp, targetWisp bool) (map[string]bool, error) {\n\tchanged := map[string]bool{}\n\tfor i, from := range persistenceAuxTables(sourceWisp) {\n\t\tto := persistenceAuxTables(targetWisp)[i]\n\t\tcolumns, key, err := persistenceColumns(from)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcopied, err := tx.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s (%s) SELECT %s FROM %s WHERE %s = ?`, to, columns, columns, from, key), id)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"copy %s: %w\", from, err)\n\t\t}\n\t\tif rows, err := copied.RowsAffected(); err == nil && rows > 0 {","sourceCodeStart":130,"sourceCodeEnd":166,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/persistence.go#L130-L166","documentation":"rejectPersistenceDemotion blocks moving an issue to a lower-durability plane (e.g. durable issues -> ephemeral wisp) when retained rows exist in issue_snapshots or compaction_snapshots for that issue. Demoting would orphan or destroy history that the snapshot tables are supposed to preserve, so the move is refused unconditionally. It is a hard guard, not a transient error.","triggerScenarios":"Calling MoveIssuePersistenceInTx to demote an issue whose id has COUNT(*) > 0 in either issue_snapshots or compaction_snapshots within the same transaction.","commonSituations":"Compacting/cleaning up old issues without first expiring their compaction snapshots; bulk demotion scripts over issues that were previously compacted; migration tooling moving issues to the wisp plane while history-retention policy still holds their snapshots.","solutions":["Expire/purge the issue's snapshots (per your retention policy) before attempting the demotion","Promote or keep the issue on its current, more durable plane instead of demoting","If demotion is genuinely required, snapshot-retention must be resolved first: delete or archive the snapshot rows in the same transaction, then retry","Split the batch: demote only issues with zero retained snapshots (check both tables first)"],"exampleFix":"// before\nerr := issueops.MoveIssuePersistenceInTx(ctx, tx, id, true) // demote to wisp\n// after\nfor _, tbl := range []string{\"issue_snapshots\", \"compaction_snapshots\"} {\n\tvar n int\n\ttx.QueryRowContext(ctx, \"SELECT COUNT(*) FROM \"+tbl+\" WHERE issue_id = ?\", id).Scan(&n)\n\tif n > 0 {\n\t\treturn fmt.Errorf(\"issue %s has %d rows in %s; expire snapshots before demotion\", id, n, tbl)\n\t}\n}\nerr := issueops.MoveIssuePersistenceInTx(ctx, tx, id, true)","handlingStrategy":"validation","validationCode":"for _, tbl := range []string{\"issue_snapshots\", \"compaction_snapshots\"} {\n\tvar n int\n\tif err := tx.QueryRowContext(ctx, \"SELECT COUNT(*) FROM \"+tbl+\" WHERE issue_id = ?\", id).Scan(&n); err != nil {\n\t\treturn err\n\t}\n\tif n > 0 {\n\t\treturn fmt.Errorf(\"issue %s retains %d snapshot(s) in %s; resolve before demotion\", id, n, tbl)\n\t}\n}","typeGuard":"func canDemote(ctx context.Context, tx issueops.DBTX, id string) (bool, error) {\n\tfor _, tbl := range []string{\"issue_snapshots\", \"compaction_snapshots\"} {\n\t\tvar n int\n\t\tif err := tx.QueryRowContext(ctx, \"SELECT COUNT(*) FROM \"+tbl+\" WHERE issue_id = ?\", id).Scan(&n); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tif n > 0 { return false, nil }\n\t}\n\treturn true, nil\n}","tryCatchPattern":null,"preventionTips":["Expire or archive snapshots per your retention policy before any demotion batch","Filter demotion candidates to issues with zero snapshot rows in both tables","Never delete snapshot rows blindly just to unblock a demotion — confirm retention policy first","Prefer keeping history-bearing issues durable; demote only truly ephemeral issues"],"tags":["go","storage","persistence","snapshots","data-loss-guard"],"backgroundTag":"demotion-blocked-by-snapshots","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}