JuliusBrussee/caveman · error

proposalrun: marshal canonical detail

Error message

proposalrun: marshal canonical detail: %w

What it means

CanonicalDetail wraps a failure from json.Marshal of the already-canonicalized value. In normal operation this is nearly unreachable: encoding/json can fail marshaling only for unsupported values (channels, funcs, cyclic maps) or a MarshalJSON that errors. Because CanonicalDetail only rebuilds maps, slices, strings, bools, and canonicalJSONNumber (whose MarshalJSON writes its own validated bytes), hitting this means an internal invariant was violated rather than bad user input.

Solutions

  1. Check the wrapped inner error from json.Marshal for the unsupported-type message; identify which Go type leaked into the canonicalized tree.
  2. Verify you are on an unmodified version of the proposalrun package; if you patched canonicalizeJSONValue, ensure every new branch returns only JSON-safe types (map, slice, string, bool, nil, canonicalJSONNumber).
  3. If reproducible on stock code, file a bug with the exact detail blob — this indicates an internal invariant violation.
  4. As a defensive measure, log the offending detail blob (truncated) when this wrapper fires so the failing value can be reproduced.

Example fix

// before (patched canonicalize adding a raw type)
case time.Time:
    return x, nil // json.Marshal now fails on... actually encodes, but any custom Marshaler error propagates here

// after
case time.Time:
    return x.UTC().Format(time.RFC3339Nano), nil // return plain JSON-safe string
Defensive patterns

Strategy: try-catch

Try / catch

canon, err := proposalrun.CanonicalDetail(detail)
if err != nil && strings.Contains(err.Error(), "marshal canonical detail") {
    // invariant violation, not bad input: alert and dump the blob for a bug report
    log.Printf("BUG: canonical marshal failed: %v; blob=%q", err, truncate(detail, 512))
    return err
}

Prevention

When it happens

Trigger: Calling CanonicalDetail/RowHash where the decoded-and-rebuilt value tree somehow contains a non-JSON-representable Go value or canonicalJSONNumber produces bytes json.Marshal rejects. In practice this surfaces only if the package's internal invariants break (e.g. a future code change injects an unsupported type into the rebuilt tree).

Common situations: After a code change to canonicalizeJSONValue that introduces a new Go type without a MarshalJSON; custom json.Marshaler implementations registered on wrapper types passed through the any tree; OOM/corrupted state causing a malformed canonicalJSONNumber string.

Understand the failure class

Background: json.Marshal / "failed to marshal" errors in Go: why "unsupported type" happens and how to fix it — this error's family across 22 libraries.

Related errors


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

Appendix: source

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

	dec := json.NewDecoder(bytes.NewReader(detail))
	dec.UseNumber()
	if err := dec.Decode(&v); err != nil {
		return nil, fmt.Errorf("proposalrun: detail is not valid JSON: %w", err)
	}
	var trailing any
	if err := dec.Decode(&trailing); err != io.EOF {
		if err == nil {
			return nil, fmt.Errorf("proposalrun: detail contains trailing JSON")
		}
		return nil, fmt.Errorf("proposalrun: detail contains trailing data: %w", err)
	}
	v, err := canonicalizeJSONValue(v)
	if err != nil {
		return nil, fmt.Errorf("proposalrun: canonicalize detail: %w", err)
	}
	out, err := json.Marshal(v)
	if err != nil {
		return nil, fmt.Errorf("proposalrun: marshal canonical detail: %w", err)
	}
	return out, nil
}

// PostgreSQL jsonb stores JSON numbers using its numeric type. These are the
// documented numeric bounds (131072 digits before and 16383 after the decimal
// point); rejecting at the canonicalization boundary keeps write and read-back
// hashing deterministic and avoids unbounded strings from hostile exponents.
// maxJSONBNumberExponent is an additional allocation guard. PostgreSQL can
// accept some zero literals with a larger positive exponent after normalizing
// their zero weight; this package rejects those spellings conservatively so a
// hostile exponent never drives unbounded intermediate arithmetic.
const (
	maxJSONBNumericIntegerDigits  = 131072
	maxJSONBNumericFractionDigits = 16383
	maxJSONBNumberCoefficient     = maxJSONBNumericIntegerDigits + maxJSONBNumericFractionDigits + 1
	maxJSONBNumberExponent        = maxJSONBNumericIntegerDigits + maxJSONBNumericFractionDigits
	maxJSONBNumberLexeme          = maxJSONBNumberCoefficient + 16

View on GitHub (pinned to 3ee70a1026)