Skip to content

Commit 6c2055f

Browse files
committed
docs(spec): container metadata injection (-k/-v/--kv/--key-namespace) design
Full design for a future nice-to-have: attach arbitrary key/value metadata to a .pbf.json container for other tooling to read first. Captures all approved forks (top-level default + reject 7 structural/integrity keys; optional --key-namespace nesting, stateful/positional; values always JSON strings; unverified annotation; quote/escape grammar; emission order; guards) plus a concrete TDD test plan. Not yet implemented; PLAN.md points at the spec.
1 parent 2b415ca commit 6c2055f

2 files changed

Lines changed: 257 additions & 0 deletions

File tree

PLAN.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ nondeterministic mtime would sink a stable "compressed hash"; max level 9), so
4141
integration would be awkward anyway. If ever revived, the open question is
4242
LZMA2-library-vs-CLI cross-impl parity (Zig/C could link z7z's core in-process;
4343
Lua/Node/Rust/Elixir could not without shelling out or their own LZMA).
44+
45+
## Container metadata injection (`-k`/`-v`/`--kv`/`--key-namespace`) — SPEC'd, FUTURE (2026-07-21 EDT)
46+
47+
Design approved by Peter (all forks ruled), NOT yet implemented — a future
48+
nice-to-have. Attach arbitrary key/value metadata to a container for other
49+
tooling to read first (xattr / HTTP-`X-`-header analogue). Full spec + TDD plan:
50+
`docs/plans/2026-07-21-container-metadata-injection-design.md`.
51+
- [ ] Implement TDD-first (Lua reference → Node → Zig → standalone-C → FFI-C) +
52+
extend the shared `test/test_container` guard. Reserved-key rejection is an
53+
MFIC cross-impl contract.
4454
## Docs and benchmark parity — active (2026-07-17 EDT)
4555

4656
- [x] Document every supported implementation—especially Rust, WebAssembly, and
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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

Comments
 (0)