carlos / platform Public

Design: the retirement of a refused checkout intent as a durable protocol (review eighteen's P2)

Reviews thirteen to eighteen moved the bound session's expiry around the
review's filing and each order left a window. The design records three
steps instead: the refusal (the review, authoritative until the end), the
session's fate proven from provider evidence and recorded on the intent
(expired or complete; the expiry requested under one fixed key by every
actor that meets an unproven fate: reader, pass, operator), and the
operator's atomic completion, which retires only on a proven expiry,
settles a paid session by a separate outcome, or keeps. The click offers no
URL under a review. Astra eighteen's two P3s (concurrent identical keeps;
attribution of a retired session's completion beside a replacement) are
folded in. Design only; nothing built.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8c72e88b4c6f99a67e287e51bdecd31bff7e0162 parent 68f6ee2
1 file changed, +81 −0
  • docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md +81 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md b/docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md
new file mode 100644
index 000000000..0fbcbfa6d
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md
@@ -0,0 +1,81 @@
+# The retirement of a refused checkout intent: a durable protocol
+
+Status: design, written 2026-10-02 on `feat/carloku-multi-currency-20261001` after review eighteen (Opus P2-1). Nothing here is built. Billing is OFF. It supersedes the ordering arguments of reviews thirteen to eighteen about when the bound session is expired relative to the review: both orders have been shown wrong by an interleaving, and the fix is not a third order but a protocol with recorded steps.
+
+## The problem, stated once
+
+An intent is bound to one Checkout session the console created. When the console learns the session is not the intent's purchase (another currency, another price, a hand-made subscription, a session read back as nobody's), the intent must stop being payable and a person must dispose of it. Three facts live in three places:
+
+- the **refusal**, a review row (`billing_checkout_currency_refused`, keyed by the intent, the session as its reference), which holds the account and under which nothing records and no ordinary retirement runs;
+- the **session's fate at the provider**: open and payable, expired, or complete (paid);
+- the **intent's end**: retired (gone, with a record), or kept for a corrected purchase.
+
+Reviews thirteen to eighteen moved the provider call around the review's filing. Filing after a successful expiry (rounds thirteen to sixteen) let a pass retire the intent between the expiry and the filing with no record (Astra, round seventeen). Filing before the expiry (round seventeen) lets the operator retire an intent whose session is still open and payable when the expiry failed, so money is taken after the operator recorded it settled (Opus, round eighteen). Each order leaves one window because the session's fate is never recorded: the code reasons about it from the order of two calls, one of which can fail.
+
+## The protocol
+
+Three durable steps, each recorded in the console's store, each idempotent, each performable by any of the four actors (a buyer's click, the pass, a webhook, the operator), in this order and only this order:
+
+1. **Refusal recorded.** The review is filed, keyed by the intent, naming the session. From this instant the intent is `refused`: it stands (no second purchase opens beside it), nothing records on it, no ordinary retirement touches it, the resume path offers its session to nobody, and every actor that meets it works toward step 2. This is today's review row; it is authoritative until step 3.
+
+2. **The session's fate proven and recorded.** The intent row gains `session_fate` (`''` | `expired` | `complete`) and `session_fate_at`. It is written once, from provider evidence that names the bound session id and the account (the same proof `retireIntent` demands today: the session's id, mode, `checkout_intent` and account metadata; `expired` with no subscription and not paid; or `complete`). Evidence comes from: an expiry answer; a read of the session (the pass, a resume, a listing); the completion webhook; the subscription webhook's read. `expired` and `complete` are final; a later contradiction (an expired session read back as complete) is a provider contradiction and files its own review, as a session read back as nobody's does.
+
+ Until the fate is `expired` or `complete`, the console **requests the expiry** by the bound id under one fixed idempotency key (`carloku-expire-<intent id>`, as today), from every actor that meets the refused intent: the reader that files the review (the same call it makes now), every pass while the review is open and the fate is unproven (new: today the pass does nothing for an open session that is still the intent's purchase), and the operator door on a `retire` (new). One key means the provider expires once and answers every later request the same way; an answer that the session is complete is the proof `complete`. The request's failure is not a state: nothing is recorded for it, and the next actor asks again. The console never records `expired` from its own request having been sent; only from an answer or a read.
+
+3. **Local completion, atomic.** The operator's resolution ends the intent in one transaction, and the transaction refuses unless the fate on the row allows the outcome:
+ - `retire`: the fate is `expired`, or the intent was never bound (no session). The intent is deleted, `billing_retired_intents` written, the quantity events closed, as today. With the fate unproven: the door first requests the expiry (step 2) and records the answer; a request that fails answers 503 and the review stays open; a fate still unproven after the answer (the answer named another session) answers 503. With the fate `complete`: 409, "the session was paid; settle or keep".
+ - `settled` (new): the fate is `complete`. The operator attests that the money was handled at the provider by hand (refunded, or accepted as a one-off). The intent is retired exactly as `retire` retires it, with the outcome recorded as `settled`; the paid session's later events settle as a retired intent's do today. A paid session is never called expired.
+ - `keep`: the fate is `complete`, the bound session reads back as the intent's own purchase with a subscription (today's gate), and the corrected purchase records afterwards; the review is closed and the intent stands.
+ - Any outcome on a review already resolved: the exact replay rule, as today (200 for the same actor, note and outcome; 409 otherwise).
+
+ The resolution transaction reads the fate from the row under the same write lock that the fate's writers take (the store's one connection), so a fate recorded after the operator looked and before the transaction ran is what the transaction sees: a completion that lands between the operator's read and their `retire` turns the `retire` into a 409, not a retirement of a paid intent.
+
+## Each actor, each state
+
+| Actor meets a refused intent whose fate is… | unproven | expired | complete |
+| --- | --- | --- | --- |
+| Buyer's click (resume) | the page says the purchase is being reviewed; no URL is offered; no new intent | same | same |
+| Pass | reads the session; records the fate if the read proves one; else requests the expiry and records the answer's fate | nothing (retirement is the operator's) | nothing |
+| Webhook (completion, subscription) | records `complete` from the session read; records no purchase (the review is open); answers 204 | a completion of an expired session: provider contradiction, review | records nothing; 204 |
+| Operator `retire` | requests the expiry; records the fate; retires on `expired`, 503 on failure | retires | 409: settle or keep |
+| Operator `settled` | 409 | 409 | retires with outcome `settled` |
+| Operator `keep` | 409 | 409 | today's gate (own purchase, subscription) |
+
+An unbound refused intent (the create response was lost and the listing found the session paid: the one case `fileCheckoutRefusedReview` has with no bound id) has its session only as the review's reference; the fate is proven from reads of that id, and `retire` requires it as for a bound one.
+
+## Two more things review eighteen named, settled by the same protocol
+
+- **Concurrent identical resolutions** (Astra 2): two identical `keep` requests both read the review open; the first commits; the second's gate then finds no standing intent (the corrected purchase recorded meanwhile) and answers 409 for a resolution that is already committed. The door's gate and its resolution run under the account's billing lock (the lock every writer of the intent takes), and a gate refusal or a provider failure is answered only after a second read of the review: an occurrence resolved meanwhile goes to the store's exact replay comparison (200 for the same actor, note and outcome; 409 otherwise). A controlled two-request test establishes it.
+- **A retired session's completion with a replacement intent standing** (Astra 3): the binder judges the late completion against the replacement, fails ownership, and settles it with no account on its rejected row. The account-scoped retirement check runs for every completion that does not record, before any judgement against a standing intent, and returns the typed identity error that carries the account, as reconcile does; the late-completion fixture inspects the rejected row.
+
+## What does not change
+
+- `ownSession` and `intentSession` as the one judgement of a session on every path.
+- Nothing records under an open review; the record's transaction consumes the exact standing intent (`consumeIntent`).
+- A retired intent's events settle (`billingIntentRetired`), and are listed for the operator (`GET /api/billing/rejected`).
+- The pass's expiry retirement of an intent with no review (`expireBillingCheckout` with proof) is untouched; it already refuses under an open review.
+- The provider is read only through the console's own bound id; no session is expired that the console did not bind.
+
+## Interleavings this closes
+
+- Expiry fails after the review (Opus eighteen): the fate stays unproven; `retire` requests the expiry itself and retires only on the answer; the pass retries meanwhile; the click offers no URL.
+- A pass between an expiry and the filing (Astra seventeen): the review is step 1, filed first; nothing retires under it.
+- The operator looks, the buyer pays, the operator retires: the completion recorded the fate `complete` before the resolution's transaction, which answers 409.
+- A reader's expiry answer and a webhook's completion for the same session: whichever records first wins the row's fate; the other, if it contradicts, files the contradiction review and records nothing.
+- Two operators: the second's resolution is a replay or a 409, as today.
+- A retired intent's session paid afterwards (impossible once `retire` requires `expired`; a `settled` intent's later events settle as retired, by the operator's attestation).
+
+## Schema and migration
+
+`billing_checkout_intents` gains `session_fate TEXT NOT NULL DEFAULT ''` and `session_fate_at INTEGER NOT NULL DEFAULT 0` (`addColumnIfMissing`). `billing_review_resolutions.outcome` gains the value `settled`; an older row reads `retire`. A refused intent from before this build has an unproven fate: its `retire` requests the expiry and proves it, as any other.
+
+## What must be verified in Stripe test mode (approval E)
+
+1. Expiring an already expired session under the same idempotency key answers the expired session (a replay), and under a new key answers an error naming its status; the console records `expired` from either.
+2. Expiring a completed session answers an error naming its status; the console records `complete` from the read that follows, never from the error alone.
+3. A session completed between the console's read and its expiry request: the expiry fails, the completion webhook arrives, the fate is `complete`.
+
+## Not decided here
+
+- The copy the buyer sees when their purchase is under review (Paul's).
+- Whether the operator door should also let a person request the expiry without resolving (a `expire` action with no outcome). The protocol does not need it: `retire` requests it.