Skip to main content

Period B zero-debit recovery (manual, proof-gated)

Internal runbook for the recovery mode of POST /api/admin/collect-approved-credit-charges. Not customer-facing. Owner: Brock. Nothing here is scheduled or automatic.

The problem

Every Period B credit-pull-{requestId} key has a SUCCESS billingCharges audit for $5, but the CRM reported no wallet debit. The normal collect path therefore refuses each key as already_charged. Some keys also have a lifetime approvedCollectionAttempts row. None of that history may be edited or cleared. Marketplace getCharges returning amountCharged: 0 is not sufficient to admit a key: it is a provider charge record, not the Credit Reports wallet ledger. A key whose only zero-evidence is getCharges is labelled provider_zero_wallet_unverified and is never charged.

What recovery mode does

  • Charges the same approved $5 under a new idempotency key recovery-credit-pull-{locationId}-{requestId}. The key carries the Location, and because it is not a credit-pull-* key it sends exactly the approved amount on the wire, independent of the credit-pull wire rule.
  • Bypasses already_charged on the original key only after the proof gate passes.
  • Appends a recovery row to the same approvedCollectionAttempts ledger (recoveryOfEventId, proofRef) and a normal billingCharges audit under the recovery key. Original rows are read, never patched or deleted.
  • Shares the hard $50 / Location / day pool with normal collections via the same atomic claim mutation. Recovery cannot raise it.
  • One Location per call. Credit-pull rows only. Explicit per-row allowlist (you post the rows).
  • Independently requires each (locationId, requestId) pair to appear in the audited 82-row / $410 server allowlist at convex/lib/periodBRecoveryAllowlist.json.

Proof gate (all required, per row)

  1. Approved target and original audit — the exact (locationId, requestId) pair is in the 82-row unpaid-eligible allowlist, and exactly one SUCCESS billingCharges row exists for credit-pull-{requestId}, same Location, created within the approved Period B window (September 11 19:01 through September 15 14:34 America/Denver), amountCharged absent or 0.
  2. Marketplace getCharges — fully paginated over the Location’s recovery interval (below), the original chargeId (or meta.eventID) appears, belongs to this Location, and has an explicit numeric amountCharged of 0. Incomplete pagination, a fetch error, or a missing/malformed amountCharged is no evidence.
  3. CRM Wallet & Transactions ledger packet — an operator-supplied, read-only export of the company-billing wallet_transactions ledger that:
    • has source: "crm_wallet_transactions" (payments listTransactions is a different ledger and is rejected),
    • records its exact as-of (capturedAt, which must be on or after the last day any coverage span claims — a packet cannot vouch for days after its capture), timeZone: "America/Denver", ledgerView (agency_export_center or location_wallet_transactions) and filter (productScope: all_products | credit_reports_only; locationScope: location | agency_all_sub_accounts),
    • may be credit_reports_only, which is sufficient to classify this Credit Reports cohort in dry-run; it does not prove another product did not collect separately,
    • for live recovery, a credit_reports_only packet must also carry honest includesResellingProfit: true evidence, or the packet must be all_products; otherwise the row is separate_product_collection_unverified,
    • covers this Location for at least the whole recovery interval (a stale export that ends before the cutoff is a ledger_coverage_gap),
    • every entry carries occurredAt and a non-negative amount (debits are positive), and no debit joins to this pull. For a live run, capturedAt must also be no more than five minutes old (and not in the future) when the route is ready to claim rows. This exact-time check runs after the read-only provider gate so a same-day packet cannot remain eligible indefinitely. Its SHA-256 (proofRef) is stored on the recovery attempt row.
    Join rule. A marketplace chargeId is a different identifier space from a CRM wallet transaction id, so “no chargeId in the CSV” is never zero-debit proof. A debit blocks the row as wallet_debit_found when it is linked by chargeId / eventId, or when Location + the wallet transaction/provider charge ObjectId timestamps attribute a CR Apps 5entrywithinthevalidated600−secondwindow.Entriesexplicitlyallocatedtoanothereventarenotresidualsforthecurrentrow.Anunlinkeddebitwithin±1Denvercivildayoftheoriginalauditthateithermatchesthe5 entry within the validated 600-second window. Entries explicitly allocated to another event are not residuals for the current row. An unlinked debit within ±1 Denver civil day of the original audit that either matches the 5 amount or mentions Credit Reports is wallet_debit_ambiguous: it isolates that row as an evidence gap without claiming the debit paid it. An unmatched $3 Credit Reports leftover is a Location-level partial/recovery confounder and isolates every posted row for that Location.
Block reasons: original_audit_missing, original_audit_ambiguous, original_collected, original_location_mismatch, not_in_approved_period_b_cohort, outside_recovery_cohort, prior_attempt_not_credit, provider_coverage_incomplete, provider_charge_missing, provider_collected, provider_debit_unreadable, provider_location_mismatch, provider_zero_wallet_unverified, separate_product_collection_unverified, ledger_coverage_gap, wallet_debit_found, wallet_debit_ambiguous. Any blocked row is identified in failures; keep ambiguous rows out of an approved batch. The request remains all-or-nothing, the same as the collect pre-flight.

September 16 CR-cohort read-only reassessment

The Brock-scoped census based on Credit Reports export coverage through September 15 plus the original/recovery ledger and provider joins is:
  • supported unpaid eligible: 129 / $645
  • paid: 0
  • prior_attempt_not_credit: 59 / $295 (September 14 attempts are not debt credits)
  • ambiguous unmatched 3leftovers:∗∗51/3 leftovers: **51 / 255**
  • overall candidate until row-substantiated: 239 / $1,195
Evidence basis from the attached read-only supported-subset packet:
  • The exact approved keys and declared totals are committed in convex/lib/periodBRecoveryAllowlist.json (129 / 645atthiscensus;82/645 at this census; 82 / 410 after the September 17 trim below); module-load integrity checks reject count, total, or duplicate drift.
  • Existing agency file agency-wallet-credit-reports-2026-09-11_to_2026-09-15.csv contains 110 rows, 108 labeled CR Apps.
  • Period A validates the ObjectId/time join: 67/67 attributable CR Apps 5entriesfallwithin600seconds.PeriodBhas0/239attributable5 entries fall within 600 seconds. Period B has 0/239 attributable 5 joins, with no leftover $5 after allocating Period A and Period C entries. Period C remains excluded from recovery.
  • Westmere Financial has an affirmative CR Apps $0 wallet entry at the pull (approximately one second from its chargeId timestamp), which is unpaid evidence.
  • The five Locations with unmatched $3 CR residuals are Gap Funded (isqlsHX3ljyFgKYAxPTQ), FundRocket.io (fB3CtoRrGiOUuhOm6cSd), Top Notch Consulting, LLC (uDaWpJXgD9fERbwSZs0J), CEO CAPITAL CONNECTION (uZ3XxusaE8dSjJDcnL3l), and Patricia Millan’s Account (XX5EUeDb8RKXnDkSQet0). Their 51 non-59 rows stay isolated.
Do not erase the CR classification for lack of an all-products Export Center packet. The 129 supported rows meet the CR evidence bar for dry-run planning, but CR absence is not proof that another product never collected: they remain blocked from live recovery as separate_product_collection_unverified until honest Profit-exclusion evidence exists or Brock explicitly accepts that residual risk. The 59 prior attempts do not reduce the candidate debt because an attempt is not a wallet credit or debit, but they remain outside the supported admit batch pending their separate release policy. Keep each of the 51 unmatched $3 rows isolated as wallet_debit_ambiguous until its own join is substantiated; do not mark it paid and do not silently charge it. A row with no Credit Reports wallet evidence remains provider_zero_wallet_unverified.

September 17 allowlist trim: 129 → 82 rows (645→645 → 410)

A read-only dry run of all 129 rows on tip 457c3169 (#1757 reservation→CRS fallback, #1759 getCharges meter fallback) admitted 48 as would_charge and left 47 uniformly report_not_verifiable (No creditReportRequests row for requestId+locationId). Every one of the 47 has a credit-pull-{attemptId} reservation at the correct Location whose requestId was never patched to a CRS id, so the #1757 bridge (which requires finalized plus a different reservation.requestId) correctly returns nothing. They split:
  • 33 charged-stuck — reservation status: charged, never finalized, SUCCESS billingCharges, no creditReportRequests row under the attemptId or any alternate charge requestId. This is undelivered charge-first debt for ops to reverse or owe-credit, not a Period B recovery collect.
  • 14 release_failed non-billable — reason non_billable: CRS marked this pull as non-billable; the contemporaneous same-contact CR is billable: false. Never $5 recovery candidates; they stay a release_failed liability tracked elsewhere.
The 47 pairs were removed from convex/lib/periodBRecoveryAllowlist.json. The 48 admitted rows and the other 34 entries (wallet-ambiguous and otherwise) are untouched. verifyRows was not widened to accept status: charged without a CRS bridge, and no attemptId was remapped to a later unrelated contact CR.

Recovery interval and getCharges semantics

  • Start: Denver (America/Denver) civil day of the earliest original SUCCESS audit for the Location, minus one day.
  • End: reconciliationCutoff (civil YYYY-MM-DD) if supplied, else the day after today in Denver.
  • The marketplace OpenAPI documents startDate as “AFTER” and endDate as “BEFORE” a civil date with no stated timezone. Empirically the end bound is exclusive: pass the day after the last day you need. The one-day start pad exists so an edge charge cannot fall outside either bound whichever timezone the provider applies.
  • Pagination is limit=100, skip stepping, until a short page or total is reached; more than 50 pages fails closed. The response records pages, chargesSeen, total, asOf, meterFilter, and on failure an error that carries the upstream HTTP status (Error fetching charges (status=500)).
  • The read asks the provider to filter by the Credit Reports meterId. The provider has returned HTTP 500 Error fetching charges for any metered request while the same window unfiltered returns 200 with meterId on each row (Midas canary, 2026-09-17). On a 5xx for the first metered page the read is retried once without meterId, every later page stays unfiltered, and rows are narrowed to the meter client-side; the receipt shows meterFilter: "client". A 4xx/auth error never strips the meter, and a 5xx after metered pages already landed fails closed rather than mixing the two series.

Request shape

The example above is the Midas canary, used as a fixture only.

Dry run vs live

Live still requires an explicit approved target and amount at execution time. CR-only classification does not satisfy the live gate. Do not run a live recovery from this runbook without Profit-exclusion evidence or a later explicit Brock GO accepting the residual risk.

Out of scope (separate tracks)

  • Wire $5 hotfix (#1751), nightly mismatch alerts, bulk multi-Location sweeps, any cron or webhook, broader all-products reconciliation, and any change to historical audits or attempts.