Agent-First Data v0.28.0: The Addresses It Would Not Read
afdata printed paths that afdata then refused to parse. The empty string is a legal key — npm writes one into every package-lock.json — and the path grammar rejected it, so `paths` emitted addresses no other command would accept. v0.28.0 closes that, and adds `values` for reading many paths from a single parse.
A path grammar has one job beyond naming things: what it prints, it must be able to read. afdata’s says so in its own source — “every other escape is rejected so a path is reversible.”
It wasn’t.
$ afdata paths package-lock.json packages
packages.
packages.node_modules/@esbuild/darwin-arm64
...
$ afdata value package-lock.json 'packages.'
{"error":{"code":"document_parse_failed","message":"failed to parse path"}}
The first line is an address afdata produced, handed back verbatim, and refused.
The empty string is a key
npm writes "" as the root package of every package-lock.json. It is legal
JSON, legal YAML, and entirely ordinary. The grammar rejected an empty segment:
'.' => {
if segment.is_empty() {
return Err(/* "empty path segment" */);
}
...
}
That rejection did not make the key unreachable. It made it unspeakable.
join_path(["packages", ""]) still rendered packages. — the joiner was happy
to produce a spelling the parser would not accept. So paths enumerated the
key, value and set refused to address it, and the round trip the grammar
claims to guarantee quietly did not hold.
Removing the rejection is the whole fix, and it makes the property more true
rather than less: adjacent dots are exactly how a sequence with an empty
component spells itself, so join and parse become a genuine bijection.
packages..version now reads and writes.
The same bug, one level up
Closing the nested case exposed the root case. A "" key at the top of a
document joins to the empty string, which names no path at all — so paths
printed a blank line, and feeding that back produced empty path provided.
The identical defect, just rarer.
There is no spelling to give it: "" means “you passed no path”, and that is
the more useful reading for a shell where an unset variable expands to nothing.
So paths now refuses instead:
$ afdata paths rooted.json
{"error":{"code":"document_unaddressable_key","message":"the document root has
an empty-string key, which has no path spelling; read its children by their
own paths, or use `keys`"}}
Failing on a legal document is not free. But the rule that made the first fix
correct is never emit an address you cannot consume, and a blank line the
caller will feed back and be rejected on is worse than being told. keys still
lists the key, because keys prints names and makes no addressing claim.
Every address paths emits now reads back — checked across package-lock.json,
Cargo.lock, Cargo.toml, pyproject.toml, and spore.core.json.
Reading many paths without re-reading the document
value re-opens and re-parses the whole file each time it runs. That is
invisible for one read and expensive for the shape you hit constantly in
configuration: which element has name X?
while read -r path; do
name=$(afdata value Cargo.lock "$path.name" --input-format toml)
...
done < <(afdata paths Cargo.lock package --input-format toml)
110 packages, 110 processes, 110 full parses of the same lockfile — 632ms, with
the parse work growing as the square of the document. values is that work done
once:
$ afdata values Cargo.lock package.0.name package.1.name ... --input-format toml
17ms. One process, one parse, linear in the document.
It keeps every guarantee value makes — --default per path, the _secret
gate, --input-format — and adds one refusal. Line framing is the contract, so
a value containing a newline is rejected rather than silently becoming two
lines:
$ afdata values config.json one two
{"error":{"code":"document_multiline_value","message":"path `one` holds a value
containing a newline, which one-per-line output cannot frame; read that path
with `value`"}}
Emitting it would put two lines where the caller expects one, and every
later value would pair with the wrong path — a silent wrong answer, which is
worse than an error. value still reads that path; it has no framing to break.
Why this surfaced now
All three came out of using afdata rather than reading it: writing the release
pipeline’s own validators in Bash on top of the CLI. The empty-key bug appeared
while trying to read a version out of package-lock.json. The 632ms was
measured in a checker that only looked fast because the crate it searched for
happens to sort first in the lockfile, so the loop broke on its first iteration.
A tool that emits addresses it cannot consume, and a read path that costs a full parse per field, are both the kind of thing you find by depending on something — not by reviewing it.