Skip to content

fix: infra hardening — compose ports, Temporal contract, balance enforcement - #159

Merged
munisp merged 23 commits into
mainfrom
fix/infra-gaps
Sep 13, 2026
Merged

fix: infra hardening — compose ports, Temporal contract, balance enforcement#159
munisp merged 23 commits into
mainfrom
fix/infra-gaps

Conversation

@munisp

@munisp munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner

What

Three infrastructure-hardening fixes from the Phase-2 program (WP6), closing audit structural items:

FIX A — Docker Compose host-port collisions

Nine host-port collisions across the compose files made docker compose up fail with "port is already allocated":

Service Was Now Collided with
open-appsec 8080 18080 Keycloak 8080
ollama-adapter 8090 18090 open-appsec mgmt 8090
lex-matcher 8090 18091 open-appsec mgmt 8090
verifier 8086 18086 InfluxDB 8086
fluvio-velocity 9090 19090 Prometheus 9090
tigerbeetle-ledger 8097 18097 kafka-schema-registry 8097
compliance-reporter 8094 18094 payment-rails 8094
openappsec-reporter 8095 18095 aml-engine 8095
compliance-worker 8096 18096 risk-scoring 8096
apisix (prod) 443 9443 nginx 443

Also: prod caddy service now strips its host-port bindings (ports: []) — nginx owns 80/443 in production. New regression test server/composePorts.test.ts parses every docker-compose*.yml and fails on any duplicate host binding. All referencing defaults updated: server/_core/env.ts (bisVerifierUrl→18086, ollamaAdapterUrl→18090), server/envValidation.ts (Ollama default→18090), docs/runbook.md (4 curl examples), docs/architecture.md (verifier/lex-matcher rows), README.md (OLLAMA_ADAPTER_URL→18090), server/fluvio.ts (comment 9090→19090).

FIX B — Temporal contract unification

  • camelCase wire convention across starters/workers; gateway registry-validated start
  • Namespace unified to bis (env default TEMPORAL_NAMESPACE ?? "bis", compose TEMPORAL_NAMESPACE default→bis)
  • Fail-closed typed TemporalWorkflowUnavailableError for workerless workflows — startAmlWorkflow now rejects with code TEMPORAL_WORKFLOW_UNAVAILABLE and never contacts the gateway when no worker registers AMLWorkflow (smoke test updated accordingly)

FIX C — TigerBeetle flow-of-funds balance enforcement

recordDebit now enforces available balance before posting: available = credits_posted − debits_posted − debits_pending. Insufficient funds → typed InsufficientFundsError (PRECONDITION_FAILED / reason INSUFFICIENT_FUNDS) with claim release after durable idempotency claim, before /transfers/create. Idempotent replay skips enforcement (no double-charge).

How tested

  • 56/56 WP6 suites: composePorts 7, temporal.contract 20, billing.insufficientFunds 6, paymentTransferWorkflow 21, debitClaim 2
  • smoke.comprehensive: 99/100 with 1 pre-existing env failure identical on pristine main (baseline restored — an earlier stale-base edit was reverted; the branch smoke file is main's version + the single AML fail-closed edit)
  • npx tsc --noEmit: clean
  • composePorts regression test reproduces: parses both compose files, asserts zero duplicate host ports + prod nginx/apisix 443 invariant

Risks / disclosures

  • Go build honestly unverified — module proxy blocked in the build sandbox; Go changes are gofmt-clean but not compiled here. Recommend CI/staging build before deploy.
  • Port remap is a client-visible change: anything hardcoding the old host ports (8080 open-appsec, 8090 ollama-adapter, etc.) must use the new ports; env defaults updated in this PR.
  • Smoke stale-base episode disclosed: an intermediate branch state had clobbered main's 871-line smoke file; repaired by restoring main's version verbatim and applying only the AML fail-closed edit.
  • Main advanced (feat: shareable reports + self-service subscriptions #157) during this work — no file overlap.

Note

This PR's commits were pushed by the orchestrator from the WP6 coder's verified fallback package (the coder's GitHub MCP lost write access mid-task); every pushed blob was SHA-256 hash-verified against the coder's workspace contents.

…ebit paths (WP6 FIX C)

Phase-1 audit: balance enforcement on debits was missing — payments/debits
could be created without an available-balance precondition.

- billingSettlement.ts: add getTenantAvailableBalanceKobo (available =
  credits_posted - debits_posted - debits_pending, matching
  services/tigerbeetle-ledger lib.rs), typed InsufficientFundsError
  (PRECONDITION_FAILED, reason INSUFFICIENT_FUNDS) and
  assertSufficientLedgerBalance; enforced in debitTenantForIntelligenceAssessment.
- billing.ts recordDebit: enforce AFTER the deterministic idempotency claim
  and BEFORE /transfers/create; release the just-claimed row on
  INSUFFICIENT_FUNDS so a post-top-up retry can re-claim; idempotent replays
  return early without re-enforcement. Idempotency/outbox semantics unchanged.
- Tests: new billing.insufficientFunds.test.ts (6 tests); debitClaim test
  updated for the new balance query.
…nt wiring (WP6 FIX C)

- server/billing.ts: recordDebit now calls assertSufficientLedgerBalance after
  the durable claim and before the TigerBeetle transfer; releases the claim on
  INSUFFICIENT_FUNDS (tenant-scoped delete) so retries after top-up proceed.
- server/billing.insufficientFunds.test.ts: sufficient funds passes;
  insufficient rejected with typed error and no transfer; holds (pending
  debits) reduce available balance; fail-closed on unreadable ledger;
  idempotent replay does not re-enforce.
…losed on unregistered types (WP6 FIX B)

- Wire convention is camelCase end-to-end ({workflowType, taskQueue,
  workflowId, input} -> {workflowId, runId, status, mode}); the Node client was
  aligned to the gateway (the server of this contract).
- startInvestigationWorkflow maps to the worker's snake_case
  InvestigationInput JSON tags at the boundary.
- startKycExpiryWorkflow: renamed to the registered KycExpiryWorkflow on
  queue bis-compliance with input {tenantId, gatewayUrl}.
- startScreeningWorkflow: sends the worker's ScreeningOrderInput shape
  (incl. required full_name; router accepts fullName/nin/bvn).
- Fail-closed typed TemporalWorkflowUnavailableError for workflows with no
  registered worker: PaymentTransferWorkflow (bis-payment), AMLWorkflow
  (bis-aml), CaseEscalationWorkflow (bis-cases), AccessReviewWorkflow
  (was literal "COMPLIANCE_TASK_QUEUE" via a fake client proxy — removed).
- Namespace default unified to "bis".
…r screening input (WP6 FIX B)

- server/temporalWorker.ts: deleted the fake HTTP poll-loop "worker" (polled
  /v1/worker/poll|heartbeat|complete|fail — endpoints that never existed in
  the gateway; phantom code). Activity helper functions retained and
  documented as NOT a Temporal worker; real execution is owned by the Go
  workers (gateway, compliance-worker).
- server/temporalRouter.ts: startScreening accepts optional fullName/nin/bvn
  required by the registered ScreeningWorkflow worker input.
server/temporal.manifest.json enumerates every client-invoked
workflow/query/signal (server/temporal.ts) and every worker registration
(services/gateway, services/compliance-worker), with task queues, namespaces,
and wire input fields. Guarded entries mark fail-closed starters with no
registered worker.
…er registrations (WP6 FIX B)

Loads server/temporal.manifest.json and asserts every client-invoked workflow
has a registered handler on the same task queue with a matching arg shape,
guarded starters behaviorally throw TemporalWorkflowUnavailableError, and
status/cancel endpoints exist.
Namespace drift (Node "default" / compliance-worker "bis-platform" /
temporal-init "bis") meant workers and the client could land in different
namespaces. Unified on "bis"; compose default updated likewise.
… status/cancel, workers actually started (WP6 FIX B)

- workflowTaskQueues registry + RegisteredWorkflowTaskQueue; StartWorkflow
  resolves the queue from the registry and rejects unregistered types
  (fail closed) instead of hardcoding bis-investigation.
- StartWorkflowWithOptions honours workflowId (idempotent re-starts) and
  validates taskQueue; returns workflowId + runId.
- Client.WorkflowStatus (DescribeWorkflowExecution) and Client.CancelWorkflow.
- StartWorker now also registers the extended workflows; StartScreeningWorker
  serves bis-screening.
The previous signature (an ad-hoc interface with non-variadic method types)
could never be satisfied by worker.Worker, and the function was never called.
It is now callable and is invoked from StartWorker().
…cel endpoints (WP6 FIX B)

- handleWorkflowStart: camelCase wire ({workflowType, taskQueue, workflowId,
  input}), validates the type against the registered-worker registry and fails
  closed with WORKFLOW_NOT_REGISTERED; honors workflowId/taskQueue; returns
  {workflowId, runId, status, mode}.
- New GET /v1/workflow/status/{id} (DescribeWorkflowExecution) and
  POST /v1/workflow/cancel/{id} (CancelWorkflow) — the Node client called both
  but they never existed.
- main(): start the registered Temporal workers (StartWorker +
  StartScreeningWorker) after client init; previously no worker ran.
…P6 FIX B)

startPaymentTransferWorkflow now rejects with typed TEMPORAL_WORKFLOW_UNAVAILABLE
(no registered worker on bis-payment); tests assert the guard, that the
gateway is never contacted, and deterministic replay rejection.
startAmlWorkflow has no registered worker; the smoke test now asserts the
typed TEMPORAL_WORKFLOW_UNAVAILABLE rejection and that the gateway is never
contacted.
@munisp
munisp merged commit 51c6686 into main Sep 13, 2026
8 of 10 checks passed
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