Agent-First Mail v0.11.0: Nothing Waits for Done
Review used to stage a person's answers and commit them all at once when they pressed Done, which meant closing the window threw the work away. Each item now settles the moment it is decided, the workspace is the source of truth rather than the process, and a draft can no longer attach a file it merely points at.
The previous release put review in a window that belongs to someone else. This one changes what happens inside it, because the model underneath had a failure mode nobody should have to think about: the person did the work, closed the tab, and the work was gone.
Done was a transaction nobody asked for
The old shape was a batch. An agent staged proposals, a person answered them, and Done committed everything atomically. Anything else — closing the window, a dropped connection, a laptop lid — discarded every answer given so far, because none of them had happened yet.
That is a reasonable design for a form. It is the wrong one for a person working through a queue on a phone, which is where these windows actually get opened.
Each item settles independently now, the moment it is decided. Closing the window ends the session; it does not roll anything back. Re-deciding an item replaces its record rather than appending another one. And the terminal result is no longer a value carried out of the UI session — it is re-read from the workspace after the session ends, reporting settled and unsettled per item.
That last detail is the one worth keeping: it means kill -9 mid-session loses
nothing already settled, because the disk is the source of truth and the process
is not.
The vocabulary shrank with the model. Session-level submitted, cancelled and
dismissed are gone — a session no longer has an outcome, its items do. On the
item side, Deferred is gone and Decided is new: a disposition the agent never
proposed, which the old outcome list had no way to express. Three invariants
travel with it — Decided carries no agent suggestion id and must be a complete
action, Accepted and Changed require one, Rejected requires a reason — and
they are checked immediately after deserialization for messages, cases and
notifications alike, instead of being a rule each writer had to remember.
Stale-suggestion detection moved the same way. It used to be a gate on the whole session: one item whose underlying message had moved on refused to open any of them. It is a per-item flag now, so the rest of the queue opens.
A draft cannot attach what it merely points at
A draft is Markdown a person edits, which makes its attachments: list
caller-controlled text at exactly the same trust level as the mail body.
Three byte-identical copies of a purely lexical check decided what got sent:
reject absolute paths, reject anything that is not a normal path component, join,
read. None of them looked at what was actually on disk. So attachments: [files/link], with files/link a symlink pointing at an SSH key, was read,
hashed, shown in the push plan as a harmless case-relative path, and attached.
The size and hash presented to whoever confirmed that push were the key’s.
There is one primitive now, and it opens rather than resolves. The managed range
is files/<name> — not a new restriction, but the one draft attach always
implied by copying into <case>/files/ and never enforced. The directory and the
file are checked separately, non-regular files are refused before any open, and
the opened handle’s identity is compared against what was checked, so a swap
between those two moments is an error rather than a silent substitution. Callers
get the handle and read from it. Draft validation goes through the same
primitive, which makes “validates” and “can be sent” the same question asked
once.
The delivery words are AFUI’s now
--mode local|lan becomes --mode window|link|session. local was window,
and lan was link — the observable behaviour of a link is unchanged: one-shot,
fifteen-minute expiry, review_ready carrying review_url, expired afterwards.
The fifteen minutes stays afmail’s number, because AFUI offers no default.
What went away is afmail’s own LAN implementation. src/lan.rs is deleted, and
both review delivery and api serve’s discovery now use the shared one. The
api serve --mode local|lan flag is a different axis — network exposure — and is
untouched.
Checked by the engine that serves it
doctor used to render each review template on its own through afmail’s plain
template renderer, while review assembles the page through AFUI. Two engines,
one promise — so a frontend that doctor called healthy could still fail to serve,
because the serving side adds an escaping policy, a rendered-markup guard and an
element contract that the checking side did not have.
Doctor renders the real page now, one render per surface, each covering that surface’s whole tree: the page, the message, both editors, the workbench, and the stylesheet, which is inlined rather than linked. The old per-key contexts had only ever exercised the case branch, leaving triage, notification and draft unchecked — and that was not hypothetical. The first run through the real engine failed on a shape those contexts never reached.
The page’s required hooks are declared as well, and checked before a window
opens. The docs name twenty-five data-afmail-* hooks without saying which a
page cannot do without, and finding the answer meant reading the runtime against
what a page actually renders. Four are load-bearing on every surface: the root
the runtime returns without, the content node every form binding hangs off, and
the workbench/suggestion pair whose refresh path throws if it finds one without
the other. The other twenty-one are not required, for good reasons — a settle
form exists only where there is a suggestion to settle, and autosave belongs to
editors, which a triage page does not have.
Less page, and one head per card
The review page’s routing, its session channel with reply correlation, and its unload guard were afmail’s own copies of things every page in this kit had. They now come from AFUI’s page kernel.
What stayed is what is genuinely afmail’s: which update ends a turn
(input_accepted only says the agent heard; response finishes it), flushing
dirty editors before a send so the page does not manufacture lock contention with
itself, and clamping the selection inside a turn. The net saving was nineteen
lines, which is worth stating plainly rather than hiding — the rest of that file
is afmail’s model, and sharing it would have meant pushing afmail’s model into
AFUI.
In a framed session the page also stops drawing its own head. The frame already says whose program this is and what the session is on; the page was saying both again, one row down, on the screen dimension there is least of. It hides the brand and the saving line and sends up only what a frame cannot know. The local-workspace pill stays, because that pill is not identity.
Smaller things
triage review with no message ids used to imply it covered every current
suggestion. It reviews the current queue newest-first up to a bound, and the
result now says whether any were left out.
The IMAP push contract is pinned by a test server that records what actually
crosses the wire — append, copy, expunge, move, store — so a change in push
behaviour has to be deliberate. The per-file locale overlay is pinned too: a
frontend overriding only review/page.html.j2 still gets the rest of the
active language pack, tested against zh-CN rather than the default so a pass
cannot come from falling back to English.
Error events no longer quote a value that came from an argument able to carry a
credential, and the local container gates run on Apple’s container runtime as
well as Docker.
Breaking changes
--mode local|lanis now--mode window|link|sessionon every review command.- Session-level
submitted,cancelledanddismissedare gone. A session has no outcome; read settled and unsettled per item from the result. Deferredis gone andDecidedis new, andbased_on_agent_suggestion_uidis optional — absent only forDecided.- A draft attachment must be a regular file under
files/. A symlink there is refused rather than followed. - Agent-First UI 0.5.0, Agent-First Data 0.34.0 and Agent-First Slug 0.7.1 are the pinned dependencies.
Getting it
$ brew install agentfirstkit/tap/afmail
$ cargo install agent-first-mail