Agent-First Data v0.31.0: An Error With Nowhere to Go
A dispatch method returned `Option` for a condition no caller could reach, so every caller had to invent an error it could not honestly describe. The fix was not a better `Option` — it was making the failure impossible to express.
An earlier release made BoundCliSpec::execute return Option<R>. The
reasoning was sound in isolation: the old code ended in .expect(...), and a
library that panics inside an agent’s CLI produces a Rust backtrace on stderr
where a protocol event should be. Returning Option removed the panic.
It also handed every caller a question with no good answer.
None had exactly two causes: an invocation resolved by a different
registry, or an action id with no handler. bind_actions already rejects the
second at startup, before any argv is parsed. The first requires deliberately
building two registries and crossing their invocations — afdata’s own test
suite is the only place that constructs the condition at all.
So the type demanded an arm for something an application could not reach. And because the condition has no natural error code, each caller had to invent one. The honest options were all bad: report it as a usage error and the caller is told to fix a command line that was fine, retrying forever; report it as a domain failure and it sits beside “the database is unreachable,” which is a thing an operator can act on. It is a defect in the program’s own dispatch table, and neither channel says that.
An Option that no caller can answer is not a safe API. It is a panic with
extra steps, paid for by everyone downstream.
Dispatch that cannot miss
The fix was not a better Option. resolve_from now returns an outcome whose
run branch already carries its handler:
match app.resolve_from(std::env::args_os())? {
BoundOutcome::Run(invocation) => {
let redirect = install_redirect(invocation.output_plan());
(invocation.run(), redirect)
}
BoundOutcome::Docs(docs) => // ...
}
The handler is looked up during resolution, by the registry that owns it, at the
point where bind_actions has already proved one exists. run cannot fail.
There is no longer a step that accepts an invocation from somewhere else, so the
mismatch the runtime check guarded against is not expressible.
The output plan stays readable before run consumes the invocation, because
redirection genuinely has to be installed before a handler writes anything.
ResolvedInvocation::required went back to being infallible for the same
reason. Reading an argument id the selected combination does not declare is a
defect in the handler, not a runtime condition, and making every correct call
site branch on it bought nothing — every one of them would answer the same way.
What it needed was the other half, shipped alongside:
let verified = app.call_every_combination();
That runs every declared combination through its handler with strict argument
reads. A handler asking for an id its combination does not declare fails there,
naming both, from a test — instead of silently receiving a default in
production. It returns what each handler produced, so a handler that returns a
Result can also be checked for building at all.
It calls your handlers, which is safe only when they project argv into a value
rather than carry the command out. The documentation says so plainly; the method
is named call_every_combination and not something reassuring for the same
reason.
Exit 2 and exit 1 are different questions
The same review found the reverse error, in the argument types.
A registry could say an argument was an integer, but not that it was a bounded integer, and not that a string was a UUID. Validation that a parser should do had to happen inside a handler instead — and a handler cannot return a usage error. A malformed UUID exited 1 as a domain failure.
Both are declarable now:
ArgSpec::option("--analysis-id", "UUID").uuid()
ArgSpec::option("--audit-repeat", "N").range(1, 5)
$ tool export not-a-uuid
{"error":{"code":"cli_invalid_argument_value","message":"invalid value for
`analysis_id`: expected a UUID (8-4-4-4-12 hexadecimal digits)"},"kind":"error"}
$ echo $?
2
Both are deliberately declarative — an enum variant and two integers. They
serialize into cli-spec-v1, so a compiler in another language can implement
them from the spec alone. A host-supplied parser closure could not have survived
that trip, and that distinction, not the size of the type list, is what the
closed value-type set is actually protecting.
CliSpec::shared_arg closes a third gap: an argument every command accepts,
declared once instead of copied onto each command where the copies can drift. It
changes declaration, not position — the command path is still matched against
the leading tokens of argv, and a caller who puts the command after its
arguments now gets told exactly that instead of “unexpected positional
argument.”
A check that could not fail
assert_redaction_canary_absent exists to prove a secret did not survive into
output. It compared the canary against the rendered text with a plain substring
search. Every renderer escapes a string before it reaches the stream.
A PEM private key carries newlines. A Windows path carries backslashes. Most real passwords carry a quote. All of them sit verbatim in the output, and all of them passed:
rendered: {"note":"-----BEGIN KEY-----\nabc\n-----END KEY-----"}
verdict : Ok(())
It now decodes the output before searching, covering JSON, YAML, logfmt and percent-encoding in one pass. Decoding the haystack rather than enumerating the escaped spellings of the needle means the check cannot drift when a renderer changes, and it biases the right way: an over-eager decode raises a false alarm, never a silent pass.
The linter had the mirror-image bug. It case-folded field names when deciding
whether a field was already marked; the redactor matches _secret and
_SECRET exactly, as the convention requires. So api_Secret looked marked to
the linter and unmarked to the redactor, and a payload was certified clean on
its way out in the clear. Both now share one matching rule:
$ afdata lint config.json
{"kind":"result","result":{"findings":[{"message":"`api_Secret` looks like a
credential but is not marked, so it is printed and logged in the clear. Rename
it with one of: _secret, _url","rule_id":"missing_suffix"}],"ok":false}}
A linter that disagrees with the renderer is worse than no linter, because it answers the question you asked with the wrong authority.
Money that rounds into a plausible lie
Negative fiat amounts used to fall through to the raw value. They format now, in all four languages, because a refund is the same unit as a charge and a second suffix for the sign would have been a worse convention.
Making them format opened a hole on the way in. -9223372036854775809 is one
below i64::MIN; the nearest f64 to it is i64::MIN, so it passed the
range check and rendered as an amount wrong by a cent. The positive side had
been guarded all along — the old non-negative-only rule had been hiding the
missing negative counterpart.
$ echo '{"chargeback_usd_cents": -9223372036854775809}' | afdata render - --output plain
chargeback_usd_cents=-9223372036854775809
$ echo '{"refund_usd_cents": -499}' | afdata render - --output plain
refund=-$4.99
Out of range falls through to the raw value. In range formats. A plausible number that is wrong is worse than no number at all, and money is where that is worst.
Also in this release
redact_urls_in_text scrubs complete URL spans inside prose in all four
languages, under one explicitly enumerated span grammar. Each language had been
using its own native “is whitespace” predicate, and the four disagreed in both
directions — one ended a span at U+FEFF and leaked the secret after it, another
ran past U+0085 and swallowed the rest of the message. The terminator is now the
Unicode White_Space set, written out code point by code point, with U+FEFF
excluded on purpose. An exact url_names list gives the same treatment to a
legacy URL field that cannot be renamed.
DocumentFile gained capped reads from a single verified handle, a no-clobber
create_atomic, typed decode, and edit_and_validate, with hardened Unix
opens behind the new default-on libc feature. An over-cap read reports
document_too_large rather than the same code as a missing file, so a size
budget no longer has to be told apart by matching on a message. TOML collection
editing now covers arrays, array elements, inline tables and ordinary tables
without inventing a comment or moving one onto a value the author never wrote it
for.
The tracing layer became a composable AfdataLayer with an injectable writer
and a StructuredLogHandle for nested JSON, and writes each line in a single
call — two writes meant another writer on the same stream could land between a
line and its newline, costing a reader both events.
ErrorSpec and ErrorCatalog declare stable public domain errors separately
from runtime diagnostics, and the afdata lint rule engine moved into the
library as lint_value with assertion helpers, so the checks the CLI performs
are available in-process without a subprocess or a temporary file.
What this release was about
Five of the changes above are the same mistake in different places. A failure
was routed to somewhere it could not be answered: an Option no caller could
resolve, a usage error reported as a domain failure, a size budget that had to
be recovered from a message string, a security assertion that could not fail, a
linter that disagreed with the renderer it was linting for.
An API is a claim about what its callers can express. The useful test is not whether the types are sound — all five of those were sound — but whether someone holding the result has an honest thing to do with it.