gastownhall/beads · error

ephemeral and no_history are mutually exclusive

Error message

ephemeral and no_history are mutually exclusive

What it means

An issue cannot be both ephemeral and no_history at the same time; these wisp-plane flags are mutually exclusive. NormalizePersistenceMode rejects the combination up front because the resulting storage semantics are contradictory.

Source

Thrown at internal/types/types.go:962

// IsValid reports whether a persistence mode is a known explicit value.
// Empty is invalid because it would be indistinguishable from an omitted mode.
func (m PersistenceMode) IsValid() bool {
	return slices.Contains(validPersistenceModes, m)
}

// NormalizePersistenceMode maps a requested mode from current to its exact
// persistence fields without mutating an issue. The returned values are
// Ephemeral, NoHistory, and StorageClass, respectively. Plane and retention
// changes preserve the create-selected class. Promotion clears an explicit
// ephemeral class marker to select normalized versioned storage. An
// unversioned record may remain persistent but cannot move to a wisp mode.
func NormalizePersistenceMode(current Issue, mode PersistenceMode) (bool, bool, StorageClass, error) {
	if !mode.IsValid() {
		return false, false, "", fmt.Errorf("invalid persistence mode %q", mode)
	}
	if current.Ephemeral && current.NoHistory {
		return false, false, "", fmt.Errorf("ephemeral and no_history are mutually exclusive")
	}
	if !current.StorageClass.IsValid() {
		return false, false, "", fmt.Errorf("invalid storage class %q", current.StorageClass)
	}
	wispPlane := current.Ephemeral || current.NoHistory
	if wispPlane && current.StorageClass != "" && current.StorageClass != StorageClassEphemeral {
		return false, false, "", fmt.Errorf("storage class %q conflicts with ephemeral/no_history", current.StorageClass)
	}
	if !wispPlane && current.StorageClass == StorageClassEphemeral {
		return false, false, "", fmt.Errorf("storage class ephemeral requires ephemeral or no_history")
	}
	if current.StorageClass == StorageClassUnversioned && mode != PersistenceModePersistent {
		return false, false, "", fmt.Errorf("cannot move unversioned record to persistence mode %q", mode)
	}
	if persistenceMode(current) == mode {
		return current.Ephemeral, current.NoHistory, current.StorageClass, nil
	}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Clear one of the two flags on the Issue before normalizing (keep ephemeral OR no_history, not both)
  2. Re-fetch the issue and apply a single persistence change
  3. Fix the update path so a persistence change sets one mode, replacing rather than OR-ing flags

Example fix

// before
issue.Ephemeral = true; issue.NoHistory = true
// after
issue.Ephemeral = true; issue.NoHistory = false
Defensive patterns

Strategy: validation

Validate before calling

if issue.Ephemeral && issue.NoHistory {
    return fmt.Errorf("issue %s has both ephemeral and no_history set", issue.ID)
}

Type guard

func hasConflictingWispFlags(i Issue) bool {
    return i.Ephemeral && i.NoHistory
}

Try / catch

res, err := NormalizePersistenceMode(issue, mode)
if err != nil && strings.Contains(err.Error(), "mutually exclusive") {
    // clear one flag and retry once
}

Prevention

When it happens

Trigger: Calling NormalizePersistenceMode with current.Ephemeral == true and current.NoHistory == true, e.g. an Issue built by code that sets both booleans, or a record mutated by two overlapping updates.

Common situations: Applying two independent updates (one enabling ephemeral, one enabling no_history) without reading the record in between; hand-edited JSONL export; copying flags from two different commands.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/37b8782806e457e5. Report an issue: GitHub.