Skip to content

Single-window regulatory layer: formalities catalogue, P&R register, and ledger-backed tariff quotas - #37

Merged
munisp merged 5 commits into
devin/nsw-parity-registrationsfrom
devin/single-window-regulatory-layer
Aug 24, 2026
Merged

munisp merged 5 commits into
devin/nsw-parity-registrationsfrom
devin/single-window-regulatory-layer

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 24, 2026

Copy link
Copy Markdown

Summary

Builds the regulatory-obligation layer the single-window comparison in docs/single-window-market-parity.md identified 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():

matchingRegisters(db, { hsCode, origin, destination, regime, at })
// hsCode LIKE hsCodePrefix || '%'  AND  (origin IS NULL OR origin = $origin)  AND validFrom <= at AND (validUntil IS NULL OR validUntil >= at)

Wiring into the declaration lifecycle:

  • Submission runs evaluateDeclarationRegulations. A matching prohibition refuses the declaration and cites the legal instrument; a restriction becomes a required formality.
  • A formality is satisfied only by a permit matching agency, permit type, approved status, consignee, validity at at, HS prefix, origin, destination and sufficient remaining quantity. Consumption is a conditional UPDATE ... WHERE usedQuantity + required <= permittedQuantity inside the same transaction as the declaration_formalities insert, so a permit can't be drawn down without the obligation row that records it.
  • Clearance calls 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.
  • Evaluation is idempotent per (declaration, obligation), enforced by partial unique indexes on (declaration_id, formality_id) and (declaration_id, restriction_id). Without that, the legitimate docs_required → draft → submitted path 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

createQuota provisions the quota's own QTY accounts server-side (ledgerAccountId is 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:

-if available.LessThan(t.Amount) {
+if debit.DebitsMustNotExceedCredits && available.LessThan(t.Amount) {

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 clearanceGraph and the release gate, so the graph can't report blocking: true while clearance releases anyway: release is refused, citing the quota's legalInstrument, 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)) and everActivated separately 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.ts retains 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

devin-ai-integration Bot and others added 4 commits August 24, 2026 19:42
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>
@devin-ai-integration

Copy link
Copy Markdown
Author
Original prompt from Patrick

Clone, review and analyze this https://github.com/munisp/singlewindow
Afterwhich, using Attach file analyze the codebase and implement the findings
ATTACHMENT:"https://app.devin.ai/attachments/80878dc3-af42-4287-9b79-2ab7bdfe3f03/codebase-defect-discovery-prompt.pdf"

@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Co-Authored-By: Patrick Munis <pmunis@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime test report — regulatory layer (tested at f837ac6, findings fixed in 6465a1b)

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 — passed
prohibition on submit        403 FORBIDDEN  "Declaration refused under LI 2145 s.4(1): ..."   status stays draft
non-matching origin          200            (matching, not blanket refusal)
restriction -> formality     declaration_formalities: status=required, satisfied_by_permit_id NULL
clearance while unsatisfied  412 PRECONDITION_FAILED "... unsatisfied under LI 2146 s.7."
permit strictness (11 x)     agency / type / pending / rejected / consignee / HS prefix / origin /
                             destination / expired / not-yet-valid / short quantity  -> all still 412
exact matching permit        200 -> status cleared
rule outside validity window not applied (registers non-empty, 0 formality rows, clearance 200)
prohibition added post-submit 403 "... LI LATE 9 ..."      formality added post-submit  412
empty registers              {registersAvailable:true, prohibited:[], obligations:[], blocking:false}
Tariff quotas & ledger integrity — passed
createQuota           server-provisioned quota-available/-allocated accounts; available credited 100 QTY
                      caller-supplied "ledgerAccountId":"attacker-account" ignored
over-allocate         60 ok, +60 -> 412 "Tariff quota is exhausted."   sql=60.000  available=40 allocated=60
5x concurrent 15/40   exactly 2 succeed, 3 x 412;  sql sum 90.000 == ledger allocated 90;  available 10
same-decl concurrency two parallel calls -> same allocation id, exactly 1 row
reverse + re-allocate reversal transfer new; re-allocation transferId 0db86ba1… != original 41ecd950…
                      ledger allocated == SQL sum of non-reversed rows at every step (35/35, 95/95)
authz                 non-owner/non-agent/non-admin/non-customs -> 403, no row, ledger unchanged
bridge down           allocate/createQuota/reverse -> 503, zero rows written (no fabricated balance)
redis down            allocation refused, no row, no ledger movement
authoring             trader -> 403 "Only authorised officers may author regulatory registers."
                      admin / customs / OGA -> 200 ;  register reads as trader -> 200
Three defects found, all fixed in 6465a1b
  1. Quotas were reported as blocking but not enforced at clearance. For a declaration matching two active quotas with zero allocations, clearanceGraph returned blocking:true while declarations.updateStatus{status:"cleared"} returned 200 and released the goods with no allocation row — assertDeclarationFormalitiesSatisfied only walked formalities/restrictions. Quota satisfaction is now computed once and shared by the graph and the release gate, so the two cannot disagree; clearance refuses (citing the quota's legalInstrument) until a sufficient non-reversed allocation exists, and a reversal makes it refuse again. Clearance still never auto-allocates — allocation stays the explicit, authorized, ledger-backed mutation.
  2. Resubmission double-consumed permit quantity. docs_required → draft → submitted re-ran evaluation, taking a permit's used_quantity from 10.000 to 20.000 for one consignment and leaving duplicate obligation rows. Evaluation is now idempotent per (declaration, obligation), enforced by partial unique indexes on (declaration_id, formality_id) and (declaration_id, restriction_id); obligations that appeared since the last submission are still picked up.
  3. Stale obligation state after a live-recheck clearance. When clearance succeeded via the runtime recheck, the persisted row stayed required / satisfied_by_permit_id NULL and the permit's used_quantity was never incremented — a released declaration reading as unsatisfied, against a permit that looked unused. The recheck now consumes the permit and writes satisfaction through in the same transaction, and refuses release if consumption fails.
Duty-payment regression (per-account overdraft flag) — passed
Mojaloop COMMITTED webhook (valid HMAC, bridge up)
  -> 200, 1 tigerbeetle_ledger_entries row, tx COMMITTED, getPaymentStatus.isSettled = true
  -> trader-1 (Trader Liability, no flag) debits past zero to -5000 as intended; ncs-revenue +5000
  -> redelivery still exactly 1 ledger row
direct 999 QTY debit on a quota-available account holding 10
  -> 422 "insufficient available balance in debit account quota-available-…", balance unchanged
same over-balance debit on trader liability -> accepted
Pre-existing issues hit (not from this PR)
  • declarations.submit sets under_assessment when risk scorers are down, and under_assessment has no entry in VALID_TRANSITIONSInvalid status transition: under_assessment → cleared. Allowed: []. Declarations submitted during a scorer outage can never be cleared.
  • services/go/tigerbeetle-bridge has no committed go.sum, and go mod tidy fails (internal/backend imports tigerbeetle-go/pkg/types, absent upstream). Only ./cmd builds, after manually adding two grpc entries.
  • drizzle-kit migrate 55P04 enum failure (use push --force); scripts/seed-demo-users.sql integer-vs-enum current_step bug.

One coverage gap, stated honestly: with a single Redis on the box, killing it to test the "Quota coordination is unavailable" branch tripped the session-revocation layer first (401), so the outcome is verified fail-closed (nothing mutated, ledger untouched) but that specific branch never executed.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review. (Configure)

Open in Devin Review

Comment thread server/routers/regulatory.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant