multica-ai/multica · warning

value must be a primitive: string, number, or bool

Error message

value must be a primitive: string, number, or bool

What it means

validateIssueMetadataValue rejects values that are not primitive JSON scalars: arrays and objects fail this branch (null fails earlier with its own message; invalid JSON fails the unmarshal step). The V1 metadata surface is deliberately flat — a JSONB object of scalars — so nested structures are out of contract. Clients that need structure must flatten it into multiple keys.

Source

Thrown at server/internal/handler/issue_metadata.go:72

// validateIssueMetadataValue rejects anything other than a primitive JSON
// scalar. Null, arrays, and objects are not allowed — the V1 surface is
// flat KV. Removing a key uses DELETE, not a null value.
func validateIssueMetadataValue(raw json.RawMessage) error {
	if len(raw) == 0 {
		return errors.New("value is required")
	}
	var v any
	if err := json.Unmarshal(raw, &v); err != nil {
		return fmt.Errorf("value must be valid JSON: %w", err)
	}
	switch v.(type) {
	case string, bool, float64:
		return nil
	case nil:
		return errors.New("value cannot be null (use DELETE to remove a key)")
	default:
		return errors.New("value must be a primitive: string, number, or bool")
	}
}

// parseIssueMetadata decodes the JSONB bytes from db.Issue.Metadata into a
// Go map suitable for response serialization. Empty or unparseable blobs
// degrade to an empty map — the DB CHECK guarantees object shape, so this
// path is only hit on rows somehow predating the migration. Shared with the
// service-layer broadcast rendering (service.IssueToMap) so both ways of
// describing an issue agree on what an unset bag looks like on the wire.
func parseIssueMetadata(raw []byte) map[string]any {
	return util.JSONObjectOrEmpty(raw)
}

// parseMetadataFilterParam reads the `metadata` query parameter (a JSON
// object) and returns it as the JSONB filter blob passed to ListIssues /
// CountIssues / ListOpenIssues. Empty input means "no filter" and returns
// a nil []byte, which the SQL layer interprets as "skip the @> check".
//

View on GitHub (pinned to 2c0912b6ec)

Solutions

  1. Flatten structures into multiple keys (e.g. tags.0, tags.1 or tags='a,b') matching the key regex.
  2. If structure is required, keep it out of this endpoint — model it as a first-class resource instead.
  3. Validate client-side: typeof value in {'string','number','boolean'} before sending.
  4. For lists, consider a comma/newline-delimited string if the reader can parse it.

Example fix

// before
await putMetadata(id, 'tags', ['a', 'b']); // sends {"value":["a","b"]}

// after
await putMetadata(id, 'tags', 'a,b'); // primitive string
// or multiple keys: 'tags.0'='a', 'tags.1'='b'
Defensive patterns

Strategy: type-guard

Validate before calling

function assertPrimitiveValue(value) {
  const t = typeof value;
  if (t !== 'string' && t !== 'number' && t !== 'boolean') {
    throw new TypeError(`metadata value must be string|number|boolean, got ${t}`);
  }
}

Type guard

const isMetadataScalar = (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';

Prevention

When it happens

Trigger: PUT with {"value":["a","b"]}, {"value":{"nested":true}}, or {"value":{"a":[1]}}. Any JSON array or object literal as the value triggers it.

Common situations: Client stuffing a whole config object or tag list under one key; schema drift where a client upgrades to structured metadata before the server does; form state serialized wholesale as the value.

Related errors


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/3375b6f7b02608af. Report an issue: GitHub.