Period B zero-debit recovery (manual, proof-gated)
Internal runbook for therecovery mode of POST /api/admin/collect-approved-credit-charges.
Not customer-facing. Owner: Brock. Nothing here is scheduled or automatic.
The problem
Every Period Bcredit-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 acredit-pull-*key it sends exactly the approved amount on the wire, independent of the credit-pull wire rule. - Bypasses
already_chargedon the original key only after the proof gate passes. - Appends a recovery row to the same
approvedCollectionAttemptsledger (recoveryOfEventId,proofRef) and a normalbillingChargesaudit under the recovery key. Original rows are read, never patched or deleted. - Shares the hard
$50 / Location / daypool 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 atconvex/lib/periodBRecoveryAllowlist.json.
Proof gate (all required, per row)
-
Approved target and original audit — the exact
(locationId, requestId)pair is in the 82-row unpaid-eligible allowlist, and exactly one SUCCESSbillingChargesrow exists forcredit-pull-{requestId}, same Location, created within the approved Period B window (September 11 19:01 through September 15 14:34 America/Denver),amountChargedabsent or 0. -
Marketplace getCharges — fully paginated over the Location’s recovery interval
(below), the original
chargeId(ormeta.eventID) appears, belongs to this Location, and has an explicit numericamountChargedof 0. Incomplete pagination, a fetch error, or a missing/malformedamountChargedis no evidence. -
CRM Wallet & Transactions ledger packet — an operator-supplied, read-only export of the
company-billing
wallet_transactionsledger that:- has
source: "crm_wallet_transactions"(paymentslistTransactionsis 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_centerorlocation_wallet_transactions) andfilter(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_onlypacket must also carry honestincludesResellingProfit: trueevidence, or the packet must beall_products; otherwise the row isseparate_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
occurredAtand a non-negativeamount(debits are positive), and no debit joins to this pull. For a live run,capturedAtmust 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.
chargeIdis 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 aswallet_debit_foundwhen it is linked bychargeId/eventId, or when Location + the wallet transaction/provider charge ObjectId timestamps attribute a CR Apps 5 amount or mentions Credit Reports iswallet_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. - has
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 255**
- overall candidate until row-substantiated: 239 / $1,195
- The exact approved keys and declared totals are committed in
convex/lib/periodBRecoveryAllowlist.json(129 / 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.csvcontains 110 rows, 108 labeled CR Apps. - Period A validates the ObjectId/time join: 67/67 attributable CR Apps 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
$0wallet entry at the pull (approximately one second from its chargeId timestamp), which is unpaid evidence. - The five Locations with unmatched
$3CR 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.
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 (410)
A read-only dry run of all 129 rows on tip457c3169 (#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, SUCCESSbillingCharges, nocreditReportRequestsrow 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_failednon-billable — reasonnon_billable: CRS marked this pull as non-billable; the contemporaneous same-contact CR isbillable: false. Never $5 recovery candidates; they stay arelease_failedliability tracked elsewhere.
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(civilYYYY-MM-DD) if supplied, else the day after today in Denver. - The marketplace OpenAPI documents
startDateas “AFTER” andendDateas “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,skipstepping, until a short page ortotalis reached; more than 50 pages fails closed. The response recordspages,chargesSeen,total,asOf,meterFilter, and on failure anerrorthat 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 500Error fetching chargesfor any metered request while the same window unfiltered returns 200 withmeterIdon each row (Midas canary, 2026-09-17). On a 5xx for the first metered page the read is retried once withoutmeterId, every later page stays unfiltered, and rows are narrowed to the meter client-side; the receipt showsmeterFilter: "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
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.

