gastownhall/beads · error
cannot demote issue %s with retained snapshots
Error message
cannot demote issue %s with retained snapshots
What it means
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.
Source
Thrown at internal/storage/issueops/persistence.go:148
}
return result, nil
}
func persistenceIssueTable(wisp bool) string {
if wisp {
return "wisps"
}
return "issues"
}
func rejectPersistenceDemotion(ctx context.Context, tx DBTX, id string) error {
for _, table := range []string{"issue_snapshots", "compaction_snapshots"} {
var count int
if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE issue_id = ?`, table), id).Scan(&count); err != nil {
return fmt.Errorf("check retained snapshots in %s: %w", table, err)
}
if count > 0 {
return fmt.Errorf("cannot demote issue %s with retained snapshots", id)
}
}
return nil
}
func copyPersistenceAuxiliary(ctx context.Context, tx DBTX, id string, sourceWisp, targetWisp bool) (map[string]bool, error) {
changed := map[string]bool{}
for i, from := range persistenceAuxTables(sourceWisp) {
to := persistenceAuxTables(targetWisp)[i]
columns, key, err := persistenceColumns(from)
if err != nil {
return nil, err
}
copied, err := tx.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s (%s) SELECT %s FROM %s WHERE %s = ?`, to, columns, columns, from, key), id)
if err != nil {
return nil, fmt.Errorf("copy %s: %w", from, err)
}
if rows, err := copied.RowsAffected(); err == nil && rows > 0 {View on GitHub (pinned to 71377f2769)
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)
Example fix
// before
err := issueops.MoveIssuePersistenceInTx(ctx, tx, id, true) // demote to wisp
// after
for _, tbl := range []string{"issue_snapshots", "compaction_snapshots"} {
var n int
tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl+" WHERE issue_id = ?", id).Scan(&n)
if n > 0 {
return fmt.Errorf("issue %s has %d rows in %s; expire snapshots before demotion", id, n, tbl)
}
}
err := issueops.MoveIssuePersistenceInTx(ctx, tx, id, true) Defensive patterns
Strategy: validation
Validate before calling
for _, tbl := range []string{"issue_snapshots", "compaction_snapshots"} {
var n int
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl+" WHERE issue_id = ?", id).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("issue %s retains %d snapshot(s) in %s; resolve before demotion", id, n, tbl)
}
} Type guard
func canDemote(ctx context.Context, tx issueops.DBTX, id string) (bool, error) {
for _, tbl := range []string{"issue_snapshots", "compaction_snapshots"} {
var n int
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+tbl+" WHERE issue_id = ?", id).Scan(&n); err != nil {
return false, err
}
if n > 0 { return false, nil }
}
return true, nil
} Prevention
- 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
When it happens
Trigger: Calling MoveIssuePersistenceInTx to demote an issue whose id has COUNT(*) > 0 in either issue_snapshots or compaction_snapshots within the same transaction.
Common situations: 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.
Related errors
- no store is open for this workspace
- not found
- db: ChildCounterSQLRepository.NextChildID: parentID must not
- db: DependencySQLRepository.Insert: dep must not be nil
- db: DependencySQLRepository.Insert: IssueID must not be empt
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/68aeeea1cf3fc5a0.
Report an issue: GitHub.