ADRs

QODY Architecture Decision Records (ADRs)

Architecture Decision Records document key architectural choices made for QODY. Each ADR captures the context, decision, and consequences of significant technical decisions.

ADR-001: RLS/BYPASSRLS Fail-Closed Guard

Status: ACCEPTED | Date: 2026-06-22 | Author: Petter Graff (CodeCraft)

Context

The Bilko product suffered a silent cross-tenant data breach where the application DB role (bilko_admin) had the BYPASSRLS attribute, which silently overrides FORCE ROW LEVEL SECURITY. RLS policies looked configured but isolated nothing. This was discovered late and required extensive remediation.

Decision

QODY will implement a fail-closed RLS role verification that runs at application startup, before any HTTP server initialization:

  1. The app connects as a dedicated role (qody_app) that MUST NOT have BYPASSRLS and MUST NOT be the table owner
  2. Migrations/owner DDL run as a separate privileged role (qody_flyway) used only by Flyway, never by the running app
  3. CI startup-validation query (fail-closed) on every boot:
    SELECT rolname, rolbypassrls FROM pg_roles WHERE rolname = 'qody_app';
    -- must return rolbypassrls = false, or the app refuses to start
    
  4. RLS isolation E2E test (Proveo): create two venues, set context to venue A, assert venue B's orders are invisible AND uninsertable

The /health endpoint also exposes RLS role status and returns HTTP 500 if BYPASSRLS is active.

Consequences

Positive:

Negative:

Validation: Phase 0 Proveo validation PASS (Test 6 — Bilko breach reproduced and guarded against).


ADR-002: Payment Provider Strategy — Provider Abstraction Layer

Status: ACCEPTED | Date: 2026-06-22 | Author: Markos Zachariadis (Finverge)

Context

QODY targets multiple markets with different payment ecosystems:

Locking into a single provider creates risk (downtime, pricing changes, market-specific requirements).

Decision

Implement a Payment Gateway Abstraction pattern with a provider-agnostic interface:

interface PaymentGateway {
    suspend fun createPaymentIntent(request: PaymentIntentRequest): PaymentIntentResponse
    suspend fun confirmPayment(intentId: String): PaymentConfirmationResponse
    suspend fun refund(paymentId: String, amount: Money): RefundResponse
    suspend fun handleWebhook(payload: String, signature: String): WebhookEvent
}

// Implementations:
class StripeGateway : PaymentGateway { /* Stripe-specific logic */ }
class VippsGateway : PaymentGateway { /* Vipps-specific logic */ }
class MonriGateway : PaymentGateway { /* Monri-specific logic */ }

// Factory for per-venue routing:
class PaymentGatewayFactory(private val config: PaymentConfig) {
    fun forVenue(venueId: UUID): PaymentGateway {
        return when (config.getProviderForVenue(venueId)) {
            PaymentProvider.STRIPE -> StripeGateway(config.stripe)
            PaymentProvider.VIPPS -> VippsGateway(config.vipps)
            PaymentProvider.MONRI -> MonriGateway(config.monri)
        }
    }
}

The database stores venues.payment_provider_id to allow per-venue provider selection.

Consequences

Positive:

Negative:

Alternatives Considered:


ADR-003: Outbox vs Kafka — Start with Transactional Outbox, Upgrade Path to Kafka

Status: ACCEPTED | Date: 2026-06-22 | Author: Petter Graff (CodeCraft)

Context

QODY needs to propagate order state transitions (e.g., SUBMITTEDACCEPTED) to:

Two architectural patterns exist:

  1. Transactional outbox: Write event to Postgres outbox table in the same transaction as the state change; a dispatcher drains the outbox
  2. Kafka: Publish event directly to Kafka topic; consumers subscribe

Decision

Start with a Postgres transactional outbox for Phase 1/2. Order state transitions write the state change AND the outbox row in the same DB transaction (no lost events, no dual-write inconsistency). A dispatcher drains the outbox to the real-time hub.

When a venue chain needs cross-service scale (Phase 3), the outbox drains to Kafka instead — same producer contract, zero domain-code rewrite.

Rationale

Consequences

Positive:

Negative:

Alternatives Considered:


ADR-004: Pay-Now vs Pay-at-End — Both, Flag-Gated

Status: ACCEPTED | Date: 2026-06-22 | Author: Markos Zachariadis (Finverge)

Context

Hospitality venues have different payment timing preferences:

Different markets and venue types require different flows.

Decision

Support both payment timing models, flag-gated per venue:

  1. Pay-per-order (Phase 1 MVP): Guest submits order → immediate payment → order goes to kitchen only after payment succeeds
  2. Pay-at-end (Phase 2): Guest orders multiple times → orders accumulate on the table_session → one settlement at the end when guest requests bill

The order lifecycle state machine supports both — the only difference is when the PAID transition fires and whether it targets order or table_session.

Flag: qody.payment.pay_at_end (venue-level, Unleash).

Consequences

Positive:

Negative:

Alternatives Considered:


Future ADRs (To Be Written)


Revision #1
Created 2026-06-22 15:52:06 UTC by John
Updated 2026-06-22 15:52:07 UTC by John