The Undo TTL Problem: Validating Rollback Windows When Humans Are in the Loop

Compensation is a capability with an expiry. Put a human approval inside a saga and the undo window math stops being optional, and a validator that can't know the wait time has to fail closed.

aiagentsdistributed-systemsspecificationopen-source

A deep-dive into one check from apology-protocol, the validator for typed agent plan artifacts. Background in Agents Can Talk, But They Can’t Commit.

Here is a plan an agent produced. Six steps, vendor onboarding. Step 1 reserves budget and comes with a compensation, budget.release, valid for 24 hours. Step 3 is a compliance review that requires a human to click “approve”. The pivot, the point past which there is no rollback and only apology, sits at step 4.

Quick question: is this plan safe to execute?

You can’t answer. Not because you lack context, but because the plan itself doesn’t contain the answer. Nobody knows how long the human at step 3 will take. Maybe four minutes. Maybe they’re on holiday until Thursday. And if they take longer than 24 hours, something quietly terrible happens: the plan is still running, no step has failed, and yet the saga can no longer be rolled back. The undo token on step 1 expired while everyone was waiting for a click.

The plan didn’t fail. It became unrecoverable while succeeding.

This post is about the validator check that catches this at plan time, and the three design options I went through before landing on the one that felt wrong at first and right afterwards.

Why Rollback Windows Exist at All

The saga pattern’s core bargain is: no distributed locks, but every step before the pivot must be compensable. What the textbook treatment glosses over is that compensation is not a mathematical inverse that exists forever. It’s usually a capability with an expiry:

In the plan artifact, this is the undoTtlSeconds field on a compensable step: how long the declared compensation remains valid after the step executes.

Which means a saga has a hidden invariant nobody writes down: the worst-case time to reach the pivot must fit inside the shortest undo window on the path. If it doesn’t, there exists an execution (a perfectly healthy one, with no errors and no retries) where a failure at the pivot leaves you with steps you can no longer unwind. You don’t discover this in your error logs. You discover it during quarterly reconciliation, which is the most expensive place to discover anything.

So the check itself is conceptually trivial: sum the worst-case durations of the steps before the pivot, compare against each step’s undo TTL, fail validation if any window can’t cover the remaining path.

Trivial, until a human shows up in the plan.

The Problem: Humans Don’t Have a maxDurationSeconds

Machine steps are easy. A tenant.provision call takes 30 seconds or times out; you can put an honest worst-case bound on it. But agent plans in the real world are full of steps like compliance.review or manager.approve: steps whose duration is decided by a person’s inbox.

When I sat down to implement undo-ttl in the validator, I had three options for these steps. I’ll walk through them in the order I considered them, because the order matters: the first two are the intuitive ones, and they’re both traps.

Option A: Estimate Human Durations with Defaults

The obvious move. Human approval steps get a default worst-case, say 72 hours, configurable per deployment. The check runs, plans validate, everyone’s happy.

This is a lie wearing a config file. The 72 is not knowledge; it’s a guess frozen into a constant, and the validator would be certifying plans based on it. The whole point of validating plans before execution is that the artifact carries verifiable claims. “A human will respond within 72 hours” is not a verifiable claim. It’s hope with a unit of measurement. The first long weekend breaks it, and now you have the worst outcome: a validator that said yes to a plan that was structurally unsafe. A validator that lies is worse than no validator, because it converts vigilance into trust.

Option B: Skip Steps Without a Declared Duration

The pragmatic move. If a step doesn’t declare maxDurationSeconds, exclude it from the sum. Validate what you can measure, stay silent on what you can’t.

This turns the check into decoration. The steps you’re skipping are precisely the dangerous ones: the unbounded waits are the entire reason undo windows get blown. A undo-ttl check that ignores human steps is a smoke detector that ignores smoke from cigarettes because they weren’t in the spec. It will pass every plan that matters and catch only the ones that were obviously broken anyway.

Worse: it creates an incentive gradient. Plan authors (human or model) learn that omitting a duration makes validation easier. You’ve built a system where less information means more green checkmarks. That’s backwards.

Option C: No Declared Duration Means Infinite Duration

The option that feels hostile: if a step doesn’t declare maxDurationSeconds, its worst-case is unbounded. And an unbounded wait sitting inside anyone’s undo window is, by definition, a violation. Infinity does not fit inside 86,400 seconds. The plan fails validation.

This is what apology-protocol implements, and here is the fixture that encodes it: unbounded-wait-inside-undo-window.json, slightly abridged.

{
  "sagaId": "sg_fixture",
  "pivotIndex": 3,
  "mandate": { "maxSpendEur": 250, "expiresAt": "2027-01-01T00:00:00Z" },
  "steps": [
    {
      "id": "s1",
      "tool": "budget.reserve",
      "class": "compensable",
      "compensation": { "tool": "budget.release", "args": {} },
      "undoTtlSeconds": 86400,        // undo valid for 24h after s1 runs
      "maxDurationSeconds": 60
    },
    {
      "id": "s2",
      "tool": "compliance.review",
      "class": "compensable",
      "compensation": { "tool": "compliance.retract", "args": {} },
      "undoTtlSeconds": 604800
      // no maxDurationSeconds, so the worst case is unbounded
    },
    {
      "id": "s3",
      "tool": "tenant.provision",
      "class": "compensable",
      "compensation": { "tool": "tenant.teardown", "args": {} },
      "undoTtlSeconds": 172800,
      "maxDurationSeconds": 300
    },
    { "id": "s4", "tool": "fee.capture", "class": "irreversible" }
  ]
}

Run it through the validator and you get:

✗ undo-ttl  steps[1]  Step "s2" (compliance.review) declares no
  maxDurationSeconds: its worst-case duration is unbounded. Step "s1"
  (budget.reserve) has an undo window of 86400s that must survive until
  the pivot (steps[3]). An unbounded wait cannot fit inside a bounded
  undo window. Declare maxDurationSeconds on "s2", or move the human
  step past the pivot and mark the path accordingly.

Notice what the check is not saying. It’s not saying “humans are not allowed before the pivot.” It’s saying: this plan makes a claim it cannot back, that step 1 remains reversible until the pivot, and the claim fails under the plan’s own declared numbers.

“But This Rejects Legitimate Plans!”

Yes. Deliberately. Let’s look at what “legitimate” actually means for a plan with a human wait before the pivot, because there are exactly two honest versions of it.

Honest version one: the undo windows genuinely cover the wait. If your compliance team’s SLA is five business days, then the plan needs undo TTLs measured in weeks, not hours: a budget hold that survives 10 days, an auth/capture window negotiated to match. If your tools can actually offer that, declare maxDurationSeconds: 432000 on the human step, size the windows accordingly, and the plan validates. The check didn’t reject your pattern; it forced your TTLs to tell the truth about your process.

Honest version two: the human decision is the pivot. In a lot of real workflows, the approval isn’t an inconvenience on the way to the point of no return; it is the point of no return. Everything before it should be cheap and reversible; everything after it is one-way by design. Restructure the plan so the human step sits at the pivot, and the unbounded wait stops being a threat: nothing behind it is holding a decaying undo token, because everything behind it stays compensable indefinitely or completes fast.

What the check forbids is the dishonest version: a plan that structurally pretends the wait is bounded, keeps perishable undo capabilities alive across it, and defers the discovery of the contradiction to production. The validator doesn’t ban humans in the loop. It bans lying about them.

This is the same design stance as the rest of apology-protocol. The class-coherence check rejects a tool that claims irreversible while shipping an undo, because it’s lying about one of them. Here, a plan that claims “reversible until step 4” while containing an unbounded wait at step 2 is lying about its own reversibility. The burden of proof sits with the plan author, because the plan author is the only party that can know.

The General Principle

One check, but the shape of the argument generalizes:

  1. Rollback is a perishable capability, not a property. Any system that treats compensation as always-available has a reconciliation incident on its calendar.
  2. Defaults are claims. A validator that fills in optimistic numbers on your behalf isn’t validating; it’s co-signing.
  3. Absence of information must fail closed. If the safe interpretation of a missing field is “unbounded”, the check must use it, even when (especially when) that makes the check annoying.

The undo TTL check is one of four static checks in apology-protocol (pivot ordering, class coherence, mandate limits, undo TTLs). All four exist for the same reason: these properties get verified before execution, or they get discovered during reconciliation. There is no third option where they verify themselves.

Agents propose. The runtime disposes. And the validator gets to say no before either of them spends your money.


The validator is MIT-licensed and on npm. If you’re hitting this class of problem with agents in production, especially the human-in-the-loop variants, I’d genuinely like to hear how the TTL math works out in your domain: open an issue on the repo.