Agent-First Data v0.19: Cleaner Output, Safer Edits
v0.19 sharpens both ends of afdata. On the way out: a one-shot CLI puts its result on stdout and every diagnostic on stderr, --output-to overrides it, and finish() maps outcomes to broken-pipe-safe exit codes. On the way in: afdata now reads and edits a Markdown page's +++/--- frontmatter by dot-path, and reading a secret-bearing config no longer risks leaking the file into an error.
afdata sits between an agent and its data twice over: it hands results back
out, and it reaches in to files to read and change them. v0.19 sharpens both
ends — what comes out of a command, and what goes into a file.
It’s a breaking release — AFDATA is still pre-1.0 and carries no compatibility layer.
The result is the only thing on stdout
A tool that prints its answer and its complaint on the same stream pushes the
problem to everyone downstream. x=$(cmd) captures whatever lands on stdout —
the result on a good day, a JSON error envelope on a bad one — and now $x is a
lie. An agent parsing stdout for its answer trips over a diagnostic that was
never the answer.
v0.19 makes the stream an event lands on a function of what the event is. A
one-shot command emits one terminal event and routes by its kind: the result
goes to stdout, and an error, progress, or log goes to stderr. So
the classic shell capture is safe by construction:
port=$(afdata value config.toml server.port) # stdout: the scalar, or nothing
Ask for a key that isn’t there and stdout stays empty — the error is on stderr,
where a shell already sends diagnostics — so $port is never a JSON error
masquerading as a value. Pair it with --default when “missing or null → fall
back” is the point, and the 2>/dev/null guard disappears too.
This is uniform now. value, paths, and keys already kept stdout clean on
failure; the odd one out was get, which wrote its error onto stdout — the one
place a captured value could silently become an error. Routing by kind closes
that: a get that fails is a kind:"error" event, and error events go to
stderr, full stop.
--output-to when you want it all in one place
The split is the default, not a mandate. One global flag overrides it:
afdata value config.toml server.port --output-to stdout # result AND errors on stdout
afdata get config.toml --output-to stderr # everything on stderr
--output-to stdout is how you capture a complete session — result and
diagnostics interleaved in order — or feed it to --stdout-file for a log.
split (the default), stdout, and stderr are the three choices, and routing
always follows the event’s kind, never the process exit code.
That distinction is the second mode in disguise. The split is right for a command that returns one answer; it is wrong for one whose output is a sequence — a long query that emits progress, then rows, then a status, where the interleaving order carries information. In the library it’s which constructor you reach for:
let mut out = CliEmitter::finite(format); // one-shot: split by kind
let mut out = CliEmitter::stream(io::stdout().lock(), format); // every event on one ordered stream
or CliEmitter::from_output_to(selector, format) when a --output-to flag
decides it at runtime. Pick by consumption model, not reflex.
Exit codes that survive a broken pipe
Turning the last event into an exit code used to be hand-rolled at every call
site — and the easy version gets a broken pipe wrong. Pipe a command into head
and the reader hangs up early; the final write fails with EPIPE; a naive
handler reports failure for a run that did exactly what was asked.
finish and finish_result fold that into one call:
return emitter.finish_result(value); // success -> 0; broken pipe -> 0; other write error -> 4
let event = json_error("invalid_request", &msg).hint_if_some(hint).build()?;
return emitter.finish(event, 1); // an error is just the json_error builder, finished
The builder is the error type — there’s no separate error-and-exit helper — and
the library never calls process::exit itself. It hands the code back and lets
main return it, so it composes instead of tearing the process down from inside
a helper.
Editing a Markdown page’s frontmatter
Config isn’t always a config.toml. Half the config an agent touches lives in
the +++ or --- block at the top of a Markdown page — a blog post’s date, a
doc’s weight, a page’s draft flag. v0.19 lets afdata address that block
directly:
afdata value post.md date --input-format yaml-frontmatter
afdata set post.md draft false --value-type bool --input-format toml-frontmatter
The same read and mutate verbs work inside the frontmatter, addressed by
dot-path, and the Markdown body is left byte-for-byte untouched — the change
lands in the header, not in a reflowed document. toml-frontmatter reads a +++
block, yaml-frontmatter a --- block.
Frontmatter is never auto-detected: extension detection only ever resolves to a
whole-file format (.md is Markdown, not “the TOML inside the Markdown”), so you
name the mode deliberately. That’s the point — reaching into a page’s header is a
choice you make on purpose, not something a .md extension does behind your back.
Reading a secret config without leaking it
Reading a config is exactly where a credential leaks, and v0.19 hardens the
library path around a real job — afpsql pulling a database secret out of a
config file it doesn’t control. Three sharp edges, now handled by afdata instead
of every caller:
let doc = DocumentFile::open_capped(path, None, MAX_BYTES)?; // size + regular-file guard, before reading
let dsn = doc.value_at("database.url_secret")?; // one call to a dot-path
open_capped rejects an oversized or non-regular file before reading a byte —
an unbounded read of an arbitrary path is a denial-of-service waiting to happen.
value_at collapses open-parse-traverse into one call, the read counterpart to
set. And when parsing fails, DocumentError::redacted_message() (and the raw
location()) give you the position — failed to parse YAML at line 5 column 12 —
with the offending source line dropped, because that line is exactly where a
secret would be. A config reader’s error must not be the thing that spills the
config.
The small stuff: accessors instead of match arms
The document types grew the accessors a consumer kept re-implementing:
DocumentError::code() for the stable error code, Format::name() for a display
label, Value::kind_name() for a type name. A caller branching on an error or
naming a format no longer matches the public enum by hand — it asks the value
what it is.
Upgrading
- Shell and agents: for the common case, nothing changes — errors now land on
stderr, where you were already looking, and
$(afdata value ...)stops capturing error envelopes as data. If you grepped stdout for an error, read stderr instead, or pass--output-to stdoutfor the old all-on-one-stream shape. - Editing Markdown frontmatter: pass
--input-format toml-frontmatteroryaml-frontmatterand address dot-paths in the header with the usual verbs. - Library callers: end a one-shot with
emitter.finish_result(value)oremitter.finish(event, code), build errors withjson_error(...).build(), and drop any manualprocess::exit. ChooseCliEmitter::finite(...)for a one-shot and::stream(...)for a streaming tool. For secret-bearing config, reach forDocumentFile::open_capped(...).value_at(...)and surface failures withDocumentError::redacted_message().
afdata always knew which of its outputs was the answer and which was commentary,
and which byte of a file it meant to change. Now the streams say the first and the
edits respect the second — so nothing reading afdata, and no file afdata touches,
has to find out the hard way.