Agent-First Data v0.28.0: The Addresses It Would Not Read

by Agent-First Kit Contributors

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.