Skip to content

bd --set-metadata infers JSON type from value content; numeric-string values silently break Go map[string]string round-trip #4146

@realies

Description

@realies

Summary

bd --set-metadata key=value infers the JSON type of value from its string content. Numeric-looking strings like "1" are stored as JSON integers (not strings), and that asymmetry breaks consumers that unmarshal metadata into map[string]string and then do string equality on the values. The bd CLI's own --metadata-field filter happens to normalize input for comparison, so the round trip looks fine from the CLI; the breakage only surfaces when a Go program (internal/beads.ListQuery.Matches style) compares the stored value against a string literal.

Reproduction

bd 1.0.4 (56477c26e), dolt-backed store:

$ bd init
$ ID=$(bd create --title "probe" --type task --json | jq -r .id)
$ bd update "$ID" --set-metadata "probe=1"
$ bd update "$ID" --set-metadata "probe2=order"

# Storage type — what dolt actually holds
$ bd sql "SELECT id, JSON_TYPE(JSON_EXTRACT(metadata, '\$.probe')) AS t1,
                JSON_TYPE(JSON_EXTRACT(metadata, '\$.probe2')) AS t2
          FROM issues WHERE id='$ID'"
+----+---------+--------+
| id | t1      | t2     |
+----+---------+--------+
| .. | INTEGER | STRING |
+----+---------+--------+

# JSON round-trip — what Go consumers see
$ bd list --metadata-field "probe=1" --json | jq '.[0].metadata'
{
  "probe": 1,        ← JSON number, not "1"
  "probe2": "order"  ← JSON string, as expected
}

Why it matters

internal/beads.Bead.Metadata is typed map[string]string (internal/beads/beads.go). Consumers that build a ListQuery{Metadata: map[string]string{"probe": "1"}} and rely on the cache's per-key equality match (internal/beads/caching_store_reads.go::matchesMetadatab.Metadata[k] != v string equality) get a silent zero result because the stored value never round-trips back as the string "1". There is no error, no warning — the query just always misses.

This bit gascity in gastownhall/gascity#2556 (a metadata-flag-based pool-demand signal). The first iteration of that PR stored gc.pool_demand="1", the supervisor queried for Metadata{"gc.pool_demand": "1"}, and the count silently stayed at zero across every reconcile tick despite the wisp being created correctly. The workaround there was to pick a non-numeric sentinel ("order") and pin it with a regression test, but that's a workaround per-consumer, not a fix in bd.

The bd CLI's own filter (bd list --metadata-field probe=1) happens to work — it must be normalizing input or comparing on the JSON-typed value rather than the round-tripped string — so the asymmetry is invisible from the shell. Only Go-level callers hit it.

What I'd want from the fix

One of these:

  1. Preserve write-time type--set-metadata key=value always stores value as a JSON string. Authors who want a JSON number can use --set-metadata-json or similar. Removes the surprise but is a behavior change.
  2. Round-trip stabilitybd list --json normalizes all metadata values to strings on the way out, so map[string]string consumers see what they wrote. Behavior change in the JSON output shape.
  3. Document the cast — keep the current behavior, add a paragraph in the bd CLI docs + a // NOTE on Bead.Metadata warning that numeric-looking values won't round-trip stably for map[string]string consumers. Cheapest fix, just documents the trap so the next person doesn't lose a day to it.

Happy to file a PR for any of the above once direction is picked. Even option 3 (the doc-only fix) would have saved me roughly half a debug session.

Adjacent

The internal bdstore.go comment at line ~382 mentions bd CLI type inference for strings like "true" and "42" — so the behavior is known at the read boundary, just not warned about at the write boundary or in the public type. Could lift that comment up to the Bead.Metadata field doc as the cheapest version of option 3.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions