Agent-First Data v0.34.0: The Success That Had Not Finished
An error has to explain itself; a success says nothing and is believed. This release is mostly one shape repeated — an operation that returned success while part of what it promised had not happened — found in an install, an uninstall, a borrowed terminal, and a rule the tool wrote for everyone but itself.
An error has to explain itself. It carries a code, a message, a hint, and anyone reading it asks what went wrong. A success carries nothing and is believed on sight — which makes it the easiest place in a program to be wrong for a long time without anyone noticing.
Nearly everything in this release is that one shape. An operation returned success while some part of what it promised had not actually happened, and in each case the gap was invisible precisely because the return value was the good one.
One atomic installation, not two
Installing a file so that a crash can never expose a half-written one is a
fixed sequence: create a private temporary file in the same directory, write
it, apply permissions, fsync the file, atomically rename it over the target,
and then fsync the parent directory. The last step is the one that gets
dropped, because everything looks finished without it — rename has returned,
the file is visibly there, and the function returns Ok. What has not happened
is the directory entry reaching storage. A power cut at that moment can take
the new name with it.
This crate had that sequence written correctly in the document layer, and
written again — a second time, slightly differently — in skill installation.
The second copy was missing the parent fsync. That is the normal fate of a
duplicated sequence: not that both copies rot, but that one of them is durable
and the other only looks it, and nothing in either file says which.
It lives once now, in atomic_file, and callers bring only what is genuinely
theirs: the pre-write guard, because what may be overwritten is a policy
question the two writers answer differently, and the error type. Failures name
the step they failed at, and one distinction survives into the caller’s message:
$ afdata skill install
{"error":{"code":"skill_write_failed","hint":"the file is installed but its
durability is unconfirmed; re-run to be sure", ...}}
Only the closing directory fsync can fail with the new file already in place.
Every other step fails with nothing done. Those are different situations for
whoever is reading — one asks for a retry, the other asks for nothing — so the
error says which one it is instead of averaging them into “write failed”.
What “removed” was not saying
Uninstall removed the skill file, then removed the bundled reference files, then
reported removed: true. The second half ignored its own errors. A managed file
that refused to go left a stale reference on disk, and the report said the target
was clean.
The reason that particular silence is worse than most: the next status reads
SKILL.md to decide what is installed, and SKILL.md is exactly the file that
did get removed. So the leftovers are not merely unreported once — they are
unreachable afterward. Nothing that runs later can see them.
Asset removal failures now return. And because “the directory is gone” and “the directory is still there” are both ordinary outcomes rather than success and failure, the report says which:
$ afdata skill uninstall --agent claude-code
{"kind":"result","result":{"code":"skill_uninstall","removed_any":true,
"targets":[{"removed":true,
"assets_removed":["references/bash.md","references/documents.md", ...],
"directory_retained":true, ...}]}}
directory_retained: true means the skill’s own directory outlived the
uninstall, because it holds files this tool did not install. Those are left
untouched — that is the right behaviour, not a fault — but it is the difference
between a clean removal and one that left something behind, and the caller
should not have to go and look to find out which happened.
The links on the way down
Skill install judged the final SKILL.md carefully: is it a symlink, is it
managed by this tool, does --force permit replacing it. It judged nothing
above that file. create_dir_all walks straight through a symlinked directory
without comment, so a workspace shipping .claude/skills/<name> as a link to
somewhere else would silently redirect a fixed-name write — and the matching
uninstall would delete files wherever the link pointed.
Every directory component below the skills root is checked now, on install,
uninstall, and status. Two things are deliberately not checked. The root itself:
the caller chose it, and a home directory that is a link onto another volume is
ordinary. And the final component: installing over it is a rename, which
replaces a link rather than following it.
--force no longer offers itself in the hint when containment is what failed.
It is permission to replace an unmanaged file inside the root; it was never
permission to write outside it, and a hint that suggests otherwise is an
invitation to do the wrong thing twice.
A terminal you borrowed and have to hand back
Reading a secret from a prompt means turning off terminal echo, and this crate
did it by spawning stty. That resolves a program name through PATH in the
moment immediately before a secret is typed, and it makes the one thing standing
between the value and the screen depend on a program being installed at all.
It talks to the terminal directly now. Two things behind that change were real
defects rather than preferences. tcsetattr reports success having applied only
part of what it was asked, so what it did is read back and checked — and if echo
is somehow still on, the original settings go back before the read is refused,
because a half-applied change is still a change. And the guard that restores the
terminal afterward was discarding its own failure, which leaves a person typing
into a shell that displays nothing and looks broken, with no clue why. A failed
restore is announced on the terminal itself, which is where the damage is, rather
than through the caller’s structured output:
warning: could not restore terminal echo; run `stty echo` to fix this terminal
The same read now works on Windows, which it never did. That platform had a
refusal — the prompt source is unsupported on this platform — and the refusal
was honest, but the gap behind it was real: a person on Windows wanting to type a
credential had to route it through an environment variable or a file instead,
which are both worse places for it to be.
The implementation reads CONIN$ and writes CONOUT$, which are that process’s
own console the way /dev/tty is its controlling terminal — so the prompt still
appears when stdout is redirected, and the structured output stream stays clean.
Echo comes off through SetConsoleMode, verified by reading the mode back.
One decision there is worth naming, because the obvious implementation is wrong
in a way that would not show up in most testing. The console handle implements
Read, and reading from it goes through ReadFile, which answers bytes in the
console’s current input code page. On a console whose code page is not UTF-8 —
the default on a great many installs — a secret containing anything outside ASCII
comes back as different bytes, and the value that gets stored is quietly not the
value that was typed. ReadConsoleW answers UTF-16, which converts losslessly.
Verified on a console running code page 936, where a passphrase mixing Han
characters, a Greek letter, and an emoji round-trips byte for byte — the emoji
being a character that code page cannot represent at all.
A build without the libc feature, which is where the terminal calls come from
on unix, now refuses the prompt source rather than falling back. Reading a
secret with the echo still on is not a degraded read. It is the wrong one.
A rule the tool did not follow
Every command-line reference generated from this crate’s spec carries a promise
about error messages, and it used to read: No error message quotes a raw value
it was given. Absolute, easy to remember, and contradicted by the most ordinary
command in this very tool — afdata value /no/such/file.json some.path quotes
both the path and the dot-path, and is far more useful for doing so.
That is not a cosmetic problem. A rule with an obvious exception is a rule you learn to read past, and the thing it was actually protecting — that an error event is routinely logged, and a header or a form field or a proxy URL may hold a credential — gets read past with it. It says what it means now: never quote a value from an argument able to carry a credential; do quote a path, a dot-path, a session name, because an error that cannot say which one it means is not worth logging either.
CommandSpec also gained reference_note, for the paragraph that belongs to no
single argument. Without somewhere to put it, that paragraph gets hand-written
into a file whose own header says it is generated — where the next regeneration
deletes it, silently, and nobody finds out until they go looking for something
that used to be documented.
Gates that can fail, and bytes that cannot be replaced
Two changes are about the machinery around the code rather than the code.
The shell lint ran when shellcheck happened to be on PATH and skipped when it
was not, so a missing tool and a clean tree printed the same thing. Accepting
whatever version was present is the subtler half of the same bug, and it is the
half that cost a release: 0.9.0 reports a diagnostic on an A && B || C whose
C is an error handler, 0.11.0 recognizes the handler and stays quiet, so a
tree that passed locally failed on a runner packaging the older one. The
version is pinned and provisioned now, the check walks the whole checkout rather
than two hand-named files, and on a platform upstream publishes no build for it
says so rather than printing nothing and looking clean.
And publishing release assets trusted the workflow’s own ref plus
--clobber, which meant a manual dispatch could build a non-tag commit under a
tag’s name and overwrite archives whose hashes were already published. The build
verifies HEAD is the tag’s own commit, and an existing asset is compared byte for
byte: identical is an idempotent success, different is refused.
What this release was about
The last release was about a verdict routed somewhere nothing could act on it. This one is about the return value that says everything went fine.
Each of these was a success that had not finished — a rename not yet durable, an asset not actually removed, a terminal not handed back, an upload that replaced what it should have refused to touch. None of them announced itself, because the one thing they all did correctly was return the value that means there is nothing to look at.
The general lesson is not “check more errors.” It is that a success has to be as
specific as an error is. removed: true was not wrong so much as it was
under-described, and the fix was not a new error code but two more fields saying
what was actually done. When an operation can finish in more than one way, the
report has to be able to say which one — otherwise the caller is left to assume,
and the assumption they will make is the good one.