Agent-First Data v0.31.0: An Error With Nowhere to Go

by Agent-First Kit Contributors

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.