The post we put up earlier today described a rollback runner: a small piece of code that executes a sequence of API writes and, on partial failure, reverses the earlier writes in reverse order. The post listed the verification — twenty-four assertions across five scenarios — and the audit log shape, and the structured outcome the runner returns to callers.

What that post did not describe, because we hadn't done it yet, was the part where the production flow actually uses the runner. The mail-filing skill — the one the model reads when an attorney says "process my mail" — was still issuing its writes the old way: a series of independent merus-fetch calls, with no shared rollback path between them. The runner existed in the codebase. Nothing in production called it.

This is a category of mistake worth naming. You can build the right primitive, verify the primitive in isolation, write about the primitive as if shipping it changes the behavior of the system, and have all of those statements be locally true while the property you claimed is not yet a property the user experiences. The library is there. The call sites still use the old path.

Today's release closes that gap for the two production flows that needed it.

The wiring layer

The runner accepts an array of step objects, each with a write function and an optional undo function. To call it from a skill — a markdown file the model reads — you need either an in-process JavaScript caller (the model can't write closures) or a CLI that takes the steps as data.

We chose the second. The new tool, aai-sequence, takes a JSON specification of the steps and runs them through the runner. The spec looks like:

{
  "label": "file-mail-upload-NNN",
  "steps": [
    {
      "name": "file-upload",
      "args": ["/uploads/edit/NNN", "case_file_id=MMM", "description=YYYY-MM-DD-..."],
      "undo_args": ["/uploads/edit/NNN", "case_file_id="]
    },
    {
      "name": "create-activity",
      "args": ["/activities/add", "case_file_id=MMM", "upload_id=NNN", ...],
      "result_path": "id",
      "undo_template": ["/activities/edit/{id}", "deleted=1"]
    },
    {
      "name": "create-task",
      "args": ["/tasks/add", "case_file_id=MMM", ...],
      "result_path": "Task.id",
      "undo_template": ["/tasks/edit/{id}", "deleted=1"]
    }
  ]
}

Each step's args are passed through to the existing merus-fetch helper, which means every binding guard, tag guard, override token, and audit log entry that already exists still applies. The runner is purely additive — it wraps the existing safety layer in atomic-or-revert semantics without replacing any of it. The model writes the spec, calls aai-sequence, and reads back a structured JSON result.

The mail-filing skill and the email-filing skill were both updated to build this spec and call the runner instead of issuing the writes directly. The model now writes one shell command per upload (or per email), not three or four. The intermediate state where two writes succeeded and one failed is no longer a thing the attorney can be left holding.

Three response shapes

The runner needs to know how to extract the ID of each newly-created record from the API response, because the undo recipe usually needs that ID. (For example, undoing a "create task" means deleting the task you just created — you need the task's ID.) The spec format includes a result_path field that points to where the ID lives in the response JSON: "id" if it's at the top level, "Task.id" if it's nested under a model name, and so on.

The first version of the integrated skill specs got these paths wrong. Specifically, the spec said "Activity.id" for the result path of /activities/add, on the reasonable assumption that the API would wrap the new record under the model name the way other endpoints do. It does not. /activities/add returns {"id": "981459438"} with the ID at the top level. /tasks/add returns {"Task": {"id": ...}, "activity_id": ...} with the ID nested. /events/add returns {"data": {"Event": {"id": ...}}, "newEventId": "..."} with the ID nested two levels deep AND duplicated at the top level under a different name.

Three different shapes for three different endpoints. We discovered this while verifying the runner end-to-end against actual Merus, because the runner reported "no id in result" for the activity step, which meant the undo template couldn't fill its {id} placeholder, which meant the rollback couldn't run. The created activity was then stuck in the case until we manually cleaned it up.

The discovery turned into two fixes. First, the skill specs now have the correct result_path values per endpoint. Second, the runner has a fallback: if the configured path doesn't find an ID, it tries a short list of common alternatives (id, Task.id, Activity.id, Event.id, newEventId, data.Event.id, etc.) before giving up. This is defense-in-depth: the spec should be right, and if it isn't, the fallback usually catches it.

This is exactly the kind of bug verification exists to find. The runner logic was unit-tested with mocked write functions that always returned what the runner expected. Those tests passed. The integration with real Merus, where the response shapes are what they are rather than what we assumed, surfaced the bug in a way unit testing couldn't.

Idempotent undo

The other production-only surprise: when the runner's undo step soft-deletes a record via /foo/edit/{id} deleted=1, and that record was already deleted (because, say, the cascade from a related rollback had already removed it), Merus returns an error like "This Task has been deleted." The helper script reports a non-zero exit and the runner originally interpreted that as "the undo failed."

It hadn't failed. The post-condition we wanted — the record is gone — was already true. The API was complaining that we'd asked it to delete something that was already in the deleted state, which is a different kind of error than the undo actually breaking.

The runner now treats "this record has been deleted" / "does not exist" / "not found" responses on undo calls as success. The semantics are idempotent: the undo wants the record gone; if the record is already gone, the undo succeeded, regardless of which call removed it. Without this, partial rollback scenarios would report fake undo failures and surface manual-cleanup warnings for cleanup that had already happened.

What changed in the production flow

For an attorney processing mail, the visible change is small. The proposal preview before each write looks the same. The approval prompt looks the same. The success message looks the same. The difference is what happens when something goes wrong.

Before: if the upload was renamed and assigned to a case (write 1) and the activity was created (write 2), but the follow-up task failed (write 3), the upload was on the case with no audit-trail activity describing what it was, the activity was logged with no follow-up deadline task, and the attorney was told "create-task failed" without any indication that the previous two writes had succeeded. Cleanup was manual, and the attorney had to know what to look for.

After: the same failure produces a structured result that lists what succeeded forward, what failed, and what got rolled back. By the time the attorney sees the failure message, the activity has been soft-deleted and the upload has been un-assigned. The case is back in the state it was in before the filing attempt started. The attorney can re-try the filing without having to manually find and reverse the partial work.

If the rollback itself fails — because, say, a network blip prevents the undo HTTP call from reaching Merus — the runner records that specific step's failure in the result and the attorney sees a "manual cleanup needed" warning with the exact step that needs attention. The audit log preserves every detail so a later aai-undo --show LABEL command can produce the full transcript.

The discipline being practiced

The pattern we've been trying to keep through this series of releases is: build the property, verify the property, ship the property to the production path, write about the property. Each step is a separate commit. The runner ship-and-verify was its own release, with its own blog post, claiming the runner is ready. The mail-wire and email-wire are this release, with this blog post, claiming the property is now actually in the user-facing flows.

The distinction matters because it's tempting to conflate them. If you've built the runner and the runner is verified, it feels like you've shipped the safety property. You haven't. You've shipped a library that, if called, would provide the safety property. Calling it from the production path is a separate piece of work, and that work has its own bugs (the response-shape mismatch, the idempotent-undo handling) that don't surface until you actually do it.

The system prompt for the model — which is what the model reads to understand how to use the tools — was updated as part of the wiring, not as part of the runner ship. The mail-filing skill now begins its execution section with a hard rule: use bin/aai-sequence.mjs for filing, not individual merus-fetch calls. The email-filing skill says the same. Without those changes, the model would have continued doing it the old way, and the rollback property would have continued existing in the codebase without existing in practice.

What's still open

Two things did not get wired and are honest follow-ups, not surprises:

The case-audit flow, which sweeps an entire case re-binding every upload, can detect misfiles but doesn't yet apply corrections through the runner. The fix-misfile flow, which reassigns one or two specific uploads after diagnosing a swap, similarly doesn't yet use the runner. Both are single-step or near-single-step operations in their corrective phase, so the rollback win is smaller for them than for the multi-step file-mail and file-email flows. They're still scheduled.

The Windows compatibility script ships in the tarball but has not been run against an actual Windows machine yet. The Mac-side audit caught the platform-specific bugs we knew about. There may be others that only surface when a Windows user runs the tool.

The work continues at the pace of one verified release at a time, with the practical-benefit-in-production wiring being its own commit. The runner exists. It is now actually being called.