Agent-First Data v0.29.0: One Address, Read and Written
Markdown becomes addressable, and an array element can be named by its content instead of its position. Both landed on the read path first — which is how we found that `paths` was emitting addresses afdata's own write verbs refused.
Every spore in this repository has to get its description to crates.io, npm,
PyPI, and a GitHub About panel from a single source: the first paragraph of its
README. Until this release, the code that read that paragraph scanned lines
with awk.
Eight CommonMark probes were written against it. It got all eight wrong. A
setext underline was not recognised as a heading. A # inside a fenced code
block was. So was one behind a four-space indent. An ATX heading did not
terminate the paragraph it interrupts. Each of those is a normal thing to write
in a README, and each produced a silently wrong description on a package page.
So afdata learned to read Markdown:
$ afdata value README.md h1.0.paragraph.0.text --input-format markdown
A naming convention that lets AI agents understand your data...
Sections, because a block list is not an address
The obvious shape for a Markdown document is a flat list of blocks, and it is
the wrong one. blocks.7 is not an address — put one badge line above the
title and every index shifts. What survives is the heading that owns the
content:
preamble everything before the first heading
h1.0 the first level-1 section
h1.0.h2 its level-2 subsections
h1.0.paragraph that section's paragraphs only
Markdown is never auto-detected. A .md file is equally readable as
frontmatter, and choosing between two valid readings is not a decision an
extension can make for you — name markdown, toml-frontmatter, or
yaml-frontmatter and get exactly that.
Every block carries source_start_line and source_end_line, 1-based and
inclusive, counting \n. Those are the lines sed, awk, head, git diff,
and your editor count, so a range can be handed straight to them. That is not
CommonMark’s rule — CommonMark also ends a line at a bare \r — and where the
two disagree afdata refuses the file rather than answering with a number only
it can interpret. A number that looks usable and points one line off is worse
than no number.
Naming a thing instead of counting to it
The second half of the release is the same problem one level down. An address
that says identities.0.email breaks the moment someone inserts an element
above it. So a non-numeric segment now resolves by content:
$ afdata value config.json identities.me.email --slug-field identity
me@example.com
Markdown needs no declaration, because its tree is one afdata synthesized and
therefore knows the shape of — h1.0.h2.look finds “A quick look” and survives
the rewording an index does not. JSON hands back whatever the file said, and
nothing in deps.foo says which field foo should match, so those still name
the field explicitly.
Matching several elements is an error reporting the candidate indices. Never the first one. Silently picking a winner answers a different question than the one asked, and does it invisibly.
The half that only read
All of the above shipped on the read path. --slug-field reached get,
value, values, keys, and paths, and every test of every one of those
passed.
Then the round trip:
$ afdata paths config.json identities.me --slug-field identity
identities.me.email
$ afdata unset config.json identities.me.email --slug-field identity
{"kind":"error","error":{"code":"cli_unknown_argument",
"message":"unknown argument `--slug-field`"}}
$ afdata unset config.json identities.me.email
{"kind":"error","error":{"code":"document_path_not_found",
"message":"array at `identities` not registered in KeyedList"}}
With the flag it was rejected outright; without it, the address resolved against nothing and the message named an internal Rust type that has no CLI surface at all.
paths was emitting an address its own write verbs could not consume. The
skill reference, meanwhile, promised that paths output “feeds back into
get/value/unset” — a sentence that had been true and quietly stopped
being true.
The cause was not a missing flag. Document::set and Document::unset walked
with index-only addressing, and beneath them six source-preserving backends
each re-walk the original text by path. Nothing in identities.me tells a
TOML editor which element me is. Teaching all six to match on content would
have given each its own chance to disagree with the read walk about what an
address means.
Instead the address is resolved once, where the whole parsed document is in
hand, into the canonical identities.0.email. The backends are unchanged; they
never see a content address at all. Reading and writing cannot drift apart
because only one of them does the resolving.
$ afdata unset config.json identities.me.email --slug-field identity
{"kind":"result","result":{"code":"document_unset", ...}}
Every verb tested alone had passed. Only the round trip failed, so there is now
a test that feeds each address paths emits straight back into set, and the
doc-example gate runs the commands the README and skill reference actually show
— including the ones wrapped across lines with a backslash, which it had been
skipping.
Four ways to not parse
document_parse_failed was carrying three unrelated failures, and the
difference is precisely what a caller needs to know:
document_format_unknown— the extension named no format; nothing was parsed.document_parse_failed— a parser read the file and rejected it. The file is malformed.document_invalid_path— the address is malformed. The file is fine.document_source_refused— the file parses, and afdata declines to answer about it anyway.
That last one is the bare-\r case above, and it exposed a second bug. Every
ParseError detail is dropped from the redacted message, because a parser’s
detail quotes the offending source line. But this detail is written by afdata,
about the file’s line endings, and holds no document content — so the careful
message ending “convert the file to LF or CRLF line endings” was reaching
callers as failed to parse Markdown. An actionable refusal had been redacted
into a dead end by a precaution against a leak that could not happen.
Getting it
$ brew install agentfirstkit/tap/afdata
$ cargo install agent-first-data
$ npm install agent-first-data
$ pip install agent-first-data
Markdown reading is Rust-only and read-only. The convention, the protocol, and the formatting surface remain identical across Rust, Go, Python, and TypeScript.