Single-window regulatory layer: formalities catalogue, P&R register, and ledger-backed tariff quotas - #37
Conversation
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
Original prompt from Patrick
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
⚙️ Control Options:
|
Co-Authored-By: Patrick Munis <pmunis@gmail.com>
|
Runtime test report — regulatory layer (tested at Local dev instance: Postgres + Redis + the Go TigerBeetle bridge (in-memory sim) running; Temporal, Mojaloop switch, Fluvio, Kafka, Permify absent. Server-side only — no UI evidence: the browser automation subsystem on the test box has been unavailable all session. Regulatory enforcement — passedTariff quotas & ledger integrity — passedThree defects found, all fixed in
|
Summary
Builds the regulatory-obligation layer the single-window comparison in
docs/single-window-market-parity.mdidentified as absent: the platform automated the customs decision well but had nothing telling it what the law requires for a given consignment. Adds a formalities catalogue (SW4), a prohibitions & restrictions register (SW5), tariff quotas with ledger-backed allocation (SW6), and the formality-aware clearance graph (J1/J2). Appeals, cancellation/refunds, binding rulings, regulatory replay and the FX defect are still open — see the same doc.Every register row is effective-dated and evaluated at the declaration's own date, not
now():Wiring into the declaration lifecycle:
evaluateDeclarationRegulations. A matching prohibition refuses the declaration and cites the legal instrument; a restriction becomes a required formality.approvedstatus, consignee, validity atat, HS prefix, origin, destination and sufficient remaining quantity. Consumption is a conditionalUPDATE ... WHERE usedQuantity + required <= permittedQuantityinside the same transaction as thedeclaration_formalitiesinsert, so a permit can't be drawn down without the obligation row that records it.assertDeclarationFormalitiesSatisfied, which recomputes the effective registers rather than trusting the submission-time rows — a prohibition or formality introduced after filing blocks release. When that recheck is what satisfies an obligation, it consumes the permit and writes the satisfaction through in the same transaction, so the release and the recorded reason for it can't disagree.(declaration_id, formality_id)and(declaration_id, restriction_id). Without that, the legitimatedocs_required → draft → submittedpath re-ran evaluation and consumed a trader's permit quantity twice for one consignment.An empty register means no rule matched, never "cleared"; register/ledger/Redis unavailability fails closed everywhere.
Tariff quotas are authoritative in the ledger, not in a SQL sum
createQuotaprovisions the quota's ownQTYaccounts server-side (ledgerAccountIdis no longer client input) and moves the opening volume in through a real transfer; allocation is available → allocated, reversal is allocated → available. Exhaustion is therefore refused by the ledger, with the non-reversed SQL sum as defence in depth. If provisioning or the opening transfer fails, quota creation fails rather than leaving a quota that can never allocate.This required making the bridge's overdraft protection per-account, the way real TigerBeetle does it, rather than global:
A blanket rule would have rejected every duty payment the platform makes — account 1001 Trader Liability is the debit side of duty settlement and starts with no credits posted. Only the quota available account sets the flag. The currency-consistency and "posted amount exceeds pending" checks stay unconditional.
Allocation idempotency is versioned by attempt (
regulatory:quota:<quotaId>:<declId>:<reversedCount>). Keyed by declaration alone, the reversal-then-reallocate path would have replayed the original transfer while still inserting a new allocation row — SQL counting the quantity twice, the ledger once, in the direction that lets quota be over-issued.Quota satisfaction is computed once and shared by
clearanceGraphand the release gate, so the graph can't reportblocking: truewhile clearance releases anyway: release is refused, citing the quota'slegalInstrument, until a sufficient non-reversed allocation exists, and a reversal makes it refuse again. Clearance never auto-allocates — allocation stays the explicit, authorized mutation.Allocation is scoped to the declaration owner, an active authorized agent, admin or customs officer; authoring registers requires admin/customs/OGA, so traders can read the law but not write it.
Also fixed
A stamp-reconciliation bug from #36 (merged, so carried here): activated-then-retired marks were counted in both buckets. Current-status buckets now define stamp accountability (
issued − (active + retired + stillIssued)) andeverActivatedseparately defines production accountability.Verification
Runtime-tested against a live instance (Postgres, Redis, the Go bridge) — full report in a comment below, including three defects it found and this branch fixes: quotas reported as blocking but not enforced at release, resubmission double-consuming permits, and stale obligation rows after a recheck clearance. Regulatory behaviour suite (9 caller-level tests through the real router against Postgres/Redis, ledger mocked), excise/declarations/mandate suites, Go bridge tests,
drizzle-kit push, typecheck at the repo's 72-error baseline with no diagnostic in a changed file.server/payments.test.tsretains one failure that reproduces on the base branch. No client pages yet, and no visual verification — the browser subsystem on this box has been dead all session. No Nigerian tariff, restriction or quota content is seeded; the registers ship empty for the authority to load.Link to Devin session: https://app.devin.ai/sessions/e68f0a7bf0e04fb8a3ba32ddb8e1fa23
Requested by: @munisp