One Timeout Cannot Answer Two Questions: Why an AI Write's Confirmation Needs Two Windows
An AI asks to write something, and a person has to approve it. There are two kinds of "expired" in that sentence, and they are not the same thing: should the conversation keep waiting for the approval, or move on wit
An AI asks to write something, and a person has to approve it.
There are two kinds of "expired" in that sentence, and they are not the same thing:
- should the conversation keep waiting for the approval, or move on without it
- can this row still be decided at all, or is it dead
We used one timeout to answer both. What that produced was a list showing "waiting on you" for a row where clicking was guaranteed to fail.
This piece is about splitting those into two windows with one function each, and about the single point where a decision has to be arbitrated.
1. Why they look like one question
Both read as "how long does a confirmation last", and once there is one number, the instinct is one column.
But they answer different questions, and different things consume the answers:
| The question | Who consumes it | What a wrong answer costs |
|---|---|---|
| should the conversation keep waiting? | the one stream that is waiting | waiting a little too long, or moving on a little early |
| can this row still be decided? | the data layer and every decision entry | a click that cannot execute, or a row that is dead and still actionable |
A wrong answer to the first costs experience. A wrong answer to the second costs consistency. And when they share a value, the second question's answer gets dragged around by the first one's needs.
2. The scene: computed in two places, held together by a comment
This happened to us.
The offline window's boundary was computed twice: the read path compared timestamps in memory, and the maintenance sweep compared them in SQL.
What kept the two in agreement was a comment in the code, saying roughly that the predicate here matches the one over there.
A comment is not a predicate. The day the two disagree, you get a row like this:
the list shows "waiting on you", while the sweep has already expired it.
So the click fails, necessarily. And it is not an occasional failure: the list manufactures it, by presenting a dead row as a pending task.
What makes this hard to find is that it looks like "the click sometimes fails", while the cause is two places holding different definitions of the same instant.
3. Fix one: two windows, and one function per boundary
There are two halves to the fix.
First, admit they are two windows.
- the in-conversation wait is short, 60 seconds by default, and its expiry only ends that wait so the conversation can continue. It does not touch the data.
- the offline window is long, 24 hours by default, and the row stays decidable inside it. Only its expiry flips the row to timed out.
Second, every boundary is computed by exactly one function.
The read path and the sweep both call it and get the same instant. So "shown as actionable" and "already expired" become structurally impossible to be true at once.
The point is not that the arithmetic is right. The point is that there is one place doing it. Two places and all you have left is a comment promising they agree.
4. Fix two: one arbitration point for decisions
Once there are two windows, there are two decision entries: the button in the conversation, and the pending list afterwards. They can race.
So the second thing is to make them contend for the same thing.
What we do is a conditional update in the database, where the condition includes "the current status is still pending". Then:
- the update touches one row, so this decision stands and it executes
- the update touches zero rows, so somebody already decided, and this one does not execute
Why not read, then decide? Because there is a gap between the read and the write, and both decisions can read "still pending". That is a decision placed before the action without the arbitration being placed before the action. A conditional update is atomic: whoever changes the row first owns the decision.
Two entries, one arbitration point. That is the whole of this section.
5. The second scene: one "we don't know" read as "yes"
Another one of ours.
A decision has to be written before it counts. What if that write throws?
The old behavior swallowed the error and released the in-memory wait anyway. Which gave this chain:
- the database hiccups, the write fails
- the in-memory wait is released, so the tool runs
- the row is still pending
- the next decision, in the conversation or from the pending list, sees "still pending" and runs it a second time
A "we do not know whether the decision landed" was read as a "yes".
The fix is plain: without evidence that the decision reached the database, do not release. Fail this one and let the person retry, rather than translating uncertainty into execution.
One thing worth being precise about: for external tools with no idempotency key, that second run is a real replay and the target really does receive it twice. On that path, failing closed is not caution. It is required.
6. A boundary this design does not cross
The arbitration point guarantees at most one decision.
It does not guarantee the tool runs at most once. If the same request gets resent, by a model retry or a restart, that is a different problem and it has to be solved at a different layer.
The two must be kept apart: one is "the same decision cannot be taken twice", the other is "the same call cannot be sent twice". Rolling them into a single sentence about "we guarantee it executes once" is overclaiming.
We put that in the result messages and in the docs rather than leaving it to be discovered.
7. A check you can run yourself
Four questions.
- How many questions does your timeout answer? If one value decides both "does the UI keep waiting" and "can this row still be used", that is a shared value.
- How many implementations does the boundary have? Two is one comment away from drifting.
- What arbitrates a racing decision? An in-memory check is not arbitration. A conditional update, or any atomic compare-and-swap, is.
- What is the default when a decision fails to persist? If the next step after a failed write is "execute anyway", the default is allow.
Closing
Back to the opening: one timeout cannot answer two questions.
What we do: split it into two windows, 60 seconds of in-conversation waiting and 24 hours of decidable pending; compute each boundary in exactly one function; make every decision contend for one conditional update; and release nothing without evidence that the decision landed.
The mechanism above is taken from KeelBase, an open-source runtime that sits between an AI agent and a business system and re-checks identity, authorization, scope, risk and confirmation state before a tool actually runs.
Repository (a Gitee mirror is the faster route from inside China):
Two things stated plainly. The boundary above: we guarantee at most one decision, not at most one execution. And a premise of the whole design. It assumes decisions can be persisted. If the database is unavailable we refuse rather than allow, which for some systems means confirmation stops working entirely. That is deliberate. If confirmation means "nothing runs that was not confirmed", then when nothing can be confirmed, nothing should run.
Drafted with an AI assistant. The system, the positions and the mistakes are mine.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes â full credit and traffic to the original publisher.