|
| 1 | +# Design: arbitrary metadata injection into `.pbf.json` containers |
| 2 | + |
| 3 | +**Date:** 2026-07-21 |
| 4 | +**Status:** DESIGN COMPLETE — approved by Peter (all forks ruled); NOT yet |
| 5 | +implemented. Future nice-to-have. Implement TDD-first (Lua reference → port to |
| 6 | +the other four container surfaces). |
| 7 | +**Depends on:** the `.pbf.json` container (`-C`/`--container`), shipped in |
| 8 | +`feat(container)` commit `ac5712a` and its predecessors. |
| 9 | +**Related:** [`2026-06-26-printable-binary-file-container-design.md`](2026-06-26-printable-binary-file-container-design.md) |
| 10 | +(container v1 schema + transport-resistance). |
| 11 | + |
| 12 | +## Problem / motivation |
| 13 | + |
| 14 | +The container carries a fixed set of fields (filename, crc32s, optional |
| 15 | +POSIX/timestamp/mime metadata). Users want to attach **arbitrary** key/value |
| 16 | +metadata to a file's container — provenance, source id, a routing hint, a |
| 17 | +content-type override — so that **other tooling can read it first**, before (or |
| 18 | +instead of) decoding the payload. This is the container analogue of extended |
| 19 | +attributes (xattrs) or HTTP `X-` headers: out-of-band annotation. |
| 20 | + |
| 21 | +Because the decoder already ignores unknown keys (it only reads keys it knows), |
| 22 | +adding arbitrary keys is forward-compatible for free — the whole feature is |
| 23 | +additive and never breaks an existing decoder. |
| 24 | + |
| 25 | +## CLI grammar (container mode only) |
| 26 | + |
| 27 | +Three input forms, all producing `(namespace, key, value)` entries: |
| 28 | + |
| 29 | +- `--kv "KEY:VALUE"` — canonical, self-contained form. |
| 30 | +- `-k KEY -v VALUE` — sugar for one pair. `-k` must **precede** its `-v` |
| 31 | + (one pending pair at a time). Repeatable. |
| 32 | +- `--key-namespace NAME` — see *Namespaces* below. |
| 33 | + |
| 34 | +### `--kv` parsing rules |
| 35 | + |
| 36 | +1. Trim leading whitespace. |
| 37 | +2. **Key:** if the string starts with `"`, the key is the text up to the next |
| 38 | + unescaped `"` (so a quoted key may itself contain `:`); otherwise the key is |
| 39 | + the text up to the **first `:`**. |
| 40 | +3. Expect a `:` next (after optional surrounding whitespace); missing → error. |
| 41 | +4. Trim optional whitespace after the `:`. |
| 42 | +5. **Value:** the remainder. If it starts and ends with `"`, strip the wrapping |
| 43 | + quotes. |
| 44 | +6. In any quoted key or value, unescape `\"` → `"` and `\\` → `\`. |
| 45 | + |
| 46 | +Colon-in-**value** is free (first-colon split): `--kv "url:https://x"` → |
| 47 | +`url` = `https://x`. Colon-in-**key** requires quoting the key, or using |
| 48 | +`-k/-v` (the escape hatch). |
| 49 | + |
| 50 | +All of these are equivalent and set `author` = `Ada Lovelace`: |
| 51 | + |
| 52 | +``` |
| 53 | +-k author -v "Ada Lovelace" |
| 54 | +--kv "author:Ada Lovelace" |
| 55 | +--kv "author: Ada Lovelace" # optional space after ':' |
| 56 | +--kv 'author:"Ada Lovelace"' # quoted value |
| 57 | +--kv "author:\"Ada Lovelace\"" # escaped-quote value |
| 58 | +--kv '"author":"Ada Lovelace"' # quoted key + value |
| 59 | +``` |
| 60 | + |
| 61 | +### Namespaces (stateful / positional) |
| 62 | + |
| 63 | +`--key-namespace NAME` sets the **current namespace** for every pair that |
| 64 | +**follows** it, until the next `--key-namespace`. Pairs before any |
| 65 | +`--key-namespace` go **top-level**. This is a deliberate exception to the |
| 66 | +"named flags parse in any order" convention — the injection flags |
| 67 | +(`-k`/`-v`/`--kv`/`--key-namespace`) form an **ordered stream**. |
| 68 | + |
| 69 | +Multiple namespaces are allowed and accumulate into their own buckets. Repeating |
| 70 | +the same NAME continues appending to that bucket. |
| 71 | + |
| 72 | +``` |
| 73 | +-k a -v 1 \ |
| 74 | +--key-namespace meta -k b -v 2 --kv "c:3" \ |
| 75 | +--key-namespace other -k d -v 4 |
| 76 | +# a -> top-level |
| 77 | +# b,c -> under "meta" |
| 78 | +# d -> under "other" |
| 79 | +``` |
| 80 | + |
| 81 | +## Resolved design decisions |
| 82 | + |
| 83 | +### 1. Placement (Peter's ruling: let the user decide) |
| 84 | + |
| 85 | +- **Default (no namespace):** pairs become **top-level** container keys. |
| 86 | +- **With `--key-namespace NAME`:** pairs nest under `container[NAME]` (an |
| 87 | + object). Nesting isolates the pair keys, so under a namespace a pair key may be |
| 88 | + anything (even `crc32`) without shadowing anything. |
| 89 | + |
| 90 | +### 2. Reserved-key set (Peter's ruling ①: structural/integrity only) |
| 91 | + |
| 92 | +Reject an injected **top-level** key, or a `--key-namespace NAME`, iff it is one |
| 93 | +of the **7 structural/integrity keys**: |
| 94 | + |
| 95 | +``` |
| 96 | +format version filename byte_length crc32 crc32_encoded data |
| 97 | +``` |
| 98 | + |
| 99 | +Rationale: these are the only keys whose presence changes decode behavior or |
| 100 | +integrity, so a user key must never shadow them. Everything else — |
| 101 | +`mime`, `mode`, `owner`, `group`, `modified_ms`, `created_ms`, and any novel |
| 102 | +key — is user-settable. Notably `-k mime -v image/png` is **allowed** and |
| 103 | +**overrides** an auto-detected value (see *Emission*). |
| 104 | + |
| 105 | +The reserved list is an **MFIC contract**: the identical 7-key list lives in all |
| 106 | +5 implementations; a cross-impl test asserts `-k crc32 …` (and |
| 107 | +`--key-namespace data`) error on **every** surface. |
| 108 | + |
| 109 | +### 3. Value type: always a JSON string |
| 110 | + |
| 111 | +Every injected value is emitted as a JSON string (properly escaped). No typed / |
| 112 | +numeric / boolean / raw-JSON values (YAGNI). `-k count -v 42` → `"count": "42"`. |
| 113 | + |
| 114 | +### 4. Integrity (Peter's ruling ②: unverified annotation) |
| 115 | + |
| 116 | +Injected metadata is **not** covered by `crc32` (original bytes) nor |
| 117 | +`crc32_encoded` (payload). It is carried as-is, like an HTTP `X-` header — a |
| 118 | +transport could alter it without tripping the integrity checks. This is |
| 119 | +documented and intentional; the field is a *hint for other tooling*, not |
| 120 | +verified content. (Covering it would require a canonicalization contract + a new |
| 121 | +`crc32_meta` schema field across all 5 impls — rejected as over-engineering for a |
| 122 | +hint.) |
| 123 | + |
| 124 | +### 5. Emission order & dedupe |
| 125 | + |
| 126 | +Container key order (the existing invariant: **`data` is always last** so |
| 127 | +metadata reads up front): |
| 128 | + |
| 129 | +``` |
| 130 | +format, version, filename, byte_length, crc32, crc32_encoded, |
| 131 | + <auto metadata: mime/mode/owner/group/timestamps, where the impl emits them>, |
| 132 | + <top-level injected pairs, in CLI order>, |
| 133 | + <namespace objects, in first-appearance order>, |
| 134 | + data |
| 135 | +``` |
| 136 | + |
| 137 | +Dedupe rules (keep valid JSON — no duplicate keys): |
| 138 | +- A top-level injected key that matches a same-named **auto** key **overrides** |
| 139 | + it (only Node currently auto-emits `mime`/`mode`/… so only Node needs the |
| 140 | + merge; Lua/C/Zig auto-emit only `filename`, which is reserved, so no non-reserved |
| 141 | + collision is possible there). |
| 142 | +- Duplicate injected key **within the same bucket** (top-level or a given |
| 143 | + namespace) → **last value wins** (Peter's "later overrides earlier" rule). |
| 144 | +- A `--key-namespace NAME` shares the top-level key space with top-level injected |
| 145 | + keys; on collision, last-wins at emit (rare; not worth a dedicated error). |
| 146 | + |
| 147 | +### 6. Guards (hard errors, nonzero exit) |
| 148 | + |
| 149 | +- Any of `-k`/`-v`/`--kv`/`--key-namespace` **without `-C`** → error (meaningless |
| 150 | + off-container). |
| 151 | +- `-k` with no following `-v` (pending key at end of args, or two `-k` in a row) |
| 152 | + → error. |
| 153 | +- Reserved top-level key or reserved `--key-namespace NAME` → error (decision 2). |
| 154 | + |
| 155 | +### 7. Decode: unchanged |
| 156 | + |
| 157 | +Decode reads only the keys it knows; injected/unknown keys (top-level or nested) |
| 158 | +are **ignored** and never required. Injected metadata is therefore **not** |
| 159 | +round-tripped through a decode — it lives in the container JSON for other tooling |
| 160 | +to read, and a decode reconstructs only the original file bytes. |
| 161 | + |
| 162 | +## Schema examples |
| 163 | + |
| 164 | +Top-level (default): |
| 165 | + |
| 166 | +```json |
| 167 | +{ |
| 168 | + "format": "printable-binary-file", |
| 169 | + "version": 1, |
| 170 | + "filename": "reading.csv", |
| 171 | + "byte_length": 2048, |
| 172 | + "crc32": "b4ccfa4a", |
| 173 | + "crc32_encoded": "7fbf27bb", |
| 174 | + "author": "Ada Lovelace", |
| 175 | + "source": "sensor-7", |
| 176 | + "data": "…" |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +Namespaced (`--key-namespace provenance …`): |
| 181 | + |
| 182 | +```json |
| 183 | +{ |
| 184 | + "format": "printable-binary-file", |
| 185 | + "version": 1, |
| 186 | + "filename": "reading.csv", |
| 187 | + "byte_length": 2048, |
| 188 | + "crc32": "b4ccfa4a", |
| 189 | + "crc32_encoded": "7fbf27bb", |
| 190 | + "provenance": { "author": "Ada Lovelace", "source": "sensor-7" }, |
| 191 | + "data": "…" |
| 192 | +} |
| 193 | +``` |
| 194 | + |
| 195 | +## Cross-implementation scope |
| 196 | + |
| 197 | +Five container surfaces, each already exercised by the parameterized |
| 198 | +`test/test_container` (run per-impl via the flake `test-container-*` checks): |
| 199 | + |
| 200 | +| Surface | File(s) | Notes | |
| 201 | +|----------------|-------------------------------------------|-------| |
| 202 | +| Lua (reference)| `bin/printable-binary` | implement first | |
| 203 | +| Node | `bin/printable-binary-node.js`, `js/printable_binary.js` | only impl with auto `mime`/`mode`/… → needs the override-merge | |
| 204 | +| Zig | `src/zig/main.zig` (container in the CLI, not the core) | manual JSON string build | |
| 205 | +| standalone C | `src/printable_binary.c` + `src/container_json.h` | array `preserve_chars` | |
| 206 | +| FFI C | `src/printable_binary_ffi_main.c` + `src/container_json.h` | `preserve_chars` is a `char *` (NULL-check!) — see the ac5712a NULL-deref lesson | |
| 207 | + |
| 208 | +Shared JSON helpers live in `src/container_json.h` (both C surfaces) — add a |
| 209 | +JSON-string emit + escape helper there if not already present (`cj_fputs_escaped` |
| 210 | +exists for values). |
| 211 | + |
| 212 | +## TDD test plan (write FIRST, in `test/test_container`; red before green) |
| 213 | + |
| 214 | +Derive any impl-specific glyphs from the impl under test (as the `--spaces` |
| 215 | +ambiguity test does), never hardcode. Concrete cases: |
| 216 | + |
| 217 | +1. **Top-level, `-k/-v`:** `-C -k author -v Ada file` → container has |
| 218 | + `"author": "Ada"` at top level; round-trip still recovers bytes. |
| 219 | +2. **`--kv` split + optional space:** `--kv "source: sensor-7"` → |
| 220 | + `"source": "sensor-7"`. |
| 221 | +3. **Quoted value / escaped value / quoted key** — all four grammar variants |
| 222 | + above → identical `author` = `Ada Lovelace`. |
| 223 | +4. **Colon in value:** `--kv "url:https://x"` → value `https://x`. |
| 224 | +5. **Namespace:** `--key-namespace provenance -k author -v Ada` → |
| 225 | + `"provenance": { "author": "Ada" }`, and NO top-level `author`. |
| 226 | +6. **Multi-namespace routing:** `-k a -v 1 --key-namespace m -k b -v 2 |
| 227 | + --key-namespace n -k c -v 3` → `a` top-level, `b` under `m`, `c` under `n`. |
| 228 | +7. **Last-wins:** `-k k -v 1 -k k -v 2` → `"k": "2"`. |
| 229 | +8. **mime override (Node):** `-k mime -v text/plain` on a file Node would |
| 230 | + auto-detect → exactly one `mime`, value `text/plain`. |
| 231 | +9. **Reserved rejection:** `-k crc32 -v deadbeef` → nonzero exit, error names the |
| 232 | + reserved key; likewise `--key-namespace data`. |
| 233 | +10. **Off-container guard:** `-k a -v 1` **without** `-C` → nonzero exit. |
| 234 | +11. **Dangling key guard:** `-C -k a file` (no `-v`) → nonzero exit. |
| 235 | +12. **Decode ignores unknown:** decode a container carrying injected top-level + |
| 236 | + namespaced keys → still yields original bytes (unknown keys ignored). |
| 237 | + |
| 238 | +## Non-goals / future considerations (YAGNI now) |
| 239 | + |
| 240 | +- **Typed values** (numbers, booleans, nested JSON) — strings only for now. |
| 241 | +- **Verifying** injected metadata with a crc — see decision 4. |
| 242 | +- **Reading/printing** injected metadata on decode (e.g. a `--show-meta` flag) — |
| 243 | + out of scope; other tooling reads the JSON directly. |
| 244 | +- **Windows `/k` `/v` `/kv` aliases** and **i18n'd flag aliases** — follow the |
| 245 | + project CLI conventions when implemented, but not part of the MVP surface. |
| 246 | +- **Quote-aware colon-in-key** is supported (rule 2); deeper quoting/escaping |
| 247 | + (single quotes, backslash-escaped colons outside quotes) is not — use `-k/-v`. |
0 commit comments