{"record":{"id":"67c744fb173be84b","repo":"RyanCodrai/turbovec","slug":"side-car-key-key-r-at-crumb-path-entry-is-t","errorCode":null,"errorMessage":"side-car key {key!r} at {_crumb_path(entry)} is {type(key).__name__}, not str. JSON object keys are strings, so writing it would stringify the key and silently merge it with any existing {str(key)!r} key, losing data on reload. Convert the key to a str before saving.","messagePattern":"side-car key (.+?) at (.+?) is (.+?), not str\\. JSON object keys are strings, so writing it would stringify the key and silently merge it with any existing (.+?) key, losing data on reload\\. Convert the key to a str before saving\\.","errorType":"validation","errorClass":"TypeError","httpStatus":null,"severity":"error","filePath":"turbovec-python/python/turbovec/_persist.py","lineNumber":309,"sourceCode":"    # children's parent link, so the walk costs one tuple per node and\n    # builds no path strings. Eagerly formatting `f\"{path}[{key!r}]\"` for\n    # every node cost ~18% of the whole validation pass on a 200k-doc\n    # payload, all of it to produce strings that are thrown away unless a\n    # save fails. `_crumb_path` reconstructs the path from the links on\n    # the failure path only.\n    root = (payload, None, None)\n    stack = [root]\n    seen: set[int] = set()\n    while stack:\n        entry = stack.pop()\n        obj = entry[0]\n        if isinstance(obj, dict):\n            if id(obj) in seen:\n                continue\n            seen.add(id(obj))\n            for key, value in obj.items():\n                if not isinstance(key, str):\n                    raise TypeError(\n                        f\"side-car key {key!r} at \"\n                        f\"{_crumb_path(entry)} is {type(key).__name__}, not \"\n                        f\"str. JSON object keys are strings, so writing it \"\n                        f\"would stringify the key and silently merge it with \"\n                        f\"any existing {str(key)!r} key, losing data on \"\n                        f\"reload. Convert the key to a str before saving.\"\n                    )\n                stack.append((value, entry, key))\n        elif isinstance(obj, (list, tuple)):\n            if id(obj) in seen:\n                continue\n            seen.add(id(obj))\n            for i, value in enumerate(obj):\n                stack.append((value, entry, i))\n        elif isinstance(obj, float) and not math.isfinite(obj):\n            if math.isnan(obj):\n                token = \"NaN\"\n            else:","sourceCodeStart":291,"sourceCodeEnd":327,"githubUrl":"https://github.com/RyanCodrai/turbovec/blob/ccab9f325e6ce2a270a87daf01ae4e443bcf2d49/turbovec-python/python/turbovec/_persist.py#L291-L327","documentation":"Before atomically writing the JSON side-car, atomic_save runs _check_json_faithful to prove the payload survives a JSON round-trip without data loss. JSON object keys are always strings, so a dict with a non-str key (int, tuple, None...) would be silently stringified by json.dump and could collide with an existing string key of the same text. The check raises TypeError at the offending path (described by _crumb_path) with instructions.","triggerScenarios":"Saving a store whose documents/metadata side-car contains a dict with non-string keys, e.g. metadata={1: 'a'} or {(0,1): 'x'}, via atomic_save/load/from_persist_path; cyclic or deeply nested metadata traversed by the checker reaching such a key.","commonSituations":"Building metadata from parsed config where numeric ids were used as dict keys; merging records from a database whose ids are ints; converting from another store format that allows non-string keys.","solutions":["Convert non-string keys to strings before saving: `{str(k): v for k, v in d.items()}`.","Fix metadata construction so ids are stored as string values in a standard field rather than dict keys.","Use a structure like a list of {id, value} pairs or explicit id fields instead of a keyed dict for non-string ids.","Wrap in try/except TypeError on save and transform the offending metadata (the path is given by _crumb_path in the message)."],"exampleFix":"// before\nmetadata = {1: \"alpha\", \"1\": \"beta\"}  # int key would merge with \"1\"\natomic_save(...)\n// after\nmetadata = {str(k): v for k, v in {1: \"alpha\", \"1\": \"beta\"}.items()}\natomic_save(...)","handlingStrategy":"validation","validationCode":"def json_faithful_keys(obj):\n    if isinstance(obj, dict):\n        assert all(isinstance(k, str) for k in obj), 'non-str JSON key'\n        for v in obj.values():\n            json_faithful_keys(v)\n    elif isinstance(obj, (list, tuple)):\n        for v in obj:\n            json_faithful_keys(v)\njson_faithful_keys(metadata)","typeGuard":"def str_keys(d: dict) -> bool:\n    return all(isinstance(k, str) for k in d)","tryCatchPattern":"try:\n    turbovec.atomic_save(...)\nexcept TypeError as e:\n    if 'side-car key' in str(e):\n        metadata = {str(k): v for k, v in walk(metadata)}  # normalize keys\n    else:\n        raise","preventionTips":["Normalize all metadata dict keys with str(k) at construction time.","Store ids as values in fields, not as dict keys, when they are not strings.","Avoid tuples/None as dict keys in data destined for JSON.","Round-trip test: json.loads(json.dumps(payload)) in CI for representative stores."],"tags":["python","json","serialization","data-loss"],"backgroundTag":"json-serialization-failed","analyzedSha":"ccab9f325e6ce2a270a87daf01ae4e443bcf2d49","analyzedAt":"2026-09-06T08:39:18.516Z","contentChangedAt":"2026-09-06T08:39:18.516Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}