Architecture

Architecture Document

Architecture Document: Drop

Version: 1.1 Date: 2026-02-08 Author: dev agent (qwen2.5-coder:32b) + John Status: Approved Approved by: John (AI Director)


1. Overview

1.1 System Purpose

Drop je fintech aplikacija za remittance i QR plaćanja za sve stanovnike Norveške i Skandinavije. Drop koristi PSD2 pass-through model — nikada ne drži novac korisnika. AISP čita stanje računa putem Open Banking-a, a PISP inicira plaćanja direktno sa bankovnog računa korisnika.

1.2 Architecture Style

Monolith — scope je 7 stranica + API rute. Microservices bi bio over-engineering za demo.

1.3 Key Design Decisions

Decision Choice Rationale Alternatives
Architecture Monolith Small scope, simple deploy Microservices (overkill)
Frontend Next.js 16 + React 19 Already built, modern stack Remix, SvelteKit
Styling Tailwind v4 Already in use Styled Components
Database PostgreSQL 16 (Drizzle ORM) Full test/prod parity, PostgreSQL-native features, type-safe schema SQLite (superseded by ADR-014)
Auth JWT via jose Lightweight, stateless Session-based (needs Redis)
JWT Storage httpOnly cookie Prevents XSS token theft localStorage (less secure)
Error Handling Centralized middleware Consistent responses, easy logging Per-route try/catch

1.4 User Requirements (ENFORCED — from vilkår.html)

These are legally binding requirements published in our Terms of Service. They MUST be enforced in code.

Requirement Value Enforcement
Minimum age 18 år Registration: DOB field → reject if < 18. BankID returns DOB → double-check.
Residency Bosatt i Norge Registration: Norwegian phone (+47) + Norwegian BankID required.
Identity verification Gyldig BankID Onboarding: BankID verification mandatory before any transaction.
Accurate personal data User obligation BankID provides verified name/DOB. User confirms address.
No illegal use User obligation AML monitoring, transaction limits, suspicious activity detection.

Source: landing/pages/vilkar.html section 3 — "Du må være minst 18 år og bosatt i Norge for å bruke Drop."

Implementation notes:

2. System Context

┌──────────┐     ┌─────────────────────────────┐
│  Mobile  │     │         Drop App             │
│  Browser │────▶│  Next.js 16 (App Router)     │
└──────────┘     │                              │
                 │  ┌─────────┐  ┌───────────┐  │
                 │  │Frontend │  │ API Routes │  │
                 │  │ React19 │─▶│ /api/*     │  │
                 │  │ TW v4   │  │ JWT Auth   │  │
                 │  └─────────┘  └─────┬─────┘  │
                 │                     │        │
                 │               ┌─────▼─────┐  │
                 │               │PostgreSQL 16│ │
                 │               │ (Drizzle) │  │
                 │               └───────────┘  │
                 └─────────────────────────────┘

2.1 External Interfaces

Currently self-contained with local PostgreSQL (Docker). No external service integrations in MVP.

System Purpose Status
Exchange rates Remittance corridors (RSD, BAM, PLN, PKR, TRY, EUR) Local DB table
Auth JWT via jose Built-in
QR payments Merchant scanning Built-in

Post-MVP roadmap (FUTURE — not yet implemented, requires partners):

3. Component Architecture

3.1 Frontend Pages

Page Route Description Status
Landing / Marketing page Core
Register /register Phone + PIN registration Core
Login /login Phone + PIN auth Core
Dashboard /dashboard Account overview, last 5 transactions Core
Send Money /send Remittance — PISP from user's bank account Core
QR Payments /scan Scan & pay via QR code — PISP from user's bank account Core
Bank Accounts /accounts View linked bank account balances via AISP Core
Transaction History /transactions Full transaction list with filters Core
Notifications /notifications Push notifications and transaction alerts Core
Settings /profile User preferences and account management Core
Cards /cards Virtual/physical card management FUTURE (feature-flagged)

3.2 API Layer

/api
  /auth
    /register/route.ts    — POST register (phone + PIN)
    /login/route.ts       — POST login → JWT in httpOnly cookie
  /account/route.ts       — GET balance, account info
  /transactions
    /route.ts             — GET list, POST send money
    /simulate/route.ts    — POST simulate incoming (demo)
  /cards                          — FUTURE (feature-flagged, requires partner)
    /route.ts             — GET cards, POST create virtual
    /[id]/route.ts        — PATCH freeze/unfreeze
    /[id]/physical/route.ts — POST order physical
  middleware/
    errorHandler.ts       — Centralized error responses
    authMiddleware.ts     — JWT verification

4. Data Architecture

4.1 Database

4.2 Schema

Total Tables: 19 (12 core + 7 compliance)

Core Tables (12)

Table Key Fields Relationships
users id, email, password_hash, first_name, last_name, phone, date_of_birth, kyc_status, role, risk_level, pep_status, sanctions_cleared, kyc_method, kyc_verified_at, national_id_hash, deleted_at, created_at → bank_accounts, cards, recipients, transactions
bank_accounts id, user_id, bank_name, account_number, iban, balance (cached AISP read), balance_synced_at, currency, is_primary, connected_at → users, transactions
cards id, user_id, type, last_four, token_ref, expiry, status, shipping_address, pin_hash, created_at → users — FUTURE (feature-flagged)
transactions id, user_id, type, status, amount, currency, fee, recipient_id, merchant_id, send_amount, send_currency, receive_amount, receive_currency, exchange_rate, purpose_code, created_at, completed_at → users, recipients, merchants
recipients id, user_id, name, country, currency, bank_account, bank_name, created_at → users, transactions
merchants id, user_id, business_name, org_number, address, bank_account, fee_rate, status, created_at → users, transactions
exchange_rates id, from_currency, to_currency, rate, updated_at
sessions id, user_id, token_hash, created_at, expires_at, revoked → users
notifications id, user_id, type, title, body, read, created_at → users
settings user_id, currency, language, push_enabled, email_enabled, updated_at → users
spending_limits id, user_id, card_id, limit_type, amount, currency, created_at → users, cards
rate_limits key, count, reset_at

Compliance Tables (7) — Added 2026-02-16

Table Key Fields Purpose
audit_log id, timestamp, user_id, action, resource_type, resource_id, details, ip_address, user_agent Audit trail of all user actions
aml_alerts id, user_id, alert_type, severity, transaction_id, details, status, reviewed_by, reviewed_at, created_at Anti-money laundering alert tracking
str_reports id, user_id, alert_id, report_type, status, filed_at, reference_number, details, created_at Suspicious transaction reports (SAR/STR)
screening_results id, user_id, screening_type, provider, result, match_details, screened_at PEP, sanctions, adverse media screening
consents id, user_id, consent_type, granted, granted_at, withdrawn_at, ip_address GDPR consent tracking (PSD2, marketing, data processing)
data_access_requests id, user_id, request_type, status, requested_at, completed_at, download_url, notes GDPR right to access/erasure/rectification
complaints id, user_id, category, subject, description, status, resolution, created_at, resolved_at Customer complaint handling

Pass-through model: Drop NEVER holds customer money. The bank_accounts.balance field is a cached AISP read from the user's actual bank account (read-only in production, synced via Open Banking). User funds remain in their bank at all times. PISP initiates payments directly from user's bank account.

No wallet, no top-up: Drop does not have a wallet feature or top-up functionality. Users do not maintain a balance with Drop.

4.3 PSD2 Pass-Through Model

Drop operates as a PSD2 Payment Initiation Service Provider (PISP) and Account Information Service Provider (AISP):

AISP (Account Information)

PISP (Payment Initiation)

Compliance Requirements (PSD2)

  1. User consent: Explicit BankID consent required for AISP + PISP access
  2. SCA (Strong Customer Authentication): Required for all payments
  3. Data minimization: Only store what's necessary for compliance
  4. Audit trail: All PISP/AISP operations logged in audit_log table
  5. Right to withdraw consent: Tracked in consents table

Regulatory tables: audit_log, aml_alerts, str_reports, screening_results, consents, data_access_requests, complaints

5. Security Architecture (from security agent threat model)

5.1 Authentication

5.2 Threats & Mitigations

Threat Severity Mitigation
Broken Access Control HIGH JWT middleware on all /api routes
SQL Injection HIGH Parameterized queries via Drizzle ORM
XSS HIGH React auto-escapes, CSP headers
Token Theft HIGH httpOnly cookie, HTTPS
CSRF MEDIUM SameSite cookie + CSRF token
Data in localStorage HIGH Move sensitive data to httpOnly cookies
Replay Attacks MEDIUM Token expiration + jti claim
Security Misconfiguration HIGH Security headers (HSTS, X-Frame, CSP)

5.3 Data Protection

6. Infrastructure

Environment Purpose URL
Development Local dev localhost:3000
Staging Pre-release TBD (Vercel preview)
Production Live demo TBD (Vercel)

CI/CD Pipeline

Push → Build (next build) → TypeScript Check → Lint → Test → Deploy Staging → Manual Approval → Deploy Prod

7. Technology Stack

Layer Technology Version
Frontend Next.js 16
UI React 19
Styling Tailwind CSS 4
Backend Next.js API Routes 16
Database PostgreSQL 16 + Drizzle ORM 16
Auth JWT (jose) latest
Hosting Vercel

8. Performance Targets

Metric Target
FCP < 1.5s
LCP < 2.5s
TTFB < 200ms
API p95 < 300ms
Lighthouse > 90
Build time < 60s

9. ADRs

ADR-001: SQLite over PostgreSQL (superseded)

ADR-002: JWT in httpOnly Cookie

ADR-003: Monolith Architecture

10. Approvals

Role Name Date Approved
Dev Agent dev (qwen2.5-coder:32b) 2026-02-08
Security Agent security (qwen2.5-coder:32b) 2026-02-08
John (AI Director) John 2026-02-08

API Specification

API Specification: Drop

Version: 1.0 Date: 2026-02-09 Author: dev agent (Ollama) + John (AI Director) Base URL: /api (Next.js API Routes) Auth: JWT in httpOnly cookie (jose library) Database: PostgreSQL 16 via Drizzle ORM (ADR-014; better-sqlite3 removed 2026-03-03)


1. Overview

API Style: REST Format: JSON Rate Limiting: 60 req/min per IP (standard), 10 req/min for auth endpoints Auth mechanism: JWT token set as httpOnly, secure, sameSite=strict cookie


2. Authentication

POST /api/auth/register

Description: Register new user account

Request:

{
  "email": "amir@example.com",
  "password": "min8chars",
  "firstName": "Amir",
  "lastName": "Hadžić",
  "phone": "+4712345678"
}

Response 201:

{
  "data": {
    "id": "usr_abc123",
    "email": "amir@example.com",
    "firstName": "Amir",
    "lastName": "Hadžić",
    "kycStatus": "pending",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Sets httpOnly JWT cookie

Errors: 400 (validation), 409 (email exists)


POST /api/auth/login

Description: Login and receive JWT cookie

Request:

{
  "email": "amir@example.com",
  "password": "min8chars"
}

Response 200:

{
  "data": {
    "id": "usr_abc123",
    "email": "amir@example.com",
    "firstName": "Amir",
    "lastName": "Hadžić",
    "kycStatus": "approved"
  }
}

Sets httpOnly JWT cookie (24h expiry). Note: balance is NOT returned here — use /api/bank-accounts (AISP) to read bank balance.

Errors: 401 (wrong credentials), 423 (account locked)


POST /api/auth/logout

Description: Clear JWT cookie Auth: Required

Response 200:

{ "message": "Logged out" }

Clears httpOnly cookie


GET /api/auth/me

Description: Get current user from JWT Auth: Required

Response 200:

{
  "data": {
    "id": "usr_abc123",
    "email": "amir@example.com",
    "firstName": "Amir",
    "lastName": "Hadžić",
    "kycStatus": "approved",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

3. Bank Accounts (AISP — Pass-through)

Pass-through model: Drop never holds customer money. Balance is read from user's real bank account via Open Banking (AISP). Payments are initiated via PISP from user's bank.

GET /api/bank-accounts

Description: Get linked bank accounts and balances via AISP (Open Banking) Auth: Required (BankID consent)

Response 200:

{
  "data": [
    {
      "id": "ba_1",
      "bankName": "SpareBank 1",
      "accountNumber": "*****1234",
      "balance": 12450.00,
      "currency": "NOK",
      "lastSynced": "2026-02-09T10:00:00Z"
    }
  ]
}

Note: Balance is a cached AISP read from the user's actual bank account. Drop does not store or manage this balance.


GET /api/users/balance — REMOVED

POST /api/users/top-up — REMOVED

These endpoints were part of the old wallet model and have been removed. In the pass-through model, there is no wallet to check or top up. Use /api/bank-accounts to read bank balances via AISP.


4. Recipients

GET /api/recipients

Description: List user's saved recipients Auth: Required Query: ?page=1&limit=20

Response 200:

{
  "data": [
    {
      "id": "rec_1",
      "name": "Mama Jasmina",
      "country": "RS",
      "countryName": "Serbia",
      "currency": "RSD",
      "bankAccount": "*****1234",
      "createdAt": "2026-02-01T10:00:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 3
  }
}

POST /api/recipients

Description: Add new recipient Auth: Required

Request:

{
  "name": "Mama Jasmina",
  "country": "RS",
  "currency": "RSD",
  "bankAccount": "265100000012345678",
  "bankName": "Banca Intesa"
}

Response 201:

{
  "data": {
    "id": "rec_new",
    "name": "Mama Jasmina",
    "country": "RS",
    "currency": "RSD",
    "bankAccount": "*****5678",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Errors: 400 (validation), 422 (unsupported country)


DELETE /api/recipients/:id

Description: Remove recipient Auth: Required Response 204: No content


5. Exchange Rates

GET /api/rates

Description: Get current exchange rates for all corridors Auth: Not required

Response 200:

{
  "data": {
    "baseCurrency": "NOK",
    "rates": {
      "RSD": 11.70,
      "BAM": 1.04,
      "PLN": 0.41,
      "PKR": 26.80,
      "TRY": 3.45,
      "EUR": 0.089
    },
    "updatedAt": "2026-02-09T10:00:00Z"
  }
}

GET /api/rates/:currency

Description: Get rate for specific currency pair Auth: Not required

Response 200:

{
  "data": {
    "from": "NOK",
    "to": "RSD",
    "rate": 11.70,
    "fee": 0.005,
    "updatedAt": "2026-02-09T10:00:00Z"
  }
}

Errors: 404 (unsupported currency)


6. Transactions — Remittance

POST /api/transactions/remittance

Description: Create new remittance transfer Auth: Required (KYC must be approved)

Request:

{
  "recipientId": "rec_1",
  "amount": 2000.00,
  "currency": "NOK"
}

Response 201:

{
  "data": {
    "id": "tx_rem_123",
    "type": "remittance",
    "status": "processing",
    "sendAmount": 2000.00,
    "sendCurrency": "NOK",
    "receiveAmount": 23400.00,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.70,
    "fee": 10.00,
    "feePercent": 0.5,
    "total": 2010.00,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "RS",
    "eta": "1-2 business days",
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Errors:


7. Transactions — QR Payment

POST /api/transactions/qr-payment

Description: Pay a merchant via QR code Auth: Required

Request:

{
  "merchantId": "mer_1",
  "amount": 129.00
}

Response 201:

{
  "data": {
    "id": "tx_qr_456",
    "type": "qr_payment",
    "status": "completed",
    "amount": 129.00,
    "currency": "NOK",
    "fee": 1.29,
    "feePercent": 1.0,
    "merchantName": "Ahmetov Kebab",
    "merchantId": "mer_1",
    "createdAt": "2026-02-09T14:23:00Z"
  }
}

Errors:


8. Transactions — List

GET /api/transactions

Description: List user's transactions (both remittance and QR) Auth: Required Query: ?page=1&limit=20&type=remittance|qr_payment&status=completed|processing|failed

Response 200:

{
  "data": [
    {
      "id": "tx_rem_123",
      "type": "remittance",
      "status": "completed",
      "amount": -2000.00,
      "currency": "NOK",
      "recipientName": "Mama Jasmina",
      "createdAt": "2026-02-09T10:00:00Z"
    },
    {
      "id": "tx_qr_456",
      "type": "qr_payment",
      "status": "completed",
      "amount": -129.00,
      "currency": "NOK",
      "merchantName": "Ahmetov Kebab",
      "createdAt": "2026-02-09T14:23:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 47
  }
}

GET /api/transactions/:id

Description: Get transaction detail Auth: Required

Response 200:

{
  "data": {
    "id": "tx_rem_123",
    "type": "remittance",
    "status": "completed",
    "sendAmount": 2000.00,
    "sendCurrency": "NOK",
    "receiveAmount": 23400.00,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.70,
    "fee": 10.00,
    "total": 2010.00,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "RS",
    "createdAt": "2026-02-09T10:00:00Z",
    "completedAt": "2026-02-10T14:30:00Z"
  }
}

9. Merchants

POST /api/merchants/register

Description: Register as a merchant Auth: Required

Request:

{
  "businessName": "Ahmetov Kebab",
  "orgNumber": "923456789",
  "address": "Grønland 12, Oslo",
  "bankAccount": "1234.56.78901"
}

Response 201:

{
  "data": {
    "id": "mer_1",
    "businessName": "Ahmetov Kebab",
    "orgNumber": "923456789",
    "qrCode": "drop://pay/mer_1",
    "status": "active",
    "feeRate": 0.01,
    "createdAt": "2026-02-09T10:00:00Z"
  }
}

Errors: 400 (validation), 409 (org number exists)


GET /api/merchants/dashboard

Description: Get merchant stats Auth: Required (merchant role) Query: ?period=today|week|month

Response 200:

{
  "data": {
    "period": "today",
    "revenue": 4350.00,
    "transactionCount": 12,
    "fees": 43.50,
    "netRevenue": 4306.50,
    "nextPayout": 4306.50,
    "payoutTime": "17:00"
  }
}

GET /api/merchants/transactions

Description: List merchant's received payments Auth: Required (merchant role) Query: ?page=1&limit=20&date=2026-02-09

Response 200:

{
  "data": [
    {
      "id": "tx_qr_456",
      "customerName": "Amir K.",
      "amount": 129.00,
      "fee": 1.29,
      "net": 127.71,
      "status": "completed",
      "time": "14:23"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 12
  }
}

GET /api/merchants/qr

Description: Get merchant's QR code data Auth: Required (merchant role)

Response 200:

{
  "data": {
    "merchantId": "mer_1",
    "businessName": "Ahmetov Kebab",
    "qrValue": "drop://pay/mer_1",
    "address": "Grønland 12, Oslo"
  }
}

10. Database Schema (PostgreSQL 16 — 19 tables)

Source: src/shared/db/schema.ts (Drizzle ORM schema — PostgreSQL 16, all environments; better-sqlite3 removed per ADR-014)

Core Tables (12)

Note: The SQL below is a historical snapshot from the original spec (SQLite syntax). The authoritative schema is src/shared/db/schema.ts (Drizzle ORM, PostgreSQL 16). Use make db-push to apply schema changes.

-- Users (NO balance field — pass-through model)
CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  first_name TEXT NOT NULL,
  last_name TEXT NOT NULL,
  phone TEXT,
  date_of_birth TEXT,
  kyc_status TEXT DEFAULT 'pending' CHECK(kyc_status IN ('pending','approved','rejected')),
  role TEXT DEFAULT 'user' CHECK(role IN ('user','merchant')),
  risk_level TEXT DEFAULT 'low' CHECK(risk_level IN ('low','medium','high')),
  pep_status TEXT DEFAULT 'not_checked' CHECK(pep_status IN ('not_checked','clear','match','pending_review')),
  sanctions_cleared INTEGER DEFAULT 0,
  kyc_method TEXT CHECK(kyc_method IN ('bankid','document','simplified')),
  kyc_verified_at TEXT,
  national_id_hash TEXT,
  deleted_at TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

-- Bank accounts (balance is cached AISP read, NOT held by Drop)
CREATE TABLE bank_accounts (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  bank_name TEXT NOT NULL,
  account_number TEXT NOT NULL,
  iban TEXT,
  balance REAL DEFAULT 0,  -- Cached AISP-read balance (read-only in production)
  balance_synced_at TEXT,  -- When balance was last synced from bank via AISP
  currency TEXT DEFAULT 'NOK',
  is_primary INTEGER DEFAULT 0,
  connected_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE recipients (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  name TEXT NOT NULL,
  country TEXT NOT NULL,
  currency TEXT NOT NULL,
  bank_account TEXT NOT NULL,
  bank_name TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE merchants (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  business_name TEXT NOT NULL,
  org_number TEXT UNIQUE NOT NULL,
  address TEXT,
  bank_account TEXT NOT NULL,
  fee_rate REAL DEFAULT 0.01,
  status TEXT DEFAULT 'active',
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE transactions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL CHECK(type IN ('remittance','qr_payment')),
  status TEXT DEFAULT 'processing' CHECK(status IN ('processing','completed','failed')),
  amount REAL NOT NULL,
  currency TEXT DEFAULT 'NOK',
  fee REAL DEFAULT 0,
  recipient_id TEXT REFERENCES recipients(id),
  merchant_id TEXT REFERENCES merchants(id),
  send_amount REAL,
  send_currency TEXT,
  receive_amount REAL,
  receive_currency TEXT,
  exchange_rate REAL,
  purpose_code TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  completed_at TEXT
);

CREATE TABLE exchange_rates (
  id INTEGER PRIMARY KEY AUTOINCREMENT,  -- SERIAL for PostgreSQL
  from_currency TEXT DEFAULT 'NOK',
  to_currency TEXT NOT NULL,
  rate REAL NOT NULL,
  updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE cards (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT DEFAULT 'virtual' CHECK(type IN ('virtual','physical')),
  last_four TEXT NOT NULL,
  token_ref TEXT,
  expiry TEXT NOT NULL,
  status TEXT DEFAULT 'active' CHECK(status IN ('active','frozen','cancelled')),
  shipping_address TEXT,
  pin_hash TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE sessions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  token_hash TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now')),
  expires_at TEXT NOT NULL,
  revoked INTEGER DEFAULT 0
);

CREATE TABLE notifications (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  read INTEGER DEFAULT 0,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE settings (
  user_id TEXT PRIMARY KEY REFERENCES users(id),
  currency TEXT DEFAULT 'NOK',
  language TEXT DEFAULT 'nb',
  push_enabled INTEGER DEFAULT 1,
  email_enabled INTEGER DEFAULT 1,
  updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE spending_limits (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  card_id TEXT REFERENCES cards(id),
  limit_type TEXT NOT NULL,
  amount REAL NOT NULL,
  currency TEXT DEFAULT 'NOK',
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE rate_limits (
  key TEXT PRIMARY KEY,
  count INTEGER NOT NULL,
  reset_at INTEGER NOT NULL
);

Compliance Tables (7) — Added 2026-02-16

CREATE TABLE audit_log (
  id TEXT PRIMARY KEY,
  timestamp TEXT DEFAULT (datetime('now')),
  user_id TEXT REFERENCES users(id),
  action TEXT NOT NULL,
  resource_type TEXT,
  resource_id TEXT,
  details TEXT,
  ip_address TEXT,
  user_agent TEXT
);

CREATE TABLE aml_alerts (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  alert_type TEXT NOT NULL,
  severity TEXT NOT NULL CHECK(severity IN ('low','medium','high','critical')),
  transaction_id TEXT REFERENCES transactions(id),
  details TEXT,
  status TEXT DEFAULT 'open' CHECK(status IN ('open','investigating','resolved','escalated','filed')),
  reviewed_by TEXT,
  reviewed_at TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE str_reports (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  alert_id TEXT REFERENCES aml_alerts(id),
  report_type TEXT NOT NULL,
  status TEXT DEFAULT 'draft' CHECK(status IN ('draft','submitted','acknowledged')),
  filed_at TEXT,
  reference_number TEXT,
  details TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE screening_results (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  screening_type TEXT NOT NULL CHECK(screening_type IN ('pep','sanctions','adverse_media')),
  provider TEXT,
  result TEXT NOT NULL CHECK(result IN ('clear','match','potential_match','error')),
  match_details TEXT,
  screened_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE consents (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  consent_type TEXT NOT NULL,
  granted INTEGER NOT NULL DEFAULT 1,
  granted_at TEXT DEFAULT (datetime('now')),
  withdrawn_at TEXT,
  ip_address TEXT
);

CREATE TABLE data_access_requests (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  request_type TEXT NOT NULL CHECK(request_type IN ('export','erasure','rectification','restriction')),
  status TEXT DEFAULT 'pending' CHECK(status IN ('pending','processing','completed','rejected')),
  requested_at TEXT DEFAULT (datetime('now')),
  completed_at TEXT,
  download_url TEXT,
  notes TEXT
);

CREATE TABLE complaints (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  category TEXT NOT NULL,
  subject TEXT NOT NULL,
  description TEXT NOT NULL,
  status TEXT DEFAULT 'received' CHECK(status IN ('received','investigating','resolved','escalated')),
  resolution TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  resolved_at TEXT
);

Indexes

-- Core indexes
CREATE INDEX idx_transactions_user ON transactions(user_id);
CREATE INDEX idx_transactions_merchant ON transactions(merchant_id);
CREATE INDEX idx_recipients_user ON recipients(user_id);
CREATE INDEX idx_merchants_org ON merchants(org_number);
CREATE INDEX idx_bank_accounts_user ON bank_accounts(user_id);
CREATE INDEX idx_cards_user ON cards(user_id);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_token ON sessions(token_hash);
CREATE INDEX idx_notifications_user ON notifications(user_id);
CREATE INDEX idx_spending_limits_user ON spending_limits(user_id);
CREATE INDEX idx_spending_limits_card ON spending_limits(card_id);

-- Compliance indexes
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp);
CREATE INDEX idx_audit_log_action ON audit_log(action);
CREATE INDEX idx_aml_alerts_user ON aml_alerts(user_id);
CREATE INDEX idx_aml_alerts_status ON aml_alerts(status);
CREATE INDEX idx_screening_user ON screening_results(user_id);
CREATE INDEX idx_consents_user ON consents(user_id);
CREATE INDEX idx_data_requests_user ON data_access_requests(user_id);
CREATE INDEX idx_complaints_user ON complaints(user_id);
CREATE INDEX idx_complaints_status ON complaints(status);

Note: Schema uses PostgreSQL 16 in all environments (development, CI, staging, production) via Drizzle ORM. The old dual-mode SQLite/PostgreSQL driver has been removed (ADR-014).


11. Common Error Format

All errors follow this format:

{
  "error": "error_code",
  "message": "Human readable message",
  "details": []
}
Code Error Description
400 bad_request Malformed request body
401 unauthorized Missing or expired JWT
402 insufficient_balance Not enough NOK in bank account (PISP will fail)
403 kyc_required KYC must be approved
404 not_found Resource not found
409 conflict Duplicate (email, org number)
422 validation_error Field validation failed
429 rate_limited Too many requests
500 internal_error Server error

12. Rate Limits

Endpoint Group Limit Window
Auth (login/register) 10/min Per IP
Transactions (create) 30/min Per user
Read endpoints 60/min Per user
Exchange rates 120/min Per IP

13. API Route File Structure (Next.js)

src/app/api/
├── auth/
│   ├── register/route.ts
│   ├── login/route.ts
│   ├── logout/route.ts
│   └── me/route.ts
├── bank-accounts/
│   └── route.ts          (GET linked accounts via AISP)
├── recipients/
│   ├── route.ts          (GET list, POST create)
│   └── [id]/route.ts     (DELETE)
├── transactions/
│   ├── route.ts          (GET list)
│   ├── [id]/route.ts     (GET detail)
│   ├── remittance/route.ts (POST)
│   └── qr-payment/route.ts (POST)
├── merchants/
│   ├── register/route.ts
│   ├── dashboard/route.ts
│   ├── transactions/route.ts
│   └── qr/route.ts
├── rates/
│   ├── route.ts          (GET all)
│   └── [currency]/route.ts (GET specific)
└── lib/
    ├── db.ts             (PostgreSQL/Drizzle connection)
    ├── auth.ts           (JWT verify/sign)
    └── middleware.ts     (auth middleware, rate limit)

Generated: 2026-02-09 by dev agent (Ollama) + John (orchestration) Status: Ready for implementation (Sprint 1)

ADR-015 — Four-Jurisdiction Plugin Architecture (CountryPlugin Kotlin Interface)

ADR-015 — Four-Jurisdiction Plugin Architecture (CountryPlugin Kotlin Interface)

Status: Accepted Date: 2026-05-13 Author: Petter Graff (CodeCraft — Architecture Lead) Decision-maker: CEO Alem Bašić MC Task: #100585 (Phase 0' ADR Consolidation — CountryPlugin interface) Supersedes: ADR-015 v1 (2026-05-11, MC #100362) — this is the authoritative version Cross-references:


1. Context

1.1 Current State (tool-verified 2026-05-11)

The Kotlin/Ktor backend (bilko-api-demo, Cloud Run, europe-north1) serves three brand hostnames (bilko.cloud, bilko.company, bilko.io) via a single runtime. ADR-023 established this as the deliberate transitional architecture. Market differentiation is currently handled by two mechanisms:

  1. ComplianceCalendarService.kt — manual when(organization.country) branching
  2. StorecoveHrFiskEInvoiceAdapter.kt — directly implements EInvoiceAdapter; no CountryPlugin wrapper exists

Neither mechanism is pluggable. Adding a fourth market requires editing shared service files. This violates the Open/Closed principle and creates unbounded audit surface.

Verified absence: find ... -name "CountryPlugin.kt" returns zero results (v3 plan §2). TaxJurisdiction.kt currently has: HR, RS, BA (BA conflates two distinct fiscal jurisdictions).

JWT reality (tool-verified): JwtService.kt embeds orgId in the JWT, NOT org.country. The org.country value is fetched from the organizations DB table via orgId in the request middleware. All DI wiring in this ADR reflects this two-step lookup.

1.2 Problem

Without a plugin abstraction:

1.3 BA Split Rationale

Bosnia-Herzegovina is not a single fiscal jurisdiction:

Dimension BA-FED BA-RS
Formal name Federacija BiH Republika Srpska entity
Tax authority UIO-FBiH (Uprava za indirektno oporezivanje FBiH) Poreska Uprava RS entity
E-invoice platform CPF (stub, mandatory ~2027) UINO (stub, mandatory TBD)
Filing pravilnik FBiH Pravilnik o kontnom okviru RS entity Pravilnik
Company identifier JIB (13 digits) JIB (13 digits)
PDV rate 17% standard, no reduced 17% standard, no reduced
Currency BAM BAM

A single PluginBA with internal branching reproduces the Variant B coupling problem (ADR-bilko-002 §3). The split is required.


2. Decision

2.1 TaxJurisdiction Enum — Canonical Form

package no.alai.bilko.country

/**
 * Canonical tax jurisdictions supported by Bilko.
 *
 * DB column constraint: CHECK country_code IN ('HR', 'RS', 'BA_FED', 'BA_RS')
 * NOTE: BA bare value is retained in the Kotlin enum during the V16 migration window
 * to allow backfill of existing DB rows. Remove BA after V16 validates on prod.
 *
 * See: ADR-015 §2.1, Plan v3 §6 Phase 1H.1
 */
enum class TaxJurisdiction {
    HR,     // Croatia — EUR, Storecove/Peppol via FINA AS4, PDV 25/13/5%
    RS,     // Serbia — RSD, SEF (Sistem e-faktura), PDV 20/10%
    BA,     // Bosnia bare value — DEPRECATED, retained for V16 backfill window only
    BA_FED, // Bosnia FBiH — BAM, CPF e-invoice (stub), UIO-FBiH, FBiH Pravilnik, PDV 17%
    BA_RS,  // Bosnia RS entity — BAM, UINO (stub), Poreska Uprava RS entity, PDV 17%
}

Migration note: Flyway V16 backfills BA → BA_FED rows, then adds the NOT NULL + CHECK constraint. BA is removed from the enum in a cleanup MC after V16 validates on prod.

2.2 CountryPlugin Interface — Full Contract

Written to apps/api/src/main/kotlin/no/alai/bilko/country/CountryPlugin.kt.

Interface invariant: Zero if jurisdiction == branches in core services (services/, routes/). All market differences are absorbed here.

package no.alai.bilko.country

import no.alai.bilko.einvoice.CanonicalInvoice
import no.alai.bilko.einvoice.EInvoiceAdapter
import java.util.Currency

/**
 * Per-jurisdiction plugin — single extension point for all market-specific behaviour.
 *
 * INVARIANT: No `if jurisdiction == X` or `when(jurisdiction)` branches in
 * apps/api/src/main/kotlin/no/alai/bilko/{services,routes}/.
 * All market differences are absorbed here. (ADR-bilko-002 Variant C)
 *
 * Implementations:
 *   PluginHR      → country/hr/PluginHR.kt          (Phase 1H — priority)
 *   PluginRS      → country/rs/PluginRS.kt           (stub, Phase 1S)
 *   PluginBAFED   → country/ba/PluginBAFED.kt        (stub, Phase 1B)
 *   PluginBARS    → country/ba/PluginBARS.kt         (stub, Phase 1B)
 *
 * DI: plugins/DI.kt registers all 4 in a Map<TaxJurisdiction, CountryPlugin>.
 * Resolution: orgId from JWT → DB lookup organizations.country → TaxJurisdiction.valueOf()
 * → PluginRegistry.resolve() (see ADR-015 §2.4 for full pipeline).
 */
interface CountryPlugin {

    /**
     * Returns the tax jurisdiction this plugin handles.
     * Used by PluginRegistry to route. Must be consistent with the plugin's
     * registration key in the DI map.
     */
    fun jurisdiction(): TaxJurisdiction

    /**
     * Calculates VAT breakdown for the given canonical invoice.
     *
     * Returns [VatResult] containing itemised tax lines per rate band.
     * Core invoice service calls this; NEVER inspects jurisdiction directly.
     *
     * HR: 25% (S — standard), 13% (AA — reduced-1), 5% (E — reduced-2), 0% (Z — zero/export)
     * RS: 20% (standard), 10% (reduced), 0% (export)
     * BA-FED / BA-RS: 17% (standard), 0% (export)
     *
     * @throws UnsupportedOperationException for stub implementations (RS, BA)
     */
    fun calculateVat(invoice: CanonicalInvoice): VatResult

    /**
     * Generates jurisdiction-specific e-invoice bytes from the canonical model.
     *
     * Delegates to the platform-specific [EInvoiceAdapter.serialize()] for this jurisdiction.
     * Returns the wire-format payload (UBL 2.1 XML Storecove envelope for HR; SEF XML for RS).
     * Contract: OFFLINE — no network, no credentials required.
     *
     * @throws UnsupportedOperationException for stub implementations
     */
    fun generateEInvoiceXml(invoice: CanonicalInvoice): ByteArray

    /**
     * Submits a previously serialized e-invoice to the fiscal platform.
     *
     * [receipt] bundles the serialized bytes from [generateEInvoiceXml] with the originating
     * [CanonicalInvoice] for idempotency key generation.
     * Returns [FiscalSubmissionHandle] with the platform submission ID.
     * Throws [no.alai.bilko.adapter.AdapterException] on all failure modes.
     *
     * HR lifecycle: STUB until MC #8675 (Storecove account activation).
     */
    fun submitToFiscalPlatform(receipt: FiscalReceipt): FiscalSubmissionHandle

    /**
     * Returns default Chart of Accounts entries for this jurisdiction.
     *
     * Called once on org creation to seed the tenant's account list with the
     * mandatory Pravilnik accounts. Company may add or rename — these are minimums.
     *
     * HR: FINA Kontni Plan (11-year retention)
     * RS: Serbian Pravilnik (10-year retention)
     * BA: FBiH / RS entity Pravilnik (10-year retention)
     */
    fun getChartOfAccountsDefaults(): List<ChartOfAccountEntry>

    /**
     * Returns filing deadline schedule for this jurisdiction.
     *
     * Returns a sorted list of [FilingDeadline] for the next 12 months from the call date.
     * Used by ComplianceCalendarService to populate per-org reminder schedules.
     *
     * HR: quarterly PDV return (last working day of month after quarter end) + annual CIT (30 April)
     * RS: monthly PDV return (within 15 days of month end)
     * BA: FBiH / RS entity PDV return schedules
     */
    fun getFilingDeadlines(): List<FilingDeadline>

    /**
     * Returns data retention policy for this jurisdiction.
     *
     * HR: 11 years — Zakon o računovodstvu NN 78/2015, čl. 10
     * RS: 10 years — Zakon o računovodstvu RS
     * BA-FED / BA-RS: 10 years
     *
     * Used by the document archiving service to set per-org retention periods and by
     * the RLS audit partition (ADR-017 Phase 2B).
     */
    fun getRetentionRules(): RetentionPolicy

    /**
     * Returns the functional currency for this jurisdiction.
     *
     * HR: Currency.getInstance("EUR")  — Croatia adopted EUR 2023-01-01
     * RS: Currency.getInstance("RSD")
     * BA-FED / BA-RS: Currency.getInstance("BAM")
     *
     * Core invoice service validates CanonicalInvoice.currencyCode against this on creation.
     */
    fun getCurrency(): Currency

    /**
     * Returns locale-specific formatters for this jurisdiction.
     *
     * HR: decimal='.', thousands=',', date='dd.MM.yyyy', tz='Europe/Zagreb'
     * RS: decimal=',', thousands='.', date='dd.MM.yyyy', tz='Europe/Belgrade'
     * BA: decimal=',', thousands='.', date='dd.MM.yyyy', tz='Europe/Sarajevo'
     *
     * Used by report generation, PDF invoices, and UI date/number display.
     */
    fun getFormatters(): JurisdictionFormatters

    /**
     * Extension hook for jurisdiction-specific validation beyond the standard 8 methods.
     *
     * Called by InvoiceService before invoice creation. Default implementation is a no-op;
     * override to add market-specific business rules (e.g., HR OIB cross-validation
     * against FINA company registry once APRCompanyRegistryAdapter is live).
     *
     * This hook is the designated extension point to avoid adding new required interface
     * methods for market-specific edge cases. See §3.2 for evolution contract.
     *
     * @param invoice draft canonical invoice before persistence
     * @throws no.alai.bilko.adapter.AdapterException with VALIDATION_BUSINESS_RULE if invalid
     */
    fun validateInvoiceForJurisdiction(invoice: CanonicalInvoice) {
        // Default: no-op. Override in PluginHR, PluginRS etc. as needed.
    }
}

2.3 Supporting Value Types

Defined in no.alai.bilko.country package (or no.alai.bilko.country.model):

// VAT calculation result
data class VatResult(
    val lines: List<VatLine>,
    val totalVatAmount: java.math.BigDecimal,
    val totalTaxableAmount: java.math.BigDecimal,
)

data class VatLine(
    val rate: java.math.BigDecimal,          // e.g. BigDecimal("25.0000")
    val category: no.alai.bilko.einvoice.TaxCategory,
    val taxableAmount: java.math.BigDecimal,
    val taxAmount: java.math.BigDecimal,
    val description: String,                 // Human-readable, e.g. "HR standard PDV 25%"
)

// Fiscal submission input
data class FiscalReceipt(
    val serializedInvoice: ByteArray,
    val canonicalInvoice: no.alai.bilko.einvoice.CanonicalInvoice,
)

data class FiscalSubmissionHandle(
    val platformInvoiceId: String,           // Storecove GUID, SEF ID, etc.
    val initialStatus: no.alai.bilko.einvoice.EInvoiceStatus,
    val submittedAt: java.time.Instant,
)

// Chart of Accounts entry
data class ChartOfAccountEntry(
    val code: String,                        // e.g. "1300" (HR) or "204" (RS)
    val name: String,
    val type: AccountType,                   // ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
    val vatTreatment: String?,
)

// Filing deadline
data class FilingDeadline(
    val name: String,                        // e.g. "Quarterly PDV return Q1 2026"
    val dueDate: java.time.LocalDate,
    val authority: String,                   // e.g. "Porezna uprava HR (ePorezna)"
    val periodStart: java.time.LocalDate,
    val periodEnd: java.time.LocalDate,
)

// Data retention
data class RetentionPolicy(
    val years: Int,                          // 10 or 11 depending on jurisdiction
    val legalBasis: String,                  // Statutory reference
    val jurisdiction: TaxJurisdiction,
)

// Formatters
data class JurisdictionFormatters(
    val decimalSeparator: Char,
    val thousandsSeparator: Char,
    val datePattern: String,                 // ISO strftime-compatible, e.g. "dd.MM.yyyy"
    val timeZoneId: String,                  // IANA tz, e.g. "Europe/Zagreb"
    val currencySymbol: String,
    val currencyPosition: CurrencyPosition,  // PREFIX or SUFFIX
)

enum class CurrencyPosition { PREFIX, SUFFIX }

2.4 DI Wiring Strategy

JWT reality: The JWT access token contains orgId only (verified in JwtService.kt lines 35–45). The org.country value is NOT embedded in the JWT. It is fetched from the organizations DB table at request time by middleware before the route handler runs.

Resolution pipeline:

HTTP request
    → JWT validation (JwtService.verifyAccessToken)
    → extract orgId from JWT claim "orgId"
    → DB: SELECT country FROM organizations WHERE id = orgId  (OrgScopePlugin / middleware)
    → TaxJurisdiction.valueOf(country)
    → PluginRegistry.resolve(jurisdiction)
    → CountryPlugin dispatch

DI registration in plugins/DI.kt:

// Phase 1H Task 1H.4
val pluginRegistry: Map<TaxJurisdiction, CountryPlugin> = mapOf(
    TaxJurisdiction.HR     to PluginHR(StorecoveHrFiskEInvoiceAdapter()),
    TaxJurisdiction.RS     to PluginRS(),      // stub — Phase 1S
    TaxJurisdiction.BA_FED to PluginBAFED(),   // stub — Phase 1B
    TaxJurisdiction.BA_RS  to PluginBARS(),    // stub — Phase 1B
)

// In Koin module:
single<Map<TaxJurisdiction, CountryPlugin>> { pluginRegistry }

// Resolution helper (usable from any Koin-injected service):
fun resolvePlugin(
    jurisdiction: TaxJurisdiction,
    registry: Map<TaxJurisdiction, CountryPlugin>
): CountryPlugin = registry[jurisdiction]
    ?: throw IllegalStateException(
        "No CountryPlugin registered for $jurisdiction — check DI.kt registration"
    )

Services that need a CountryPlugin receive it via constructor injection:

class InvoiceService(
    private val pluginRegistry: Map<TaxJurisdiction, CountryPlugin>
    // ... other deps
) {
    private fun plugin(org: Organization): CountryPlugin =
        resolvePlugin(TaxJurisdiction.valueOf(org.country), pluginRegistry)
}

2.5 OrgScopePlugin Sequencing Decision

Decision: CountryPlugin resolution runs AFTER OrgScopePlugin (org isolation middleware).

Rationale:

  1. Security gate must run first. OrgScopePlugin validates that the authenticated user belongs to the org being operated on and sets the app.current_org_id Postgres session variable for RLS PERMISSIVE enforcement (Phase 2A). This is a security boundary; no business logic should execute before it.

  2. CountryPlugin requires an authenticated, org-scoped context. Resolving a CountryPlugin requires reading organizations.country from DB, which in turn requires a verified orgId. OrgScopePlugin is what establishes and validates that orgId.

  3. Failure mode is clean. If OrgScopePlugin fails (user not in org, org not found), the request is rejected with 403 before CountryPlugin resolution is attempted. No country-specific logic runs on unauthenticated requests.

Execution order in the Ktor pipeline:

1. Authentication plugin (JWT validation)
2. OrgScopePlugin:
   a. Validate user.org_id matches the resource being accessed
   b. SET app.current_org_id = :orgId  (for RLS)
   c. Fetch org record → populate OrgContext (includes org.country)
3. CountryPlugin resolution:
   a. TaxJurisdiction.valueOf(orgContext.country)
   b. resolvePlugin(jurisdiction) → inject into route handler
4. Route handler executes with both OrgContext and CountryPlugin available

Parisa Tabriz (Securion) note: OrgScopePlugin must complete step 2b before any CountryPlugin method is called. This ensures the RLS session variable is set before any DB query inside the plugin executes. Violating this order creates a window where a CountryPlugin DB query runs without the RLS filter active.

2.6 TypeScript Packages — Separate Concern

The five TypeScript packages (packages/domain-rs, packages/domain-hr, packages/domain-ba, packages/domain-ba-fed, packages/domain-ba-rs) contain frontend domain types compiled to dist/. They are not loaded by the Kotlin runtime and are not in scope for this ADR.

The TaxJurisdiction enum values must remain consistent between the Kotlin enum and any TypeScript enums in these packages (same string values: "HR", "RS", "BA_FED", "BA_RS"). That alignment is enforced at the API boundary (JWT claim and REST API JSON) — not via a shared runtime dependency.

Backwards compatibility rule: if TaxJurisdiction gains a new value (e.g., SI for Slovenia), the corresponding TypeScript packages must be updated in the same PR. This is a documentation constraint, not a compile-time enforcement.


3. Enforcement

3.1 Linting Rule

A custom Detekt rule must reject any file in apps/api/src/main/kotlin/no/alai/bilko/{services,routes}/ that contains patterns:

This rule is a Phase 1H CI gate. It runs before any Phase 1H code merges to main. The rule is not applied to country/ package itself (plugin implementations may internally branch on jurisdiction during their own construction if absolutely necessary).

3.2 Interface Evolution Contract

When a new method must be added to CountryPlugin:

  1. Prefer the extension hook (validateInvoiceForJurisdiction) for market-specific validation that does not generalise across all markets.
  2. If a new method is genuinely cross-market: add it with a default body that throws UnsupportedOperationException("Not implemented for $jurisdiction — see MC #XXXX").
  3. Override in PluginHR (priority market) first; other plugins follow in their phase.
  4. Default throws surface as clear runtime errors, not silent wrong behaviour.

4. Implementation Path

Phase Task Files Status
Phase 0' This ADR docs/architecture/ADR-015-...md DONE
Phase 1H.1 TaxJurisdiction expanded {HR,RS,BA,BA_FED,BA_RS} TaxJurisdiction.kt Blocked by 0'
Phase 1H.1 CountryPlugin.kt interface + supporting types written country/CountryPlugin.kt (NEW) Blocked by 0'
Phase 1H.2 PluginHR implemented (9 methods + hook) country/hr/PluginHR.kt (NEW) Blocked by 1H.1
Phase 1H.3 PluginRS, PluginBAFED, PluginBARS stubs country/{rs,ba}/Plugin*.kt Blocked by 1H.1
Phase 1H.4 DI registration; OrgScopePlugin order enforced plugins/DI.kt Blocked by 1H.2+3
Phase 1H.5 Flyway V16 — backfill BA→BA_FED, add NOT NULL + CHECK V16__country_jurisdiction_constraint.sql Blocked by 0'3
Phase 1S PluginRS fully implemented country/rs/PluginRS.kt Post-HR GA
Phase 1B PluginBAFED, PluginBARS implemented country/ba/Plugin*.kt Post-RS GA

5. Consequences

5.1 Positive

5.2 Negative

5.3 Risks


6. References

Reference Path Lines Referenced
TaxJurisdiction.kt (current) apps/api/src/main/kotlin/no/alai/bilko/country/TaxJurisdiction.kt 1–23
JwtService.kt (JWT claims — orgId only) apps/api/src/main/kotlin/no/alai/bilko/auth/JwtService.kt 35–45
BilkoPrincipal.kt apps/api/src/main/kotlin/no/alai/bilko/auth/BilkoPrincipal.kt 1–10
EInvoiceAdapter interface apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 200–224
StorecoveHrFiskEInvoiceAdapter.kt (HR reference) apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 537–777
DI.kt (current Koin module — no country plugin yet) apps/api/src/main/kotlin/no/alai/bilko/plugins/DI.kt 1–67
Plan v3 §2 current state truth ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 28–73
Plan v3 §4a (Option D not triggered) ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 100–119
Plan v3 §4b (Phase 0 ADR scope) ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 121–133
Plan v3 §6 Phase 0' Task 0'1 ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 246–255

7. Approval

Status: Accepted — no CEO sign required (architecture contract, not data migration)

Unblocks:

Role Sign Date
Architecture Lead (Petter Graff) Signed 2026-05-13
CEO (Alem Bašić) Not required for interface ADR

8. Document History

Date Author Change
2026-05-11 Petter Graff v1 — Phase 0' initial (MC #100362)
2026-05-13 Petter Graff v2 — MC #100585: OrgScopePlugin sequencing decision; JWT reality (orgId, not country claim); extension hook validateInvoiceForJurisdiction; TypeScript packages backwards-compat section; DI wiring corrected to reflect actual JwtService contract

ADR-016 — EInvoiceAdapter Lifecycle and Contract

ADR-016 — EInvoiceAdapter Lifecycle and Contract

Status: Accepted Date: 2026-05-13 Author: Petter Graff (CodeCraft — Architecture Lead) Finverge Co-author: Markos Zachariadis (Payments & Fiscal Integration) Decision-maker: CEO Alem Bašić MC Task: #100585 (Phase 0' ADR Consolidation — EInvoiceAdapter lifecycle) Supersedes: ADR-016 v1 (2026-05-11, MC #100362) — this is the authoritative version Cross-references:


0. Addendum — HR Provider Pivot: Storecove Abandoned, sveRačun (PostLink) Adopted (2026-06-11)

This section documents a decision that supersedes the HR row of §1.1 below, which was never updated in place.


1. Context

1.1 The Four-Platform Problem

Bilko targets four tax jurisdictions with four incompatible e-invoice fiscal platforms:

Market Platform Transport Format Status
HR HR-FISK / FINA via Storecove Peppol AS4 UBL 2.1 + HR CIUS STUB (MC #8675)
RS SEF (efaktura.gov.rs) REST API SEF XML (Serbian-specific) Phase 1S
BA-FED CPF (Centralna platforma za fakture) TBD ~2027 TBD Phase 1B
BA-RS UINO (stub name) TBD TBD Phase 1B

Without a canonical abstraction, each platform's integration detail bleeds into the core invoice service — reproducing the Variant B coupling problem (ADR-bilko-002 §3).

1.2 Existing Types on Disk (verified 2026-05-11)

EInvoiceTypes.kt already defines (lines 1–224):

AdapterTypes.kt (in no.alai.bilko.adapter) defines:

The EInvoiceAdapter interface and lifecycle states exist but are not formally documented. StorecoveHrFiskEInvoiceAdapter implements the interface — serialize() is fully operational offline; all other methods throw NOT_IMPLEMENTED. This ADR formalises the contract and lifecycle governance.


2. Decision

2.1 EInvoiceAdapter Interface — Formal Contract

Defined in apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt lines 200–224. Reproduced here as the normative specification with full contract annotations:

interface EInvoiceAdapter {
    val jurisdiction: TaxJurisdiction
    val lifecycleState: AdapterLifecycleState

    /**
     * Serialize a canonical invoice to the adapter-specific wire format.
     *
     * CONTRACT:
     * - MUST be offline-capable — no network, no credentials required.
     * - MUST be deterministic: same [invoice] input produces identical bytes.
     * - MUST NOT log raw PII fields (OIB, IBAN, document_data) — call sanitizeForLog().
     * - Returns the full wire-format payload for the platform:
     *     HR: Storecove JSON envelope wrapping UBL 2.1 XML
     *     RS: SEF XML (Serbian Ministry of Finance schema)
     *     BA: CPF/UINO platform format (TBD)
     * - Throws AdapterException(VALIDATION_BUSINESS_RULE) for constraint violations
     *   (non-EUR currency for HR, invalid OIB, empty lines, etc.)
     * - AdapterConfig.enabled check is NOT performed here — callers check before invoking.
     * - This method is ALWAYS available, even in STUB lifecycle.
     */
    fun serialize(invoice: CanonicalInvoice): ByteArray

    /**
     * Submit the serialized invoice bytes to the fiscal platform.
     *
     * CONTRACT:
     * - Requires live credentials (API key, OAuth token, or certificate).
     * - MUST include an idempotency key (platform-specific — see §2.3).
     * - Returns SubmitResult on success; throws AdapterException on ALL failures.
     * - NEVER propagates platform-native exceptions (Ktor ResponseException, etc.) —
     *   map every platform exception to AdapterException before propagating.
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5).
     * - Idempotency: platforms may return 409 DUPLICATE on re-submission.
     *   Caller should treat 409 as success — extract submission ID from error body.
     *
     * @param serializedInvoice bytes from serialize()
     * @param invoice original CanonicalInvoice (needed for idempotency key generation)
     */
    fun submit(serializedInvoice: ByteArray, invoice: CanonicalInvoice): SubmitResult

    /**
     * Poll the fiscal platform for the current status of a submitted invoice.
     *
     * CONTRACT:
     * - [submissionId] is SubmitResult.platformInvoiceId from submit().
     * - Returns current EInvoiceStatus.
     * - This method is IDEMPOTENT — safe to call multiple times with the same submissionId.
     * - Callers implement exponential backoff; this method does NOT retry internally.
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5).
     * - NEVER log rawPayload without sanitizeForLog().
     */
    fun pollStatus(submissionId: String, invoice: CanonicalInvoice): EInvoiceStatus

    /**
     * Parse an inbound invoice from a raw fiscal platform webhook payload.
     *
     * CONTRACT:
     * - [rawPayload] is the raw bytes from the platform webhook (Storecove POST, SEF callback).
     * - Returns CanonicalInvoice with adapterMetadata populated for platform-specific fields:
     *     HR: "hr.supplierOib", "hr.buyerOib", "hr.pozivNaBroj"
     *     RS: "rs.supplierPib", "rs.buyerPib", "rs.sefId"
     * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5).
     * - NEVER log rawPayload before passing through sanitizeForLog().
     * - parseIncoming() is deferred for HR: not required for v1 HR GA (Phase 1H.6 scope).
     *   Implement 90 days post-GA (see Plan v3 §4d).
     */
    fun parseIncoming(rawPayload: ByteArray): CanonicalInvoice
}

2.2 CanonicalInvoice — EN 16931 Subset

The internal invoice representation, independent of any platform wire format. Defined in EInvoiceTypes.kt lines 141–156:

data class CanonicalInvoice(
    val id: String,                          // Internal UUID — Storecove document_id (D2 dedup)
    val invoiceNumber: String,               // BT-1: human-readable invoice number
    val issueDate: LocalDate,                // BT-2
    val dueDate: LocalDate,                  // BT-9
    val typeCode: InvoiceTypeCode,           // BT-3: UNTDID 1001 (380/381/383/384)
    val currencyCode: String,                // BT-5: ISO 4217 ("EUR", "RSD", "BAM")
    val jurisdiction: TaxJurisdiction,       // Routing discriminator (non-EN16931)
    val supplier: PartyInfo,                 // BG-4: name, taxId (OIB/PIB/JIB), address
    val buyer: PartyInfo,                    // BG-7: same structure
    val lines: List<InvoiceLine>,            // BG-25: quantity, unitPrice, lineTotal, taxRate
    val taxBreakdowns: List<TaxBreakdown>,   // BG-23: one entry per rate band
    val paymentMeans: PaymentMeans? = null,  // BG-16: paymentMeansCode, IBAN, reference
    val note: String? = null,                // BT-22: free text note
    val adapterMetadata: Map<String, String> = emptyMap(), // platform-specific extras
)

Field constraints:

Field Constraint Enforced in
currencyCode "EUR" for HR (HALT-3 — Croatia adopted EUR 2023-01-01) serialize() HR
supplier.taxId OIB (HR, 11-digit ISO 7064 MOD 11,10) / PIB (RS, 9-digit) / JIB (BA, 13-digit) serialize() per market
lines Non-empty — EN 16931 §BG-25 minimum one line serialize()
taxBreakdowns Must sum to lines.(taxRate * lineTotal) — tolerance 0.01 InvoiceService
adapterMetadata HR inbound: hr.supplierOib, hr.buyerOib, hr.pozivNaBroj parseIncoming()

What CanonicalInvoice is NOT:

2.3 Adapter Lifecycle State Machine

Defined in EInvoiceTypes.kt lines 22–26. Transition criteria formalised here:

STUB
  │ Compiles. All 3 network methods throw NOT_IMPLEMENTED.
  │ serialize() MAY be operational (HR: already works offline).
  │ AdapterConfig row not required.
  │
  │ Transition criteria → SANDBOX_VERIFIED:
  │   1. Provider account provisioned (MC #8675 for HR/Storecove)
  │   2. Credentials loaded in GCP Secret Manager (see §2.6 secret taxonomy)
  │   3. 5 sandbox test invoice types pass with REAL platform submission IDs (§2.4)
  │   4. pollStatus() confirmed for each submitted invoice
  │   5. Proveo evidence file with submission IDs uploaded to BookStack
  │   6. lifecycleState field updated to SANDBOX_VERIFIED in adapter source
  │
  ▼
SANDBOX_VERIFIED
  │ All 4 methods operational against provider sandbox.
  │ AdapterConfig(market, EINVOICE, enabled=true) in STAGE DB.
  │
  │ Transition criteria → PRODUCTION:
  │   1. Securion audit: adapter error handling + PII sanitization (see §2.7)
  │   2. 30 continuous days on STAGE Cloud Run with zero
  │      AdapterErrorCode.PLATFORM_INTERNAL_ERROR alerts
  │      (Prometheus metric: bilko_integration_request_total)
  │   3. AdapterConfig(market, EINVOICE, enabled=true) in PRODUCTION DB
  │   4. CEO sign-off (this is the go-live gate)
  │
  ▼
PRODUCTION
  │ Live. All 4 methods operational against production platform.
  │ Incident response: if critical error rate > 5% over 15min window,
  │   automated alert → Slack #bilko-incidents → human decision to flip
  │   AdapterConfig.enabled = false (no redeploy needed).

Current HR state (2026-05-13): STUB

2.4 HR-FISK Storecove Sandbox Validation Matrix

5 invoice types required for SANDBOX_VERIFIED transition. All must produce real Storecove submission GUIDs (not mock strings). Proveo (Angie Jones) runs these tests.

# Invoice Type UNTDID Code Scenario Expected Storecove Response Evidence Required
1 B2B outbound commercial 380 Supplier OIB + Buyer OIB both valid. EUR. 25% PDV. Standard commercial transaction. HTTP 200 + {"id": "<guid>", "status": "pending"} Storecove submission GUID in evidence file
2 B2G outbound 380 Buyer is HR government entity (OIB format same). PaymentMeans.paymentMeansCode=30. HTTP 200 + GUID GUID + Storecove routing.peppol.id verified as buyer OIB
3 Credit note 381 References original invoice number in note field. Negative line totals. HTTP 200 + GUID GUID + typeCode=381 confirmed in Storecove portal
4 Cancelled invoice 384 CORRECTIVE_INVOICE type. Status flow: submit → pollStatus until APPROVED or REJECTED HTTP 200 + GUID, then pollStatus APPROVED/REJECTED GUID + final status confirmed
5 Inbound received 380 (inbound) Storecove sends test webhook to Bilko's webhook endpoint. parseIncoming() invoked. Webhook received. CanonicalInvoice returned. hr.supplierOib in adapterMetadata. Log entry showing successful parse + extracted OIB value

HR-specific validation rules verified in each test case:

Storecove-specific notes:

2.5 NOT_IMPLEMENTED Transition Rules

AdapterErrorCode.NOT_IMPLEMENTED is the canonical error code for STUB lifecycle methods. Rules for callers and implementers:

Implementer rules:

  1. Any STUB lifecycle method that is not yet operational MUST throw:
    throw AdapterException(
        code = AdapterErrorCode.NOT_IMPLEMENTED,
        market = jurisdiction,
        retryable = false,
        rawPayload = "",
        message = "<Platform> <method> requires account — MC #<id>"
    )
    
  2. serialize() is EXEMPT from the NOT_IMPLEMENTED requirement — it SHOULD be operational even in STUB lifecycle because it needs no credentials (offline contract).
  3. Once an implementation moves to SANDBOX_VERIFIED, no method may throw NOT_IMPLEMENTED for the sandbox environment. If a method is genuinely deferred (e.g., parseIncoming() for HR v1), the lifecycle state must remain STUB until all 4 methods are operational. Exception: parseIncoming() for HR is formally deferred to 90 days post-GA per Plan v3 §4d. The HR adapter will hold a partial SANDBOX_VERIFIED state tracked by the AdapterConfig feature flag with reason = "parseIncoming deferred — Phase 1H.6".

Caller rules:

  1. Before calling submit() or pollStatus(), callers MUST check:
    val config = adapterConfigRepo.find(jurisdiction, "EINVOICE")
        ?: throw AdapterException(NOT_IMPLEMENTED, ...)
    if (!config.enabled) throw AdapterException(NOT_IMPLEMENTED, ..., message="Adapter disabled: ${config.reason}")
    
  2. NOT_IMPLEMENTED caught at the route handler level maps to HTTP 503 (Service Unavailable) with body {"error": "ADAPTER_NOT_AVAILABLE", "market": "<jurisdiction>"}, NOT HTTP 500. This is the stub plugin HTTP 500 risk mitigation from ADR-015 §5.3.
  3. serialize() callers do NOT need to check AdapterConfig — serialize is always available.

Error code precedence when multiple codes could apply:

NOT_IMPLEMENTED > AUTH_INVALID_CREDENTIALS > VALIDATION_BUSINESS_RULE > NETWORK_TIMEOUT

If a STUB adapter is also missing credentials, NOT_IMPLEMENTED takes precedence. Lifecycle state check happens before credential check.

2.6 Secret Management — GCP Secret Manager Taxonomy

All adapter credentials follow the taxonomy defined in ADR-019 §2.5:

Bilko/{env}/{market}/{secret-name}

HR Storecove secrets (provision after MC #8675):

GCP Secret Manager path Content Access binding
Bilko/stage/HR/storecove-api-key Storecove sandbox API key Cloud Run SA bilko-stage-sa
Bilko/prod/HR/storecove-api-key Storecove production API key Cloud Run SA bilko-prod-sa
Bilko/stage/HR/storecove-legal-entity-id Storecove legal entity ID (sandbox) Cloud Run SA bilko-stage-sa
Bilko/prod/HR/storecove-legal-entity-id Storecove legal entity ID (prod) Cloud Run SA bilko-prod-sa

Mounting in Cloud Run:

# gcp-deploy.yml (Cloud Run --set-secrets pattern):
--set-secrets="STORECOVE_API_KEY=Bilko/stage/HR/storecove-api-key:latest,\
STORECOVE_LEGAL_ENTITY_ID=Bilko/stage/HR/storecove-legal-entity-id:latest"

Env var naming convention: <PLATFORM>_<FIELD>, uppercase, underscores. Accessed in StorecoveApiClient via System.getenv("STORECOVE_API_KEY").

Secret rotation policy:

Never in source code or logs: API keys, legal entity IDs, OIB values, IBAN values. StorecoveHrFiskEInvoiceAdapter.sanitizeForLog() must be called on all Storecove response bodies before logging.

RS future secrets (Phase 1S):

GCP Secret Manager path Content
Bilko/stage/RS/sef-api-key SEF sandbox access token
Bilko/prod/RS/sef-api-key SEF production access token
Bilko/stage/RS/sef-username SEF API username
Bilko/prod/RS/sef-username SEF API username (prod)

SEF uses OAuth2 with client credentials. The token endpoint is https://efaktura.mfin.gov.rs/ (Serbian Ministry of Finance). Exact credentials shape to be confirmed at Phase 1S kickoff.

2.7 Per-Platform Field Mapping

How CanonicalInvoice fields map to platform-specific XML/JSON:

CanonicalInvoice field HR (UBL 2.1 / Peppol) RS (SEF XML) BA-FED BA-RS
supplier.taxId AccountingSupplierParty/.../CompanyID @schemeID="9934" (OIB) /Invoice/Seller/TaxId (PIB) TBD TBD
buyer.taxId AccountingCustomerParty/.../CompanyID @schemeID="9934" (OIB) /Invoice/Buyer/TaxId (PIB) TBD TBD
invoiceNumber cbc:ID /Invoice/InvoiceNumber TBD TBD
issueDate cbc:IssueDate (ISO 8601) /Invoice/IssueDate TBD TBD
typeCode.untdidCode cbc:InvoiceTypeCode (380/381/384) /Invoice/InvoiceType TBD TBD
currencyCode cbc:DocumentCurrencyCode + @currencyID on all amounts /Invoice/Currency TBD TBD
taxBreakdowns[].taxRate TaxSubtotal/TaxCategory/Percent /Invoice/TaxTotal/TaxRate TBD TBD
taxBreakdowns[].taxCategory TaxSubtotal/TaxCategory/ID (S/Z/E/K per EN 16931 BT-118) Serbian code set TBD TBD
paymentMeans.paymentReference PaymentMeans/PaymentID (HR "Poziv na broj") /Invoice/PaymentReference TBD TBD
paymentMeans.iban PayeeFinancialAccount/ID /Invoice/BankAccount/IBAN TBD TBD
adapterMetadata "hr.supplierOib", "hr.buyerOib", "hr.pozivNaBroj" (inbound) "rs.sefId", "rs.supplierPib" TBD TBD

SEF XML note: SEF does not use UBL 2.1. It uses a Serbian-specific XML schema published by the Ministry of Finance. The SEF adapter maps CanonicalInvoice → SEF schema directly; it does NOT go through UBL. EInvoiceAdapter.serialize() returns the platform's native format.

BA adapters (Phase 1B): CPF and UINO platforms have no published API specifications as of 2026-05-13. Phase 1B cannot begin until regulatory mandates define the technical specification (~2027 per plan v3 context).

2.8 HR Reference Implementation Design Decisions

StorecoveHrFiskEInvoiceAdapter is the reference implementation. Future adapters MUST replicate these patterns:

Design decision Location in reference impl Rule for future adapters
PII field redaction before logging Lines 24–59 (REDACT_PII_FIELDS, sanitizeForLog) REQUIRED — GDPR / audit rules
Offline serialization (no credentials) Lines 567–571 (serialize()) REQUIRED per §2.1 contract
Idempotency key (SHA-256 of id + invoiceNumber) Lines 591–600 (stub comment — activate post-#8675) REQUIRED if platform supports
Credential validation on startup flag Lines 83–138 (validateOnStartup, validate()) REQUIRED — default false for tests
Error code mapping to AdapterException Lines 469–515 (StorecoveErrorMapper) REQUIRED — NEVER propagate native
Structured metrics recording (StorecoveMetrics) Lines 537–540 REQUIRED — Prometheus counters
Tax ID format validation in serialize() Lines 748–774 (OIB check) REQUIRED — early error, no network
document_id for deduplication Lines 420–437 (StorecovePayloadBuilder.wrap()) REQUIRED if platform supports 409

3. Adapter Lifecycle Governance

3.1 AdapterConfig Feature Flag

All adapter network paths (submit, pollStatus, parseIncoming) are gated by an AdapterConfig row in the database. Defined fully in ADR-019 §2.4; referenced here:

CREATE TABLE adapter_config (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    market       VARCHAR(8) NOT NULL,   -- TaxJurisdiction enum value
    adapter_type VARCHAR(32) NOT NULL,  -- 'EINVOICE', 'BANK_STATEMENT', etc.
    enabled      BOOLEAN NOT NULL DEFAULT FALSE,
    reason       TEXT,                  -- Human-readable status note
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (market, adapter_type)
);

Seed row for HR STUB state (Flyway V17):

INSERT INTO adapter_config (market, adapter_type, enabled, reason)
VALUES ('HR', 'EINVOICE', false, 'Storecove account pending — MC #8675');

Row is flipped to enabled = true by the operator (not by code) after SANDBOX_VERIFIED transition is confirmed by Proveo evidence.

3.2 Adapter Versioning

Each adapter exposes:

val adapterVersion: String  // e.g. "1.0.0"

The CountryPlugin implementation declares a minimum adapter version. Incompatibility detected at startup → application fails fast with a clear error (not silent degradation).


4. Consequences

4.1 Positive

4.2 Negative

4.3 Risks


5. References

Reference Path Lines
EInvoiceAdapter interface apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 200–224
CanonicalInvoice definition apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 141–156
AdapterLifecycleState enum apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 22–26
AdapterErrorCode enum + AdapterException apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterTypes.kt 1–41
HR reference impl — full file apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 1–777
StorecoveMetrics (Micrometer counters) apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveMetrics.kt 1–73
StorecoveApiClient (credentials + base URL) StorecoveHrFiskEInvoiceAdapter.kt 77–179
StorecoveOibValidator (ISO 7064 MOD 11,10) StorecoveHrFiskEInvoiceAdapter.kt 194–225
StorecoveErrorMapper (HTTP → AdapterErrorCode) StorecoveHrFiskEInvoiceAdapter.kt 469–515
PII sanitize helper (sanitizeForLog) StorecoveHrFiskEInvoiceAdapter.kt 24–59
HrUblBuilder (UBL 2.1 offline build) StorecoveHrFiskEInvoiceAdapter.kt 241–387
StorecovePayloadBuilder (wrap JSON + dedup D2) StorecoveHrFiskEInvoiceAdapter.kt 418–450
ADR-019 §2.4 (AdapterConfig table) docs/architecture/ADR-019-INTEGRATION-ADAPTER-REGISTRY.md §2.4
Plan v3 §4d HR critical path (sandbox verification) ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 147–176
Plan v3 §4b ADR-016 requirement ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 125–126
ADR-bilko-003 §Layer 2 (EInvoice serialization) ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md 103–117

6. Approval

Status: Accepted

Unblocks:

Role Sign Date
Finverge — Markos Zachariadis Signed 2026-05-13
Architecture Lead (Petter Graff) Signed 2026-05-13
CEO (Alem Bašić) Not required for contract ADR

7. Document History

Date Author Change
2026-05-11 Markos Zachariadis / Petter Graff v1 — Phase 0' initial (MC #100362)
2026-05-13 Petter Graff v2 — MC #100585: Full lifecycle state machine with explicit transition criteria; sandbox validation matrix (5 invoice types for HR-FISK Storecove); NOT_IMPLEMENTED transition rules; GCP Secret Manager taxonomy with HR+RS secret paths; HTTP 503 mapping for NOT_IMPLEMENTED; HALT items D3/D4 documented; StorecoveMetrics and StorecoveApiClient cited explicitly

ADR-017 — RLS Multi-Tenancy Migration

ADR-017 — RLS Multi-Tenancy Migration

Status: Accepted — CEO Signed 2026-05-11 (Alem Bašić). Phase 2A V17 Flyway PERMISSIVE migration authorized for stage execution. Phase 2C RESTRICTIVE flip remains gated on Securion audit + 30-day soak per §4 schedule. Date: 2026-05-11 Author: Bruce Momjian (Database Architecture, CodeCraft) Architecture Review: Petter Graff (CodeCraft) Decision-maker: CEO Alem Bašić — SIGNED 2026-05-11 ("ok adr17 odobreno") via session f73dafab Mehanik clearance: /tmp/mehanik-cleared-100362 MC Task: #100362 (Phase 0' ADR Consolidation) Promoted from: ADR-bilko-001 draft (~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-001-multi-tenant-architecture.md) Cross-references:


1. Context

1.1 Current DB State (tool-verified 2026-05-11)

Component State
Database bilko-demo-db, Cloud SQL PostgreSQL 15, europe-north1
Flyway migrations V1..V15 applied
Row-Level Security NOT enabled — zero RLS policies on any table
Tenant isolation Application-layer only: WHERE org_id = :principalOrgId clauses
organizations.country Column exists; values 'RS', 'HR', 'BA'; NOT NULL constraint absent
Cross-tenant leak Confirmed: PUT /api/v1/invoices/{id} and GET /api/v1/invoices/{id}/pdf with cross-tenant JWT return HTTP 500 (test drift memo 2026-05-10, Round 12.1/12.5)

The current application-layer scoping (ADR-005) is the sole isolation mechanism. A single missing WHERE org_id clause in any new route — or a refactoring that silently drops it — is a cross-tenant data exposure. This is not theoretical: Round 12 probes confirmed it in two existing routes.

1.2 Why Single Database Remains Correct (ADR-023 §6 Check)

ADR-023 §6 defines the conditions that would trigger migration to Option D (per-country DBs). All five conditions are unmet as of 2026-05-11 (Plan v3 §4a lines 100–108):

Option D costs +$60/month infra and 2–4 weeks engineering per market with no customer-facing benefit today. This ADR is explicitly compatible with Option D migration — RLS policies are portable to separate databases. If Option D triggers, the same policy DDL applies to each per-country DB with zero changes.

1.3 Why RLS Cannot Wait Until Post-HR GA

Plan v3 §4c (lines 135–145): the cross-tenant 500 leaks are a live security defect. With 0 paying customers today it is unexploited — but a second registered organization (required for HR demo) creates an immediately exploitable state.

RLS PERMISSIVE mode (Phase 2A) imposes zero user-facing change and zero risk of service disruption. The existing WHERE org_id middleware still fires, and RLS fires alongside it. Both must pass for data to be returned. A latent policy gap is caught by the application layer rather than exposing data to the wrong tenant.

CEO sign is required before Phase 2A Flyway migrations run on stage — not before this ADR document is accepted. The ADR records the decision; the sign unblocks execution.


2. Decision

Option C is adopted: Shared codebase, shared deployment, shared database, with PostgreSQL Row-Level Security enforcing tenant isolation.

This is the unanimous recommendation from the 5-agent architecture review (ADR-bilko-001 §framing, line 28–30). One codebase. One Cloud Run deployment. One PostgreSQL instance with RLS.

2.1 Binding Constraints

  1. Organization.taxJurisdiction (TaxJurisdiction enum {HR, RS, BA_FED, BA_RS} per ADR-015) is the primary discriminator for jurisdiction-specific behaviour.
  2. Organization.id (UUID) is the primary tenant discriminator for data isolation.
  3. RLS policies enforce data isolation at the database layer. Application code MUST NOT rely solely on WHERE org_id = :id clauses (ADR-005 flaw — being retired by Phase 2C).
  4. The country_code column on organizations is NOT NULL with CHECK constraint IN ('HR', 'RS', 'BA_FED', 'BA_RS') — enforced by Flyway V16 (Phase 1H Task 1H.1).
  5. EU data residency: Current bilko-demo-db is in Cloud SQL europe-north1 (Finland). This IS within EU/EEA — GDPR Article 44 satisfied. Frankfurt migration (eu-central-1) is not required to unblock HR GA (Plan v3 §4d lines 179–183).

2.2 Three-Phase Migration Path

The migration is split into three phases to ensure zero service disruption and a safe rollback path at each step.

Phase 2A — PERMISSIVE RLS (parallel with Phase 1H, target: end of Week 2)

Goal: RLS policies created and attached, set to PERMISSIVE. Existing application-layer scoping continues to operate. Both layers must pass — RLS is a second check, not a replacement.

Who signs this off: CEO Alem Bašić (this ADR signature) — required before any Phase 2A Flyway migrations run on the stage database.

DDL — PERMISSIVE policies (Flyway V17):

-- V17__rls_permissive.sql
-- ZAKON: CEO sign required before this migration runs on stage.
-- Apply PERMISSIVE RLS on core tables. Application-layer WHERE org_id
-- clauses remain active. Both must pass.

-- Enable RLS on tables
ALTER TABLE organizations       ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices            ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice_items       ENABLE ROW LEVEL SECURITY;
ALTER TABLE expenses            ENABLE ROW LEVEL SECURITY;
ALTER TABLE transactions        ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions   ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts       ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounts            ENABLE ROW LEVEL SECURITY;
ALTER TABLE contacts            ENABLE ROW LEVEL SECURITY;

-- PERMISSIVE policy: organization-scoped isolation
-- current_setting() reads the app.current_org_id session variable
-- set by the Ktor connection pool before each query (connection middleware).

CREATE POLICY org_isolation ON invoices
    AS PERMISSIVE
    FOR ALL
    TO bilko_app              -- application role (NOT superuser)
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON invoice_items
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (
        invoice_id IN (
            SELECT id FROM invoices
            WHERE org_id = current_setting('app.current_org_id')::uuid
        )
    );

CREATE POLICY org_isolation ON expenses
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON transactions
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON bank_transactions
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (
        bank_account_id IN (
            SELECT id FROM bank_accounts
            WHERE org_id = current_setting('app.current_org_id')::uuid
        )
    );

CREATE POLICY org_isolation ON bank_accounts
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON accounts
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

CREATE POLICY org_isolation ON contacts
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

-- BYPASS for migrations and admin tooling (Flyway runs as bilko_admin)
ALTER TABLE invoices         FORCE ROW LEVEL SECURITY;
ALTER TABLE expenses         FORCE ROW LEVEL SECURITY;
ALTER TABLE transactions     FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts    FORCE ROW LEVEL SECURITY;
ALTER TABLE accounts         FORCE ROW LEVEL SECURITY;
ALTER TABLE contacts         FORCE ROW LEVEL SECURITY;
-- Flyway runs as bilko_admin (superuser bypasses RLS by default).
-- Explicit FORCE is belt-and-suspenders — admin role grants BYPASSRLS if needed.

-- Set connection middleware (Kotlin Exposed / HikariCP):
-- On each connection checkout:
--   SET LOCAL app.current_org_id = '<org_uuid_from_jwt>';
-- On connection return to pool:
--   SET LOCAL app.current_org_id = '';  -- or reset_config('app.current_org_id', true)

Verification after Phase 2A:

-- Rogue-role test (Proveo E2E + Securion audit):
SET ROLE bilko_app;
SET LOCAL app.current_org_id = '<hr_org_uuid>';
SELECT count(*) FROM invoices;  -- must return only HR org rows
SET LOCAL app.current_org_id = '<rs_org_uuid>';
SELECT count(*) FROM invoices;  -- must return only RS org rows
-- Cross-tenant access attempt:
SET LOCAL app.current_org_id = '<hr_org_uuid>';
SELECT * FROM invoices WHERE org_id = '<rs_org_uuid>';  -- must return 0 rows (PERMISSIVE blocks)

Phase 2B — Audit Log Partitioning (post-HR GA)

Goal: Partition the logged_actions audit table by country_code to enable per-jurisdiction GDPR data extraction requests and enforce per-jurisdiction retention.

-- V36__audit_log_partitioning.sql (Phase 2B — post-HR GA)
-- NOTE: V18/V19 slots are taken by enum migrations (V18__create_invoice_status_enum,
-- V19__create_expense_status_enum). V30/V30.1/V31/V32/V33/V34/V35 are also taken
-- by deployed auth/RLS/demo corrective migrations. As of 2026-05-20 the next planned
-- Phase 2B/2C slots are V36/V37. Re-check Flyway history before implementation;
-- if a later migration has consumed these slots, use the next free versions and
-- update this ADR in the same PR. Flyway forbids renumbering applied migrations.

-- Declarative partitioning by country_code
CREATE TABLE logged_actions_partitioned (
    LIKE logged_actions INCLUDING ALL
) PARTITION BY LIST (country_code);

CREATE TABLE logged_actions_hr     PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('HR');
CREATE TABLE logged_actions_rs     PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('RS');
CREATE TABLE logged_actions_ba_fed PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('BA_FED');
CREATE TABLE logged_actions_ba_rs  PARTITION OF logged_actions_partitioned
    FOR VALUES IN ('BA_RS');

-- Retention policy enforcement (aligned with CountryPlugin.getRetentionRules()):
-- HR: 11 years (Zakon o računovodstvu NN 78/2015, čl. 10)
-- RS/BA: 10 years
-- Implemented as pg_cron job deleting rows WHERE action_tstamp_tx < now() - interval '11 years'
-- per partition.

-- country_code column backfilled from organizations.country via:
-- UPDATE logged_actions SET country_code = o.country
-- FROM organizations o WHERE o.id = logged_actions.org_id;

RLS policy for logged_actions (applied in Phase 2B):

CREATE POLICY org_isolation ON logged_actions_partitioned
    AS PERMISSIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid);

Phase 2C — RESTRICTIVE + Retire Application-Layer Scoping (post-Securion Audit)

Goal: Convert PERMISSIVE policies to RESTRICTIVE. Remove ADR-005 application-layer WHERE org_id middleware. RLS is the sole isolation mechanism.

Gate conditions (all must be true before Phase 2C begins):

  1. Securion audit of Phase 2A policies completed — no critical findings
  2. Automated rogue-role test suite passing in CI (Proveo — see Phase 2A verification above)
  3. Zero cross-tenant RLS bypass incidents on stage for 30 consecutive days
  4. CEO explicit sign-off for Phase 2C
-- V37__rls_restrictive.sql (Phase 2C — post Securion audit)
-- Convert PERMISSIVE → RESTRICTIVE on all tables
-- This is the point of no return: application layer WHERE org_id is retired after this.

DROP POLICY org_isolation ON invoices;
CREATE POLICY org_isolation ON invoices
    AS RESTRICTIVE
    FOR ALL
    TO bilko_app
    USING (org_id = current_setting('app.current_org_id')::uuid)
    WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);

-- Same pattern for expenses, transactions, bank_transactions, bank_accounts,
-- accounts, contacts, invoice_items (repeat for each table).

2.3 Versioned Chart of Accounts Table

The chart_of_accounts table stores jurisdiction-specific CoA entries with time-ranged validity. This supports:

-- Part of Flyway V17 or separate V17b (Phase 2A / 1H parallel)

CREATE TABLE chart_of_accounts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    jurisdiction    VARCHAR(8) NOT NULL,    -- TaxJurisdiction enum value: 'HR', 'RS', 'BA_FED', 'BA_RS'
    code            VARCHAR(16) NOT NULL,   -- e.g. '1300' (HR Kontni Plan), '204' (RS Pravilnik)
    name            VARCHAR(256) NOT NULL,
    account_type    VARCHAR(16) NOT NULL    -- ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
                    CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE')),
    vat_treatment   VARCHAR(64),            -- e.g. 'STANDARD_RATE', 'EXEMPT', null for non-VAT accounts
    valid_from      DATE NOT NULL,
    valid_to        DATE,                   -- NULL = currently valid
    version         INT NOT NULL DEFAULT 1, -- increments per Pravilnik revision
    notes           TEXT,                   -- statutory reference e.g. "NN 78/2015, čl. 5"
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (jurisdiction, code, valid_from)
);

CREATE INDEX idx_coa_jurisdiction_date
    ON chart_of_accounts (jurisdiction, valid_from, valid_to);

-- Query pattern: entries valid on a given transaction date
-- SELECT * FROM chart_of_accounts
-- WHERE jurisdiction = $1
--   AND valid_from <= $2
--   AND (valid_to IS NULL OR valid_to > $2)
-- ORDER BY code;

-- When Croatia raises PDV from 25% to 27% on 2027-01-01:
-- INSERT INTO chart_of_accounts (jurisdiction, code, name, account_type, vat_treatment, valid_from, version)
-- VALUES ('HR', '2400', 'PDV po stopi 27%', 'LIABILITY', 'STANDARD_RATE', '2027-01-01', 2);
-- UPDATE chart_of_accounts SET valid_to = '2026-12-31'
-- WHERE jurisdiction = 'HR' AND code = '2400' AND valid_to IS NULL AND version = 1;
-- No code change required.

Seeding: CountryPlugin.getChartOfAccountsDefaults() returns the list of entries that Flyway data migrations insert into chart_of_accounts for each jurisdiction. Flyway V18 is already used for invoice-status enum documentation. Current CoA seed/reference migrations use later slots (for example V23/V24/V27 for BA/RS chart references); future HR CoA table-driven seeding must use the next available Flyway version at implementation time.

2.4 Exchange Rate Precision Upgrade

Current precision (CLAUDE.md database rules): NUMERIC(19,4) for ALL monetary amounts.

Upgrade required for FX rate columns specifically:

Exchange rates require higher precision than invoice monetary amounts. Using NUMERIC(19,4) for an exchange rate means EUR/RSD at 117.2350 is representable, but EUR/BAM at 1.95583 is stored as 1.9558 — a systematic rounding error that compounds across large invoice volumes and cross-currency reconciliation.

Decision: FX rate columns upgrade to NUMERIC(20,10). Monetary amount columns (invoice totals, line amounts, tax amounts) remain NUMERIC(19,4).

-- V17c__exchange_rate_precision.sql (Phase 2A parallel)

ALTER TABLE exchange_rates
    ALTER COLUMN rate TYPE NUMERIC(20,10);  -- was NUMERIC(19,4)

-- If an exchange_rate_history or similar snapshot table exists:
-- ALTER TABLE exchange_rate_history
--     ALTER COLUMN rate TYPE NUMERIC(20,10);

-- NEVER change invoice_items.unit_price, invoice_items.line_total,
-- transactions.amount, etc. — those remain NUMERIC(19,4).
-- Only rate/exchange_rate columns receive this upgrade.

Invariant: All monetary arithmetic (invoice totals, tax calculations, double-entry postings) remains at NUMERIC(19,4). The precision upgrade is scoped to the FX rate storage layer only. Rounding when applying FX rates to amounts: round half-even (banker's rounding) to 4 decimal places after multiplication.


3. Connection Middleware — Setting app.current_org_id

The RLS policies use current_setting('app.current_org_id')::uuid. This session variable must be set on every database connection before any query executes.

Pattern (Kotlin / Exposed / HikariCP):

// apps/api/src/main/kotlin/no/alai/bilko/db/OrgContextInterceptor.kt (Phase 2A NEW)

/**
 * Sets the PostgreSQL session variable `app.current_org_id` to the authenticated
 * org's UUID before any database access.
 *
 * Called from the Ktor routing pipeline after JWT validation, before the
 * database transaction opens.
 *
 * Must reset after the request completes — use try/finally or Ktor plugin lifecycle.
 */
fun setOrgContext(orgId: UUID) {
    transaction {
        exec("SET LOCAL app.current_org_id = '${orgId}'")
    }
}

fun clearOrgContext() {
    transaction {
        exec("RESET app.current_org_id")
        // or: exec("SET LOCAL app.current_org_id = ''")
    }
}

Failure mode: If app.current_org_id is not set, current_setting('app.current_org_id') throws an error in PostgreSQL (by default). To make it return NULL instead (for Flyway admin connections that do not set the variable):

-- In V17 migration, set default:
ALTER DATABASE bilko_demo SET app.current_org_id = '';

And in the policy, guard against empty string:

USING (
    CASE WHEN current_setting('app.current_org_id', true) = ''
         THEN false  -- deny if not set
         ELSE org_id = current_setting('app.current_org_id', true)::uuid
    END
)

The true parameter to current_setting() makes it return NULL rather than throw when the variable is not set.


4. Migration Schedule

Phase Flyway Version Target Blocking
Phase 1H.1 V16: organizations.country NOT NULL + CHECK HR enum expansion (ADR-015) ADR-015 accepted
Phase 2A V17: PERMISSIVE RLS + CoA table + FX rate precision Stage only CEO sign (this ADR)
Phase 1H CoA seed/reference migrations use occupied later slots; new table-driven CoA migrations must use next free slot Stage only CountryPlugin defaults + CoA template table decision
Phase 2B V36: audit log partitioning (planned; re-check next free Flyway slot before writing migration) Post-HR GA Securion review
Phase 2C V37: RESTRICTIVE + retire ADR-005 app scoping (planned; re-check next free Flyway slot before writing migration) Post-Securion audit Securion audit pass + CEO sign

All migrations use Flyway's expand/contract pattern. No migration modifies data in a way that cannot be reversed by a subsequent compensating migration. Backward compatibility is required across all rolling deployments.


5. Consequences

5.1 Positive

  1. Defence in depth. Even if a developer introduces a missing WHERE org_id in a new route, RLS at the database layer prevents cross-tenant data exposure.
  2. GDPR jurisdiction extraction. With country_code on logged_actions (Phase 2B), a request from Croatian DPA for "all data held on Croatian entities" is a single partition query, not a full-table scan with a filter.
  3. Audit surface. Securion can review one set of RLS policies rather than auditing every application route for correct scoping.
  4. Option D readiness. If ADR-023 §6 triggers (e.g., first paying HR customer), the same RLS DDL applies to the per-country databases without change. Migration path is not blocked by this ADR.

5.2 Negative

  1. Connection middleware requirement. Every DB connection must set app.current_org_id before any query. Forgetting this in a new service or background job will cause all queries to return 0 rows (PERMISSIVE) or error (RESTRICTIVE). Mitigated by integration tests that verify the context middleware fires.
  2. Flyway admin bypass. Flyway and admin tooling must run as a role that bypasses RLS (bilko_admin with BYPASSRLS). This role must be kept tightly restricted — it is a privilege escalation path.
  3. Phase 2A adds overhead. Each query now evaluates an additional predicate. At current scale (0 paying customers) the overhead is immeasurable. Monitor p95 query latency after Phase 2A migration on stage.

5.3 Risks

  1. GDPR data residency. Croatian entity data in Cloud SQL europe-north1 (Finland) is legally compliant (EU/EEA). If a future HR DPA contract specifies Frankfurt, a regional migration is required. This ADR does not block that migration.
  2. RLS policy gap. An incorrect USING clause (e.g., JOIN condition that broadens the scope) could expose cross-tenant data. Mitigation: Securion audit before Phase 2C (RESTRICTIVE), automated rogue-role test in CI.
  3. Migration synchronization. A Flyway migration failure mid-run leaves all markets degraded. All V17+ migrations must be backward-compatible and use expand/contract pattern. If V17 fails, rollback is: DROP POLICY + ALTER TABLE ... DISABLE ROW LEVEL SECURITY.

6. References

Reference Path Lines
ADR-bilko-001 (ancestor draft, absorbed by this ADR) ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-001-multi-tenant-architecture.md 1–162
ADR-bilko-003 §Layer 3 (versioned CoA model) ~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md 122–143
ADR-023 §6 (single-DB migration triggers — not fired) docs/architecture/ADR-023-TRANSITIONAL-MULTI-MARKET-ROUTING.md 166–176
Plan v3 §4a (Option D not triggered — evidence) ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 100–108
Plan v3 §4c (RLS timing — PERMISSIVE before Phase 1H) ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 135–145
Plan v3 §4d (EU data residency does not block HR GA) ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 179–183
ADR-015 §2.1 (TaxJurisdiction enum — country_code values) docs/architecture/ADR-015-FOUR-JURISDICTION-PLUGIN.md §2.1
Test drift memo (cross-tenant 500 leaks, Round 12.1/12.5) ~/.claude/projects/-Users-makinja/memory/project_bilko_test_strategy_drift_2026-05-10.md

7. Approval

Architecture status: Accepted (Phase 0' ADR consolidation) CEO sign status: SIGNED 2026-05-11 — Phase 2A V17 Flyway PERMISSIVE migration authorized for stage. Phase 2C RESTRICTIVE flip remains gated on Securion audit + 30-day soak per §4 schedule.

This ADR records the architectural decision. The CEO signature below is the gate for execution of Phase 2A database migrations. It is not a gate for writing this document or for Phase 1H code work (CountryPlugin, PluginHR, DI wiring).

Role Sign Date
Architecture Lead (Petter Graff) Signed 2026-05-11
Database Architecture (Bruce Momjian) Signed 2026-05-11
CEO (Alem Bašić) SIGNED — session f73dafab, transcript "ok adr17 odobreno" 2026-05-11

8. Document History

Date Author Change
2026-04-22 ALAI / ADR-bilko-001 Initial draft (multi-tenant architecture options analysis)
2026-05-11 Bruce Momjian / Petter Graff Promoted from ADR-bilko-001 draft; ID changed to ADR-017; DDL examples added; versioned CoA DDL added; NUMERIC(20,10) FX precision noted; Phase 2B audit log partitioning added; connection middleware pattern added; CEO sign gate formalised. MC #100362.
2026-05-11 John (AI Director) CEO Alem Bašić signed ADR-017 via session f73dafab ("ok adr17 odobreno"). Phase 2A V17 Flyway PERMISSIVE migration authorized for stage. Status header + §7 approval table updated. Unblocks Bruce Momjian dispatch for Phase 2A.
2026-05-18 CodeCraft (MC #101153 C2) Initial Phase 2B/2C slot collision fix: V18→V32 (audit_log_partitioning), V19→V33 (rls_restrictive). Doc-only change — no migration files modified. Superseded by 2026-05-20 update after V32–V35 were consumed by deployed auth/demo corrective migrations.
2026-05-20 John (MC #101153 C2) Updated planned Phase 2B/2C Flyway slots to V36/V37, documented V18/V19 enum ownership and V30/V30.1–V35 occupancy, and added a re-check rule so future RLS migrations use the next free version before implementation.

ADR-019 — Integration Adapter Registry

ADR-019 — Integration Adapter Registry

Status: Accepted Date: 2026-05-11 Author: Petter Graff (CodeCraft — Architecture Lead) Decision-maker: CEO Alem Bašić Mehanik clearance: /tmp/mehanik-cleared-100362 MC Task: #100362 (Phase 0' ADR Consolidation) Cross-references:


1. Context

1.1 Problem: Seven Integration Surfaces, No Governance

Bilko integrates with external systems across seven functional domains. As of 2026-05-11, only one adapter exists (StorecoveHrFiskEInvoiceAdapter). Without a registry and governance model, adding the second adapter (SEF for RS) and every subsequent adapter will produce:

  1. Inconsistent error handling — platform-native exceptions leaking across boundaries
  2. No feature-flag mechanism — a broken SEF adapter takes down all RS users
  3. Secret sprawl — STORECOVE_API_KEY as an env var pattern, but no taxonomy when there are 7 adapters × 4 markets × 3 environments = up to 84 secrets
  4. No observability standard — each adapter invents its own logging and metrics
  5. No lifecycle discipline — adapters deployed to production without sandbox verification

1.2 Reference Implementation Patterns

StorecoveHrFiskEInvoiceAdapter.kt already demonstrates all the patterns this ADR formalises. This ADR makes those patterns enforceable for all future adapters:

Pattern StorecoveHrFiskEInvoiceAdapter ADR-019 makes it
PII redaction before logging Lines 24–59 (sanitizeForLog) Mandatory
AdapterException only (no platform exceptions) Lines 469–516 (StorecoveErrorMapper) Mandatory
Per-adapter Prometheus metrics Lines 537–540 (StorecoveMetrics) Mandatory
Lifecycle state field Lines 547–548 (lifecycleState = STUB) Mandatory
Idempotency key on submit Lines 591–600 (D5 comment) Mandatory
Credentials NOT required for serialize() Lines 567–571 Mandatory
Startup credential validation flag Lines 83–138 Recommended

2. Decision

2.1 Seven Adapter Categories

Every external integration belongs to exactly one of the following categories. Each category is a Kotlin interface in apps/api/src/main/kotlin/no/alai/bilko/adapter/.

Category Interface Purpose Markets
1 EInvoiceAdapter E-invoice serialization + fiscal platform submission HR (Storecove), RS (SEF), BA-FED (CPF), BA-RS (UINO)
2 CompanyRegistryAdapter Company data lookup (name, address, tax status) from government registries HR (FINA), RS (APR), BA (stub)
3 BankStatementAdapter Bank statement import (MT940, CAMT.053, PSD2 AISP) All markets — via Tok Open Banking platform
4 ExchangeRateAdapter FX rate feed (daily/live) All markets (ECB primary, HNB for HR, NBS for RS)
5 TaxFilingAdapter Electronic VAT/CIT return submission to tax authority HR (ePorezna), RS (ePorezi), BA (TBD)
6 FiscalDeviceAdapter Fiscal receipt device or cloud fiscal service HR (Fiskalizacija cloud cert), RS (LPFR chip card), BA (TBD)
7 QESSigningAdapter Qualified Electronic Signature for invoice signing HR (FINA QES), RS (stub), BA (stub)

Current implementation status:

2.2 Common Interface Contract

Every adapter interface extends a common BilkoAdapter base:

package no.alai.bilko.adapter

import no.alai.bilko.country.TaxJurisdiction
import no.alai.bilko.einvoice.AdapterLifecycleState

/**
 * Base contract for all Bilko integration adapters.
 *
 * Every adapter implementation MUST:
 * 1. Expose [jurisdiction] and [lifecycleState] as first-class properties.
 * 2. Throw only [AdapterException] — NEVER platform-native exceptions.
 * 3. Pass all log writes through [sanitizeForLog] (defined per-adapter for PII fields).
 * 4. Record Prometheus metrics on every external call (see §2.6).
 * 5. Not require credentials for read-only / serialization operations.
 */
interface BilkoAdapter {
    val jurisdiction: TaxJurisdiction
    val lifecycleState: AdapterLifecycleState
    val adapterVersion: String  // Semantic version string, e.g. "1.0.0"
}

2.3 AdapterException — Canonical Error Contract

All adapters throw AdapterException and nothing else. This exception type is the single crossing point from adapter space to core service space.

package no.alai.bilko.adapter

import no.alai.bilko.country.TaxJurisdiction

/**
 * Canonical adapter error. The ONLY exception type that crosses the adapter boundary.
 *
 * INVARIANT: Core services catch AdapterException only. They MUST NOT catch
 * platform-native exceptions (Ktor ResponseException, HttpRequestTimeoutException,
 * java.net.SocketTimeoutException, etc.). Map those to AdapterException in the adapter.
 *
 * [retryable]: if true, caller may retry with exponential backoff.
 * [rawPayload]: sanitized (PII-redacted) raw response body for audit. NEVER raw.
 */
data class AdapterException(
    val code: AdapterErrorCode,
    val market: TaxJurisdiction,
    val retryable: Boolean,
    val rawPayload: String,
    override val message: String = code.name,
    override val cause: Throwable? = null,
) : RuntimeException(message, cause)

/**
 * Canonical error codes — adapter-independent.
 *
 * Adapters map platform-specific HTTP status codes and error bodies to these codes.
 * See StorecoveErrorMapper (lines 469–516) for the HR reference mapping.
 */
enum class AdapterErrorCode {
    // Validation errors — not retryable
    VALIDATION_SCHEMA_ERROR,       // Invalid document structure (HTTP 400/422)
    VALIDATION_BUSINESS_RULE,      // Business rule violation (e.g., invalid OIB, non-EUR currency)
    VALIDATION_DUPLICATE_DOCUMENT, // Idempotency conflict (HTTP 409)

    // Authentication/authorisation — not retryable
    AUTH_INVALID_CREDENTIALS,      // API key invalid / token expired / certificate rejected

    // Platform errors — retryable
    PLATFORM_RATE_LIMITED,         // HTTP 429 — back off and retry
    PLATFORM_MAINTENANCE,          // HTTP 503 — platform in scheduled maintenance
    PLATFORM_INTERNAL_ERROR,       // HTTP 5xx — transient platform error

    // Network errors — retryable
    NETWORK_TIMEOUT,               // Connection or read timeout
    NETWORK_UNREACHABLE,           // DNS resolution failure or TCP refused

    // Implementation status — not retryable
    NOT_IMPLEMENTED,               // Adapter is in STUB lifecycle state
    UNKNOWN,                       // Unmapped error; always log rawPayload for triage
}

Mapping rule for new adapters: Every HTTP status code the platform can return MUST have a mapping to an AdapterErrorCode. Use UNKNOWN only as a catch-all, never as the primary mapping for a known status code. See StorecoveErrorMapper (lines 469–516 in StorecoveHrFiskEInvoiceAdapter.kt) as the reference pattern.

2.4 AdapterConfig — DB-Level Feature Flag

Every adapter is gated by an AdapterConfig row. An adapter MUST NOT execute any network call if its AdapterConfig.enabled = false. This allows disabling a broken adapter without redeployment.

-- V20__adapter_config.sql (Phase 1H — during Phase 2A window)

CREATE TABLE adapter_config (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    market       VARCHAR(8) NOT NULL,
                 -- TaxJurisdiction enum value: 'HR', 'RS', 'BA_FED', 'BA_RS'
    adapter_type VARCHAR(32) NOT NULL,
                 -- Matches the 7 categories: 'EINVOICE', 'COMPANY_REGISTRY',
                 -- 'BANK_STATEMENT', 'EXCHANGE_RATE', 'TAX_FILING',
                 -- 'FISCAL_DEVICE', 'QES_SIGNING'
    enabled      BOOLEAN NOT NULL DEFAULT FALSE,
    reason       TEXT,
                 -- Why disabled, e.g. "MC #8675 pending — Storecove account not activated"
                 -- Required when enabled=false.
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_by   TEXT NOT NULL DEFAULT 'system',  -- MC task ID or admin user
    CONSTRAINT pk_adapter_config UNIQUE (market, adapter_type)
);

-- Seed: all adapters start disabled
INSERT INTO adapter_config (market, adapter_type, enabled, reason, updated_by)
VALUES
    ('HR', 'EINVOICE',         false, 'MC #8675 — Storecove account pending', 'MC-100362'),
    ('HR', 'COMPANY_REGISTRY', false, 'Not implemented — Phase 1S', 'MC-100362'),
    ('HR', 'BANK_STATEMENT',   false, 'Tok AISP integration pending', 'MC-100362'),
    ('HR', 'EXCHANGE_RATE',    false, 'ECB feed not configured', 'MC-100362'),
    ('HR', 'TAX_FILING',       false, 'ePorezna integration Phase 2 scope', 'MC-100362'),
    ('HR', 'FISCAL_DEVICE',    false, 'Fiskalizacija cert not configured', 'MC-100362'),
    ('HR', 'QES_SIGNING',      false, 'FINA QES Phase 2 scope', 'MC-100362'),
    ('RS', 'EINVOICE',         false, 'SEF adapter Phase 1S scope', 'MC-100362'),
    ('BA_FED', 'EINVOICE',     false, 'CPF platform TBD ~2027', 'MC-100362'),
    ('BA_RS', 'EINVOICE',      false, 'UINO platform TBD', 'MC-100362');
-- (Remaining BA/RS adapter rows follow same pattern)

-- Admin can enable without redeploy:
-- UPDATE adapter_config SET enabled = true, reason = NULL, updated_by = 'MC-8675-DONE'
-- WHERE market = 'HR' AND adapter_type = 'EINVOICE';

Kotlin enforcement pattern:

// In the adapter registry (apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterRegistry.kt)
fun requireEnabled(market: TaxJurisdiction, adapterType: String) {
    val config = adapterConfigRepository.find(market, adapterType)
        ?: throw AdapterException(
            code = AdapterErrorCode.NOT_IMPLEMENTED,
            market = market,
            retryable = false,
            rawPayload = "",
            message = "No AdapterConfig row for ($market, $adapterType) — run Flyway V20"
        )
    if (!config.enabled) {
        throw AdapterException(
            code = AdapterErrorCode.NOT_IMPLEMENTED,
            market = market,
            retryable = false,
            rawPayload = "",
            message = "Adapter ($market, $adapterType) is disabled: ${config.reason}"
        )
    }
}

2.5 Lifecycle States and Transition Criteria

Formalised from AdapterLifecycleState enum in EInvoiceTypes.kt lines 22–26, and from ADR-016 §2.3. Applies to ALL adapter categories.

STUB ──────────────────► SANDBOX_VERIFIED ──────────────────► PRODUCTION

STUB (initial state for all adapters):

SANDBOX_VERIFIED transition criteria (all must be true):

PRODUCTION transition criteria (all must be true):

2.6 Secret Taxonomy

Runtime secrets follow the pattern Bilko/{env}/{market}/{secret-name}.

Env first, not market first. This ensures that all production secrets are under Bilko/production/ and can be granted/revoked as a unit for environment promotion.

Bilko/
  production/
    HR/
      STORECOVE_API_KEY
      STORECOVE_LEGAL_ENTITY_ID
      FINA_QES_CERTIFICATE         (Phase 2)
      EPOREZNA_CLIENT_SECRET       (Phase 2)
    RS/
      SEF_API_KEY                  (Phase 1S)
      LPFR_DEVICE_CERT             (Phase 2)
    BA_FED/
      CPF_API_KEY                  (Phase 1B — pending platform launch)
    BA_RS/
      UINO_API_KEY                 (Phase 1B — pending platform launch)
  stage/
    HR/
      STORECOVE_API_KEY
      STORECOVE_LEGAL_ENTITY_ID
    RS/
      SEF_API_KEY
    ...
  local/
    HR/
      STORECOVE_API_KEY            (developer sandbox credentials only)
    ...

Secret resolution hierarchy:

  1. Runtime: GCP Secret Manager (current) — accessed via SecretResolver interface
  2. Break-glass: Vaultwarden (vault.basicconsulting.no) — human access only, NOT runtime source
  3. Local dev: .env.local file (.gitignore'd) — NEVER committed
// apps/api/src/main/kotlin/no/alai/bilko/adapter/SecretResolver.kt

/**
 * Abstracts secret retrieval behind a testable interface.
 *
 * Production implementation: GCP Secret Manager.
 * Test implementation: environment variables / in-memory map.
 *
 * Secret path convention: Bilko/{env}/{market}/{secret-name}
 */
interface SecretResolver {
    /**
     * Resolves a secret value by its canonical path.
     *
     * @param path e.g. "Bilko/production/HR/STORECOVE_API_KEY"
     * @return Secret value, or null if not found.
     * @throws AdapterException(AUTH_INVALID_CREDENTIALS) if path exists but value is empty/blank.
     */
    fun resolve(path: String): String?

    /**
     * Convenience method: builds canonical path and resolves.
     * @param env "production" | "stage" | "local"
     * @param market TaxJurisdiction enum value as string
     * @param secretName The specific secret name
     */
    fun resolve(env: String, market: String, secretName: String): String? =
        resolve("Bilko/$env/$market/$secretName")
}

Vaultwarden is NOT the runtime secret source. Vaultwarden is the human break-glass vault for emergency access. Do not write Kotlin code that reads from Vaultwarden at runtime. GCP Secret Manager is the runtime source.

2.7 Observability Mandate

Every adapter MUST emit the following for every network call:

Structured log line (one per call):

level=INFO market=HR integration=EINVOICE env=production org_id=<uuid>
  action=submit status=SUCCESS duration_ms=234 submission_id=<guid>

Required fields: market, integration, env, org_id. Optional but recommended: duration_ms, submission_id, attempt (for retries).

NEVER log:

Use sanitizeForLog() (pattern from StorecoveHrFiskEInvoiceAdapter.kt lines 24–59) before any log write that touches a response body.

Prometheus metrics (one counter per adapter):

// apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterMetrics.kt

/**
 * Prometheus counter for all adapter network calls.
 *
 * Labels: market, integration, status (SUCCESS | ERROR | NOT_IMPLEMENTED | TIMEOUT)
 *
 * Example PromQL for HR e-invoice error rate:
 *   rate(bilko_integration_request_total{market="HR",integration="EINVOICE",status="ERROR"}[5m])
 *   /
 *   rate(bilko_integration_request_total{market="HR",integration="EINVOICE"}[5m])
 */
// bilko_integration_request_total{market, integration, status}
// bilko_integration_request_duration_seconds{market, integration, status}

Per-(market, integration) alert rule:

2.8 Adapter Versioning

Each adapter declares val adapterVersion: String (semantic version, e.g., "1.0.0"). The corresponding CountryPlugin implementation declares the minimum adapter version it requires:

// In PluginHR:
companion object {
    const val MIN_EINVOICE_ADAPTER_VERSION = "1.0.0"
}

// Startup check in DI.kt:
val adapter = StorecoveHrFiskEInvoiceAdapter()
require(semVer(adapter.adapterVersion) >= semVer(PluginHR.MIN_EINVOICE_ADAPTER_VERSION)) {
    "PluginHR requires EInvoiceAdapter >= ${PluginHR.MIN_EINVOICE_ADAPTER_VERSION}, got ${adapter.adapterVersion}"
}

Adapters are versioned independently of the CountryPlugin. Breaking changes to an adapter interface (e.g., new required parameter in submit()) require a major version bump and a coordinated plugin + adapter update.

2.9 Idempotency Requirements

All submit-type methods in all adapters MUST include an idempotency key.

The idempotency key format is adapter-specific, but the value MUST be derived deterministically from the invoice or entity content — never a random UUID.

Adapter Method Idempotency key derivation
EInvoiceAdapter (HR) submit() SHA-256(invoice.id + invoice.invoiceNumber) — matches Storecove D5
EInvoiceAdapter (RS) submit() SHA-256(invoice.id + invoice.invoiceNumber) (same pattern)
TaxFilingAdapter submit() SHA-256(filing.periodStart + filing.periodEnd + org.taxId)
QESSigningAdapter sign() SHA-256(document.contentHash + signer.taxId)

Rationale: A network timeout after the platform receives the request but before the response arrives will cause the client to retry. Without idempotency, this creates a duplicate document. Storecove returns HTTP 409 on duplicate document_id (D2 in StorecovePayloadBuilder.wrap() lines 420–436) — the pattern must be replicated.


3. Implementation Path

Phase Task Deliverable Status
Phase 0' This ADR written to disk ADR-019-INTEGRATION-ADAPTER-REGISTRY.md DONE
Phase 1H AdapterException + AdapterErrorCode formalized adapter/AdapterException.kt (already exists — verify package) Verify existing
Phase 1H AdapterConfig Flyway migration (V20) V20__adapter_config.sql BLOCKED BY Phase 2A
Phase 1H SecretResolver interface + GCP impl adapter/SecretResolver.kt + GcpSecretResolver.kt Phase 1H.4+
Phase 1H AdapterRegistry + requireEnabled() check adapter/AdapterRegistry.kt Phase 1H.4+
Phase 1H Prometheus metrics wired in StorecoveHrFiskEInvoiceAdapter StorecoveMetrics.kt (skeleton exists) Phase 1H.2+
Phase 1S SEF RS EInvoiceAdapter country/rs/SefRsEInvoiceAdapter.kt Post-HR GA
Phase 1B CPF BA-FED + UINO BA-RS stubs country/ba/Cpf*, country/ba/Uino* Post-RS GA

4. Consequences

4.1 Positive

4.2 Negative

4.3 Risks


5. References

Reference Path Lines
AdapterLifecycleState enum (on disk) apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt 22–26
StorecoveErrorMapper (error mapping reference) apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 469–516
StorecoveMetrics (metrics reference) apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 537–540
PII sanitization reference apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 24–59
Idempotency key (D5) reference apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 94–98
StorecovePayloadBuilder (D2 document_id) apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt 420–436
ADR-016 §2.3 (lifecycle states — EInvoice) docs/architecture/ADR-016-EINVOICE-ADAPTER.md §2.3
ADR-015 §2.4 (DI registration pattern) docs/architecture/ADR-015-FOUR-JURISDICTION-PLUGIN.md §2.4
Plan v3 §6 Task 0'4 acceptance criteria ~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md 279–290

6. Approval

Status: Accepted Unblocks:

Role Sign Date
Architecture Lead (Petter Graff) Signed 2026-05-11
CEO (Alem Bašić) Not required for registry pattern ADR

7. Document History

Date Author Change
2026-05-11 Petter Graff Initial — Phase 0' ADR consolidation (MC #100362)

ADR-020 — Canonical Backend Location (apps/api, Deprecate apps/api-kotlin) + Phase 1 Track A Supersession

ADR-020: Canonical Backend is backend/ — Deprecate apps/api-kotlin/

Status: Accepted Date: 2026-04-28 Author: ALAI, 2026 Related: ADR-009 (superseded), ADR-011, ADR-015, ADR-016, ADR-017, ADR-018, ADR-019

MAJOR PATH UPDATE (2026-04-29): backend/apps/api/ (canonical Kotlin/Ktor location now). apps/api-legacy/.archive/api-legacy/. The deprecation of api-kotlin/ in this ADR was executed in MC #10034 (deleted as apps/api-kotlin-abandoned). See ADR-021.


Context

The Dual-Backend Incident

As of 2026-04-27, the Bilko repository contained two parallel, independent Kotlin/Ktor backends serving identical purposes — an architectural anomaly discovered during forensic audit MC #9892.

Timeline — git-verified (SHA + date + author):

SHA Date Author Event
5f97eff 2026-03-04 John AI Earliest backup commit — backend/ fully present with build.gradle.kts, package no.alai.bilko, Kotlin 2.3.0 / Ktor 3.4.0 / JVM 25
e23ade3 2026-03-19 Makinja Security headers plugin and security audit added to backend/
6b76981 2026-04-10 Makinja CI fixes applied to backend/
6c71a79 2026-04-14 Makinja apps/api-kotlin/ created — "Complete Kotlin/Ktor backend — Auth, Invoices, Clients, Expenses, Health" — package io.bilko, Kotlin 2.1.20 / Ktor 3.1.2 / JVM 21
f66ddec 2026-04-14 Makinja GCP Terraform + CI added (lands in both directories)
ee27c6b 2026-04-15 Makinja apps/api-kotlin/ scaffold finalised — 10 feature modules, 17 source files, titled "migration scaffold"

Result: backend/ was first on 2026-03-04 (6 weeks before apps/api-kotlin/). A generic builder agent (dispatched without Mehanik gate clearance) created apps/api-kotlin/ on 2026-04-14 while backend/ was already the active implementation. From 2026-04-15 onward, apps/api-kotlin/ received no further commits. backend/ continued active development with commits through 2026-04-23.

Background — Why This Happened

CEO decision (2026-03-17, ALAI/CLAUDE.md) mandated migration of all products from Express/TS to Kotlin/Ktor. Migration task MC #5125 ("Bilko backend: Express/TS → Kotlin/Ktor") was created but not formally unblocked. The Phase 1 Track A execution document (docs/bookstack-sync/phase1-track-a-execution.md, lines 241 and 299) designated apps/api-kotlin/ as the FUTURE migration target — explicitly stating:

"Do NOT start Track B until MC #5125 unblocks. All Track B work goes into apps/api-kotlin/."

However, a generic builder agent was dispatched into apps/api-kotlin/ on 2026-04-14 before MC #5125 was formally unblocked and before Mehanik clearance was obtained. This created an unauthorized parallel scaffold while the actual canonical domain implementation continued to grow in backend/.

State at Time of Discovery (MC #9892, 2026-04-27)

backend/:

apps/api-kotlin/:

Prior Audit Gap

The preliminary architecture audit (2026-04-27) saw apps/api-kotlin/ in the directory listing and noted: "need to confirm these are indeed empty/removed" — but did not follow through with a find verification. The tool-first discipline (ZAKON NULA) required explicit verification before any assumption about directory contents. The audit concluded without detecting the 17 active Kotlin files, full auth module, and complete Gradle build in apps/api-kotlin/.


Decision

backend/ is the canonical Kotlin/Ktor backend for Bilko.

apps/api-kotlin/ is deprecated and will be archived as of MC #9894.

All present and future development of the Bilko API occurs in backend/. The io.bilko package namespace is abandoned. The no.alai.bilko namespace (established 2026-03-04) is permanent for the Kotlin backend.


Rationale

Three hard facts — all git-verified, zero assumptions:

Fact 1 — Scale disparity (51 vs 17 files). backend/ contains 51 Kotlin source files. apps/api-kotlin/ contains 17. More critically, the qualitative gap is larger than the count suggests: backend/ contains every domain-specific component (fiscal adapters, error registry, DI wiring, PDF generation, rate limiting, Sentry telemetry). apps/api-kotlin/ contains only the routing skeleton.

Fact 2 — All ADR paths reference backend/. ADR-014 through ADR-019 — every architecture decision record written for this product — reference no.alai.bilko paths inside backend/. ADR-019 was explicitly verified against backend/src/main/kotlin/no/alai/bilko/adapter/AdapterException.kt. Zero architecture documents reference io.bilko or apps/api-kotlin/. An ADR is a commitment. Reversing ADR-015 through ADR-019 evidence chains to point at apps/api-kotlin/ would be rework with no technical benefit.

Fact 3 — Version inversion confirms direction of travel. apps/api-kotlin/ is pinned to Kotlin 2.1.20 / Ktor 3.1.2 — the versions specified in the original Phase 1 Track A scaffold spec. backend/ runs Kotlin 2.3.0 / Ktor 3.4.0 / JVM 25 — current as of 2026-04-28. This is not a coincidence: apps/api-kotlin/ was scaffolded once to a fixed spec and never updated. backend/ was actively maintained and upgraded. The version delta tells the entire story: one codebase is alive, the other is frozen at its creation point.


Consequences

For Lane B BLOCKER Tasks (#9852 / #9853 / #9854 / #9855)

All Lane B backend tasks are unblocked against backend/ as the target. No work should be directed to apps/api-kotlin/. Any task whose scope referenced apps/api-kotlin/ or io.bilko must be updated to reference backend/ and no.alai.bilko before execution begins.

For MC #5125 (Bilko Express → Kotlin Migration)

MC #5125 was the formal trigger for the Kotlin migration and the stated prerequisite for any work in apps/api-kotlin/. With this ADR:

For BUILD-BLUEPRINT.md

BUILD-BLUEPRINT.md §3 currently documents apps/api/ (now apps/api-legacy/) as the backend and makes no mention of either backend/ or apps/api-kotlin/. This is pre-migration documentation. MC #9897 (BUILD-BLUEPRINT update) must:

  1. Replace the backend section to reference backend/ (package no.alai.bilko, Kotlin 2.3.0, Ktor 3.4.0)
  2. Document the directory structure: backend/ lives outside Turborepo workspace (independent Gradle + GCP Cloud Run deploy)
  3. Update build commands to cd backend && ./gradlew run
  4. Mark apps/api-legacy/ as deprecated with a pointer to its decommission timeline

For DEPLOY-MAP.md

DEPLOY-MAP.md currently records bilko-api as "Manual only (Kotlin TBD)". After Dockerfile work (MC #9898), this entry must be updated to reflect: source = backend/, build = Docker multi-stage, deploy target = GCP Cloud Run via gcp-deploy.yml.

Negative Consequences

  1. One-time porting effort. apps/api-kotlin/ contains a more rigorous implementation of refresh token rotation (features/auth/AuthRepository.rotateRefreshToken()). This pattern should be reviewed against backend/ before archiving — MC #9895 covers this comparison.

  2. Track A plan invalidated. The Phase 1 Track A execution document explicitly designated apps/api-kotlin/ as the future target. That plan is now superseded. The document must be annotated with a pointer to this ADR to prevent future agents from acting on stale guidance.

  3. Feature-sliced architecture not adopted. apps/api-kotlin/ used a feature-sliced layout (features/auth/, features/invoices/). backend/ uses a layered layout (routes/, services/, auth/). The architectural pattern debate is resolved in favor of the layered approach by inertia — 51 files are not being reorganized. This is a deliberate trade-off: stability over structural preference.


Lessons Learned

Lesson 1 — Premature Scaffold Incident

The Track A document stated that apps/api-kotlin/ should NOT be built until MC #5125 formally unblocked. A generic builder agent built it anyway (2026-04-14). This is the root cause of the entire incident. The constraint in writing was not sufficient — it required a hard gate (Mehanik) enforcing it programmatically.

Fix: Mehanik pre-dispatch gate (activated 2026-04-25, MC #9274) is the structural remedy. No backend task may be dispatched without Mehanik clearance that checks: task MC ID exists, BUILD-BLUEPRINT.md read, scope ceiling verified, CI green if deploy.

Lesson 2 — "Probably Empty" Hallucination in Audit

The preliminary audit saw apps/api-kotlin/ in the ls output and wrote "need to confirm these are indeed empty/removed" — then concluded without verifying. The correct tool-first discipline (ZAKON NULA) required a find apps/api-kotlin/src -name "*.kt" call before any assumption about directory state. A single verification command would have revealed 17 Kotlin files and flagged the duplicate immediately.

Fix: Any directory flagged as "need to confirm" in an audit is an open obligation, not a closed finding. Audits are not complete until all flagged items are machine-verified. Post-audit review by a second agent (MC #9892 forensic) should be standard for architecture-level audits on active codebases.

Lesson 3 — ZAKON NULA Violation (Tool-First)

John dispatched the backend-dev agent to apps/api-kotlin/ without reading BUILD-BLUEPRINT.md, without running node ~/system/tools/mc.js show 5125, and without querying the existing project structure. Had BUILD-BLUEPRINT.md been read first, it would have been apparent that backend/ was the active implementation and that apps/api-kotlin/ was the designated (but not yet active) future target — a distinction requiring a human (Alem) decision, not an agent initiative.

Fix: ZAKON NULA (CLAUDE.md) is enforced by the Mehanik pre-dispatch hook. The hook requires tool-verified project state before clearing any build dispatch.

Lesson 4 — ZAKON #1 Violation (Specialist Routing)

MC #5125 is a complex backend migration (Express/TS → Kotlin/Ktor, domain logic, multi-market fiscal adapters, DI framework selection). This requires a specialist — CodeCraft (Petter Graff / Hadi Hariri), not a generic builder agent. CLAUDE.md §5 is unambiguous: "Never generic builder/minion. Route to the right company." Generic agents lack the architectural judgment to navigate this class of decision (where does the backend live? which package namespace? which version pins?).

Fix: Complex backend migrations are categorically CodeCraft work. If the specialist routing table in CLAUDE.md is unclear for a given task, the correct action is to ask John, not to default to a generic pool. The Mehanik gate now enforces specialist routing as part of its clearance criteria.


Migration Path

The following tasks (C2–C6, MC #9894–#9898) execute the deprecation and consolidation. All tasks have Mehanik-cleared MC IDs. Sequencing matters: C2 (archive) must complete before C3 (port auth) to avoid confusion about which directory to edit.

C2 — Archive apps/api-kotlin/ (MC #9894)

Owner: CodeCraft | Effort: S (1h)

  1. Rename apps/api-kotlin/ to apps/api-kotlin-abandoned/.
  2. Add README.md at the root of the renamed directory:
    DEPRECATED 2026-04-28 — see ADR-020
    This directory is the abandoned migration scaffold created 2026-04-14 to 2026-04-15.
    The canonical Kotlin backend is /backend/ (no.alai.bilko, Kotlin 2.3.0, Ktor 3.4.0).
    Do not edit this directory. It will be deleted after 2026-05-28.
    
  3. Verify turbo.json and root package.json workspaces do NOT include apps/api-kotlin or apps/api-kotlin-abandoned (Turborepo workspace scope must not resolve against it).
  4. Annotate docs/bookstack-sync/phase1-track-a-execution.md lines 241 and 299 with: [SUPERSEDED by ADR-020 — apps/api-kotlin abandoned, backend/ is canonical].

C3 — Port Auth Improvements to backend/ (MC #9895)

Owner: CodeCraft | Effort: M (4h)

Compare apps/api-kotlin-abandoned/features/auth/AuthRepository.kt (specifically rotateRefreshToken() and the ThreadLocal side-channel pattern) against backend/src/main/kotlin/no/alai/bilko/auth/AuthService.kt. If the abandoned version is more rigorous, port the improvement. Do not port file structure or package names.

Scope: auth only. No feature modules, no table objects, no routing changes.

C4 — Update BUILD-BLUEPRINT.md (MC #9897)

Owner: CodeCraft | Effort: S (2h)

See Consequences section above for mandatory content. In addition, add an explicit architectural note: "backend/ lives outside the Turborepo workspace by design. It is a standalone Gradle project with its own GCP Cloud Run deploy pipeline. Do not move it inside apps/."

C5 — Add Dockerfile to backend/ + Update DEPLOY-MAP.md (MC #9898)

Owner: FlowForge | Effort: M (4h)

  1. Port apps/api-kotlin-abandoned/Dockerfile (JVM 21, multi-stage, non-root user, health check) to backend/Dockerfile. Upgrade base image from JVM 21 to JVM 25 (matching backend/ JVM target).
  2. Verify fat JAR output name: bilko-api.jar (check build.gradle.kts shadowJar config).
  3. Local build validation: docker build -t bilko-api-test ./backend must succeed.
  4. Update DEPLOY-MAP.md bilko-api entry: source = backend/, Dockerfile = backend/Dockerfile, deploy = GCP Cloud Run via gcp-deploy.yml.

C6 — Proveo Verification (MC #9898 gate, Proveo)

Owner: Proveo (Angie Jones) | Effort: S (2h)

Acceptance criteria:

  1. docker build -t bilko-api ./backend exits 0.
  2. docker run --rm -p 8080:8080 bilko-api starts and responds to GET /health with HTTP 200.
  3. apps/api-kotlin/ directory no longer exists in repo root (renamed per C2).
  4. turbo.json workspaces grep returns no match for api-kotlin.
  5. grep -r "io.bilko" backend/ returns no matches (no namespace contamination).

References


Approval

Accepted: 2026-04-28 Executed by: ALAI, 2026 Execution tasks: MC #9894, #9895, #9897, #9898