Back to portfolio

Chapter 08 - Dispute lifecycle engine

Banding - Dispute Lifecycle Engine

v1 shipped - publication gated

A Go/PostgreSQL chargeback lifecycle engine that owns every dispute deadline in a durable due_at row and books terminal money impact into Arus exactly once.

Source: disputes/backend origin/main b6e50ac; PR #1 fixup 3259d59; F1-F3 verified in Phase-0 evidence and fresh local race runs
Banding dispute lifecycle flowAlur captures enter Banding, durable deadlines drive dispute transitions, SimNetwork sends deterministic events, and same-transaction outbox rows post balanced entries to Arus.disputeopenevidenceescalaterulingpersistclaimeventsbookAlur capturealur:...:captureOpenedprovisionalUnder reviewrepresentment dueRepresentmentevidence roundPre-arbescalationTerminalwin / lossdue_at rowdeadline_kindSchedulerSKIP LOCKEDSimNetworkHMAC eventsArusoutboxInvariant: deadline state and money writeback intent live in durable rows, not process memory.
  • 500seeded dispute storm across every reason code and scenario token
  • 0non-terminal or past-deadline awaiting disputes after convergence
  • 1xbalanced terminal writeback per dispute external-ref
  • Restartscheduler recreated mid-convergence without losing durable due_at truth
  • ~61/shonest end-to-end storm convergence, not pure intake

The adjudication layer

Arus records money, Alur moves captures, Banding adjudicates disputed captures, and Selaras reconciles the final settlement trail.

Source: disputes/docs/product-requirements/2026-06-12-v01-disputes-v1-prd.md

The acquiring failure mode

In card disputes, the two expensive failures are missing a deadline, which can become an automatic loss, and booking the financial impact wrong or twice.

Source: disputes/docs/product-requirements/2026-06-12-v01-disputes-v1-prd.md

Durable deadline scheduler

The deadline is a row Banding owns, not a poll of someone else's system.

Own durable due_at

Every awaiting state stores state_deadline and deadline_kind on the dispute row in the same transaction as the transition that created the deadline.

Source: disputes/backend/internal/domain/transition.go

Claimed, not remembered

The scheduler selects due rows with FOR UPDATE SKIP LOCKED, then leases the row by moving state_deadline forward before applying timeout work.

Source: disputes/backend/internal/worker/scheduler.go

Beyond Alur's reaper

Alur polls PSP truth for stuck payment attempts; Banding owns the deadline truth itself, so a restart cannot lose an in-memory timer.

Source: disputes/docs/adr/2026-06-12-v01-adr-001-durable-deadline-scheduler.md

Closed state machine

One legal path, illegal pairs rejected, out-of-order events converged.

One transition function

Every state change calls transition(), locks the dispute row, checks legal_transition, writes the new state, and appends transition_log in the same transaction.

Source: disputes/backend/internal/domain/transition.go

Illegal-pair matrix

The acceptance tests iterate every state x every state pair except the legal set, proving illegal transitions are rejected instead of becoming hidden shortcuts.

Source: disputes/backend/internal/httpapi/p1_acceptance_test.go

append-only transition_log

Database trigger and privilege checks keep transition_log append-only; timeline output is a read of that audit spine.

Source: disputes/backend/db/migrations/000001_init.up.sql

Out-of-order events become no-ops

SimNetwork can deliver a ruling before representment; the state machine rejects the illegal-from-state move and the later valid event converges.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

Exactly-once balanced money

Every terminal outcome has one stable Arus writeback shape.

Same-transaction outbox

API, scheduler, and SimNetwork paths enqueue booking_outbox rows inside the transition transaction, then the drainer posts to Arus out of band.

Source: disputes/docs/adr/2026-06-12-v01-adr-003-outbox-arus-http-only.md

Stable dispute refs

Money movement external refs use dispute:<id>:open|release|loss|arb_fee|withdraw:v1 and are unique in the outbox before Arus sees them.

Source: disputes/backend/internal/domain/transition.go

Existing entry is success

The drainer treats Arus ErrEntryAlreadyExists as posted, so a retry cannot convert a safe replay into an operational failure.

Source: disputes/backend/internal/worker/outbox.go

HTTP boundary only

A guard test scans production files for Arus database datasource tokens; Banding reaches Arus through the HTTP API only.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

ADR callouts

The four decisions expose the hard invariant.

ADR-001: durable scheduler

The deadline lives in Postgres, not a timer wheel, and tests advance an injected clock to prove restart-safe convergence.

Source: disputes/docs/adr/2026-06-12-v01-adr-001-durable-deadline-scheduler.md

ADR-002: table-driven state machine

The lifecycle is small enough to keep visible, so legal transitions and reason codes stay closed and audited.

Source: disputes/docs/adr/2026-06-12-v01-adr-002-state-machine-and-taxonomy.md

ADR-003: same-tx Arus outbox

State and intent-to-book commit together; Arus availability affects the drainer, not the dispute lifecycle.

Source: disputes/docs/adr/2026-06-12-v01-adr-003-outbox-arus-http-only.md

ADR-004: deterministic SimNetwork

Scenario tokens pick every verdict, duplicate event, out-of-order event, timeout, and arbitration branch with no RNG.

Source: disputes/docs/adr/2026-06-12-v01-adr-004-deterministic-simnetwork.md

Deadline-storm gate

The merge gate attacks deadlines, events, restarts, and outbox posting together.

TestDeadlineStormSeededDisputes

The CI gate seeds 500 disputes across four reason codes and ten scenario tokens, each against a distinct alur:<intent>:capture:v1 reference.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

Restart inside convergence

The loop advances the fake clock, runs a partial scheduler batch, recreates the scheduler, then continues until no non-terminal disputes remain.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

Deadline and outbox assertions

The gate fails unless there are zero past-deadline awaiting disputes, zero pending/failed outbox rows, and exactly one balanced Arus post per external ref.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

Review and fixup story

Independent review tightened the public proof instead of being hidden.

F1 fail-closed secret

The local SimNetwork default secret was removed; bandingd api now requires BANDING_NETWORK_SECRET and empty secrets reject callbacks.

Source: disputes/backend/cmd/bandingd/main.go

F2 scheduler restart placement

The storm now recreates the scheduler after a partial convergence batch, not during seeding, so the restart proof exercises live due rows.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

F3 terminal booking direction

Terminal state chooses the expected release, loss, or withdraw outbox ref, with exactly-once arb_fee rows only for arbitration scenarios.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

Loop closing

Banding adjudicates Alur captures, books into Arus, and leaves Selaras a trail.

Starts from Alur captures

Each dispute references the original Alur capture ref, so the chargeback engine attaches to the payment orchestration artifact rather than floating alone.

Source: disputes/backend/internal/httpapi/p3_acceptance_test.go

Books into Arus

Open, release, loss, arbitration fee, and withdraw effects are balanced Arus entry payloads behind same-transaction outbox rows.

Source: disputes/backend/internal/domain/transition.go

Reconciled by Selaras

Banding closes the platform arc by producing stable external refs that can be compared against settlement and ledger truth.

Source: disputes/docs/product-requirements/2026-06-12-v01-disputes-v1-prd.md

Honest limits

The page publishes the gate and the caveats together.

  • The honest throughput number is about 61 disputes/s end-to-end storm convergence, not pure intake throughput.
  • CI runs the deadline storm against an in-repo Arus HTTP boundary double, not live Arus.
  • The compose demo proves startup and health only; it does not seed a merchant or capture end-to-end.
  • v1 models one representment round plus one arbitration escalation, not real multi-cycle arbitration.
  • SimNetwork proves lifecycle classes, not ISO 8583, VROL, Mastercom, or real network wire formats.
  • v1 is IDR only.
  • The disputes docs live in a workspace mirror outside the published backend repo.
  • Cloud Run is prepare-only and gated; no cloud command executed.
  • Source: family-finance/docs/docs/system-analysis/2026-06-13-v01-banding-case-study-evidence-sa.md

Roadmap

v1.1 work is named as follow-up, not implied as shipped.

merchant/capture seed CLI

The compose demo starts Postgres, Banding, and SimNetwork, but a seed CLI is still needed before it can open a dispute end-to-end.

Source: family-finance/docs/docs/system-analysis/2026-06-13-v01-banding-case-study-evidence-sa.md

Outbox max-attempts sweep

Review follow-up tracks operational cleanup for rows that exhaust attempts, separate from the proven exactly-once posting path.

Source: family-finance/docs/docs/system-analysis/2026-06-13-v01-banding-case-study-evidence-sa.md

Real network formats

SimNetwork proves lifecycle behavior; ISO 8583, VROL, Mastercom, and processor-specific network auth remain outside v1.

Source: disputes/docs/adr/2026-06-12-v01-adr-004-deterministic-simnetwork.md

Gated Cloud Run deploy

The script prints no cloud command executed and requires explicit human DEPLOY approval plus real project values outside the script.

Source: disputes/backend/scripts/prepare-cloud-run.sh

Platform links

Banding closes the acquire, adjudicate, reconcile loop.

  • Read Alur

    Banding adjudicates the captures Alur moves through payment orchestration.

    Source: disputes/docs/product-requirements/2026-06-12-v01-disputes-v1-prd.md
  • Read Arus ledger

    Banding books dispute money effects into Arus through balanced HTTP entries.

    Source: disputes/docs/adr/2026-06-12-v01-adr-003-outbox-arus-http-only.md
  • Read Selaras

    The same external-ref discipline makes dispute outcomes reconciliable against settlement.

    Source: disputes/docs/product-requirements/2026-06-12-v01-disputes-v1-prd.md

Evidence trail

Every public claim points to a real artifact.

  1. Phase-0 evidence SAfamily-finance/docs/docs/system-analysis/2026-06-13-v01-banding-case-study-evidence-sa.md
  2. Portfolio walkthroughfamily-finance/docs/docs/runbooks/2026-06-13-v01-banding-case-study-walkthrough.md
  3. PR #1 mergegit:disputes/backend origin/main b6e50ac Merge pull request #1
  4. PR #1 fixup commitgit:disputes/backend 3259d59 fix: fail closed on network secret and tighten storm gate
  5. Banding PRDdisputes/docs/product-requirements/2026-06-12-v01-disputes-v1-prd.md
  6. Banding system analysisdisputes/docs/system-analysis/2026-06-12-v01-disputes-v1-sa.md
  7. ADR-001 durable deadline schedulerdisputes/docs/adr/2026-06-12-v01-adr-001-durable-deadline-scheduler.md
  8. ADR-002 state machinedisputes/docs/adr/2026-06-12-v01-adr-002-state-machine-and-taxonomy.md
  9. ADR-003 Arus outboxdisputes/docs/adr/2026-06-12-v01-adr-003-outbox-arus-http-only.md
  10. ADR-004 deterministic SimNetworkdisputes/docs/adr/2026-06-12-v01-adr-004-deterministic-simnetwork.md
  11. Threat modeldisputes/docs/security/2026-06-12-v01-disputes-threat-model.md
  12. Deadline storm acceptance testdisputes/backend/internal/httpapi/p3_acceptance_test.go
  13. Scheduler claim loopdisputes/backend/internal/worker/scheduler.go
  14. Transition and booking helpersdisputes/backend/internal/domain/transition.go
  15. Outbox drainerdisputes/backend/internal/worker/outbox.go
  16. HTTP boundary and HMAC callbacksdisputes/backend/internal/httpapi/server.go
  17. Deadline storm baseline transcriptdisputes/backend/build/20260612T234107Z-deadline-storm.txt
  18. Compose startup transcriptdisputes/backend/build/20260612T233952Z-demo.txt
  19. Prepare-only Cloud Run scriptdisputes/backend/scripts/prepare-cloud-run.sh
  20. Portfolio registryfamily-finance/web/src/components/portfolio/portfolioRegistry.ts