gastownhall/beads · error · issueops.ErrValidation

%w: %v

Error message

%w: %v

What it means

PlanCompareAndSetKey validates the metadata key with ValidateMetadataKey and wraps any failure as issueops.ErrValidation with the underlying message. ValidateMetadataKey rejects invalid characters (including quotes and backslashes) and empty/oversized keys, since keys are embedded in JSON path expressions and must be safe. The wrapped %v carries the specific rule that was violated.

Source

Thrown at internal/storage/metadata_cas.go:68

// PlanCompareAndSetKey validates a compare-and-set request and canonicalizes
// its values. It is the whole of the role's request validation: every
// implementation calls it before touching a substrate, so a refused request
// costs no database work anywhere.
//
// 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.
//

View on GitHub (pinned to 71377f2769)

Solutions

  1. Call ValidateMetadataKey(key) yourself first and surface the specific rule violation to the user.
  2. Sanitize the key: lowercase kebab-case, letters/digits/hyphens, no quotes or backslashes.
  3. Trim whitespace and reject empty keys before constructing the request.
  4. Check errors.Is(err, issueops.ErrValidation) to distinguish this from storage errors.

Example fix

// before
key := "my key \"quoted\"" // illegal characters
plan, err := PlanCompareAndSetKey(req) // ErrValidation
// after
key := "my-key"
if err := ValidateMetadataKey(key); err == nil {
    plan, err = PlanCompareAndSetKey(req)
}
Defensive patterns

Strategy: validation

Validate before calling

if err := ValidateMetadataKey(req.Key); err != nil {
    return fmt.Errorf("invalid metadata key %q: %v", req.Key, err)
} // run before PlanCompareAndSetKey

Type guard

func safeKey(k string) bool { return ValidateMetadataKey(k) == nil }

Try / catch

plan, err := PlanCompareAndSetKey(req)
if err != nil {
    if errors.Is(err, issueops.ErrValidation) {
        return fmt.Errorf("compare-and-set rejected: %v", err)
    }
    return err
}

Prevention

When it happens

Trigger: Calling PlanCompareAndSetKey with in.Key containing illegal characters (quotes, backslashes, whitespace/control chars), an empty key, or a key exceeding the allowed length — e.g. a key read from user input without sanitization.

Common situations: Building keys dynamically from labels or filenames that contain spaces/quotes; copy-pasting keys with trailing whitespace; API consumers passing arbitrary user input straight through as a key.

Related errors


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