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) User consent: Explicit BankID consent required for AISP + PISP access SCA (Strong Customer Authentication): Required for all payments Data minimization: Only store what's necessary for compliance Audit trail: All PISP/AISP operations logged in audit_log table 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: { "email": "amir@example.com", "password": "min8chars", "firstName": "Amir", "lastName": "Hadžić", "phone": "+4712345678" } Response 201: { "data": { "id": "usr_abc123", "email": "amir@example.com", "firstName": "Amir", "lastName": "Hadžić", "kycStatus": "pending", "createdAt": "2026-02-09T10:00:00Z" } } Sets httpOnly JWT cookie Errors: 400 (validation), 409 (email exists) POST /api/auth/login Description: Login and receive JWT cookie Request: { "email": "amir@example.com", "password": "min8chars" } Response 200: { "data": { "id": "usr_abc123", "email": "amir@example.com", "firstName": "Amir", "lastName": "Hadžić", "kycStatus": "approved" } } Sets httpOnly JWT cookie (24h expiry). Note: balance is NOT returned here — use /api/bank-accounts (AISP) to read bank balance. Errors: 401 (wrong credentials), 423 (account locked) POST /api/auth/logout Description: Clear JWT cookie Auth: Required Response 200: { "message": "Logged out" } Clears httpOnly cookie GET /api/auth/me Description: Get current user from JWT Auth: Required Response 200: { "data": { "id": "usr_abc123", "email": "amir@example.com", "firstName": "Amir", "lastName": "Hadžić", "kycStatus": "approved", "createdAt": "2026-02-09T10:00:00Z" } } 3. Bank Accounts (AISP — Pass-through) Pass-through model: Drop never holds customer money. Balance is read from user's real bank account via Open Banking (AISP). Payments are initiated via PISP from user's bank. GET /api/bank-accounts Description: Get linked bank accounts and balances via AISP (Open Banking) Auth: Required (BankID consent) Response 200: { "data": [ { "id": "ba_1", "bankName": "SpareBank 1", "accountNumber": "*****1234", "balance": 12450.00, "currency": "NOK", "lastSynced": "2026-02-09T10:00:00Z" } ] } Note: Balance is a cached AISP read from the user's actual bank account. Drop does not store or manage this balance. GET /api/users/balance — REMOVED POST /api/users/top-up — REMOVED These endpoints were part of the old wallet model and have been removed. In the pass-through model, there is no wallet to check or top up. Use /api/bank-accounts to read bank balances via AISP. 4. Recipients GET /api/recipients Description: List user's saved recipients Auth: Required Query: ?page=1&limit=20 Response 200: { "data": [ { "id": "rec_1", "name": "Mama Jasmina", "country": "RS", "countryName": "Serbia", "currency": "RSD", "bankAccount": "*****1234", "createdAt": "2026-02-01T10:00:00Z" } ], "pagination": { "page": 1, "limit": 20, "total": 3 } } POST /api/recipients Description: Add new recipient Auth: Required Request: { "name": "Mama Jasmina", "country": "RS", "currency": "RSD", "bankAccount": "265100000012345678", "bankName": "Banca Intesa" } Response 201: { "data": { "id": "rec_new", "name": "Mama Jasmina", "country": "RS", "currency": "RSD", "bankAccount": "*****5678", "createdAt": "2026-02-09T10:00:00Z" } } Errors: 400 (validation), 422 (unsupported country) DELETE /api/recipients/:id Description: Remove recipient Auth: Required Response 204: No content 5. Exchange Rates GET /api/rates Description: Get current exchange rates for all corridors Auth: Not required Response 200: { "data": { "baseCurrency": "NOK", "rates": { "RSD": 11.70, "BAM": 1.04, "PLN": 0.41, "PKR": 26.80, "TRY": 3.45, "EUR": 0.089 }, "updatedAt": "2026-02-09T10:00:00Z" } } GET /api/rates/:currency Description: Get rate for specific currency pair Auth: Not required Response 200: { "data": { "from": "NOK", "to": "RSD", "rate": 11.70, "fee": 0.005, "updatedAt": "2026-02-09T10:00:00Z" } } Errors: 404 (unsupported currency) 6. Transactions — Remittance POST /api/transactions/remittance Description: Create new remittance transfer Auth: Required (KYC must be approved) Request: { "recipientId": "rec_1", "amount": 2000.00, "currency": "NOK" } Response 201: { "data": { "id": "tx_rem_123", "type": "remittance", "status": "processing", "sendAmount": 2000.00, "sendCurrency": "NOK", "receiveAmount": 23400.00, "receiveCurrency": "RSD", "exchangeRate": 11.70, "fee": 10.00, "feePercent": 0.5, "total": 2010.00, "recipientName": "Mama Jasmina", "recipientCountry": "RS", "eta": "1-2 business days", "createdAt": "2026-02-09T10:00:00Z" } } Errors: 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: { "merchantId": "mer_1", "amount": 129.00 } Response 201: { "data": { "id": "tx_qr_456", "type": "qr_payment", "status": "completed", "amount": 129.00, "currency": "NOK", "fee": 1.29, "feePercent": 1.0, "merchantName": "Ahmetov Kebab", "merchantId": "mer_1", "createdAt": "2026-02-09T14:23:00Z" } } Errors: 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: { "data": [ { "id": "tx_rem_123", "type": "remittance", "status": "completed", "amount": -2000.00, "currency": "NOK", "recipientName": "Mama Jasmina", "createdAt": "2026-02-09T10:00:00Z" }, { "id": "tx_qr_456", "type": "qr_payment", "status": "completed", "amount": -129.00, "currency": "NOK", "merchantName": "Ahmetov Kebab", "createdAt": "2026-02-09T14:23:00Z" } ], "pagination": { "page": 1, "limit": 20, "total": 47 } } GET /api/transactions/:id Description: Get transaction detail Auth: Required Response 200: { "data": { "id": "tx_rem_123", "type": "remittance", "status": "completed", "sendAmount": 2000.00, "sendCurrency": "NOK", "receiveAmount": 23400.00, "receiveCurrency": "RSD", "exchangeRate": 11.70, "fee": 10.00, "total": 2010.00, "recipientName": "Mama Jasmina", "recipientCountry": "RS", "createdAt": "2026-02-09T10:00:00Z", "completedAt": "2026-02-10T14:30:00Z" } } 9. Merchants POST /api/merchants/register Description: Register as a merchant Auth: Required Request: { "businessName": "Ahmetov Kebab", "orgNumber": "923456789", "address": "Grønland 12, Oslo", "bankAccount": "1234.56.78901" } Response 201: { "data": { "id": "mer_1", "businessName": "Ahmetov Kebab", "orgNumber": "923456789", "qrCode": "drop://pay/mer_1", "status": "active", "feeRate": 0.01, "createdAt": "2026-02-09T10:00:00Z" } } Errors: 400 (validation), 409 (org number exists) GET /api/merchants/dashboard Description: Get merchant stats Auth: Required (merchant role) Query: ?period=today|week|month Response 200: { "data": { "period": "today", "revenue": 4350.00, "transactionCount": 12, "fees": 43.50, "netRevenue": 4306.50, "nextPayout": 4306.50, "payoutTime": "17:00" } } GET /api/merchants/transactions Description: List merchant's received payments Auth: Required (merchant role) Query: ?page=1&limit=20&date=2026-02-09 Response 200: { "data": [ { "id": "tx_qr_456", "customerName": "Amir K.", "amount": 129.00, "fee": 1.29, "net": 127.71, "status": "completed", "time": "14:23" } ], "pagination": { "page": 1, "limit": 20, "total": 12 } } GET /api/merchants/qr Description: Get merchant's QR code data Auth: Required (merchant role) Response 200: { "data": { "merchantId": "mer_1", "businessName": "Ahmetov Kebab", "qrValue": "drop://pay/mer_1", "address": "Grønland 12, Oslo" } } 10. Database Schema (PostgreSQL 16 — 19 tables) Source: src/shared/db/schema.ts (Drizzle ORM schema — PostgreSQL 16, all environments; better-sqlite3 removed per ADR-014) Core Tables (12) Note: The SQL below is a historical snapshot from the original spec (SQLite syntax). The authoritative schema is src/shared/db/schema.ts (Drizzle ORM, PostgreSQL 16). Use make db-push to apply schema changes. -- Users (NO balance field — pass-through model) CREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, first_name TEXT NOT NULL, last_name TEXT NOT NULL, phone TEXT, date_of_birth TEXT, kyc_status TEXT DEFAULT 'pending' CHECK(kyc_status IN ('pending','approved','rejected')), role TEXT DEFAULT 'user' CHECK(role IN ('user','merchant')), risk_level TEXT DEFAULT 'low' CHECK(risk_level IN ('low','medium','high')), pep_status TEXT DEFAULT 'not_checked' CHECK(pep_status IN ('not_checked','clear','match','pending_review')), sanctions_cleared INTEGER DEFAULT 0, kyc_method TEXT CHECK(kyc_method IN ('bankid','document','simplified')), kyc_verified_at TEXT, national_id_hash TEXT, deleted_at TEXT, created_at TEXT DEFAULT (datetime('now')) ); -- Bank accounts (balance is cached AISP read, NOT held by Drop) CREATE TABLE bank_accounts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), bank_name TEXT NOT NULL, account_number TEXT NOT NULL, iban TEXT, balance REAL DEFAULT 0, -- Cached AISP-read balance (read-only in production) balance_synced_at TEXT, -- When balance was last synced from bank via AISP currency TEXT DEFAULT 'NOK', is_primary INTEGER DEFAULT 0, connected_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE recipients ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, country TEXT NOT NULL, currency TEXT NOT NULL, bank_account TEXT NOT NULL, bank_name TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE merchants ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), business_name TEXT NOT NULL, org_number TEXT UNIQUE NOT NULL, address TEXT, bank_account TEXT NOT NULL, fee_rate REAL DEFAULT 0.01, status TEXT DEFAULT 'active', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE transactions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), type TEXT NOT NULL CHECK(type IN ('remittance','qr_payment')), status TEXT DEFAULT 'processing' CHECK(status IN ('processing','completed','failed')), amount REAL NOT NULL, currency TEXT DEFAULT 'NOK', fee REAL DEFAULT 0, recipient_id TEXT REFERENCES recipients(id), merchant_id TEXT REFERENCES merchants(id), send_amount REAL, send_currency TEXT, receive_amount REAL, receive_currency TEXT, exchange_rate REAL, purpose_code TEXT, created_at TEXT DEFAULT (datetime('now')), completed_at TEXT ); CREATE TABLE exchange_rates ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- SERIAL for PostgreSQL from_currency TEXT DEFAULT 'NOK', to_currency TEXT NOT NULL, rate REAL NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE cards ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), type TEXT DEFAULT 'virtual' CHECK(type IN ('virtual','physical')), last_four TEXT NOT NULL, token_ref TEXT, expiry TEXT NOT NULL, status TEXT DEFAULT 'active' CHECK(status IN ('active','frozen','cancelled')), shipping_address TEXT, pin_hash TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), token_hash TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), expires_at TEXT NOT NULL, revoked INTEGER DEFAULT 0 ); CREATE TABLE notifications ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), type TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, read INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE settings ( user_id TEXT PRIMARY KEY REFERENCES users(id), currency TEXT DEFAULT 'NOK', language TEXT DEFAULT 'nb', push_enabled INTEGER DEFAULT 1, email_enabled INTEGER DEFAULT 1, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE spending_limits ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), card_id TEXT REFERENCES cards(id), limit_type TEXT NOT NULL, amount REAL NOT NULL, currency TEXT DEFAULT 'NOK', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE rate_limits ( key TEXT PRIMARY KEY, count INTEGER NOT NULL, reset_at INTEGER NOT NULL ); Compliance Tables (7) — Added 2026-02-16 CREATE TABLE audit_log ( id TEXT PRIMARY KEY, timestamp TEXT DEFAULT (datetime('now')), user_id TEXT REFERENCES users(id), action TEXT NOT NULL, resource_type TEXT, resource_id TEXT, details TEXT, ip_address TEXT, user_agent TEXT ); CREATE TABLE aml_alerts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), alert_type TEXT NOT NULL, severity TEXT NOT NULL CHECK(severity IN ('low','medium','high','critical')), transaction_id TEXT REFERENCES transactions(id), details TEXT, status TEXT DEFAULT 'open' CHECK(status IN ('open','investigating','resolved','escalated','filed')), reviewed_by TEXT, reviewed_at TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE str_reports ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), alert_id TEXT REFERENCES aml_alerts(id), report_type TEXT NOT NULL, status TEXT DEFAULT 'draft' CHECK(status IN ('draft','submitted','acknowledged')), filed_at TEXT, reference_number TEXT, details TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE screening_results ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), screening_type TEXT NOT NULL CHECK(screening_type IN ('pep','sanctions','adverse_media')), provider TEXT, result TEXT NOT NULL CHECK(result IN ('clear','match','potential_match','error')), match_details TEXT, screened_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE consents ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), consent_type TEXT NOT NULL, granted INTEGER NOT NULL DEFAULT 1, granted_at TEXT DEFAULT (datetime('now')), withdrawn_at TEXT, ip_address TEXT ); CREATE TABLE data_access_requests ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), request_type TEXT NOT NULL CHECK(request_type IN ('export','erasure','rectification','restriction')), status TEXT DEFAULT 'pending' CHECK(status IN ('pending','processing','completed','rejected')), requested_at TEXT DEFAULT (datetime('now')), completed_at TEXT, download_url TEXT, notes TEXT ); CREATE TABLE complaints ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), category TEXT NOT NULL, subject TEXT NOT NULL, description TEXT NOT NULL, status TEXT DEFAULT 'received' CHECK(status IN ('received','investigating','resolved','escalated')), resolution TEXT, created_at TEXT DEFAULT (datetime('now')), resolved_at TEXT ); Indexes -- Core indexes CREATE INDEX idx_transactions_user ON transactions(user_id); CREATE INDEX idx_transactions_merchant ON transactions(merchant_id); CREATE INDEX idx_recipients_user ON recipients(user_id); CREATE INDEX idx_merchants_org ON merchants(org_number); CREATE INDEX idx_bank_accounts_user ON bank_accounts(user_id); CREATE INDEX idx_cards_user ON cards(user_id); CREATE INDEX idx_sessions_user ON sessions(user_id); CREATE INDEX idx_sessions_token ON sessions(token_hash); CREATE INDEX idx_notifications_user ON notifications(user_id); CREATE INDEX idx_spending_limits_user ON spending_limits(user_id); CREATE INDEX idx_spending_limits_card ON spending_limits(card_id); -- Compliance indexes CREATE INDEX idx_audit_log_user ON audit_log(user_id); CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp); CREATE INDEX idx_audit_log_action ON audit_log(action); CREATE INDEX idx_aml_alerts_user ON aml_alerts(user_id); CREATE INDEX idx_aml_alerts_status ON aml_alerts(status); CREATE INDEX idx_screening_user ON screening_results(user_id); CREATE INDEX idx_consents_user ON consents(user_id); CREATE INDEX idx_data_requests_user ON data_access_requests(user_id); CREATE INDEX idx_complaints_user ON complaints(user_id); CREATE INDEX idx_complaints_status ON complaints(status); Note: Schema uses PostgreSQL 16 in all environments (development, CI, staging, production) via Drizzle ORM. The old dual-mode SQLite/PostgreSQL driver has been removed (ADR-014). 11. Common Error Format All errors follow this format: { "error": "error_code", "message": "Human readable message", "details": [] } Code Error Description 400 bad_request Malformed request body 401 unauthorized Missing or expired JWT 402 insufficient_balance Not enough NOK in bank account (PISP will fail) 403 kyc_required KYC must be approved 404 not_found Resource not found 409 conflict Duplicate (email, org number) 422 validation_error Field validation failed 429 rate_limited Too many requests 500 internal_error Server error 12. Rate Limits Endpoint Group Limit Window Auth (login/register) 10/min Per IP Transactions (create) 30/min Per user Read endpoints 60/min Per user Exchange rates 120/min Per IP 13. API Route File Structure (Next.js) src/app/api/ ├── auth/ │ ├── register/route.ts │ ├── login/route.ts │ ├── logout/route.ts │ └── me/route.ts ├── bank-accounts/ │ └── route.ts (GET linked accounts via AISP) ├── recipients/ │ ├── route.ts (GET list, POST create) │ └── [id]/route.ts (DELETE) ├── transactions/ │ ├── route.ts (GET list) │ ├── [id]/route.ts (GET detail) │ ├── remittance/route.ts (POST) │ └── qr-payment/route.ts (POST) ├── merchants/ │ ├── register/route.ts │ ├── dashboard/route.ts │ ├── transactions/route.ts │ └── qr/route.ts ├── rates/ │ ├── route.ts (GET all) │ └── [currency]/route.ts (GET specific) └── lib/ ├── db.ts (PostgreSQL/Drizzle connection) ├── auth.ts (JWT verify/sign) └── middleware.ts (auth middleware, rate limit) Generated: 2026-02-09 by dev agent (Ollama) + John (orchestration) Status: Ready for implementation (Sprint 1) ADR-015 — Four-Jurisdiction Plugin Architecture (CountryPlugin Kotlin Interface) ADR-015 — Four-Jurisdiction Plugin Architecture (CountryPlugin Kotlin Interface) Status: Accepted Date: 2026-05-13 Author: Petter Graff (CodeCraft — Architecture Lead) Decision-maker: CEO Alem Bašić MC Task: #100585 (Phase 0' ADR Consolidation — CountryPlugin interface) Supersedes: ADR-015 v1 (2026-05-11, MC #100362) — this is the authoritative version Cross-references: 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: ComplianceCalendarService.kt — manual when(organization.country) branching 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 package no.alai.bilko.country /** * Canonical tax jurisdictions supported by Bilko. * * DB column constraint: CHECK country_code IN ('HR', 'RS', 'BA_FED', 'BA_RS') * NOTE: BA bare value is retained in the Kotlin enum during the V16 migration window * to allow backfill of existing DB rows. Remove BA after V16 validates on prod. * * See: ADR-015 §2.1, Plan v3 §6 Phase 1H.1 */ enum class TaxJurisdiction { HR, // Croatia — EUR, Storecove/Peppol via FINA AS4, PDV 25/13/5% RS, // Serbia — RSD, SEF (Sistem e-faktura), PDV 20/10% BA, // Bosnia bare value — DEPRECATED, retained for V16 backfill window only BA_FED, // Bosnia FBiH — BAM, CPF e-invoice (stub), UIO-FBiH, FBiH Pravilnik, PDV 17% BA_RS, // Bosnia RS entity — BAM, UINO (stub), Poreska Uprava RS entity, PDV 17% } Migration note: Flyway V16 backfills BA → BA_FED rows, then adds the NOT NULL + CHECK constraint. BA is removed from the enum in a cleanup MC after V16 validates on prod. 2.2 CountryPlugin Interface — Full Contract Written to apps/api/src/main/kotlin/no/alai/bilko/country/CountryPlugin.kt . Interface invariant: Zero if jurisdiction == branches in core services ( services/ , routes/ ). All market differences are absorbed here. package no.alai.bilko.country import no.alai.bilko.einvoice.CanonicalInvoice import no.alai.bilko.einvoice.EInvoiceAdapter import java.util.Currency /** * Per-jurisdiction plugin — single extension point for all market-specific behaviour. * * INVARIANT: No `if jurisdiction == X` or `when(jurisdiction)` branches in * apps/api/src/main/kotlin/no/alai/bilko/{services,routes}/. * All market differences are absorbed here. (ADR-bilko-002 Variant C) * * Implementations: * PluginHR → country/hr/PluginHR.kt (Phase 1H — priority) * PluginRS → country/rs/PluginRS.kt (stub, Phase 1S) * PluginBAFED → country/ba/PluginBAFED.kt (stub, Phase 1B) * PluginBARS → country/ba/PluginBARS.kt (stub, Phase 1B) * * DI: plugins/DI.kt registers all 4 in a Map. * 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 /** * 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 /** * Returns data retention policy for this jurisdiction. * * HR: 11 years — Zakon o računovodstvu NN 78/2015, čl. 10 * RS: 10 years — Zakon o računovodstvu RS * BA-FED / BA-RS: 10 years * * Used by the document archiving service to set per-org retention periods and by * the RLS audit partition (ADR-017 Phase 2B). */ fun getRetentionRules(): RetentionPolicy /** * Returns the functional currency for this jurisdiction. * * HR: Currency.getInstance("EUR") — Croatia adopted EUR 2023-01-01 * RS: Currency.getInstance("RSD") * BA-FED / BA-RS: Currency.getInstance("BAM") * * Core invoice service validates CanonicalInvoice.currencyCode against this on creation. */ fun getCurrency(): Currency /** * Returns locale-specific formatters for this jurisdiction. * * HR: decimal='.', thousands=',', date='dd.MM.yyyy', tz='Europe/Zagreb' * RS: decimal=',', thousands='.', date='dd.MM.yyyy', tz='Europe/Belgrade' * BA: decimal=',', thousands='.', date='dd.MM.yyyy', tz='Europe/Sarajevo' * * Used by report generation, PDF invoices, and UI date/number display. */ fun getFormatters(): JurisdictionFormatters /** * Extension hook for jurisdiction-specific validation beyond the standard 8 methods. * * Called by InvoiceService before invoice creation. Default implementation is a no-op; * override to add market-specific business rules (e.g., HR OIB cross-validation * against FINA company registry once APRCompanyRegistryAdapter is live). * * This hook is the designated extension point to avoid adding new required interface * methods for market-specific edge cases. See §3.2 for evolution contract. * * @param invoice draft canonical invoice before persistence * @throws no.alai.bilko.adapter.AdapterException with VALIDATION_BUSINESS_RULE if invalid */ fun validateInvoiceForJurisdiction(invoice: CanonicalInvoice) { // Default: no-op. Override in PluginHR, PluginRS etc. as needed. } } 2.3 Supporting Value Types Defined in no.alai.bilko.country package (or no.alai.bilko.country.model ): // VAT calculation result data class VatResult( val lines: List, val totalVatAmount: java.math.BigDecimal, val totalTaxableAmount: java.math.BigDecimal, ) data class VatLine( val rate: java.math.BigDecimal, // e.g. BigDecimal("25.0000") val category: no.alai.bilko.einvoice.TaxCategory, val taxableAmount: java.math.BigDecimal, val taxAmount: java.math.BigDecimal, val description: String, // Human-readable, e.g. "HR standard PDV 25%" ) // Fiscal submission input data class FiscalReceipt( val serializedInvoice: ByteArray, val canonicalInvoice: no.alai.bilko.einvoice.CanonicalInvoice, ) data class FiscalSubmissionHandle( val platformInvoiceId: String, // Storecove GUID, SEF ID, etc. val initialStatus: no.alai.bilko.einvoice.EInvoiceStatus, val submittedAt: java.time.Instant, ) // Chart of Accounts entry data class ChartOfAccountEntry( val code: String, // e.g. "1300" (HR) or "204" (RS) val name: String, val type: AccountType, // ASSET, LIABILITY, EQUITY, INCOME, EXPENSE val vatTreatment: String?, ) // Filing deadline data class FilingDeadline( val name: String, // e.g. "Quarterly PDV return Q1 2026" val dueDate: java.time.LocalDate, val authority: String, // e.g. "Porezna uprava HR (ePorezna)" val periodStart: java.time.LocalDate, val periodEnd: java.time.LocalDate, ) // Data retention data class RetentionPolicy( val years: Int, // 10 or 11 depending on jurisdiction val legalBasis: String, // Statutory reference val jurisdiction: TaxJurisdiction, ) // Formatters data class JurisdictionFormatters( val decimalSeparator: Char, val thousandsSeparator: Char, val datePattern: String, // ISO strftime-compatible, e.g. "dd.MM.yyyy" val timeZoneId: String, // IANA tz, e.g. "Europe/Zagreb" val currencySymbol: String, val currencyPosition: CurrencyPosition, // PREFIX or SUFFIX ) enum class CurrencyPosition { PREFIX, SUFFIX } 2.4 DI Wiring Strategy JWT reality: The JWT access token contains orgId only (verified in JwtService.kt lines 35–45). The org.country value is NOT embedded in the JWT. It is fetched from the organizations DB table at request time by middleware before the route handler runs. Resolution pipeline: HTTP request → JWT validation (JwtService.verifyAccessToken) → extract orgId from JWT claim "orgId" → DB: SELECT country FROM organizations WHERE id = orgId (OrgScopePlugin / middleware) → TaxJurisdiction.valueOf(country) → PluginRegistry.resolve(jurisdiction) → CountryPlugin dispatch DI registration in plugins/DI.kt : // Phase 1H Task 1H.4 val pluginRegistry: Map = 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> { pluginRegistry } // Resolution helper (usable from any Koin-injected service): fun resolvePlugin( jurisdiction: TaxJurisdiction, registry: Map ): CountryPlugin = registry[jurisdiction] ?: throw IllegalStateException( "No CountryPlugin registered for $jurisdiction — check DI.kt registration" ) Services that need a CountryPlugin receive it via constructor injection: class InvoiceService( private val pluginRegistry: Map // ... 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: 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. 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 . 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 : Prefer the extension hook ( validateInvoiceForJurisdiction ) for market-specific validation that does not generalise across all markets. If a new method is genuinely cross-market: add it with a default body that throws UnsupportedOperationException("Not implemented for $jurisdiction — see MC #XXXX") . Override in PluginHR (priority market) first; other plugins follow in their phase. 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: interface EInvoiceAdapter { val jurisdiction: TaxJurisdiction val lifecycleState: AdapterLifecycleState /** * Serialize a canonical invoice to the adapter-specific wire format. * * CONTRACT: * - MUST be offline-capable — no network, no credentials required. * - MUST be deterministic: same [invoice] input produces identical bytes. * - MUST NOT log raw PII fields (OIB, IBAN, document_data) — call sanitizeForLog(). * - Returns the full wire-format payload for the platform: * HR: Storecove JSON envelope wrapping UBL 2.1 XML * RS: SEF XML (Serbian Ministry of Finance schema) * BA: CPF/UINO platform format (TBD) * - Throws AdapterException(VALIDATION_BUSINESS_RULE) for constraint violations * (non-EUR currency for HR, invalid OIB, empty lines, etc.) * - AdapterConfig.enabled check is NOT performed here — callers check before invoking. * - This method is ALWAYS available, even in STUB lifecycle. */ fun serialize(invoice: CanonicalInvoice): ByteArray /** * Submit the serialized invoice bytes to the fiscal platform. * * CONTRACT: * - Requires live credentials (API key, OAuth token, or certificate). * - MUST include an idempotency key (platform-specific — see §2.3). * - Returns SubmitResult on success; throws AdapterException on ALL failures. * - NEVER propagates platform-native exceptions (Ktor ResponseException, etc.) — * map every platform exception to AdapterException before propagating. * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5). * - Idempotency: platforms may return 409 DUPLICATE on re-submission. * Caller should treat 409 as success — extract submission ID from error body. * * @param serializedInvoice bytes from serialize() * @param invoice original CanonicalInvoice (needed for idempotency key generation) */ fun submit(serializedInvoice: ByteArray, invoice: CanonicalInvoice): SubmitResult /** * Poll the fiscal platform for the current status of a submitted invoice. * * CONTRACT: * - [submissionId] is SubmitResult.platformInvoiceId from submit(). * - Returns current EInvoiceStatus. * - This method is IDEMPOTENT — safe to call multiple times with the same submissionId. * - Callers implement exponential backoff; this method does NOT retry internally. * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5). * - NEVER log rawPayload without sanitizeForLog(). */ fun pollStatus(submissionId: String, invoice: CanonicalInvoice): EInvoiceStatus /** * Parse an inbound invoice from a raw fiscal platform webhook payload. * * CONTRACT: * - [rawPayload] is the raw bytes from the platform webhook (Storecove POST, SEF callback). * - Returns CanonicalInvoice with adapterMetadata populated for platform-specific fields: * HR: "hr.supplierOib", "hr.buyerOib", "hr.pozivNaBroj" * RS: "rs.supplierPib", "rs.buyerPib", "rs.sefId" * - Implementations in STUB lifecycle MUST throw NOT_IMPLEMENTED (see §2.5). * - NEVER log rawPayload before passing through sanitizeForLog(). * - parseIncoming() is deferred for HR: not required for v1 HR GA (Phase 1H.6 scope). * Implement 90 days post-GA (see Plan v3 §4d). */ fun parseIncoming(rawPayload: ByteArray): CanonicalInvoice } 2.2 CanonicalInvoice — EN 16931 Subset The internal invoice representation, independent of any platform wire format. Defined in EInvoiceTypes.kt lines 141–156: data class CanonicalInvoice( val id: String, // Internal UUID — Storecove document_id (D2 dedup) val invoiceNumber: String, // BT-1: human-readable invoice number val issueDate: LocalDate, // BT-2 val dueDate: LocalDate, // BT-9 val typeCode: InvoiceTypeCode, // BT-3: UNTDID 1001 (380/381/383/384) val currencyCode: String, // BT-5: ISO 4217 ("EUR", "RSD", "BAM") val jurisdiction: TaxJurisdiction, // Routing discriminator (non-EN16931) val supplier: PartyInfo, // BG-4: name, taxId (OIB/PIB/JIB), address val buyer: PartyInfo, // BG-7: same structure val lines: List, // BG-25: quantity, unitPrice, lineTotal, taxRate val taxBreakdowns: List, // 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 = 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": "", "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 = 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: Any STUB lifecycle method that is not yet operational MUST throw: throw AdapterException( code = AdapterErrorCode.NOT_IMPLEMENTED, market = jurisdiction, retryable = false, rawPayload = "", message = " requires account — MC #" ) serialize() is EXEMPT from the NOT_IMPLEMENTED requirement — it SHOULD be operational even in STUB lifecycle because it needs no credentials (offline contract). 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: Before calling submit() or pollStatus() , callers MUST check: val config = adapterConfigRepo.find(jurisdiction, "EINVOICE") ?: throw AdapterException(NOT_IMPLEMENTED, ...) if (!config.enabled) throw AdapterException(NOT_IMPLEMENTED, ..., message="Adapter disabled: ${config.reason}") NOT_IMPLEMENTED caught at the route handler level maps to HTTP 503 (Service Unavailable) with body {"error": "ADAPTER_NOT_AVAILABLE", "market": ""} , NOT HTTP 500. This is the stub plugin HTTP 500 risk mitigation from ADR-015 §5.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: # 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: _ , 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: CREATE TABLE adapter_config ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), market VARCHAR(8) NOT NULL, -- TaxJurisdiction enum value adapter_type VARCHAR(32) NOT NULL, -- 'EINVOICE', 'BANK_STATEMENT', etc. enabled BOOLEAN NOT NULL DEFAULT FALSE, reason TEXT, -- Human-readable status note updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (market, adapter_type) ); Seed row for HR STUB state (Flyway V17): INSERT INTO adapter_config (market, adapter_type, enabled, reason) VALUES ('HR', 'EINVOICE', false, 'Storecove account pending — MC #8675'); Row is flipped to enabled = true by the operator (not by code) after SANDBOX_VERIFIED transition is confirmed by Proveo evidence. 3.2 Adapter Versioning Each adapter exposes: val adapterVersion: String // e.g. "1.0.0" The CountryPlugin implementation declares a minimum adapter version. Incompatibility detected at startup → application fails fast with a clear error (not silent degradation). 4. Consequences 4.1 Positive 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 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 Organization.taxJurisdiction ( TaxJurisdiction enum {HR, RS, BA_FED, BA_RS} per ADR-015) is the primary discriminator for jurisdiction-specific behaviour. Organization.id (UUID) is the primary tenant discriminator for data isolation. 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). 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). EU data residency: Current bilko-demo-db is in Cloud SQL europe-north1 (Finland). This IS within EU/EEA — GDPR Article 44 satisfied. Frankfurt migration (eu-central-1) is not required to unblock HR GA (Plan v3 §4d lines 179–183). 2.2 Three-Phase Migration Path The migration is split into three phases to ensure zero service disruption and a safe rollback path at each step. Phase 2A — PERMISSIVE RLS (parallel with Phase 1H, target: end of Week 2) Goal: RLS policies created and attached, set to PERMISSIVE. Existing application-layer scoping continues to operate. Both layers must pass — RLS is a second check, not a replacement. Who signs this off: CEO Alem Bašić (this ADR signature) — required before any Phase 2A Flyway migrations run on the stage database. DDL — PERMISSIVE policies (Flyway V17): -- V17__rls_permissive.sql -- ZAKON: CEO sign required before this migration runs on stage. -- Apply PERMISSIVE RLS on core tables. Application-layer WHERE org_id -- clauses remain active. Both must pass. -- Enable RLS on tables ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; ALTER TABLE invoice_items ENABLE ROW LEVEL SECURITY; ALTER TABLE expenses ENABLE ROW LEVEL SECURITY; ALTER TABLE transactions ENABLE ROW LEVEL SECURITY; ALTER TABLE bank_transactions ENABLE ROW LEVEL SECURITY; ALTER TABLE bank_accounts ENABLE ROW LEVEL SECURITY; ALTER TABLE accounts ENABLE ROW LEVEL SECURITY; ALTER TABLE contacts ENABLE ROW LEVEL SECURITY; -- PERMISSIVE policy: organization-scoped isolation -- current_setting() reads the app.current_org_id session variable -- set by the Ktor connection pool before each query (connection middleware). CREATE POLICY org_isolation ON invoices AS PERMISSIVE FOR ALL TO bilko_app -- application role (NOT superuser) USING (org_id = current_setting('app.current_org_id')::uuid); CREATE POLICY org_isolation ON invoice_items AS PERMISSIVE FOR ALL TO bilko_app USING ( invoice_id IN ( SELECT id FROM invoices WHERE org_id = current_setting('app.current_org_id')::uuid ) ); CREATE POLICY org_isolation ON expenses AS PERMISSIVE FOR ALL TO bilko_app USING (org_id = current_setting('app.current_org_id')::uuid); CREATE POLICY org_isolation ON transactions AS PERMISSIVE FOR ALL TO bilko_app USING (org_id = current_setting('app.current_org_id')::uuid); CREATE POLICY org_isolation ON bank_transactions AS PERMISSIVE FOR ALL TO bilko_app USING ( bank_account_id IN ( SELECT id FROM bank_accounts WHERE org_id = current_setting('app.current_org_id')::uuid ) ); CREATE POLICY org_isolation ON bank_accounts AS PERMISSIVE FOR ALL TO bilko_app USING (org_id = current_setting('app.current_org_id')::uuid); CREATE POLICY org_isolation ON accounts AS PERMISSIVE FOR ALL TO bilko_app USING (org_id = current_setting('app.current_org_id')::uuid); CREATE POLICY org_isolation ON contacts AS PERMISSIVE FOR ALL TO bilko_app USING (org_id = current_setting('app.current_org_id')::uuid); -- BYPASS for migrations and admin tooling (Flyway runs as bilko_admin) ALTER TABLE invoices FORCE ROW LEVEL SECURITY; ALTER TABLE expenses FORCE ROW LEVEL SECURITY; ALTER TABLE transactions FORCE ROW LEVEL SECURITY; ALTER TABLE bank_transactions FORCE ROW LEVEL SECURITY; ALTER TABLE bank_accounts FORCE ROW LEVEL SECURITY; ALTER TABLE accounts FORCE ROW LEVEL SECURITY; ALTER TABLE contacts FORCE ROW LEVEL SECURITY; -- Flyway runs as bilko_admin (superuser bypasses RLS by default). -- Explicit FORCE is belt-and-suspenders — admin role grants BYPASSRLS if needed. -- Set connection middleware (Kotlin Exposed / HikariCP): -- On each connection checkout: -- SET LOCAL app.current_org_id = ''; -- On connection return to pool: -- SET LOCAL app.current_org_id = ''; -- or reset_config('app.current_org_id', true) Verification after Phase 2A: -- Rogue-role test (Proveo E2E + Securion audit): SET ROLE bilko_app; SET LOCAL app.current_org_id = ''; SELECT count(*) FROM invoices; -- must return only HR org rows SET LOCAL app.current_org_id = ''; SELECT count(*) FROM invoices; -- must return only RS org rows -- Cross-tenant access attempt: SET LOCAL app.current_org_id = ''; SELECT * FROM invoices WHERE org_id = ''; -- must return 0 rows (PERMISSIVE blocks) Phase 2B — Audit Log Partitioning (post-HR GA) Goal: Partition the logged_actions audit table by country_code to enable per-jurisdiction GDPR data extraction requests and enforce per-jurisdiction retention. -- V36__audit_log_partitioning.sql (Phase 2B — post-HR GA) -- NOTE: V18/V19 slots are taken by enum migrations (V18__create_invoice_status_enum, -- V19__create_expense_status_enum). V30/V30.1/V31/V32/V33/V34/V35 are also taken -- by deployed auth/RLS/demo corrective migrations. As of 2026-05-20 the next planned -- Phase 2B/2C slots are V36/V37. Re-check Flyway history before implementation; -- if a later migration has consumed these slots, use the next free versions and -- update this ADR in the same PR. Flyway forbids renumbering applied migrations. -- Declarative partitioning by country_code CREATE TABLE logged_actions_partitioned ( LIKE logged_actions INCLUDING ALL ) PARTITION BY LIST (country_code); CREATE TABLE logged_actions_hr PARTITION OF logged_actions_partitioned FOR VALUES IN ('HR'); CREATE TABLE logged_actions_rs PARTITION OF logged_actions_partitioned FOR VALUES IN ('RS'); CREATE TABLE logged_actions_ba_fed PARTITION OF logged_actions_partitioned FOR VALUES IN ('BA_FED'); CREATE TABLE logged_actions_ba_rs PARTITION OF logged_actions_partitioned FOR VALUES IN ('BA_RS'); -- Retention policy enforcement (aligned with CountryPlugin.getRetentionRules()): -- HR: 11 years (Zakon o računovodstvu NN 78/2015, čl. 10) -- RS/BA: 10 years -- Implemented as pg_cron job deleting rows WHERE action_tstamp_tx < now() - interval '11 years' -- per partition. -- country_code column backfilled from organizations.country via: -- UPDATE logged_actions SET country_code = o.country -- FROM organizations o WHERE o.id = logged_actions.org_id; RLS policy for logged_actions (applied in Phase 2B): CREATE POLICY org_isolation ON logged_actions_partitioned AS PERMISSIVE FOR ALL TO bilko_app USING (org_id = current_setting('app.current_org_id')::uuid); Phase 2C — RESTRICTIVE + Retire Application-Layer Scoping (post-Securion Audit) Goal: Convert PERMISSIVE policies to RESTRICTIVE. Remove ADR-005 application-layer WHERE org_id middleware. RLS is the sole isolation mechanism. Gate conditions (all must be true before Phase 2C begins): Securion audit of Phase 2A policies completed — no critical findings Automated rogue-role test suite passing in CI (Proveo — see Phase 2A verification above) Zero cross-tenant RLS bypass incidents on stage for 30 consecutive days CEO explicit sign-off for Phase 2C -- V37__rls_restrictive.sql (Phase 2C — post Securion audit) -- Convert PERMISSIVE → RESTRICTIVE on all tables -- This is the point of no return: application layer WHERE org_id is retired after this. DROP POLICY org_isolation ON invoices; CREATE POLICY org_isolation ON invoices AS RESTRICTIVE FOR ALL TO bilko_app USING (org_id = current_setting('app.current_org_id')::uuid) WITH CHECK (org_id = current_setting('app.current_org_id')::uuid); -- Same pattern for expenses, transactions, bank_transactions, bank_accounts, -- accounts, contacts, invoice_items (repeat for each table). 2.3 Versioned Chart of Accounts Table The chart_of_accounts table stores jurisdiction-specific CoA entries with time-ranged validity. This supports: 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) -- Part of Flyway V17 or separate V17b (Phase 2A / 1H parallel) CREATE TABLE chart_of_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), jurisdiction VARCHAR(8) NOT NULL, -- TaxJurisdiction enum value: 'HR', 'RS', 'BA_FED', 'BA_RS' code VARCHAR(16) NOT NULL, -- e.g. '1300' (HR Kontni Plan), '204' (RS Pravilnik) name VARCHAR(256) NOT NULL, account_type VARCHAR(16) NOT NULL -- ASSET, LIABILITY, EQUITY, INCOME, EXPENSE CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE')), vat_treatment VARCHAR(64), -- e.g. 'STANDARD_RATE', 'EXEMPT', null for non-VAT accounts valid_from DATE NOT NULL, valid_to DATE, -- NULL = currently valid version INT NOT NULL DEFAULT 1, -- increments per Pravilnik revision notes TEXT, -- statutory reference e.g. "NN 78/2015, čl. 5" created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (jurisdiction, code, valid_from) ); CREATE INDEX idx_coa_jurisdiction_date ON chart_of_accounts (jurisdiction, valid_from, valid_to); -- Query pattern: entries valid on a given transaction date -- SELECT * FROM chart_of_accounts -- WHERE jurisdiction = $1 -- AND valid_from <= $2 -- AND (valid_to IS NULL OR valid_to > $2) -- ORDER BY code; -- When Croatia raises PDV from 25% to 27% on 2027-01-01: -- INSERT INTO chart_of_accounts (jurisdiction, code, name, account_type, vat_treatment, valid_from, version) -- VALUES ('HR', '2400', 'PDV po stopi 27%', 'LIABILITY', 'STANDARD_RATE', '2027-01-01', 2); -- UPDATE chart_of_accounts SET valid_to = '2026-12-31' -- WHERE jurisdiction = 'HR' AND code = '2400' AND valid_to IS NULL AND version = 1; -- No code change required. Seeding: CountryPlugin.getChartOfAccountsDefaults() returns the list of entries that Flyway data migrations insert into chart_of_accounts for each jurisdiction. Flyway V18 is already used for invoice-status enum documentation. Current CoA seed/reference migrations use later slots (for example V23/V24/V27 for BA/RS chart references); future HR CoA table-driven seeding must use the next available Flyway version at implementation time. 2.4 Exchange Rate Precision Upgrade Current precision (CLAUDE.md database rules): NUMERIC(19,4) for ALL monetary amounts. Upgrade required for FX rate columns specifically: Exchange rates require higher precision than invoice monetary amounts. Using NUMERIC(19,4) for an exchange rate means EUR/RSD at 117.2350 is representable, but EUR/BAM at 1.95583 is stored as 1.9558 — a systematic rounding error that compounds across large invoice volumes and cross-currency reconciliation. Decision: FX rate columns upgrade to NUMERIC(20,10) . Monetary amount columns (invoice totals, line amounts, tax amounts) remain NUMERIC(19,4) . -- V17c__exchange_rate_precision.sql (Phase 2A parallel) ALTER TABLE exchange_rates ALTER COLUMN rate TYPE NUMERIC(20,10); -- was NUMERIC(19,4) -- If an exchange_rate_history or similar snapshot table exists: -- ALTER TABLE exchange_rate_history -- ALTER COLUMN rate TYPE NUMERIC(20,10); -- NEVER change invoice_items.unit_price, invoice_items.line_total, -- transactions.amount, etc. — those remain NUMERIC(19,4). -- Only rate/exchange_rate columns receive this upgrade. Invariant: All monetary arithmetic (invoice totals, tax calculations, double-entry postings) remains at NUMERIC(19,4) . The precision upgrade is scoped to the FX rate storage layer only. Rounding when applying FX rates to amounts: round half-even (banker's rounding) to 4 decimal places after multiplication. 3. Connection Middleware — Setting app.current_org_id The RLS policies use current_setting('app.current_org_id')::uuid . This session variable must be set on every database connection before any query executes. Pattern (Kotlin / Exposed / HikariCP): // apps/api/src/main/kotlin/no/alai/bilko/db/OrgContextInterceptor.kt (Phase 2A NEW) /** * Sets the PostgreSQL session variable `app.current_org_id` to the authenticated * org's UUID before any database access. * * Called from the Ktor routing pipeline after JWT validation, before the * database transaction opens. * * Must reset after the request completes — use try/finally or Ktor plugin lifecycle. */ fun setOrgContext(orgId: UUID) { transaction { exec("SET LOCAL app.current_org_id = '${orgId}'") } } fun clearOrgContext() { transaction { exec("RESET app.current_org_id") // or: exec("SET LOCAL app.current_org_id = ''") } } Failure mode: If app.current_org_id is not set, current_setting('app.current_org_id') throws an error in PostgreSQL (by default). To make it return NULL instead (for Flyway admin connections that do not set the variable): -- In V17 migration, set default: ALTER DATABASE bilko_demo SET app.current_org_id = ''; And in the policy, guard against empty string: USING ( CASE WHEN current_setting('app.current_org_id', true) = '' THEN false -- deny if not set ELSE org_id = current_setting('app.current_org_id', true)::uuid END ) The true parameter to current_setting() makes it return NULL rather than throw when the variable is not set. 4. Migration Schedule Phase Flyway Version Target Blocking Phase 1H.1 V16: organizations.country NOT NULL + CHECK HR enum expansion (ADR-015) ADR-015 accepted Phase 2A V17: PERMISSIVE RLS + CoA table + FX rate precision Stage only CEO sign (this ADR) Phase 1H CoA seed/reference migrations use occupied later slots; new table-driven CoA migrations must use next free slot Stage only CountryPlugin defaults + CoA template table decision Phase 2B V36: audit log partitioning (planned; re-check next free Flyway slot before writing migration) Post-HR GA Securion review Phase 2C V37: RESTRICTIVE + retire ADR-005 app scoping (planned; re-check next free Flyway slot before writing migration) Post-Securion audit Securion audit pass + CEO sign All migrations use Flyway's expand/contract pattern. No migration modifies data in a way that cannot be reversed by a subsequent compensating migration. Backward compatibility is required across all rolling deployments. 5. Consequences 5.1 Positive 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. 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. Audit surface. Securion can review one set of RLS policies rather than auditing every application route for correct scoping. 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 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. 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. 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 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. 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. 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 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. 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). 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). 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. 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. 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. 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: Inconsistent error handling — platform-native exceptions leaking across boundaries No feature-flag mechanism — a broken SEF adapter takes down all RS users 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 No observability standard — each adapter invents its own logging and metrics 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: package no.alai.bilko.adapter import no.alai.bilko.country.TaxJurisdiction import no.alai.bilko.einvoice.AdapterLifecycleState /** * Base contract for all Bilko integration adapters. * * Every adapter implementation MUST: * 1. Expose [jurisdiction] and [lifecycleState] as first-class properties. * 2. Throw only [AdapterException] — NEVER platform-native exceptions. * 3. Pass all log writes through [sanitizeForLog] (defined per-adapter for PII fields). * 4. Record Prometheus metrics on every external call (see §2.6). * 5. Not require credentials for read-only / serialization operations. */ interface BilkoAdapter { val jurisdiction: TaxJurisdiction val lifecycleState: AdapterLifecycleState val adapterVersion: String // Semantic version string, e.g. "1.0.0" } 2.3 AdapterException — Canonical Error Contract All adapters throw AdapterException and nothing else. This exception type is the single crossing point from adapter space to core service space. package no.alai.bilko.adapter import no.alai.bilko.country.TaxJurisdiction /** * Canonical adapter error. The ONLY exception type that crosses the adapter boundary. * * INVARIANT: Core services catch AdapterException only. They MUST NOT catch * platform-native exceptions (Ktor ResponseException, HttpRequestTimeoutException, * java.net.SocketTimeoutException, etc.). Map those to AdapterException in the adapter. * * [retryable]: if true, caller may retry with exponential backoff. * [rawPayload]: sanitized (PII-redacted) raw response body for audit. NEVER raw. */ data class AdapterException( val code: AdapterErrorCode, val market: TaxJurisdiction, val retryable: Boolean, val rawPayload: String, override val message: String = code.name, override val cause: Throwable? = null, ) : RuntimeException(message, cause) /** * Canonical error codes — adapter-independent. * * Adapters map platform-specific HTTP status codes and error bodies to these codes. * See StorecoveErrorMapper (lines 469–516) for the HR reference mapping. */ enum class AdapterErrorCode { // Validation errors — not retryable VALIDATION_SCHEMA_ERROR, // Invalid document structure (HTTP 400/422) VALIDATION_BUSINESS_RULE, // Business rule violation (e.g., invalid OIB, non-EUR currency) VALIDATION_DUPLICATE_DOCUMENT, // Idempotency conflict (HTTP 409) // Authentication/authorisation — not retryable AUTH_INVALID_CREDENTIALS, // API key invalid / token expired / certificate rejected // Platform errors — retryable PLATFORM_RATE_LIMITED, // HTTP 429 — back off and retry PLATFORM_MAINTENANCE, // HTTP 503 — platform in scheduled maintenance PLATFORM_INTERNAL_ERROR, // HTTP 5xx — transient platform error // Network errors — retryable NETWORK_TIMEOUT, // Connection or read timeout NETWORK_UNREACHABLE, // DNS resolution failure or TCP refused // Implementation status — not retryable NOT_IMPLEMENTED, // Adapter is in STUB lifecycle state UNKNOWN, // Unmapped error; always log rawPayload for triage } Mapping rule for new adapters: Every HTTP status code the platform can return MUST have a mapping to an AdapterErrorCode . Use UNKNOWN only as a catch-all, never as the primary mapping for a known status code. See StorecoveErrorMapper (lines 469–516 in StorecoveHrFiskEInvoiceAdapter.kt ) as the reference pattern. 2.4 AdapterConfig — DB-Level Feature Flag Every adapter is gated by an AdapterConfig row. An adapter MUST NOT execute any network call if its AdapterConfig.enabled = false . This allows disabling a broken adapter without redeployment. -- V20__adapter_config.sql (Phase 1H — during Phase 2A window) CREATE TABLE adapter_config ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), market VARCHAR(8) NOT NULL, -- TaxJurisdiction enum value: 'HR', 'RS', 'BA_FED', 'BA_RS' adapter_type VARCHAR(32) NOT NULL, -- Matches the 7 categories: 'EINVOICE', 'COMPANY_REGISTRY', -- 'BANK_STATEMENT', 'EXCHANGE_RATE', 'TAX_FILING', -- 'FISCAL_DEVICE', 'QES_SIGNING' enabled BOOLEAN NOT NULL DEFAULT FALSE, reason TEXT, -- Why disabled, e.g. "MC #8675 pending — Storecove account not activated" -- Required when enabled=false. updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_by TEXT NOT NULL DEFAULT 'system', -- MC task ID or admin user CONSTRAINT pk_adapter_config UNIQUE (market, adapter_type) ); -- Seed: all adapters start disabled INSERT INTO adapter_config (market, adapter_type, enabled, reason, updated_by) VALUES ('HR', 'EINVOICE', false, 'MC #8675 — Storecove account pending', 'MC-100362'), ('HR', 'COMPANY_REGISTRY', false, 'Not implemented — Phase 1S', 'MC-100362'), ('HR', 'BANK_STATEMENT', false, 'Tok AISP integration pending', 'MC-100362'), ('HR', 'EXCHANGE_RATE', false, 'ECB feed not configured', 'MC-100362'), ('HR', 'TAX_FILING', false, 'ePorezna integration Phase 2 scope', 'MC-100362'), ('HR', 'FISCAL_DEVICE', false, 'Fiskalizacija cert not configured', 'MC-100362'), ('HR', 'QES_SIGNING', false, 'FINA QES Phase 2 scope', 'MC-100362'), ('RS', 'EINVOICE', false, 'SEF adapter Phase 1S scope', 'MC-100362'), ('BA_FED', 'EINVOICE', false, 'CPF platform TBD ~2027', 'MC-100362'), ('BA_RS', 'EINVOICE', false, 'UINO platform TBD', 'MC-100362'); -- (Remaining BA/RS adapter rows follow same pattern) -- Admin can enable without redeploy: -- UPDATE adapter_config SET enabled = true, reason = NULL, updated_by = 'MC-8675-DONE' -- WHERE market = 'HR' AND adapter_type = 'EINVOICE'; Kotlin enforcement pattern: // In the adapter registry (apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterRegistry.kt) fun requireEnabled(market: TaxJurisdiction, adapterType: String) { val config = adapterConfigRepository.find(market, adapterType) ?: throw AdapterException( code = AdapterErrorCode.NOT_IMPLEMENTED, market = market, retryable = false, rawPayload = "", message = "No AdapterConfig row for ($market, $adapterType) — run Flyway V20" ) if (!config.enabled) { throw AdapterException( code = AdapterErrorCode.NOT_IMPLEMENTED, market = market, retryable = false, rawPayload = "", message = "Adapter ($market, $adapterType) is disabled: ${config.reason}" ) } } 2.5 Lifecycle States and Transition Criteria Formalised from AdapterLifecycleState enum in EInvoiceTypes.kt lines 22–26, and from ADR-016 §2.3. Applies to ALL adapter categories. STUB ──────────────────► SANDBOX_VERIFIED ──────────────────► PRODUCTION STUB (initial state for all adapters): 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: Runtime: GCP Secret Manager (current) — accessed via SecretResolver interface Break-glass: Vaultwarden ( vault.basicconsulting.no ) — human access only, NOT runtime source Local dev: .env.local file ( .gitignore 'd) — NEVER committed // apps/api/src/main/kotlin/no/alai/bilko/adapter/SecretResolver.kt /** * Abstracts secret retrieval behind a testable interface. * * Production implementation: GCP Secret Manager. * Test implementation: environment variables / in-memory map. * * Secret path convention: Bilko/{env}/{market}/{secret-name} */ interface SecretResolver { /** * Resolves a secret value by its canonical path. * * @param path e.g. "Bilko/production/HR/STORECOVE_API_KEY" * @return Secret value, or null if not found. * @throws AdapterException(AUTH_INVALID_CREDENTIALS) if path exists but value is empty/blank. */ fun resolve(path: String): String? /** * Convenience method: builds canonical path and resolves. * @param env "production" | "stage" | "local" * @param market TaxJurisdiction enum value as string * @param secretName The specific secret name */ fun resolve(env: String, market: String, secretName: String): String? = resolve("Bilko/$env/$market/$secretName") } Vaultwarden is NOT the runtime secret source. Vaultwarden is the human break-glass vault for emergency access. Do not write Kotlin code that reads from Vaultwarden at runtime. GCP Secret Manager is the runtime source. 2.7 Observability Mandate Every adapter MUST emit the following for every network call: Structured log line (one per call): level=INFO market=HR integration=EINVOICE env=production org_id= action=submit status=SUCCESS duration_ms=234 submission_id= 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): // 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: // 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: Replace the backend section to reference backend/ (package no.alai.bilko , Kotlin 2.3.0, Ktor 3.4.0) Document the directory structure: backend/ lives outside Turborepo workspace (independent Gradle + GCP Cloud Run deploy) Update build commands to cd backend && ./gradlew run 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 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. 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. 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) Rename apps/api-kotlin/ to apps/api-kotlin-abandoned/ . 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. 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). 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) 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). Verify fat JAR output name: bilko-api.jar (check build.gradle.kts shadowJar config). Local build validation: docker build -t bilko-api-test ./backend must succeed. 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: docker build -t bilko-api ./backend exits 0. docker run --rm -p 8080:8080 bilko-api starts and responds to GET /health with HTTP 200. apps/api-kotlin/ directory no longer exists in repo root (renamed per C2). turbo.json workspaces grep returns no match for api-kotlin . 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