fix(metering): durable exactly-once receipt for meter/grace; document the no-budget contract (#92, #99)#117
Merged
Conversation
… the no-budget contract (#92, #99) #92: meter and grace-backfill apply spend with no reservation, so the reaped idempotency_key row was their ONLY dedup — a retry after its TTL (default 24h) re-ran apply_spend and double-booked the spend, possibly in the wrong period (period_start is recomputed at meter time). reserve is guarded forever by the reservation UNIQUE; commit/cancel by the reservation status machine; meter/grace had no such durable backstop. Adds a never-reaped `metered_receipt` table with the same shape and claim/replay/mismatch semantics as `idempotency_key`. The idempotency repo is parameterized by table so the identical mechanism serves both the reaped table (reserve/commit/cancel) and the permanent one (meter/grace); the CommandContext exposes it as `metered_receipt`, and both handlers dedup through it. The receipt persists for the life of the record (like a reservation row), so exactly-once holds beyond any retry window, exact replay is preserved, and key-reuse is still caught by fingerprint. Additive migration 0006 (no existing table/data touched). #99 (decision: keep 403, document the contract): a completed call governed by no budget is refused (403 budget_not_found, ADR 0020 default-deny) rather than booked — intended for V1, since metering needs a governing budget to attribute against and "never deny" means "never deny on headroom." Documented in ADR 0037, with a synthesized/unattributed-node alternative noted as future work. Tests: an integration proof that meter dedup survives the idempotency-key reaper (delete idempotency_key, retry -> exact replay, no double-apply); meter/grace unit tests now run against metered_receipt with an idempotency tripwire so a regression to the reaped store is caught; conftest truncation, schema table set, and all CommandContext fakes updated for the new member. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjJ4QScUKbrMzF1jujL5Bg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#92 — meter/grace double-apply spend after the idempotency-key TTL
reserveis guarded forever by the reservationUNIQUE(principal_id, idempotency_key);commit/cancelby the reservation status machine. Butmeterandgrace-backfillapply spend with no reservation, so the reapedidempotency_keyrow was their only dedup — and the reaper deletes it atidempotency_ttl_hours(default 24h). A retry after that finds no row →FRESH→apply_spendruns a second time and double-bookscommitted/overageon every node. Becauseperiod_startis recomputed at meter time, a cross-month retry books the duplicate in the wrong period.Fix: a durable, never-reaped
metered_receipttable with the same shape and claim/replay/mismatch semantics asidempotency_key. The idempotency repo is parameterized by table, so the same mechanism serves both the reaped table (reserve/commit/cancel — whose durable dedup lives elsewhere) and the permanent one (meter/grace).CommandContextexposes it asmetered_receipt, and both handlers dedup through it. The receipt persists for the life of the record (like a reservation row), so:MISMATCH→ 409).Migration
0006is additive — aCREATE TABLE, no existing table or data touched. The idempotency reaper is unchanged (it only targetsidempotency_key), so the receipt is never reaped.#99 — no-budget metering returns 403 and drops spend (decision: keep 403, document)
A completed call governed by no budget is refused (403
budget_not_found, per ADR 0020's default-deny on an empty applicable set) rather than booked. This is intended for V1: metering needs a governing budget to attribute against, and "never deny" means "never deny on headroom," not "accept ungoverned spend." Documented explicitly in ADR 0037, with a synthesized/unattributed-node alternative noted as future work.Tests
metered_receipt(notidempotency_key) → simulate the reaper (DELETE FROM idempotency_key) → retry → exact replay, spend applied once, 2 ledger rows (would double to 4 on the old code).metered_receipt, with anidempotencytripwire stub so any regression to the reaped store fails loudly.CommandContextfake for the new member.Local gate: ruff, mypy --strict, import-linter, full unit suite (492), full integration suite (180), schema-migration drift check — all green.
Closes #92, #99.