Agent-First Data v0.27.0: If It Isn't Registered, It Doesn't Run
A CLI that accepts each flag individually will accept combinations that mean nothing, and then discovers that at runtime — or doesn't. v0.27.0 replaces the help layer with a closed-world compiler: one registry decides argv parsing, legal combinations, output contracts, and help, and an invocation runs only when it matches exactly one registered shape.
A conventional CLI parser validates arguments one at a time. Each flag is recognised, each value has a type, and if every argument checks out the parser hands you a struct. Whether those arguments mean anything together is left to the program.
So every tool grows a second layer: a run of if statements after parsing that
rejects the combinations the parser was happy to accept. --takeover with
--render none. --skills-dir without a single named --agent. --dry-run
alongside --stream-rows. That layer is invisible to --help, which lists
arguments rather than the shapes they legally form, so an agent reading the help
cannot tell a supported call from one that will be rejected on contact.
The failure mode is worse than rejection. An argument that is accepted, parsed,
and then quietly ignored — --context when --from-source is absent,
--inline-max-rows while streaming — produces no error at all. The caller
believes it configured something. It didn’t.
The registry is the parser
v0.27.0 replaces help-v1 with a closed-world compiler. A tool registers the
finite set of shapes it accepts — each one a partition of that command’s
arguments into fixed, required, and optional — and a single cli-spec-v1
registry becomes the source for argv parsing, typed values, legal combinations,
output contracts, help, and the offline reference.
An invocation runs only when it matches exactly one registered shape. Zero
matches is cli_unregistered_combination. Two matches fails the build, not the
call: shapes that overlap are a registry bug, and the compiler refuses to
produce a binary that could be ambiguous at runtime.
This inverts what --help can promise. It no longer enumerates arguments and
leave the caller to solve the constraint problem; it prints the shapes:
afdata set --help
returns the distinct set-value, set-null, and set-secret shapes, each
complete. There is no second level to request. The one thing a second level
could omit is the optional arguments — and a caller that stopped at the first
would have neither them nor any knowledge they existed. Registered but
undiscoverable is the exact defect the design exists to remove, so
--help-combination is gone.
What it deleted
We migrated seven tools onto it, which is the part that taught us something. The registry did not just relocate the hand-written checks — it made most of them unnecessary, and revealed that some had never been reachable.
agent-first-mail deleted three files: a dry-run/confirm check, an eleven-table
map of hand-maintained error hints, and an allow-list enumerating which commands
supported --request-id. The last one is now simply the set of commands that
declare the argument. agent-first-psql deleted two hand-rolled scanners that
walked raw argv before the real parser ran. agent-first-http deleted its
mirror enums — the copies of domain types that existed only to satisfy the
parser — and now carries the domain types directly.
agent-first-terminal is the compact illustration. Its ui command had
conflicts_with on seven arguments plus a runtime check that the session
options require a session id. Both are structural: attaching to a running API
cannot configure a session it did not create, and there is nothing to configure
without a session. As three shapes, the parser rejects those mixes and one
--help shows all three.
And in this crate’s own CLI, a validation of the log level turned out to be
unreachable — the registry had already declared level a closed enum, so the
parser rejected a bad value long before the check could run. It had been dead
for as long as the enum had existed. That is the quiet cost of the two-layer
design: you cannot tell, by reading it, which half of your validation is load
bearing.
Errors name themselves
CLI failures used to carry a generic cli_error with the actual classification
in a second field beside it. Every other error in this crate spells its
classification straight into the code — document_path_not_found,
document_write_would_corrupt — and the standing instruction to agents is to
branch on error.code. CLI errors were the one family that asked you to branch
somewhere else.
They now name themselves: cli_unknown_argument, cli_unknown_command,
cli_missing_argument_value, cli_invalid_argument_value,
cli_duplicate_argument, cli_unexpected_positional,
cli_unregistered_combination, cli_invalid_utf8. One key to read, and
cli_unregistered_combination in particular tells a caller something precise:
every argument was known, the mixture was not, so read the shapes rather than
dropping arguments at random.
The exit codes stay a two-way split worth relying on. Exit 2 means the call was never made, so retrying it unchanged cannot help. Exit 1 means it ran and failed.
Secrets, and the difference between naming one and carrying one
Three redaction leaks are closed in all four languages. A _secret parameter in
a URL fragment was printed in the clear while the same name in the query was
masked — which is exactly where the OAuth implicit flow puts a credential. The
redaction policy meant three different things depending on the path it reached.
And plain rendering stripped the _secret suffix unconditionally, so turning
redaction off printed the live credential and deleted the only mark saying it
was one.
Redaction is also path-aware now: a _secret name marks its whole subtree, so
credentials_secret.password no longer walks past the marked node.
The subtler fix is about what the suffix means. --reveal-secret is a boolean
flag; it asks to reveal a secret, it does not carry one. Deriving sensitivity
from the name alone marked it as a credential — putting the bit on something
that structurally cannot leak, which reads as a real secret to anything
downstream that trusts the bit. A flag has no value to redact, so flags are
never marked, and marking one explicitly now fails the build.
Idempotence should not depend on path depth
Document::unset returned false for a missing leaf but errored on a missing
ancestor. Both describe the same fact — the key is not there — and which one you
hit depended only on how many segments you had to write to name the same
nothing. It is idempotent at any depth now. A path that no document could
satisfy, like traversing through a scalar, is still an error.
Upgrading
This is a breaking release. Tools built on the removed help-v1 helpers
(cli_handle_version_or_help_or_continue, HelpConfig) and the cli-help /
cli-help-markdown features must move to the registry; the whole CLI surface is
now behind the default-on cli feature. --help --recursive --output markdown
is replaced by --docs, which renders the entire registry as Markdown.
The compiler cannot express short flags, so a migrating tool loses them. That is a real cost and worth stating plainly: the convention still permits short flags, but this compiler does not implement them.
The smallest complete example is rust/examples/agent_cli.rs — 174 lines,
covering the whole flow from registry to dispatch. The contracts are
cli-spec-v1
and
cli-help-v2.