Skip to content

Settle-Then-Lock SFW Processor Assignment

This ADR extends Settle-Then-Lock NSFW Processor Assignment to the SFW lane and supersedes it. That ADR’s mechanism is unchanged and its reasoning still applies; two of its statements about the SFW lane — that SFW allocation is unchanged and that SFW keeps eager assignment — became false with this decision.

Context

The SFW lane picked a processor before any payment existed. Any user without a stored one was written cybersource during checkout resolution, best-effort, with the write error swallowed.

Three consequences followed from that ordering, and only the first is obvious:

Routing could not change without a deploy. Adding Shift4, Nuvei or Airwallex, or shifting weight between processors, meant editing Go and shipping it. The Primer dashboard could express the routing, but Sirloin had already decided.

Nothing recorded which processor actually took the money. Primer’s Regular Access Traffic workflow pairs Cybersource with Stripe as a fallback on every authorization block, and Stripe is the primary on the AMEX branch. So Stripe was already settling real SFW payments while the column claimed Cybersource, and the fallback rate had never been measured because nothing observed it.

The eager write always won. AssignSFWProcessor’s guard is sfw_processor IS NULL OR sfw_processor = '', first-write-wins. A write that lands before any payment exists therefore wins by construction, so a settle-observing hook could never have recorded anything on the first subscription checkout — the highest-volume payment there is. This is why extending the NSFW hook to SFW was not sufficient on its own, and why the eager write had to go in the same issue.

Decision

Adopt settle-then-lock on the SFW lane, on the model FOXY-596 established for NSFW: send Primer no processor preference for an unassigned user, let its workflow route the payment, then persist whichever processor actually settled it, first-write-wins, and replay that on every later charge.

Concretely, and each of these is load-bearing rather than incidental:

Neither lane assigns before a settle. ResolveProcessor reads the lane’s stored processor and returns empty when there is none. processorRepo carries no writer at all, so the type system forbids a checkout from assigning — stronger than a test asserting that it does not.

Each lane resolves processor names through its own table. nsfwProcessorNameToPSP holds NMI and eMerchantpay; sfwProcessorNameToPSP holds Cybersource and Stripe. A name belonging to the other lane resolves to nothing, so a cross-lane lock is impossible regardless of what the payment’s access label says. One shared table would have made that correctness depend on the label being right, and first-write-wins makes a wrong lane permanent. This was verified rather than assumed: adding both SFW names to the single shared table breaks three frozen NSFW tests, two of which use a real SFW processor name as their example of an unmapped one.

The lane comes from the payment, resolved once. Two of the four settle call sites hold no plan or tier when they reach the hook, so the access label is the only lane signal available at all of them. A label that is absent, empty, not a string, or a value we do not know locks on neither lane: a skipped lock self-heals on the user’s next settle, whereas a wrong-lane lock does not.

The merchant-account column stays NSFW-only. The SFW lane has no such concept, so the writer switch drops the merchant ID on that arm in one place rather than at each call site.

Never send a processor we did not read. Three paths previously seeded the preference from domain.ResolvePSPRouting, whose SFW branch returns a hardcoded Stripe; for a user with an empty column that default was emitted as though it were a stored preference. All three now route preference-absent instead. ResolvePSPRouting itself is untouched, because NSFW vaulting depends on its return — the SFW lane stopped using it rather than the function changing shape.

Both lanes fail closed on a credits-read error during a renewal. An earlier revision of this branch had SFW route preference-absent instead, reasoning that the SFW lane carries a configured fallback on every authorization block where the locked NSFW workflow carries none. Review rejected it, and correctly: preference-absent on a read error is indistinguishable from preference-absent on a genuinely unlocked user, so the discovery workflow routes a locked user by its own rules and can settle them off their lock — and first-write-wins then keeps the original value, manufacturing exactly the divergence billing_processor_lock_drift_total exists to report. Aborting costs almost nothing by comparison: the routing error returns before any attempt is recorded, so no dunning attempt is consumed and the next tick re-reads about fifteen minutes later. It also restores consistency, since every other infrastructure read on that path — Chargebee, the Primer existing-payment probe, and four consecutive credits and dunning queries — already aborted. The residual risk both lanes now share: the final dunning slot has a one-hour tail before a time-driven cancellation that does not count attempts, so a read failure inside that window cancels a subscription whose last charge was never tried.

Observability is split by lane, and drift and repair each have their own counter. Removing the NSFW-only gate put SFW volume — the larger lane — onto billing_processor_lock_outcome_total, so it must be read per lane: healthy SFW traffic would otherwise keep the series alive through a total NSFW failure. That counter records exactly once per gated settle and must keep doing so, which is why the other two signals are separate instruments rather than extra records on it. History repair uses billing_processor_repair_outcome_total because its denominator is an invocation rather than a settle and its scan visits many payments per call. Drift uses billing_processor_lock_drift_total, carrying the locked and settled processors, because on the already-locked path the lock counter records the value read from the column rather than the one that took the money — so without it a drifting settle and a healthy one are the same sample, which is the belief this whole decision exists to stop trusting.

Consequences

Easier. Operations can change SFW eligibility, weights and routing rules in Primer without an engineering deploy. The SFW fallback rate becomes a readable number for the first time. Users who settle on Stripe stop paying a wasted Cybersource attempt on every renewal once the locked workflow reads the preference: production’s Regular Access Traffic sends every non-AMEX arm to Cybersource first and reaches Stripe only on the fallback leg, so today a Stripe-homed cardholder burns one authorization per renewal getting there. AMEX is already the exception — both AMEX arms run Stripe as primary with Cybersource as the fallback — so AMEX holders gain nothing here and stand to lose if they are routed by a stale cybersource value; see the ordering note below.

Harder, and worth being explicit about. sfw_processor can now hold stripe, which no code wrote before. The admin processor editor cannot express that value and overwrites it on any unrelated NSFW edit — tracked as FOXY-905, dormant until this ships. A first-write-wins column means a wrong value cannot be corrected by the settle hook, only by a deliberate operator write.

A new writer on a billing column. Users whose column was empty start having it written. That cohort is real: the cybersource backfill in 106_processor_assignments.sql is commented out and was never run, eager assignment only dates to ca63d8628, and the eager write’s error was swallowed. The value written is what actually settled, which is the point, but this is not a dark release.

Routing correctness lives partly outside this repository. The locked Primer workflow routes psp = cybersource to Cybersource and everything else to Stripe. A processor added to the Go table without a matching workflow branch therefore routes silently to Stripe, while a branch with no table entry is harmless. The safe order is Discovery workflow, then locked branch, then table entry — and a processor is added to both together, when it is actually integrated.

Deploy this repository before the production workflows, not after. Production’s Regular Access Traffic v5 reads psp zero times, so today the column has no routing effect at all. The eager cybersource write has been live since ca63d8628, which means a large cohort already carries cybersource without a payment ever having settled on it. Publish a psp-reading workflow pair while that write is still in place and every one of those users matches the locked branch and routes to Cybersource — including AMEX holders, whom the current workflow deliberately starts on Stripe. That is a routing change on live traffic, caused by ordering rather than by either change on its own. Ship Sirloin first so the eager write is gone and unassigned users reach the discovery workflow; publish FOXY-881 after. Stale cybersource values written during the eager era remain, and first-write-wins keeps them — repairing those is a separate operator decision, not something the settle hook can undo.

Confirmed end to end in the Primer sandbox, 2026-08-07. The two SFW workflows were published there — Accept all card payments v62 (psp IS_NOT_EMPTY) and SFW Discovery v1 (psp IS_EMPTY) — and two live payments settled the questions this section used to list as open.

A first subscription payment for a user with no stored processor was emitted with the psp key absent, not empty. It matched SFW Discovery, took the non-AMEX arm, and settled on Cybersource:

  • processor.name came back as CYBERSOURCE verbatim, with processorMerchantId alongside it — the object shape the MID backfill reads. That is exactly the key in sfwProcessorNameToPSP, so the mapping needed no change.
  • The lock landed from SubmitPaidInvoice, storing sfw_processor = cybersource, and the NSFW columns stayed empty.
  • Because the payment routed at all, IS_EMPTY demonstrably matches an absent key rather than only a present-but-empty one. That was the single assumption the whole psp-absent design rested on.

A second payment by the same, now-locked user carried psp = cybersource, matched the locked workflow, and settled on Cybersource — so a stored processor replays and v62 routes by the value sent, not by the card. It was a top-up, so the hook correctly declined to lock from it and sfw_processor was untouched.

A read-only sweep of 800 earlier sandbox payments adds the other two names: STRIPE (368), EMERCHANTPAY (304), NMI (128), with no lane ever crossing — every access=full payment on NMI or eMerchantpay, every access=regular on Stripe.

Token portability across the fallback pair is measured, and it holds. A card vaulted through Cybersource was charged directly on Stripe by sending psp = stripe against the stored token: payment kgtjZjNgg, status SETTLED, processor.name STRIPE. The same token had already settled three times on Cybersource. This was the one gate that could have invalidated the configuration rather than a comment: had the token not crossed, Cybersource and Stripe could not be paired as fallbacks for locked payments at all, and every authorization node in both SFW workflows pairs them. It crossed. One limit for the record: only the Cybersource-vaulted direction was charged on Stripe. The strict reverse — a Stripe-vaulted token charged on Cybersource — was not run, so portability is measured one way, not both. The vault is Primer-level, which is the reason to expect symmetry rather than evidence of it; it stays an open deploy gate alongside the Cybersource→Stripe failover.

A fallback settle reports the winner, in both fields this design reads. This was the gate that mattered most: if Primer named the processor it attempted first, settle-then-lock would store the loser and no unit test would catch it. It is now measured, and it did not need a forced Cybersource decline — the same fallbackProcessor mechanism runs on the NSFW discovery workflow, where a decline is easy to come by.

Two sandbox payments failed over inside a single payment: DR3axON8H and 7QEYbcbrh, both routed by NSFW Entry purchase v7, whose nodes pair eMerchantpay as primary with NMI as fallback. In each, transactions[0] is eMerchantpay DECLINED (GATEWAY_REJECTED, 'eci' is invalid) and transactions[1] is NMI, two seconds later. The payment’s processor object reports {"name": "NMI", "processorMerchantId": "1224275"} — the winner’s name and the winner’s merchant account, not the first attempt’s. Both are exactly the fields the lock hook reads, so a fallback leg locks the processor that took the money.

Two limits on that evidence, for the record. It exercised eMerchantpay→NMI rather than Cybersource→Stripe; processor is a platform-level field with one shape for every processor and the failover came from the same fallbackProcessor configuration the SFW workflows use, so the mechanism is shared, but that specific pair has not run. And the trigger was a gateway rejection rather than an issuer decline — irrelevant to how the outcome is reported, but no issuer-decline failover has been observed.

Worth knowing when reading NMI drift: the NMI-locked workflows fall back NMI→NMI, between merchant accounts rather than between processors. A fallback there can therefore change nsfw_processor_mid while leaving nsfw_processor correct, and the hook stores the winning MID.

Metadata survives 3DS and the MIT path, measured across 400 sandbox payments. access came back on all 400. Of the 163 carrying retry_attempt — Sirloin’s own dunning MITs — all 163 returned access, chargebee_subscription_id and payment_type, and 151 returned psp. Thirty-five payments authenticated through real 3DS (34 AUTH_SUCCESS, one AUTH_FAILED) with metadata intact, and both fallback payments above are among them: 3DS succeeded, the primary declined, the fallback settled, and the metadata still round-tripped. No refund exists in the sandbox, so that one is unmeasured — though metadata is a property of the payment rather than of a transaction, so a refund has nothing to rewrite.

The MIT path honours psp, and the sandbox recorded the before-and-after. Of 151 MITs that sent a processor preference, the 35 that diverged all sent cybersource and all settled on Stripe, spanning 2026-07-08 to 2026-08-07 15:50 — the workflow then live carried no psp condition, so the preference was inert. Every MIT after that evening’s publish honoured it, including eight that sent cybersource and settled on Cybersource. The split has no exceptions. This is the same condition production is in today, and it quantifies the second consequence in the Context above: real SFW renewals were settling on Stripe while the column said Cybersource.

Still not verified. The Cybersource→Stripe pair failing over specifically, and a refund. See the Deploy Gates in the NSFW ADR; the same posture applies.

Alternatives Considered

Stay stateless and never lock. The renewal MIT branch is one fixed processor pair for all non-AMEX traffic, so a user whose card the primary dislikes pays a wasted attempt every month, forever — nothing learns. “SFW is not token-homed” was a code comment rather than a measurement, and the same claim is documented false for NSFW.

Keep eager assignment and distribute in Go. FOXY-596 deleted exactly this (domain.HashSplit). There are no Go feature flags in Sirloin, so every weight change would be a deploy — against the requirement that percentage allocation not be hard-coded here.

Rename the SFW default from Stripe to Cybersource instead. A one-line change that would have removed the immediate hazard of renewals jumping processor when the preference-reading workflows go live. Rejected by the team: at least three SFW processors are coming, and a hardcoded default is precisely what settle-then-lock exists to remove.

Lock at vaulting. Vault-only authorizations are cancelled rather than captured, and the vault branch routes differently from the CIT branch, so the token’s home at vault time is not the subscription’s home.

Derive from payment history only, with no settle hook. Costs a list call plus up to 35 detail fetches inside a 10-second budget on a user-facing path, learns thirty days late, and leaves the first payment unobservable.

Let Primer pin the customer and store nothing. Not buildable. Across every exported workflow there is no per-customer state — no customer lookup, variable, store or sticky routing — and every processor binding is a literal, so a percentage split re-rolls on each payment.