Offline and recovery
Capturing an action while disconnected, replaying it honestly, and what happens when the world changed while it waited.
Cached templates are template availability. They are not action durability. A collector in a basement can still see a screen; the question is what happens to the payment they capture.
What may be captured
Only an action whose capability declares connectivity: "offline-capturable", and only an event the
render plan marks offline-eligible. Eligibility is refused at capture time rather than at replay,
because a queue full of actions that can never be replayed is worse than a refusal in front of the
operator.
Every capture requires a stable idempotency key. Replay without one is a duplicate payment waiting for a flaky network.
Authority is re-checked when it executes
An action's authorityMoment is capture, execution or both. An offline capture is never
standing permission: an approval that lapsed while the capture sat in a queue does not fund a loan
when connectivity returns.
The replay carries the marker and the real capture instant:
{ executionReason: "offline_replay", capturedAt: "2026-08-31T09:14:22Z", … }Both travel with the request rather than by adapter convention. An adapter that had to remember to set them would eventually forget, and capture-authority evidence stamped at receipt time attests a moment nobody was there for.
A gateway that accepts replay must declare maxOfflineReplayAgeMs, and refuses replay outright
without one. Deferring the lower bound to the platform looks safe and is not: the Host checks
staleness only when the capture-authority adapter supplied an expiresAt, and skips the
execution-time expiry check entirely for actions governed at capture time. A replay with no
capturedAt is refused rather than defaulted, for the same reason.
Six dispositions, not two
Replay resolves each capture to one of these, and collapsing them is the common mistake.
| Outcome | Meaning | What happens |
|---|---|---|
| Completed | Delivered and progressing: the platform holds the invocation | Removed from the queue |
| Parked | Delivered, waiting on a human decision | Removed from the queue |
| Reconciled | Delivered and needing attention, or never deliverable | Removed, surfaced with the status and reason |
| Failed | Not delivered, worth another attempt | Stays queued |
| Expired | Outlived its TTL or its plan before it could be sent | Removed, with the records |
| Store failure | The capture store itself threw mid-replay | Stays in the store, named separately |
A store failure is its own bucket rather than a Failed, because the delivery may have succeeded
before the store threw: Failed means never delivered, and this is neither that nor a success. The
record stays either way, so nothing the operator did is lost. If it had been delivered, the next
replay resubmits under the same idempotency key and the platform returns the original invocation. If
it had expired or conflicted, the next replay reaches the same verdict and tries the store again.
Delivery is what the queue is responsible for. Once the platform returns an outcome it holds the
invocation durably, so the capture has done its job and leaves the queue either way; what differs is
whether a person needs to hear about it. An adapter that returns nothing has not delivered — the
capture stays queued and the adapter fault surfaces as transient, rather than draining the queue into
unknown over a missing return statement. A reconciled entry carries certainty, because an adapter
whose outcome could not be determined is not the same as a refusal — telling an operator the charge
was refused would be as wrong as telling them it succeeded.
A refusal is a returned outcome, not an exception. The platform treats a lapsed authority as a
business result and returns reconciliation_required with the reason, so a queue that inspects only
the exception path deletes the capture and reports it as applied. Read the outcome.
Replaying a repeated idempotency key is the mechanism working: the platform returns the original invocation without complaint. An idempotency conflict is different: the key is already held by an invocation this replay does not match. It is settled, not transient, because retrying yields the same conflict forever, so it is reconciled to a person who has the operator's work in the record. What that person is told depends on why it conflicted, which the next section covers.
Reconciled also carries certainty. An authority check that failed on first delivery is
refused; the same check failing on a recovery re-execution is unknown, because the lease worker
re-runs authorization but skips adapter steps that already succeeded, so the effect may have landed
before the binding expired. The platform persists which attempt it was, and the queue reads it.
A policy block or a validation failure is reported unknown too, for the same reason and one more: on
recovery, policy is re-evaluated with a fresh clock and parameters are re-parsed against a possibly
newer action, either can flip after effects committed — and the platform does not persist the
attempt for these, so the queue cannot tell first delivery from recovery. It errs toward a person
checking, which is the cheaper mistake.
An idempotency conflict is settled either way, but its certainty depends on the reason the platform
gives. parameters or actor means the key was reused for a different command and this capture was
never applied: refused, under status not_delivered. authority_binding or action_version means
the original invocation exists and may have applied, because the capture-authority adapter mints a
fresh binding on every attempt, so a first attempt whose response was lost conflicts with itself on
the second: unknown, under status conflict_prior_invocation, carrying the invocation it conflicted
with so it can be reconciled. The queue reads the conflict off the thrown error's data when the
gateway passes it through, and falls back to the message otherwise. A conflict whose reasons cannot
be read is still settled, and resolves to unknown, never to refused.
A reconciled capture must reach a person. Retrying a settled refusal only delays the moment somebody is told, and the operator is the one who can act on it.
A thrown failure that is not recognised stays transient, which errs toward retrying rather than discarding a capture. A returned outcome that is not recognised does the opposite and is surfaced for attention, because the platform answered and the answer was not one we understand — treating that as success is how a refusal gets reported as applied.
Parked and Expired carry their records too. A bare count leaves nobody able to say which action
somebody now has to approve, or whose work was dropped.
Reading offline
A projection is readable offline only if its view declares offlineUsable, and only within its
freshness bound. A stale cache fails closed with PROJECTION_CACHE_STALE rather than serving data
that quietly stopped being true.
Storage
The queue is storage-agnostic; you supply IndexedDB, SQLite or AsyncStorage. Production requires an encrypted-at-rest store — captured parameters are business data sitting on a device that gets left in a car.