# 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:**
- BankID returns fødselsnummer (11-digit) which encodes DOB → extract and validate age >= 18
- In demo/MVP: mock BankID with DOB field, enforce 18+ check in `/api/auth/register`
- Pass-through model: Drop never holds money, uses Open Banking (PSD2) to read balance and initiate transfers

## 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):**
- Open Banking provider (PSD2 AISP/PISP) for production bank account access
- Card issuing provider (Stripe or similar) for physical/virtual cards — gated behind feature flags
- KYC provider (Sumsub/Onfido or partner's existing system)

## 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
- **Engine:** PostgreSQL 16 (all environments — development, CI, staging, production)
- **ORM:** Drizzle ORM (`src/shared/db/schema.ts` — single source of truth)
- **Local dev:** Docker (`docker compose up -d`), port 5433
- **Rationale:** Full test/prod parity, type-safe schema, PostgreSQL-native features (ADR-014)

### 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)
- **Purpose:** Read user's bank account balance and transaction history
- **Method:** Open Banking API via BankID consent
- **Storage:** Cached balance in `bank_accounts.balance` (read-only, synced periodically)
- **Note:** Drop never controls or holds this balance

### PISP (Payment Initiation)
- **Purpose:** Initiate payments directly from user's bank account
- **Use cases:** Remittance transfers, QR merchant payments
- **Method:** Open Banking payment initiation with Strong Customer Authentication (SCA)
- **Flow:** User approves payment → PISP initiates → Bank debits user's account → Drop records transaction

### 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
- **Method:** JWT (jose library)
- **Storage:** httpOnly cookie (NOT localStorage)
- **Expiry:** 1h access token
- **PIN:** bcrypt hashed, never stored plain

### 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
- **In Transit:** HTTPS/TLS (when deployed)
- **At Rest:** AWS RDS AES-256 encryption (TLS 1.3 in transit to DB)
- **PII:** Phone numbers hashed in logs

## 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)
- **Date:** 2026-02-08
- **Status:** Superseded by ADR-014
- **Context:** Demo app needs simple DB setup
- **Decision (original):** SQLite — zero config, file-based
- **Consequence:** Could not handle concurrent writes well. Superseded before production use.
- **Current state:** PostgreSQL 16 in all environments (development, CI, staging, production). See ADR-014.

### ADR-002: JWT in httpOnly Cookie
- **Date:** 2026-02-08
- **Status:** Accepted
- **Context:** Need secure token storage
- **Decision:** httpOnly cookie prevents XSS token theft
- **Consequence:** Slightly more complex CSRF handling needed.

### ADR-003: Monolith Architecture
- **Date:** 2026-02-08
- **Status:** Accepted
- **Context:** 7 pages, simple API
- **Decision:** Single Next.js app handles everything
- **Consequence:** Easy to deploy and maintain. Refactor if scaling needed.

## 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:**
```json
{
  "email": "amir@example.com",
  "password": "min8chars",
  "firstName": "Amir",
  "lastName": "Hadžić",
  "phone": "+4712345678"
}
```

**Response 201:**
```json
{
  "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:**
```json
{
  "email": "amir@example.com",
  "password": "min8chars"
}
```

**Response 200:**
```json
{
  "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:**
```json
{ "message": "Logged out" }
```
*Clears httpOnly cookie*

---

### GET /api/auth/me
**Description:** Get current user from JWT
**Auth:** Required

**Response 200:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "name": "Mama Jasmina",
  "country": "RS",
  "currency": "RSD",
  "bankAccount": "265100000012345678",
  "bankName": "Banca Intesa"
}
```

**Response 201:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "recipientId": "rec_1",
  "amount": 2000.00,
  "currency": "NOK"
}
```

**Response 201:**
```json
{
  "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:**
- 400 — invalid amount (min 100 NOK, max 50000 NOK)
- 402 — insufficient balance
- 403 — KYC not approved
- 404 — recipient not found
- 422 — unsupported corridor

---

## 7. Transactions — QR Payment

### POST /api/transactions/qr-payment
**Description:** Pay a merchant via QR code
**Auth:** Required

**Request:**
```json
{
  "merchantId": "mer_1",
  "amount": 129.00
}
```

**Response 201:**
```json
{
  "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:**
- 400 — invalid amount (min 1 NOK)
- 402 — insufficient balance
- 404 — merchant not found

---

## 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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "businessName": "Ahmetov Kebab",
  "orgNumber": "923456789",
  "address": "Grønland 12, Oslo",
  "bankAccount": "1234.56.78901"
}
```

**Response 201:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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.

```sql
-- 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

```sql
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

```sql
-- 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:

```json
{
  "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:**

- ADR-016 (EInvoiceAdapter — `generateEInvoiceXml()` and `submitToFiscalPlatform()` delegate to it)
- ADR-017 (RLS multi-tenancy — `TaxJurisdiction` enum drives `country_code` column values)
- ADR-019 (Integration Adapter Registry — adapters called by plugin implementations)
- ADR-023 (transitional routing — single backend, market selected from org record)
- ADR-bilko-001 (promoted as ADR-017 — Option C single-DB decision context)
- ADR-bilko-002 (extraction strategy — Variant C package isolation rationale)
- ADR-bilko-003 (3-layer market abstraction — CountryPlugin is Layer 1)
- Plan v3 §4a, §4b, §5, §6 Phase 0' — `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`

---

## 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:

- Each new market forces edits to `ComplianceCalendarService`, `InvoiceService`, and any
  other service that branches on `organization.country`.
- The Open/Closed principle is violated: adding Bosnia FBiH requires modifying existing
  code across multiple files, not extending it.
- Tax auditors reviewing Croatian PDV compliance must read shared files that also contain
  Serbian PDV logic — audit surface is unbounded.
- `StorecoveHrFiskEInvoiceAdapter` has no dispatch mechanism routing "HR org, generate
  invoice" to it cleanly.

### 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

```kotlin
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.

```kotlin
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`):

```kotlin
// 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`:**

```kotlin
// 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:**

```kotlin
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:

- `if.*jurisdiction`
- `when.*jurisdiction`
- `if.*country ==`
- `when.*country`

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

- **Fifth market = one new file.** Adding Slovenia (SI) requires `PluginSI.kt`, one DI
  registration, and `SI` added to `TaxJurisdiction`. Zero core service changes.
- **Bounded audit surface.** Croatian PDV auditors read `country/hr/PluginHR.kt` only.
- **Team parallelism.** HR sprint and RS sprint work concurrently on separate files.
- **Versioned CoA.** `getChartOfAccountsDefaults()` seeds Pravilnik data; rate changes
  handled via the versioned `chart_of_accounts` table (ADR-017 §2.4).

### 5.2 Negative

- **New required method touches all 4 implementations.** Mitigation: default throw pattern
  (§3.2) + extension hook for non-cross-cutting additions.
- **Boilerplate at scaffolding time.** Each market: ~9 method bodies, CoA seed data, test
  harness. Estimate: 2 days per market for the core plugin scaffold.
- **OrgScopePlugin coupling.** CountryPlugin resolution depends on OrgScopePlugin having
  run and fetched the org record. If OrgScopePlugin is ever refactored, the CountryPlugin
  resolution pipeline must be updated in lockstep.

### 5.3 Risks

- **Jurisdiction if-branches in core services.** Deadline pressure leads to
  `if (jurisdiction == TaxJurisdiction.HR)` shortcuts. **Mitigation:** Detekt rule (§3.1).
- **Stub plugin HTTP 500.** If `PluginRS` is a stub and an RS user triggers `calculateVat()`,
  `UnsupportedOperationException` propagates as HTTP 500. **Mitigation:** DI registry should
  check `lifecycleState` at request time and return HTTP 503 (market feature not available).
- **BA backfill assumption.** V16 migrates `BA → BA_FED` as default. If any existing BA
  org is actually RS entity, the assumption is wrong. **Mitigation:** CEO notified before
  V16 runs on prod; manual verification of all BA rows (currently 0 paying customers).

---

## 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:**

- Phase 1H Task 1H.1: `TaxJurisdiction` enum expansion + `CountryPlugin.kt`
- Phase 1H Task 1H.2: `PluginHR` implementation
- ADR-016: EInvoiceAdapter contract (referenced from `generateEInvoiceXml()`)
- ADR-019: Adapter Registry (referenced from `submitToFiscalPlatform()`)

| 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:**

- ADR-015 (CountryPlugin — `generateEInvoiceXml()` and `submitToFiscalPlatform()` delegate to adapters)
- ADR-019 (Integration Adapter Registry — `AdapterConfig`, secret taxonomy, categories)
- ADR-023 §3.3 (backend country differentiation — market selected before adapter dispatch)
- `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt` (canonical types on disk)
- `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` (HR reference)
- Plan v3 §4b ADR-016 requirement + §4d HR critical path — `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`

---

## 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):

- `AdapterLifecycleState` enum: `STUB`, `SANDBOX_VERIFIED`, `PRODUCTION`
- `EInvoiceStatus` enum: `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED`, `ERROR`
- `InvoiceTypeCode`: UNTDID codes 380, 381, 383, 384
- `Address`, `PartyInfo` / `Party` typealias
- `PaymentMeans`: `paymentMeansCode`, `paymentReference`, `iban`
- `TaxCategory` enum: `S, Z, E, K, G, O, AE` per EN 16931 BT-118
- `TaxBreakdown`, `InvoiceLine`, `CanonicalInvoice`, `SubmitResult`, `InvoiceTotals`
- `EInvoiceAdapter` interface with 4 methods + 2 properties

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

- `AdapterErrorCode` enum with 10 codes including `NOT_IMPLEMENTED`
- `AdapterException(code, market, retryable, rawPayload, message, cause)`

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:

```kotlin
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:

```kotlin
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:**

- Not a DB entity (mapped from `invoices` + `invoice_items` tables on read)
- Not a REST API DTO (API layer maps separately)
- Not versioned independently — evolves with EN 16931 minor revisions

### 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

- `serialize()`: WORKS (offline). Unit-tested.
- `submit()`: throws NOT_IMPLEMENTED — MC #8675 pending
- `pollStatus()`: throws NOT_IMPLEMENTED
- `parseIncoming()`: throws NOT_IMPLEMENTED (deferred post-GA)

### 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:**

- `currencyCode = "EUR"` (HALT-3)
- Supplier OIB: ISO 7064 MOD 11,10 checksum valid
- Buyer OIB: ISO 7064 MOD 11,10 checksum valid
- CustomizationID: verify with Storecove support which to use (PEPPOL_BIS3 or HR_CIUS — TODO MC #8675 D3)
- `routing.peppol.scheme = "9934"` and `routing.peppol.id = <buyerOIB>`

**Storecove-specific notes:**

- Sandbox URL is the same as production (`api.storecove.com/api/v2`) — sandbox mode is a
  payload flag, not a different host. Set `STORECOVE_ENV=sandbox` env var.
- Idempotency key: SHA-256(`invoice.id` + `invoice.invoiceNumber`) → sent as `Idempotency-Key` header.
  Platform returns HTTP 409 on duplicate — treat as success (re-fetch GUID from error body).
- `document_id` field in Storecove payload = `CanonicalInvoice.id` (Bilko UUID) — Storecove
  dedup key, prevents double-billing on retry (D2 in StorecoveHrFiskEInvoiceAdapter).

### 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:
   ```kotlin
   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:
   ```kotlin
   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}
```

- `{env}`: `dev`, `stage`, `prod`
- `{market}`: `HR`, `RS`, `BA_FED`, `BA_RS`
- `{secret-name}`: platform-specific identifier (kebab-case)

**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:**

```yaml
# 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:**

- Rotate API keys every 90 days OR on any Storecove security notice, whichever comes first.
- Previous version retained in Secret Manager for 24h to allow graceful failover.
- Rotation event: create new secret version → update Cloud Run env → verify health endpoint
  → delete previous version 24h later.

**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:

```sql
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):

```sql
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:

```kotlin
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

- **Offline serialization.** `serialize()` contract requires no network. Enables invoice
  PDF preview, offline testing, and regression test suites without live platform credentials.
- **Uniform error handling.** `AdapterException` is the only exception type crossing the
  adapter boundary. Callers implement one error handler, not four platform-specific ones.
- **Lifecycle visibility.** `lifecycleState` is first-class. Dashboards show "HR adapter: STUB"
  and alert when a market operates in degraded state.
- **Canonical model.** `CanonicalInvoice` enables cross-market reporting and analytics.
- **NOT_IMPLEMENTED → HTTP 503.** Clients receive a clean "feature not available" response
  instead of an HTTP 500 stack trace when an adapter is in STUB state.

### 4.2 Negative

- **SEF XML schema maintenance.** RS's SEF format changes without semantic versioning
  guarantees. The adapter must track schema changes proactively.
- **BA adapters are TBD.** Phase 1B work cannot begin until regulations define the spec.
- **4 methods = all or nothing lifecycle.** If `parseIncoming()` is the last unfinished
  method, the adapter cannot advance to SANDBOX_VERIFIED. The HR partial-SANDBOX exception
  (§2.5 rule 3) is a pragmatic workaround; it should not become a pattern.

### 4.3 Risks

- **CanonicalInvoice field gap.** A platform-specific required field has no canonical
  counterpart. **Resolution:** `adapterMetadata: Map<String, String>` for platform-specific
  extras until they generalise to first-class fields.
- **Storecove CustomizationID ambiguity (D3).** Two candidate CustomizationIDs —
  PEPPOL_BIS3 and HR_CIUS. **Resolution:** Verify with Storecove support before MC #8675
  sandbox activation. This is a HALT item. Wrong choice → all HR invoices rejected.
- **Storecove routing.network field (HALT-4).** Existing code does not include
  `routing.network` field. Verify with Storecove sandbox whether this is required.
- **Secret rotation lag.** Expired API key → all `submit()` calls throw
  `AUTH_INVALID_CREDENTIALS`. **Mitigation:** 90-day rotation schedule + cert-expiry-monitor
  (Task 4.3 in Plan v3).
- **OIB validation at serialize() vs submit().** Validates early (offline) but couples
  format and validation logic. Accepted trade-off: early errors are better than late ones.

---

## 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:**

- Phase 1H Task 1H.2: `PluginHR.generateEInvoiceXml()` delegation to `StorecoveHrFiskEInvoiceAdapter`
- Phase 1H Task 1H.4: DI wiring — lifecycle state check before submit/pollStatus dispatch
- Phase 1H Task 1H.6: Storecove submit() activation (after MC #8675)
- ADR-019: Integration Adapter Registry — `AdapterConfig` table and secret taxonomy

| 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:**

- ADR-023 (why single DB remains correct — §6 supersession triggers not fired; §2 context)
- ADR-015 (TaxJurisdiction enum drives `country_code` column CHECK values)
- ADR-bilko-001 (ancestor draft, fully absorbed by this ADR — do not reference ancestor)
- ADR-bilko-003 §Layer 3 (versioned CoA data model)
- Plan v3 §4a (Option D not triggered), §4c (RLS timing — PERMISSIVE before Phase 1H merge)
- `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`

---

## 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):

- Paying customers in 2+ markets: 0 — NOT triggered
- Regulatory request for per-country data extract: none received — NOT triggered
- HR-FISK kernel-level coupling: Storecove API path requires no kernel isolation — NOT triggered
- p95 query latency > 500ms from cross-country noise: 0 paying customers — NOT triggered
- 2 customers complain about cross-country data visibility: 0 customers — NOT triggered

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):**

```sql
-- 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:**

```sql
-- 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.

```sql
-- 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):

```sql
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

```sql
-- 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:

- Pravilnik revisions without code changes (ADR-bilko-003 §Layer 3, lines 122–143)
- Historical invoice accuracy (rate in force at transaction date, not current rate)
- `CountryPlugin.getChartOfAccountsDefaults()` seeding on org creation (ADR-015 §2.2)

```sql
-- 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)`.

```sql
-- 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):**

```kotlin
// 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):

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

And in the policy, guard against empty string:

```sql
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.                                                                                                                 |
| 2026-08-23 | Securion (MC #900154) / John | Added §9 addendum: NO-GO verdict for Phase 2C RESTRICTIVE flip — customer-facing connection (`bilko-api-demo`) confirmed live to use BYPASSRLS role `bilko_admin`; application-layer WHERE org_id is the only active isolation layer today. Preconditions, soak metrics, and next-review trigger documented in §9. MC #900154, #900155. |

---

## 9. Addendum — 2026-08-23 Securion Audit Verdikt (Phase 2C Gate)

**MC:** #900154 (repo-analytic audit) + live runtime-role probe (same MC, 2026-08-23 ~10:20)
**Verdict:** **NO-GO** for Phase 2C RESTRICTIVE flip in the current state.
**Method:** read-only `git show/grep` against `azdo/main`, plus one read-only
`az containerapp show` env-var probe against `bilko-api-demo` (no live DB connection, no
code change, no deploy).

### 9.1 Core finding — RLS currently has no effect on the customer-facing connection

The customer-facing API (`bilko-api-demo`, serving `api.bilko.cloud` / `api.bilko.io` /
`api.bilko.company` per `DEPLOY-MAP.md:127,248-250`) connects to Postgres as the
`bilko_admin` role. That role carries the `BYPASSRLS` attribute
(`V30_1__ensure_bilko_admin_role.sql:19`, `V32__fix_v31_bilko_admin_role.sql:33`), which
means Postgres does not evaluate RLS policies — PERMISSIVE or RESTRICTIVE — on that
connection at all. This is independently corroborated by `DEPLOY-MAP.md:146`,
`V148__jit_org_creator_owner.sql:138-140`, `ADR-023-G1-01-DOCUMENT-GATEWAY.md` §D5, and
`docs/evidence/106851-g1-01/builder-evidence.md:88-89`, and was confirmed live on
2026-08-23 (`az containerapp show -n bilko-api-demo -g rg-bilko-demo`:
`DATABASE_USER=bilko_admin`). The only tenant-isolation layer actually in effect on the
customer-facing connection today is the ADR-005 application-layer `WHERE org_id` clause —
§2.1.3 of this ADR ("Application code MUST NOT rely solely on `WHERE org_id`") is therefore
not currently satisfied in practice, even though the RLS policies exist on disk and pass CI
against a non-bypass role.

Converting to RESTRICTIVE and retiring the application-layer `WHERE org_id` (§2.2, Phase 2C)
while the runtime role still bypasses RLS would remove the only isolation mechanism that is
actually working today and add zero protection in its place — a net regression from one
active layer to zero.

### 9.2 Additional findings (repo-analytic, not independently blocking but must close before Phase 2C)

- RESTRICTIVE has never been applied in any of the 165 Flyway migrations to date; the
  `V37__rls_restrictive.sql` DDL sketched in §2.2 above is stale against the current 60+-table
  RLS inventory (which includes split-per-command and `SECURITY DEFINER` patterns this ADR's
  original sketch did not anticipate) and must be rewritten against the current schema, not
  applied mechanically.
- At least one table holding real accounting data (`journal_postings`) has no RLS policy at
  all — not even a subquery-based one — unlike its peers (`invoice_items`,
  `bank_transactions`). `chat_conversations`, `invoice_send_events`, `account_mapping`, and
  `bilko_flags` are also uncovered gaps with an `org_id`/`organization_id` column.
- The accountant portal (`accountant_organization_access`, `accountant_profiles`,
  `V88__accountant_portal_phase1.sql`) has RLS disabled by design, with cross-org control
  held entirely in Kotlin (`AccountantContextPlugin`/`effectiveOrgId()`) — flagged in its own
  migration text as "Securion pre-prod review required" but never formally closed with a
  named, signed exception.

Full detail, evidence paths, and line citations: `~/system/evidence/900154/securion-rls-audit-2026-08-23.md`. Live-probe evidence: `~/system/evidence/900154/runtime-role-live-probe-20260823.md`.

### 9.3 GO-uz-preduslove — required before Phase 2C, in order

1. **Runtime role fix.** Change `bilko-api-demo` (and `bilko-api-stage` if it is also
   customer/E2E-facing) to connect as a role WITHOUT `BYPASSRLS` (i.e. `bilko`, a member of
   `bilko_app`), confirmed by a read-only `SELECT rolname, rolbypassrls FROM pg_roles` on the
   live connection. This is the P0-1 gap `V156__rls_bilko_app_grants_and_definer_search_path.sql`
   already flagged as a separate CEO-gated deploy-topology decision (MC #106880) and it is
   still open. Nothing below matters until this closes.
2. **Close the RLS coverage gaps** — `journal_postings` (subquery policy via
   `journal_entries`, matching the `invoice_items` pattern), `chat_conversations`,
   `invoice_send_events`, `account_mapping`, `bilko_flags`. Each proven with a positive AND
   negative rogue-role probe (0 rows for another org, correct row count for the caller's own
   org).
3. **Resolve the accountant-portal exception** — either a `SECURITY DEFINER` pattern (as
   `received_einvoice`/`V142` already demonstrates), or a formal, named, CEO-signed permanent
   exception with documented compensating controls (rate limiting on `X-Accountant-Org`,
   audit logging of every cross-org access).
4. **Rewrite the Phase 2C DDL** in §2.2 above against the current full RLS-table inventory
   (60+ tables, including split-per-command and `SECURITY DEFINER` cases) — the original
   sketch does not cover them.
5. **30-day soak starts only after step 1.** The V17 on-disk date (2026-05-11) does not
   count toward the soak clock while the connection bypasses RLS; the clock starts the day
   the runtime role is confirmed correct.
6. **CI proof the existing RLS integration test suite passes against a real Postgres**
   (Testcontainers-based, not mocked) — `RlsGate0TenantIsolationIntegrationTest.kt` and the
   13 other suites listed in the Securion audit evidence file — confirmed as a precondition,
   not assumed.
7. **Separate CEO sign-off for Phase 2C**, distinct from the 2026-05-11 sign for Phase 2A
   (§7 above already requires this; restated here as a hard precondition given the NO-GO).

### 9.4 30-day soak metrics (start only after precondition #1 closes)

| Metric                                                                         | Threshold                   | Abort trigger                                                       |
| ------------------------------------------------------------------------------ | --------------------------- | ------------------------------------------------------------------- |
| Cross-tenant RLS bypass incidents (Securion rogue-role CI probe)               | 0                           | any > 0 → reset clock to day 0                                      |
| **New:** daily confirmation `bilko-api-demo` connection has no `BYPASSRLS`     | must stay `false` every day | any day `true` → reset clock to day 0                               |
| `[OrgScope]` WARN/ERROR rows in production logs (`OrgScopeSessionVariable.kt`) | 0/day                       | > 0 → investigate before continuing                                 |
| p99 RLS overhead on top-10 query patterns                                      | < 0.5ms                     | exceeded → investigate, does not auto-reset but gate does not close |
| CI RLS integration suite                                                       | 100% green on every push    | any failure → reset clock                                           |
| Unexpected `SET ROLE` / FORCE RLS bypass attempts in Postgres audit log        | 0                           | any → reset clock                                                   |

### 9.5 Next review

Next review is scheduled **at closure of precondition #1** (runtime-role fix,
§9.3 item 1) — the date is set when that precondition closes, not on a fixed calendar date.
Owner: John (AI Director). Until precondition #1 closes, this addendum's NO-GO stands and no
further Phase 2C review is scheduled.

**Evidence:**

- `~/system/evidence/900154/securion-rls-audit-2026-08-23.md`
- `~/system/evidence/900154/runtime-role-live-probe-20260823.md`

# 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:**

- ADR-015 (CountryPlugin — plugin selects adapters for its market; plugin version compatibility)
- ADR-016 (EInvoiceAdapter — one of the 7 adapter categories; lifecycle states formalised here)
- ADR-023 (routing — market resolved at edge before adapter dispatch)
- `apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` (reference impl)
- `apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt` (AdapterLifecycleState on disk)
- Plan v3 §6 Phase 0' Task 0'4 — `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`

---

## 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:**

- `EInvoiceAdapter`: `StorecoveHrFiskEInvoiceAdapter` (HR, STUB lifecycle)
- All other categories: NOT YET IMPLEMENTED

### 2.2 Common Interface Contract

Every adapter interface extends a common `BilkoAdapter` base:

```kotlin
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.

```kotlin
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.

```sql
-- 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:**

```kotlin
// 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):

- Compiles and registers successfully
- All network methods throw `AdapterException(code=NOT_IMPLEMENTED)`
- `serialize()` / read-only operations may work (e.g., HR serialize works in STUB)
- `AdapterConfig.enabled` is `false`

**SANDBOX_VERIFIED** transition criteria (all must be true):

- Minimum 5 distinct happy-path test cases pass against the real sandbox platform
  (not mocked — real submission IDs, real response payloads)
- All test case submission IDs are archived in BookStack as evidence
- Error mapping verified: at least HTTP 400, 401, 409, 429, 503, 5xx all produce
  correct `AdapterErrorCode` values (not `UNKNOWN`)
- Proveo sign-off with evidence file path in MC task
- `AdapterConfig.enabled` can be set to `true` after this point

**PRODUCTION** transition criteria (all must be true):

- `SANDBOX_VERIFIED` already achieved
- Securion review of adapter error handling, PII sanitization, and idempotency key
  implementation — no critical findings
- 30 consecutive days on stage Cloud Run with:
  - Zero `PLATFORM_INTERNAL_ERROR` alerts
  - Zero cross-market routing errors
  - `bilko_integration_request_total{status="error"}` < 1% of total requests
- CEO approval for production activation
- `AdapterConfig.enabled = true` in production DB (separate row from stage DB)

### 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

```kotlin
// 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:**

- OIB, PIB, JIB (tax IDs)
- IBAN
- `document_data` (invoice XML body)
- `api_key`, `api_secret`

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

**Prometheus metrics (one counter per adapter):**

```kotlin
// 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:

- Error rate > 10% over 5 minutes: PAGE (PagerDuty or Slack alert)
- Error rate > 25% over 1 minute: CRITICAL (adapter auto-disabled via `AdapterConfig`)

### 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:

```kotlin
// 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

- **Zero platform exception leakage.** `AdapterException` as the only crossing type means
  core services have one error handler for all 7 × 4 = 28 potential adapter instances.
- **Hot disable without redeploy.** `AdapterConfig.enabled = false` disables a broken
  adapter in < 1 minute (DB write). No restart required. Incident response time drops
  from minutes (redeploy) to seconds (DB update).
- **Observability from day one.** Every adapter emits standardized metrics. Error rate
  alerts fire before users report issues.
- **Secret hygiene.** Env-first taxonomy (`Bilko/{env}/{market}/{secret}`) makes
  environment promotion (stage → production) a structured operation, not an ad-hoc
  copy. Break-glass access is separated from runtime access.

### 4.2 Negative

- **Adapter scaffolding cost.** Each new adapter requires implementing the full
  interface contract, `AdapterConfig` rows, `SecretResolver` wiring, and Prometheus
  metrics. Estimate: 1–2 days for a new adapter in STUB state.
- **AdapterConfig is a deployment dependency.** The application fails at startup if
  `adapter_config` rows do not exist. Flyway V20 must run before the application
  version that adds `requireEnabled()` checks.

### 4.3 Risks

- **`AdapterConfig` DB unavailable.** If the database is unreachable, `requireEnabled()`
  fails, blocking all adapters. Mitigation: cache `AdapterConfig` in-memory at startup
  with a TTL of 5 minutes. Use cached state if DB is unreachable.
- **Metrics cardinality.** High `org_id` cardinality in metrics labels would cause
  Prometheus memory issues. The observability mandate specifies `org_id` in log lines,
  NOT in Prometheus labels. Labels are `market`, `integration`, `status` only — bounded
  cardinality.

---

## 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:**

- Phase 1H: `AdapterConfig` Flyway V20 migration
- Phase 1H: `SecretResolver` GCP implementation
- Phase 1H: Prometheus metrics wiring in `StorecoveHrFiskEInvoiceAdapter`
- Phase 1S: SEF RS adapter scaffolding (knows the contract to implement against)
- Phase 1B: CPF/UINO BA adapter stubs

| 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/`:**

- Package: `no.alai.bilko`
- Kotlin 2.3.0 / Ktor 3.4.0 / JVM 25
- 51 `.kt` source files + 3 test files
- Full domain: HR-FISK, SEF, EInvoice, AdapterException (14 codes), Koin DI, Redis, Apache PDFBox, Sentry, EmailService, SecurityHeaders, CORS, RateLimit
- All ADR-015 through ADR-019 reference `no.alai.bilko` paths inside `backend/`
- Last commit: 2026-04-23 (John, active)

**`apps/api-kotlin/`:**

- Package: `io.bilko`
- Kotlin 2.1.20 / Ktor 3.1.2 / JVM 21
- 17 `.kt` source files + 3 test files
- Skeleton only: Auth, DB tables, feature route scaffolds
- Missing: HR-FISK, SEF, EInvoice adapter interface, AdapterException, Koin DI, Redis, PDFBox, Sentry, EmailService, RateLimit, CORS
- Last commit: 2026-04-15 `ee27c6b` (Makinja, **13 days stale** at discovery)
- NOT deployed (confirmed by `docs/evidence/9386/verification.json` line 36 and `docs/evidence/9398/verify-cookie-fix.js` line 76)
- NOT referenced in any architecture document

### 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:

- `backend/` fulfills the Kotlin/Ktor migration requirement — the migration is structurally
  complete at the backend layer.
- MC #5125 may be closed (DONE) once BUILD-BLUEPRINT.md is updated (MC #9897) to document
  `backend/` as the canonical backend and the Express legacy (`apps/api-legacy/`) as
  deprecated.
- The specific Track A intent ("apps/api-kotlin/ is the migration landing zone") is superseded
  by this ADR. Track B work proceeds in `backend/` directly.

### 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

- **MC #9892** — Forensic audit: dual Kotlin backend root-cause analysis
- **MC #9894** — C2: Archive `apps/api-kotlin/`
- **MC #9895** — C3: Port auth improvements to `backend/`
- **MC #9897** — C4: Update BUILD-BLUEPRINT.md
- **MC #9898** — C5/C6: Dockerfile + DEPLOY-MAP.md + Proveo verify
- **MC #5125** — Bilko backend migration: Express/TS → Kotlin/Ktor (to be closed after MC #9897)
- **ADR-015** — Four-Jurisdiction Plugin Architecture (references `no.alai.bilko`)
- **ADR-016** — E-Invoice Adapter and UBL 2.1 Canonical Model (references `no.alai.bilko`)
- **ADR-017** — RLS Multi-Tenancy (references `no.alai.bilko`)
- **ADR-018** — Market Locale Separation (references `no.alai.bilko`)
- **ADR-019** — Integration Adapter Registry (explicitly verified against `backend/` path)
- **docs/bookstack-sync/phase1-track-a-execution.md** — Phase 1 Track A intent document (lines 241, 299 superseded by this ADR)
- **Forensic reports** — `/tmp/bilko-dual-backend-da.md`, `/tmp/bilko-dual-backend-petter.md`

---

## Approval

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