gastownhall/beads · error · issueops.ErrValidation

%w: expected value for metadata key %q: %v

Error message

%w: expected value for metadata key %q: %v

What it means

PlanCompareAndSetKey canonicalizes the expected and new pointer values via CanonicalMetadataPointer and wraps failures as issueops.ErrValidation with the key name and reason. Compare-and-set semantics depend on the expected value matching canonically, so both operands must be valid, canonical metadata pointers. The wrapped %v explains exactly what CanonicalMetadataPointer rejected (type or content).

Source

Thrown at internal/storage/metadata_cas.go:73

// It COPIES both raw values rather than aliasing the caller's, because the
// canonical form is written into the plan and the request belongs to the caller
// for the whole call.
func PlanCompareAndSetKey(in issueops.CompareAndSetKeyRequest) (CompareAndSetKeyPlan, error) {
	if in.Actor == "" {
		return CompareAndSetKeyPlan{}, fmt.Errorf(
			"%w: compare-and-set requires an actor to attribute the swap to", issueops.ErrValidation)
	}
	if in.IssueID == "" {
		return CompareAndSetKeyPlan{}, fmt.Errorf(
			"%w: compare-and-set requires an issue id", issueops.ErrValidation)
	}
	if err := ValidateMetadataKey(in.Key); err != nil {
		return CompareAndSetKeyPlan{}, fmt.Errorf("%w: %v", issueops.ErrValidation, err)
	}
	plan := CompareAndSetKeyPlan{Actor: in.Actor, IssueID: in.IssueID, Key: in.Key}
	var err error
	if plan.Expected, err = CanonicalMetadataPointer(in.Expected); err != nil {
		return CompareAndSetKeyPlan{}, fmt.Errorf("%w: expected value for metadata key %q: %v",
			issueops.ErrValidation, in.Key, err)
	}
	if plan.Value, err = CanonicalMetadataPointer(in.Value); err != nil {
		return CompareAndSetKeyPlan{}, fmt.Errorf("%w: new value for metadata key %q: %v",
			issueops.ErrValidation, in.Key, err)
	}
	return plan, nil
}

// CanonicalMetadataValue returns raw's canonical encoding: the encoding two
// JSON metadata values share exactly when issueops.MetadataCAS calls them
// equal.
//
// Insignificant whitespace goes and object keys are emitted in sorted order, so
// a value re-serialized by a different encoder still matches — the property
// that keeps a caller from losing a compare-and-set to its own formatting.
// Duplicate keys in one object collapse to the last, which is what every JSON
// reader in this tree already does with them.

View on GitHub (pinned to 71377f2769)

Solutions

  1. Read the current stored value and canonicalize it (or use CanonicalMetadataPointer directly) before constructing Expected.
  2. Match the JSON type of the stored value exactly — quote strings: --metadata-json with \"expected\" not bare expected.
  3. Call CanonicalMetadataPointer on both Expected and Value in a pre-check and surface its error to the user.
  4. If uncertain of the current value, fetch it first rather than guessing; CAS exists to avoid blind overwrites.

Example fix

// before
req.Value = json.RawMessage(`alice`) // bare word, invalid JSON
plan, err := PlanCompareAndSetKey(req) // ErrValidation
// after
req.Value = json.RawMessage(`"alice"`)
plan, err := PlanCompareAndSetKey(req)
Defensive patterns

Strategy: validation

Validate before calling

if _, err := CanonicalMetadataPointer(req.Expected); err != nil {
    return fmt.Errorf("bad expected value: %v", err)
}
if _, err := CanonicalMetadataPointer(req.Value); err != nil {
    return fmt.Errorf("bad new value: %v", err)
} // pre-check both operands before PlanCompareAndSetKey

Type guard

func canonicalPointer(raw json.RawMessage) bool {
    _, err := CanonicalMetadataPointer(raw)
    return err == nil
}

Try / catch

plan, err := PlanCompareAndSetKey(req)
if err != nil {
    if errors.Is(err, issueops.ErrValidation) && strings.Contains(err.Error(), "value for metadata key") {
        return fmt.Errorf("expected/new value rejected for key %q: fetch the stored value and match its JSON type", req.Key)
    }
    return err
}

Prevention

When it happens

Trigger: Calling PlanCompareAndSetKey with in.Expected or in.Value that CanonicalMetadataPointer rejects — e.g. a non-JSON value, a value of the wrong type for the stored pointer, or a malformed raw fragment — e.g. passing "null" where a concrete value is required, or an object where a string pointer exists.

Common situations: Shell scripts passing unquoted values so scalars become something else; comparing against a value typed differently from what is stored (number vs string); building expected values by hand instead of reading the current stored value.

Related errors


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