carlos / platform Public

Record the two late-request defects as defects, with the design that removes them

Documents only; the code tip is still 43383b93. A quantity request that runs
after Stripe has forgotten its key, and a late invoice-line request that can
leave a finalized invoice with a wrong total a customer could pay, are open
defects with their paths disabled. Neither is offered as a risk to accept.
The design binds every mutation to its connection: the last check happens
after the connection is established, on a connection that is never reused,
kept alive or retried, so a request cannot arrive later than the provider's
idle limit after its check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b6692549232781b43dc412764d17ec5ff5b9a3d8 parent 6d377c8
4 files changed, +74 −5
  • .claude/HANDOFF.md +1 −1
  • docs/superpowers/plans/2026-10-01-late-request-fencing.md +69 −0
  • docs/superpowers/specs/2026-10-01-stripe-idempotency-evidence.md +2 −2
  • implementation/carloku-paid-launch/OUTSTANDING.md +2 −2

CI cancelled — superseded by a newer push — run details

diff --git a/.claude/HANDOFF.md b/.claude/HANDOFF.md
index d6e073be2..065f7f356 100644
--- a/.claude/HANDOFF.md
+++ b/.claude/HANDOFF.md
@@ -20,9 +20,9 @@ Review servers on docs-t1, each answering 200 on its IP form at 04:32 Dublin tim
**NEEDED:**
- Paul: approve or edit both copy reviews; confirm the HALF code word; decide the proration policy; approve or change the whole-paid-day rule for partial months; rule on a store's first clock hour and a box's first minutes; review member copy for a bucket retained by another account.
- Scoped approvals, prepared as plans only: the `rk_live` restricted key and Stripe catalog; the Cost and Usage Report export; Stripe test mode, to run `TestSandboxQuantityFence` and confirm the provider's treatment of a proration date.
-- A decision on the fence's limit: a request frozen for more than a day is detected and held, not prevented.
**RECOMMENDED:**
+- Two open defects, both with their paths disabled: a quantity request that runs after Stripe has forgotten its key, and a late invoice-line request that can leave a finalized invoice with a wrong total a customer could pay. Design: `docs/superpowers/plans/2026-10-01-late-request-fencing.md`, built on `feat/carloku-late-request-fencing-20261001`.
- Read round seven, fix what it finds, re-review the exact tip. Push when the repository answers and read CI on that tip.
- Then, each on its own branch: path B of the attestation plan, quantity reduction at renewal, the AWS principal inventory and the bucket ownership attestation door, Free-tier enforcement, dormancy and pool accounting (none started).
- Find what moved 1,395 GB out of Ireland to three other regions in September (collector plan).
diff --git a/docs/superpowers/plans/2026-10-01-late-request-fencing.md b/docs/superpowers/plans/2026-10-01-late-request-fencing.md
new file mode 100644
index 000000000..d79b299c8
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-01-late-request-fencing.md
@@ -0,0 +1,69 @@
+# Late provider requests: designing out the two open defects
+
+Date: 2026-10-01. Status: design, being built on `feat/carloku-late-request-fencing-20261001`. Not on the reviewed launch snapshot. Billing is off and the quantity worker is off, so neither defect can occur today. Nothing here is to be accepted as a risk: both are defects, and the paths stay disabled until this is built, reviewed and verified in a Stripe sandbox.
+
+## The two defects
+
+Both come from one cause: a request a console process issued, which reaches Stripe much later than the process believes.
+
+1. **Quantity** (`OUTSTANDING.md` item 7). A sender is frozen between its last check and the wire. Its operation is retired and replaced. The key fence refuses its request for 24 hours. After that Stripe has forgotten the key ("we generate a new request if a key is reused after the original is pruned") and the old request runs, setting an old quantity over the new one.
+2. **Invoice lines** (item 9). A sender is frozen with a line request. Another process sends the same line under the same key and it is acknowledged. More than 24 hours later the frozen copy arrives and runs as new: a second invoice item on the draft. If that lands between the engine's line check and the finalize, the invoice is finalized with a wrong total. The engine flags it and does not charge it. But a finalized invoice is open at Stripe and an open invoice can be paid by the customer from the hosted page or the portal. That is customer-payable exposure.
+
+Detection after the write is not a fix for either.
+
+## What Stripe does not offer
+
+Checked against the API reference on 2026-10-01:
+
+- No conditional write. Neither the subscription item update nor the invoice finalize takes an expected version, quantity or total.
+- No permanent idempotency. Keys last at least 24 hours and then a reuse runs as new.
+- No client-chosen ids for invoice items, so a create cannot be made idempotent by identity.
+- No expiring credential a single request could carry. Restricted keys do not expire per request.
+
+So no parameter of the request can make Stripe refuse it for being old. What can is the connection it travels on.
+
+## The mechanism: a request cannot outlive its connection
+
+A request reaches Stripe on a TLS connection. Stripe's edge closes a connection that sits idle. If the console's last check happens **after** the connection is established, then a process frozen after that check holds an established connection that goes idle for as long as the freeze lasts. When the freeze is longer than Stripe's idle limit, Stripe has closed the connection, and whatever the process writes when it wakes is answered with a reset and is never processed.
+
+The client must be built so that this is the only way a request can leave:
+
+1. **Check after connect.** The send deadline (the lease's end, less the clock tolerance) is checked in the transport's got-connection hook, after the TLS handshake and immediately before the request is written, against the wall clock.
+2. **One connection, one request.** No connection reuse. A request never travels on a connection that existed before its check, and nothing is left open afterwards.
+3. **No keepalive.** TCP keepalive is off on these connections. Otherwise the kernel keeps the connection looking alive to a middlebox while the process is stopped.
+4. **No transport retry.** A write that fails is an error. The transport never re-dials and re-sends on its own, which it would otherwise do for any request carrying an `Idempotency-Key` header.
+5. **HTTP/1.1.** One request per connection is then literally true.
+
+With that, a mutation reaches Stripe no later than its check plus Stripe's idle limit, `T_idle`.
+
+## Why that closes both defects
+
+Let a process pass its check at `t1`. Its request arrives no later than `t1 + T_idle`, or never.
+
+- **Quantity.** The operation can be retired only after the sender's lease has run out, and only after its key is fenced, at some `t2 > t1`. The fence holds until at least `t2 + 24h`. The late request arrives before `t1 + T_idle < t2 + T_idle`. If `T_idle < 24h` it arrives inside the fence and is refused. There is no later arrival. The gap past retention is gone.
+- **Invoice lines.** The frozen copy arrives before `t1 + T_idle`. The other sender's copy of the same line ran at some `t2 ≥ t1`, and Stripe remembers that key until at least `t2 + 24h`. So the frozen copy always arrives while the key is remembered and is answered from memory. No second invoice item is created, so no wrong total can be finalized.
+
+Both rest on two provider behaviours:
+
+| Behaviour | Source | Status |
+| --- | --- | --- |
+| Key rules: a remembered key replays or refuses | Stripe documentation | documented; sandbox check not run |
+| `T_idle`: an idle connection is closed well inside 24 hours | not documented | must be measured in the sandbox; not run |
+
+`T_idle` is the weak leg. No public document states it. It is a property of every HTTP server, and the margin needed is enormous (anything under about 23 hours), but it is measured, not promised. The sandbox test measures it and fails if a connection left idle for the configured time still accepts a request.
+
+If `T_idle` turns out shorter than the lease margin (five minutes), the key fence is not even needed for this purpose: no request could arrive after its lease ends. It is kept as the second layer.
+
+## What is built with it
+
+- `internal/billingstripe`: the transport above for every POST; `WithSendBy(ctx, t)` carries the deadline; a test with a server that closes idle connections shows a request held after its check is never processed and never re-sent.
+- The quantity worker and the invoice engine pass their lease ends as the send deadline.
+- `TestSandboxQuantityFence` gains the idle measurement.
+- The invoice engine voids an invoice whose finalized total fails verification, in the pass that finds it. With the mechanism above that state should be unreachable; the void is there so that if it is ever reached, the invoice is not left payable.
+- The simulation's fake provider forgets keys after 24 hours, so a schedule that relied on a permanent key fails.
+
+## What stays true
+
+- A consumed key is still not a permanent token. The argument above does not need it to be.
+- The worker and billing stay off until the sandbox run passes, including the idle measurement.
+- If a request's path to Stripe ever gains an intermediary that holds connections open (an egress proxy), the argument fails at that hop. The client refuses to run through a proxy.
diff --git a/docs/superpowers/specs/2026-10-01-stripe-idempotency-evidence.md b/docs/superpowers/specs/2026-10-01-stripe-idempotency-evidence.md
index 429a8f07a..ec94121b4 100644
--- a/docs/superpowers/specs/2026-10-01-stripe-idempotency-evidence.md
+++ b/docs/superpowers/specs/2026-10-01-stripe-idempotency-evidence.md
@@ -81,9 +81,9 @@ What bounds it:
- The request carries `proration_date`, which Stripe refuses outside the subscription's current period. On an annual plan that is a wide window. It stops a charge into a later period and little else.
- Detection. The console never lowers a quantity inside a period, so a quantity observed to have gone down files `billing_quantity_lowered`, which holds the account's invoices until a person has looked (`TestQuantityLoweredAtTheProviderFilesAReview`). Before 2026-10-01 04:50 this did not exist: the console followed the provider's number down with no review. An earlier comment in the code said a mismatch review covered this case. It did not, and the comment was wrong.
-So past retention the outcome is **detected and held, not prevented**. Preventing it needs a provider-side precondition Stripe's subscription item update does not offer. Closing it fully means changing how seats are charged (for example a subscription schedule phase per change, or invoice items in place of quantity changes). That is not built and not proposed for this launch.
+So past retention the outcome on this commit is **detected after the write and held. That is not a fix, and the worker stays disabled.** It is an open defect. `docs/superpowers/plans/2026-10-01-late-request-fencing.md` is the design that removes it, built on a separate branch so this reviewed snapshot does not move.
-The same limit applies to the invoice engine (`internal/console/billinginvoice.go`). Its operations are never replaced, so within retention a late request is an identical replay. Past retention a late request runs as new: a second empty draft, a duplicate line that `validateDraftLines` catches before finalize, or, in one narrow schedule, a finalized invoice with a wrong total that `verifyFinalized` flags and the engine then refuses to charge. Whether the customer could pay such an invoice from Stripe's hosted page was not determined.
+The same limit applies to the invoice engine (`internal/console/billinginvoice.go`). Its operations are never replaced, so within retention a late request is an identical replay. Past retention a late request runs as new: a second empty draft, a duplicate line that `validateDraftLines` catches before finalize, or, in one narrow schedule, a finalized invoice with a wrong total that `verifyFinalized` flags and the engine then refuses to charge. A finalized invoice is open at Stripe, and an open invoice can be paid from the hosted page or the portal, so this is treated as customer-payable exposure: an open defect, with billing off, designed out in the same plan.
## The gate
diff --git a/implementation/carloku-paid-launch/OUTSTANDING.md b/implementation/carloku-paid-launch/OUTSTANDING.md
index 962e98c0c..7b71654f5 100644
--- a/implementation/carloku-paid-launch/OUTSTANDING.md
+++ b/implementation/carloku-paid-launch/OUTSTANDING.md
@@ -33,9 +33,9 @@ Each is concrete. None is waived.
5. **No catalog, portal configuration, webhook endpoint or runtime key exists.** The plan and a print-only script are written. Needs scoped approval, then a test-mode rehearsal, then a real purchase.
6. **The quantity worker's fence is unverified against Stripe.** It rests on documented idempotency rules, modelled by a fake. `TestSandboxQuantityFence` must pass in a Stripe sandbox before `CONSOLE_BILLING_QUANTITY_FENCE` is set. Test mode is not authorized, so it has not been run.
-7. **The fence is not permanent.** Stripe forgets keys after 24 hours. A sender frozen longer than that can still land an old request. It is detected and held, not prevented. Accepting that, or redesigning how seats are charged, is a decision.
+7. **Defect, open: an old quantity request can run after Stripe has forgotten its key.** Stripe forgets keys after 24 hours. A sender frozen longer than that can still land a request for an operation that was retired and replaced. Today that is only detected after the write (`billing_quantity_lowered`), which is not a fix. It is being designed out on `feat/carloku-late-request-fencing-20261001` (`docs/superpowers/plans/2026-10-01-late-request-fencing.md`). The path that could cause it, the quantity worker, stays disabled until the design is built, reviewed and verified in a Stripe sandbox. Nobody is asked to accept this risk.
8. **Stripe's treatment of a proration date outside the period** is assumed from documentation. Same sandbox run.
-9. **The invoice engine's late-request limit** past Stripe's key memory: detected and held, not prevented; one schedule leaves an open wrong-total invoice the engine will not charge. Whether a customer could pay it from the hosted page is not determined.
+9. **Defect, open: a late invoice-line request can leave a finalized invoice with a wrong total.** Past Stripe's key memory a stale line request runs as new. If it lands between the engine's line check and the finalize, the invoice is finalized with a duplicate line. The engine flags it and will not charge it, but a finalized invoice is open at Stripe, and an open invoice can be paid by the customer from Stripe's hosted page or the portal. That is customer-payable exposure and it is treated as such. It is being designed out on the same branch (a draft whose operations are not all confirmed inside one key-retention window is deleted, never finalized, and a deleted draft refuses every late request for good). Billing stays OFF, so the path is disabled.
### Decisions that are Paul's