gastownhall/beads · error

journal: marshal issue %s: %w

Error message

journal: marshal issue %s: %w

What it means

insertEventRow marshals the issue snapshot to JSON before inserting the journal row; if json.Marshal fails the error is wrapped as 'journal: marshal issue'. Since types.Issue is JSON-tag-annotated this should be nearly impossible, but it surfaces invalid field types (e.g. non-UTF8, unsupported types injected via custom fields).

Source

Thrown at internal/storage/issueops/journal.go:425

		}
		issue.IsBlocked = blocked != 0
		return issue, nil
	}
	return nil, fmt.Errorf("%w: issue %s", storage.ErrNotFound, issueID)
}

// insertEventRow performs the actual INSERT. It is the ONE seam both write
// plumbings funnel through, so the seq mechanism cannot drift between them. A
// nil issue is stored as SQL NULL (deletes); a nil dep is stored as SQL NULL
// (non-dependency ops). ts is the insert time, stamped inside the committing
// transaction. actor is stored as-is — "" for the genuinely unattributable
// paths — in the NOT NULL DEFAULT ” actor column.
func insertEventRow(ctx context.Context, tx DBTX, op EventOp, issueID string, issue *types.Issue, dep *EventDep, comment *EventComment, actor string) error {
	var issueJSON any
	if issue != nil {
		b, err := json.Marshal(issue)
		if err != nil {
			return fmt.Errorf("journal: marshal issue %s: %w", issueID, err)
		}
		issueJSON = string(b)
	}
	var depJSON any
	if dep != nil {
		b, err := json.Marshal(dep)
		if err != nil {
			return fmt.Errorf("journal: marshal dep for %s: %w", issueID, err)
		}
		depJSON = string(b)
	}
	var commentJSON any
	if comment != nil {
		b, err := json.Marshal(comment)
		if err != nil {
			return fmt.Errorf("journal: marshal comment for %s: %w", issueID, err)
		}
		commentJSON = string(b)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Inspect the wrapped %w error to find the offending field
  2. Sanitize issue metadata to JSON-serializable types before calling journal APIs
  3. Upgrade/downgrade so the in-process types.Issue matches the persisted data
  4. Report upstream if stock types.Issue fails to marshal (likely a bug)

Example fix

// before
issue.Metadata["cb"] = func() {} // unmarshalable
ops.RecordEventInTx(ctx, tx, op, id, issue, nil, nil)
// after
issue.Metadata["cb"] = nil // strip unsupported values before journaling
ops.RecordEventInTx(ctx, tx, op, id, issue, nil, nil)
Defensive patterns

Strategy: try-catch

Validate before calling

if _, err := json.Marshal(issue); err != nil {
    return fmt.Errorf("issue %s not JSON-serializable: %w", issue.ID, err)
}

Try / catch

if err := ops.RecordEventInTx(ctx, tx, op, id, issue, nil, nil); err != nil {
    if strings.Contains(err.Error(), "marshal issue") {
        return fmt.Errorf("bad issue payload: %w", err)
    }
    return err
}

Prevention

When it happens

Trigger: RecordEventInTx/RecordDeleteInTx/RecordDepEventInTx/RecordCommentEventInTx called with an *types.Issue whose contents cannot be marshaled — typically custom metadata holding unmarshalable values or corrupted in-memory state.

Common situations: Custom importers stuffing raw map/func values into issue metadata structs; version mismatch where a field type changed and holds incompatible data; fixtures with cyclic references.

Related errors


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