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.
Five 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 |
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 the opposite — the key was reused for a materially different command, so this capture was never applied. It is settled, not transient: retrying yields the same conflict forever, so it is reconciled to a person who has the operator's work in the record and can resubmit it under a fresh key.
Reconciled also carries certainty. A refusal decided before any effect — a policy block, a
validation failure, an authority check on first delivery — is refused. An authority check that
failed on a recovery re-execution is unknown, because the lease worker re-runs authorization but
skips adapter steps that already succeeded, so the external effect may have landed before the binding
expired.
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.