Architecture
- Architecture Document
- API Specification
- ADR-015 — Four-Jurisdiction Plugin Architecture (CountryPlugin Kotlin Interface)
- ADR-016 — EInvoiceAdapter Lifecycle and Contract
- ADR-017 — RLS Multi-Tenancy Migration
- ADR-019 — Integration Adapter Registry
- ADR-020 — Canonical Backend Location (apps/api, Deprecate apps/api-kotlin) + Phase 1 Track A Supersession
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.balancefield 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_logtable - Right to withdraw consent: Tracked in
consentstable
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-accountsto 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)
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). Usemake db-pushto 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()andsubmitToFiscalPlatform()delegate to it) - ADR-017 (RLS multi-tenancy —
TaxJurisdictionenum drivescountry_codecolumn 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— manualwhen(organization.country)branchingStorecoveHrFiskEInvoiceAdapter.kt— directly implementsEInvoiceAdapter; noCountryPluginwrapper 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 onorganization.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.
StorecoveHrFiskEInvoiceAdapterhas 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<TaxJurisdiction, CountryPlugin>.
* Resolution: orgId from JWT → DB lookup organizations.country → TaxJurisdiction.valueOf()
* → PluginRegistry.resolve() (see ADR-015 §2.4 for full pipeline).
*/
interface CountryPlugin {
/**
* Returns the tax jurisdiction this plugin handles.
* Used by PluginRegistry to route. Must be consistent with the plugin's
* registration key in the DI map.
*/
fun jurisdiction(): TaxJurisdiction
/**
* Calculates VAT breakdown for the given canonical invoice.
*
* Returns [VatResult] containing itemised tax lines per rate band.
* Core invoice service calls this; NEVER inspects jurisdiction directly.
*
* HR: 25% (S — standard), 13% (AA — reduced-1), 5% (E — reduced-2), 0% (Z — zero/export)
* RS: 20% (standard), 10% (reduced), 0% (export)
* BA-FED / BA-RS: 17% (standard), 0% (export)
*
* @throws UnsupportedOperationException for stub implementations (RS, BA)
*/
fun calculateVat(invoice: CanonicalInvoice): VatResult
/**
* Generates jurisdiction-specific e-invoice bytes from the canonical model.
*
* Delegates to the platform-specific [EInvoiceAdapter.serialize()] for this jurisdiction.
* Returns the wire-format payload (UBL 2.1 XML Storecove envelope for HR; SEF XML for RS).
* Contract: OFFLINE — no network, no credentials required.
*
* @throws UnsupportedOperationException for stub implementations
*/
fun generateEInvoiceXml(invoice: CanonicalInvoice): ByteArray
/**
* Submits a previously serialized e-invoice to the fiscal platform.
*
* [receipt] bundles the serialized bytes from [generateEInvoiceXml] with the originating
* [CanonicalInvoice] for idempotency key generation.
* Returns [FiscalSubmissionHandle] with the platform submission ID.
* Throws [no.alai.bilko.adapter.AdapterException] on all failure modes.
*
* HR lifecycle: STUB until MC #8675 (Storecove account activation).
*/
fun submitToFiscalPlatform(receipt: FiscalReceipt): FiscalSubmissionHandle
/**
* Returns default Chart of Accounts entries for this jurisdiction.
*
* Called once on org creation to seed the tenant's account list with the
* mandatory Pravilnik accounts. Company may add or rename — these are minimums.
*
* HR: FINA Kontni Plan (11-year retention)
* RS: Serbian Pravilnik (10-year retention)
* BA: FBiH / RS entity Pravilnik (10-year retention)
*/
fun getChartOfAccountsDefaults(): List<ChartOfAccountEntry>
/**
* Returns filing deadline schedule for this jurisdiction.
*
* Returns a sorted list of [FilingDeadline] for the next 12 months from the call date.
* Used by ComplianceCalendarService to populate per-org reminder schedules.
*
* HR: quarterly PDV return (last working day of month after quarter end) + annual CIT (30 April)
* RS: monthly PDV return (within 15 days of month end)
* BA: FBiH / RS entity PDV return schedules
*/
fun getFilingDeadlines(): List<FilingDeadline>
/**
* Returns data retention policy for this jurisdiction.
*
* HR: 11 years — Zakon o računovodstvu NN 78/2015, čl. 10
* RS: 10 years — Zakon o računovodstvu RS
* BA-FED / BA-RS: 10 years
*
* Used by the document archiving service to set per-org retention periods and by
* the RLS audit partition (ADR-017 Phase 2B).
*/
fun getRetentionRules(): RetentionPolicy
/**
* Returns the functional currency for this jurisdiction.
*
* HR: Currency.getInstance("EUR") — Croatia adopted EUR 2023-01-01
* RS: Currency.getInstance("RSD")
* BA-FED / BA-RS: Currency.getInstance("BAM")
*
* Core invoice service validates CanonicalInvoice.currencyCode against this on creation.
*/
fun getCurrency(): Currency
/**
* Returns locale-specific formatters for this jurisdiction.
*
* HR: decimal='.', thousands=',', date='dd.MM.yyyy', tz='Europe/Zagreb'
* RS: decimal=',', thousands='.', date='dd.MM.yyyy', tz='Europe/Belgrade'
* BA: decimal=',', thousands='.', date='dd.MM.yyyy', tz='Europe/Sarajevo'
*
* Used by report generation, PDF invoices, and UI date/number display.
*/
fun getFormatters(): JurisdictionFormatters
/**
* Extension hook for jurisdiction-specific validation beyond the standard 8 methods.
*
* Called by InvoiceService before invoice creation. Default implementation is a no-op;
* override to add market-specific business rules (e.g., HR OIB cross-validation
* against FINA company registry once APRCompanyRegistryAdapter is live).
*
* This hook is the designated extension point to avoid adding new required interface
* methods for market-specific edge cases. See §3.2 for evolution contract.
*
* @param invoice draft canonical invoice before persistence
* @throws no.alai.bilko.adapter.AdapterException with VALIDATION_BUSINESS_RULE if invalid
*/
fun validateInvoiceForJurisdiction(invoice: CanonicalInvoice) {
// Default: no-op. Override in PluginHR, PluginRS etc. as needed.
}
}
2.3 Supporting Value Types
Defined in no.alai.bilko.country package (or no.alai.bilko.country.model):
// VAT calculation result
data class VatResult(
val lines: List<VatLine>,
val totalVatAmount: java.math.BigDecimal,
val totalTaxableAmount: java.math.BigDecimal,
)
data class VatLine(
val rate: java.math.BigDecimal, // e.g. BigDecimal("25.0000")
val category: no.alai.bilko.einvoice.TaxCategory,
val taxableAmount: java.math.BigDecimal,
val taxAmount: java.math.BigDecimal,
val description: String, // Human-readable, e.g. "HR standard PDV 25%"
)
// Fiscal submission input
data class FiscalReceipt(
val serializedInvoice: ByteArray,
val canonicalInvoice: no.alai.bilko.einvoice.CanonicalInvoice,
)
data class FiscalSubmissionHandle(
val platformInvoiceId: String, // Storecove GUID, SEF ID, etc.
val initialStatus: no.alai.bilko.einvoice.EInvoiceStatus,
val submittedAt: java.time.Instant,
)
// Chart of Accounts entry
data class ChartOfAccountEntry(
val code: String, // e.g. "1300" (HR) or "204" (RS)
val name: String,
val type: AccountType, // ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
val vatTreatment: String?,
)
// Filing deadline
data class FilingDeadline(
val name: String, // e.g. "Quarterly PDV return Q1 2026"
val dueDate: java.time.LocalDate,
val authority: String, // e.g. "Porezna uprava HR (ePorezna)"
val periodStart: java.time.LocalDate,
val periodEnd: java.time.LocalDate,
)
// Data retention
data class RetentionPolicy(
val years: Int, // 10 or 11 depending on jurisdiction
val legalBasis: String, // Statutory reference
val jurisdiction: TaxJurisdiction,
)
// Formatters
data class JurisdictionFormatters(
val decimalSeparator: Char,
val thousandsSeparator: Char,
val datePattern: String, // ISO strftime-compatible, e.g. "dd.MM.yyyy"
val timeZoneId: String, // IANA tz, e.g. "Europe/Zagreb"
val currencySymbol: String,
val currencyPosition: CurrencyPosition, // PREFIX or SUFFIX
)
enum class CurrencyPosition { PREFIX, SUFFIX }
2.4 DI Wiring Strategy
JWT reality: The JWT access token contains orgId only (verified in JwtService.kt
lines 35–45). The org.country value is NOT embedded in the JWT. It is fetched from the
organizations DB table at request time by middleware before the route handler runs.
Resolution pipeline:
HTTP request
→ JWT validation (JwtService.verifyAccessToken)
→ extract orgId from JWT claim "orgId"
→ DB: SELECT country FROM organizations WHERE id = orgId (OrgScopePlugin / middleware)
→ TaxJurisdiction.valueOf(country)
→ PluginRegistry.resolve(jurisdiction)
→ CountryPlugin dispatch
DI registration in plugins/DI.kt:
// Phase 1H Task 1H.4
val pluginRegistry: Map<TaxJurisdiction, CountryPlugin> = mapOf(
TaxJurisdiction.HR to PluginHR(StorecoveHrFiskEInvoiceAdapter()),
TaxJurisdiction.RS to PluginRS(), // stub — Phase 1S
TaxJurisdiction.BA_FED to PluginBAFED(), // stub — Phase 1B
TaxJurisdiction.BA_RS to PluginBARS(), // stub — Phase 1B
)
// In Koin module:
single<Map<TaxJurisdiction, CountryPlugin>> { pluginRegistry }
// Resolution helper (usable from any Koin-injected service):
fun resolvePlugin(
jurisdiction: TaxJurisdiction,
registry: Map<TaxJurisdiction, CountryPlugin>
): CountryPlugin = registry[jurisdiction]
?: throw IllegalStateException(
"No CountryPlugin registered for $jurisdiction — check DI.kt registration"
)
Services that need a CountryPlugin receive it via constructor injection:
class InvoiceService(
private val pluginRegistry: Map<TaxJurisdiction, CountryPlugin>
// ... other deps
) {
private fun plugin(org: Organization): CountryPlugin =
resolvePlugin(TaxJurisdiction.valueOf(org.country), pluginRegistry)
}
2.5 OrgScopePlugin Sequencing Decision
Decision: CountryPlugin resolution runs AFTER OrgScopePlugin (org isolation middleware).
Rationale:
-
Security gate must run first. OrgScopePlugin validates that the authenticated user belongs to the org being operated on and sets the
app.current_org_idPostgres 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
CountryPluginrequires readingorganizations.countryfrom DB, which in turn requires a verifiedorgId. OrgScopePlugin is what establishes and validates thatorgId. -
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.*jurisdictionwhen.*jurisdictionif.*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, andSIadded toTaxJurisdiction. Zero core service changes. - Bounded audit surface. Croatian PDV auditors read
country/hr/PluginHR.ktonly. - Team parallelism. HR sprint and RS sprint work concurrently on separate files.
- Versioned CoA.
getChartOfAccountsDefaults()seeds Pravilnik data; rate changes handled via the versionedchart_of_accountstable (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
PluginRSis a stub and an RS user triggerscalculateVat(),UnsupportedOperationExceptionpropagates as HTTP 500. Mitigation: DI registry should checklifecycleStateat request time and return HTTP 503 (market feature not available). - BA backfill assumption. V16 migrates
BA → BA_FEDas 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:
TaxJurisdictionenum expansion +CountryPlugin.kt - Phase 1H Task 1H.2:
PluginHRimplementation - 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()andsubmitToFiscalPlatform()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
0. Addendum — HR Provider Pivot: Storecove Abandoned, sveRačun (PostLink) Adopted (2026-06-11)
This section documents a decision that supersedes the HR row of §1.1 below, which was never updated in place.
- 2026-04-22 (MC #8672 / #8675): CEO selected "Peppol Option B" — outsource Croatian HR-FISK 2.0 transport to a Peppol access-point reseller (Storecove, with Pagero as an alternative under consideration). Rationale recorded on MC #8675: no Croatian legal entity required, fastest setup path (1–3 days), Storecove confirmed Croatia/FINA support.
StorecoveHrFiskEInvoiceAdapter.ktwas built as the referenceEInvoiceAdapterimplementation on this basis (§1.1 table below, ADR-019 §Storecove secret taxonomy). - 2026-06-11 (MC #103434, status: done): CEO abandoned Storecove and accepted a direct partnership with sveRačun (PostLink d.o.o.) instead — a Croatian e-invoicing intermediary, not a generic Peppol access point. Test credentials issued directly by PostLink (docs:
sveracun-public-api.redocly.app). The existing UBL 2.1 + HR-FISK XML validation logic (built for Storecove) was reused; only the transport/submission layer was rewired — seeSveRacunHttpClient.kt,SveRacunHrEInvoiceAdapter.kt(git697592a9, MC #103434). - Current state (verified 2026-07-21,
docs/runbooks/sveracun-hr-go-live.md): sveRačun/PostLink integration is TEST-verified and dormant-ready for production (SVERACUN_HR_LIVE=false); no production sveRačun account exists yet. This is the live path — Storecove is dead code / historical reference only, not an open integration option. - What did NOT change: the
EInvoiceAdapterinterface/contract this ADR defines, and the offlineserialize()(UBL/CIUS build) logic — both carried over from the Storecove-era implementation to the sveRačun one unchanged. Only the network transport provider changed. - Stale references still on disk, not corrected by this addendum (out of scope for this doc pass): the HR row in the §1.1 table below still reads "STUB (MC #8675)" / "via Storecove";
StorecoveOibValidatornaming survives inside the current adapter code;StorecoveHrFiskEInvoiceAdapter.ktfile itself is still present. Any future edit of §1.1 or the code should cite this addendum, not re-derive the history. - MC #8672 ("Bilko HR — Peppol Opcija B odabrana (Storecove/Pagero routing)") was the original April decision record. It is superseded by MC #103434 and should be closed referencing this addendum, not treated as an open action.
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):
AdapterLifecycleStateenum:STUB,SANDBOX_VERIFIED,PRODUCTIONEInvoiceStatusenum:PENDING,APPROVED,REJECTED,CANCELLED,ERRORInvoiceTypeCode: UNTDID codes 380, 381, 383, 384Address,PartyInfo/PartytypealiasPaymentMeans:paymentMeansCode,paymentReference,ibanTaxCategoryenum:S, Z, E, K, G, O, AEper EN 16931 BT-118TaxBreakdown,InvoiceLine,CanonicalInvoice,SubmitResult,InvoiceTotalsEInvoiceAdapterinterface with 4 methods + 2 properties
AdapterTypes.kt (in no.alai.bilko.adapter) defines:
AdapterErrorCodeenum with 10 codes includingNOT_IMPLEMENTEDAdapterException(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<InvoiceLine>, // BG-25: quantity, unitPrice, lineTotal, taxRate
val taxBreakdowns: List<TaxBreakdown>, // BG-23: one entry per rate band
val paymentMeans: PaymentMeans? = null, // BG-16: paymentMeansCode, IBAN, reference
val note: String? = null, // BT-22: free text note
val adapterMetadata: Map<String, String> = emptyMap(), // platform-specific extras
)
Field constraints:
| Field | Constraint | Enforced in |
|---|---|---|
currencyCode |
"EUR" for HR (HALT-3 — Croatia adopted EUR 2023-01-01) | serialize() HR |
supplier.taxId |
OIB (HR, 11-digit ISO 7064 MOD 11,10) / PIB (RS, 9-digit) / JIB (BA, 13-digit) | serialize() per market |
lines |
Non-empty — EN 16931 §BG-25 minimum one line | serialize() |
taxBreakdowns |
Must sum to lines.(taxRate * lineTotal) — tolerance 0.01 | InvoiceService |
adapterMetadata |
HR inbound: hr.supplierOib, hr.buyerOib, hr.pozivNaBroj |
parseIncoming() |
What CanonicalInvoice is NOT:
- Not a DB entity (mapped from
invoices+invoice_itemstables 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 pendingpollStatus(): throws NOT_IMPLEMENTEDparseIncoming(): throws NOT_IMPLEMENTED (deferred post-GA)
2.4 HR-FISK Storecove Sandbox Validation Matrix
5 invoice types required for SANDBOX_VERIFIED transition. All must produce real Storecove submission GUIDs (not mock strings). Proveo (Angie Jones) runs these tests.
| # | Invoice Type | UNTDID Code | Scenario | Expected Storecove Response | Evidence Required |
|---|---|---|---|---|---|
| 1 | B2B outbound commercial | 380 | Supplier OIB + Buyer OIB both valid. EUR. 25% PDV. Standard commercial transaction. | HTTP 200 + {"id": "<guid>", "status": "pending"} |
Storecove submission GUID in evidence file |
| 2 | B2G outbound | 380 | Buyer is HR government entity (OIB format same). PaymentMeans.paymentMeansCode=30. |
HTTP 200 + GUID | GUID + Storecove routing.peppol.id verified as buyer OIB |
| 3 | Credit note | 381 | References original invoice number in note field. Negative line totals. |
HTTP 200 + GUID | GUID + typeCode=381 confirmed in Storecove portal |
| 4 | Cancelled invoice | 384 | CORRECTIVE_INVOICE type. Status flow: submit → pollStatus until APPROVED or REJECTED | HTTP 200 + GUID, then pollStatus APPROVED/REJECTED | GUID + final status confirmed |
| 5 | Inbound received | 380 (inbound) | Storecove sends test webhook to Bilko's webhook endpoint. parseIncoming() invoked. |
Webhook received. CanonicalInvoice returned. hr.supplierOib in adapterMetadata. |
Log entry showing successful parse + extracted OIB value |
HR-specific validation rules verified in each test case:
currencyCode = "EUR"(HALT-3)- Supplier OIB: ISO 7064 MOD 11,10 checksum valid
- Buyer OIB: ISO 7064 MOD 11,10 checksum valid
- CustomizationID: verify with Storecove support which to use (PEPPOL_BIS3 or HR_CIUS — TODO MC #8675 D3)
routing.peppol.scheme = "9934"androuting.peppol.id = <buyerOIB>
Storecove-specific notes:
- Sandbox URL is the same as production (
api.storecove.com/api/v2) — sandbox mode is a payload flag, not a different host. SetSTORECOVE_ENV=sandboxenv var. - Idempotency key: SHA-256(
invoice.id+invoice.invoiceNumber) → sent asIdempotency-Keyheader. Platform returns HTTP 409 on duplicate — treat as success (re-fetch GUID from error body). document_idfield 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 = "<Platform> <method> requires account — MC #<id>" ) 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 theAdapterConfigfeature flag withreason = "parseIncoming deferred — Phase 1H.6".
Caller rules:
- Before calling
submit()orpollStatus(), 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_IMPLEMENTEDcaught at the route handler level maps to HTTP 503 (Service Unavailable) with body{"error": "ADAPTER_NOT_AVAILABLE", "market": "<jurisdiction>"}, NOT HTTP 500. This is the stub plugin HTTP 500 risk mitigation from ADR-015 §5.3.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: <PLATFORM>_<FIELD>, uppercase, underscores.
Accessed in StorecoveApiClient via System.getenv("STORECOVE_API_KEY").
Secret rotation policy:
- Rotate API keys every 90 days OR on any Storecove security notice, whichever comes first.
- Previous version retained in Secret Manager for 24h to allow graceful failover.
- Rotation event: create new secret version → update Cloud Run env → verify health endpoint → delete previous version 24h later.
Never in source code or logs: API keys, legal entity IDs, OIB values, IBAN values.
StorecoveHrFiskEInvoiceAdapter.sanitizeForLog() must be called on all Storecove response
bodies before logging.
RS future secrets (Phase 1S):
| GCP Secret Manager path | Content |
|---|---|
Bilko/stage/RS/sef-api-key |
SEF sandbox access token |
Bilko/prod/RS/sef-api-key |
SEF production access token |
Bilko/stage/RS/sef-username |
SEF API username |
Bilko/prod/RS/sef-username |
SEF API username (prod) |
SEF uses OAuth2 with client credentials. The token endpoint is https://efaktura.mfin.gov.rs/
(Serbian Ministry of Finance). Exact credentials shape to be confirmed at Phase 1S kickoff.
2.7 Per-Platform Field Mapping
How CanonicalInvoice fields map to platform-specific XML/JSON:
| CanonicalInvoice field | HR (UBL 2.1 / Peppol) | RS (SEF XML) | BA-FED | BA-RS |
|---|---|---|---|---|
supplier.taxId |
AccountingSupplierParty/.../CompanyID @schemeID="9934" (OIB) |
/Invoice/Seller/TaxId (PIB) |
TBD | TBD |
buyer.taxId |
AccountingCustomerParty/.../CompanyID @schemeID="9934" (OIB) |
/Invoice/Buyer/TaxId (PIB) |
TBD | TBD |
invoiceNumber |
cbc:ID |
/Invoice/InvoiceNumber |
TBD | TBD |
issueDate |
cbc:IssueDate (ISO 8601) |
/Invoice/IssueDate |
TBD | TBD |
typeCode.untdidCode |
cbc:InvoiceTypeCode (380/381/384) |
/Invoice/InvoiceType |
TBD | TBD |
currencyCode |
cbc:DocumentCurrencyCode + @currencyID on all amounts |
/Invoice/Currency |
TBD | TBD |
taxBreakdowns[].taxRate |
TaxSubtotal/TaxCategory/Percent |
/Invoice/TaxTotal/TaxRate |
TBD | TBD |
taxBreakdowns[].taxCategory |
TaxSubtotal/TaxCategory/ID (S/Z/E/K per EN 16931 BT-118) |
Serbian code set | TBD | TBD |
paymentMeans.paymentReference |
PaymentMeans/PaymentID (HR "Poziv na broj") |
/Invoice/PaymentReference |
TBD | TBD |
paymentMeans.iban |
PayeeFinancialAccount/ID |
/Invoice/BankAccount/IBAN |
TBD | TBD |
adapterMetadata |
"hr.supplierOib", "hr.buyerOib", "hr.pozivNaBroj" (inbound) |
"rs.sefId", "rs.supplierPib" |
TBD | TBD |
SEF XML note: SEF does not use UBL 2.1. It uses a Serbian-specific XML schema published
by the Ministry of Finance. The SEF adapter maps CanonicalInvoice → SEF schema directly;
it does NOT go through UBL. EInvoiceAdapter.serialize() returns the platform's native format.
BA adapters (Phase 1B): CPF and UINO platforms have no published API specifications as of 2026-05-13. Phase 1B cannot begin until regulatory mandates define the technical specification (~2027 per plan v3 context).
2.8 HR Reference Implementation Design Decisions
StorecoveHrFiskEInvoiceAdapter is the reference implementation. Future adapters MUST
replicate these patterns:
| Design decision | Location in reference impl | Rule for future adapters |
|---|---|---|
| PII field redaction before logging | Lines 24–59 (REDACT_PII_FIELDS, sanitizeForLog) |
REQUIRED — GDPR / audit rules |
| Offline serialization (no credentials) | Lines 567–571 (serialize()) |
REQUIRED per §2.1 contract |
| Idempotency key (SHA-256 of id + invoiceNumber) | Lines 591–600 (stub comment — activate post-#8675) | REQUIRED if platform supports |
| Credential validation on startup flag | Lines 83–138 (validateOnStartup, validate()) |
REQUIRED — default false for tests |
Error code mapping to AdapterException |
Lines 469–515 (StorecoveErrorMapper) |
REQUIRED — NEVER propagate native |
Structured metrics recording (StorecoveMetrics) |
Lines 537–540 | REQUIRED — Prometheus counters |
Tax ID format validation in serialize() |
Lines 748–774 (OIB check) | REQUIRED — early error, no network |
document_id for deduplication |
Lines 420–437 (StorecovePayloadBuilder.wrap()) |
REQUIRED if platform supports 409 |
3. Adapter Lifecycle Governance
3.1 AdapterConfig Feature Flag
All adapter network paths (submit, pollStatus, parseIncoming) are gated by an
AdapterConfig row in the database. Defined fully in ADR-019 §2.4; referenced here:
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.
AdapterExceptionis the only exception type crossing the adapter boundary. Callers implement one error handler, not four platform-specific ones. - Lifecycle visibility.
lifecycleStateis first-class. Dashboards show "HR adapter: STUB" and alert when a market operates in degraded state. - Canonical model.
CanonicalInvoiceenables cross-market reporting and analytics. - NOT_IMPLEMENTED → HTTP 503. Clients receive a clean "feature not available" response instead of an HTTP 500 stack trace when an adapter is in STUB state.
4.2 Negative
- SEF XML schema maintenance. RS's SEF format changes without semantic versioning guarantees. The adapter must track schema changes proactively.
- BA adapters are TBD. Phase 1B work cannot begin until regulations define the spec.
- 4 methods = all or nothing lifecycle. If
parseIncoming()is the last unfinished method, the adapter cannot advance to SANDBOX_VERIFIED. The HR partial-SANDBOX exception (§2.5 rule 3) is a pragmatic workaround; it should not become a pattern.
4.3 Risks
- CanonicalInvoice field gap. A platform-specific required field has no canonical
counterpart. Resolution:
adapterMetadata: Map<String, String>for platform-specific extras until they generalise to first-class fields. - Storecove CustomizationID ambiguity (D3). Two candidate CustomizationIDs — PEPPOL_BIS3 and HR_CIUS. Resolution: Verify with Storecove support before MC #8675 sandbox activation. This is a HALT item. Wrong choice → all HR invoices rejected.
- Storecove routing.network field (HALT-4). Existing code does not include
routing.networkfield. Verify with Storecove sandbox whether this is required. - Secret rotation lag. Expired API key → all
submit()calls throwAUTH_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 toStorecoveHrFiskEInvoiceAdapter - 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 —
AdapterConfigtable 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_codecolumn 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(TaxJurisdictionenum{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 = :idclauses (ADR-005 flaw — being retired by Phase 2C). - The
country_codecolumn onorganizationsis NOT NULL with CHECK constraintIN ('HR', 'RS', 'BA_FED', 'BA_RS')— enforced by Flyway V16 (Phase 1H Task 1H.1). - EU data residency: Current
bilko-demo-dbis in Cloud SQLeurope-north1(Finland). This IS within EU/EEA — GDPR Article 44 satisfied. Frankfurt migration (eu-central-1) is not required to unblock HR GA (Plan v3 §4d lines 179–183).
2.2 Three-Phase Migration Path
The migration is split into three phases to ensure zero service disruption and a safe rollback path at each step.
Phase 2A — PERMISSIVE RLS (parallel with Phase 1H, target: end of Week 2)
Goal: RLS policies created and attached, set to PERMISSIVE. Existing application-layer scoping continues to operate. Both layers must pass — RLS is a second check, not a replacement.
Who signs this off: CEO Alem Bašić (this ADR signature) — required before any Phase 2A Flyway migrations run on the stage database.
DDL — PERMISSIVE policies (Flyway V17):
-- V17__rls_permissive.sql
-- ZAKON: CEO sign required before this migration runs on stage.
-- Apply PERMISSIVE RLS on core tables. Application-layer WHERE org_id
-- clauses remain active. Both must pass.
-- Enable RLS on tables
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE expenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
-- PERMISSIVE policy: organization-scoped isolation
-- current_setting() reads the app.current_org_id session variable
-- set by the Ktor connection pool before each query (connection middleware).
CREATE POLICY org_isolation ON invoices
AS PERMISSIVE
FOR ALL
TO bilko_app -- application role (NOT superuser)
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON invoice_items
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (
invoice_id IN (
SELECT id FROM invoices
WHERE org_id = current_setting('app.current_org_id')::uuid
)
);
CREATE POLICY org_isolation ON expenses
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON transactions
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON bank_transactions
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (
bank_account_id IN (
SELECT id FROM bank_accounts
WHERE org_id = current_setting('app.current_org_id')::uuid
)
);
CREATE POLICY org_isolation ON bank_accounts
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON accounts
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON contacts
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
-- BYPASS for migrations and admin tooling (Flyway runs as bilko_admin)
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
ALTER TABLE expenses FORCE ROW LEVEL SECURITY;
ALTER TABLE transactions FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts FORCE ROW LEVEL SECURITY;
ALTER TABLE accounts FORCE ROW LEVEL SECURITY;
ALTER TABLE contacts FORCE ROW LEVEL SECURITY;
-- Flyway runs as bilko_admin (superuser bypasses RLS by default).
-- Explicit FORCE is belt-and-suspenders — admin role grants BYPASSRLS if needed.
-- Set connection middleware (Kotlin Exposed / HikariCP):
-- On each connection checkout:
-- SET LOCAL app.current_org_id = '<org_uuid_from_jwt>';
-- On connection return to pool:
-- SET LOCAL app.current_org_id = ''; -- or reset_config('app.current_org_id', true)
Verification after Phase 2A:
-- Rogue-role test (Proveo E2E + Securion audit):
SET ROLE bilko_app;
SET LOCAL app.current_org_id = '<hr_org_uuid>';
SELECT count(*) FROM invoices; -- must return only HR org rows
SET LOCAL app.current_org_id = '<rs_org_uuid>';
SELECT count(*) FROM invoices; -- must return only RS org rows
-- Cross-tenant access attempt:
SET LOCAL app.current_org_id = '<hr_org_uuid>';
SELECT * FROM invoices WHERE org_id = '<rs_org_uuid>'; -- must return 0 rows (PERMISSIVE blocks)
Phase 2B — Audit Log Partitioning (post-HR GA)
Goal: Partition the logged_actions audit table by country_code to enable
per-jurisdiction GDPR data extraction requests and enforce per-jurisdiction retention.
-- V36__audit_log_partitioning.sql (Phase 2B — post-HR GA)
-- NOTE: V18/V19 slots are taken by enum migrations (V18__create_invoice_status_enum,
-- V19__create_expense_status_enum). V30/V30.1/V31/V32/V33/V34/V35 are also taken
-- by deployed auth/RLS/demo corrective migrations. As of 2026-05-20 the next planned
-- Phase 2B/2C slots are V36/V37. Re-check Flyway history before implementation;
-- if a later migration has consumed these slots, use the next free versions and
-- update this ADR in the same PR. Flyway forbids renumbering applied migrations.
-- Declarative partitioning by country_code
CREATE TABLE logged_actions_partitioned (
LIKE logged_actions INCLUDING ALL
) PARTITION BY LIST (country_code);
CREATE TABLE logged_actions_hr PARTITION OF logged_actions_partitioned
FOR VALUES IN ('HR');
CREATE TABLE logged_actions_rs PARTITION OF logged_actions_partitioned
FOR VALUES IN ('RS');
CREATE TABLE logged_actions_ba_fed PARTITION OF logged_actions_partitioned
FOR VALUES IN ('BA_FED');
CREATE TABLE logged_actions_ba_rs PARTITION OF logged_actions_partitioned
FOR VALUES IN ('BA_RS');
-- Retention policy enforcement (aligned with CountryPlugin.getRetentionRules()):
-- HR: 11 years (Zakon o računovodstvu NN 78/2015, čl. 10)
-- RS/BA: 10 years
-- Implemented as pg_cron job deleting rows WHERE action_tstamp_tx < now() - interval '11 years'
-- per partition.
-- country_code column backfilled from organizations.country via:
-- UPDATE logged_actions SET country_code = o.country
-- FROM organizations o WHERE o.id = logged_actions.org_id;
RLS policy for logged_actions (applied in Phase 2B):
CREATE POLICY org_isolation ON logged_actions_partitioned
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
Phase 2C — RESTRICTIVE + Retire Application-Layer Scoping (post-Securion Audit)
Goal: Convert PERMISSIVE policies to RESTRICTIVE. Remove ADR-005 application-layer
WHERE org_id middleware. RLS is the sole isolation mechanism.
Gate conditions (all must be true before Phase 2C begins):
- 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_idin a new route, RLS at the database layer prevents cross-tenant data exposure. - GDPR jurisdiction extraction. With
country_codeonlogged_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_idbefore 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_adminwith 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. |
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_KEYas 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.enabledisfalse
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
AdapterErrorCodevalues (notUNKNOWN) - Proveo sign-off with evidence file path in MC task
AdapterConfig.enabledcan be set totrueafter this point
PRODUCTION transition criteria (all must be true):
SANDBOX_VERIFIEDalready 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_ERRORalerts - Zero cross-market routing errors
bilko_integration_request_total{status="error"}< 1% of total requests
- Zero
- CEO approval for production activation
AdapterConfig.enabled = truein 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
SecretResolverinterface - Break-glass: Vaultwarden (
vault.basicconsulting.no) — human access only, NOT runtime source - Local dev:
.env.localfile (.gitignore'd) — NEVER committed
// apps/api/src/main/kotlin/no/alai/bilko/adapter/SecretResolver.kt
/**
* Abstracts secret retrieval behind a testable interface.
*
* Production implementation: GCP Secret Manager.
* Test implementation: environment variables / in-memory map.
*
* Secret path convention: Bilko/{env}/{market}/{secret-name}
*/
interface SecretResolver {
/**
* Resolves a secret value by its canonical path.
*
* @param path e.g. "Bilko/production/HR/STORECOVE_API_KEY"
* @return Secret value, or null if not found.
* @throws AdapterException(AUTH_INVALID_CREDENTIALS) if path exists but value is empty/blank.
*/
fun resolve(path: String): String?
/**
* Convenience method: builds canonical path and resolves.
* @param env "production" | "stage" | "local"
* @param market TaxJurisdiction enum value as string
* @param secretName The specific secret name
*/
fun resolve(env: String, market: String, secretName: String): String? =
resolve("Bilko/$env/$market/$secretName")
}
Vaultwarden is NOT the runtime secret source. Vaultwarden is the human break-glass vault for emergency access. Do not write Kotlin code that reads from Vaultwarden at runtime. GCP Secret Manager is the runtime source.
2.7 Observability Mandate
Every adapter MUST emit the following for every network call:
Structured log line (one per call):
level=INFO market=HR integration=EINVOICE env=production org_id=<uuid>
action=submit status=SUCCESS duration_ms=234 submission_id=<guid>
Required fields: market, integration, env, org_id. Optional but recommended:
duration_ms, submission_id, attempt (for retries).
NEVER log:
- 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.
AdapterExceptionas 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 = falsedisables 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,
AdapterConfigrows,SecretResolverwiring, 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_configrows do not exist. Flyway V20 must run before the application version that addsrequireEnabled()checks.
4.3 Risks
AdapterConfigDB unavailable. If the database is unreachable,requireEnabled()fails, blocking all adapters. Mitigation: cacheAdapterConfigin-memory at startup with a TTL of 5 minutes. Use cached state if DB is unreachable.- Metrics cardinality. High
org_idcardinality in metrics labels would cause Prometheus memory issues. The observability mandate specifiesorg_idin log lines, NOT in Prometheus labels. Labels aremarket,integration,statusonly — 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:
AdapterConfigFlyway V20 migration - Phase 1H:
SecretResolverGCP 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 ofapi-kotlin/in this ADR was executed in MC #10034 (deleted asapps/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
.ktsource 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.bilkopaths insidebackend/ - Last commit: 2026-04-23 (John, active)
apps/api-kotlin/:
- Package:
io.bilko - Kotlin 2.1.20 / Ktor 3.1.2 / JVM 21
- 17
.ktsource 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.jsonline 36 anddocs/evidence/9398/verify-cookie-fix.jsline 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/(packageno.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 againstbackend/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/toapps/api-kotlin-abandoned/. - Add
README.mdat 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.jsonand rootpackage.jsonworkspaces do NOT includeapps/api-kotlinorapps/api-kotlin-abandoned(Turborepo workspace scope must not resolve against it). - Annotate
docs/bookstack-sync/phase1-track-a-execution.mdlines 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) tobackend/Dockerfile. Upgrade base image from JVM 21 to JVM 25 (matchingbackend/JVM target). - Verify fat JAR output name:
bilko-api.jar(checkbuild.gradle.ktsshadowJar config). - Local build validation:
docker build -t bilko-api-test ./backendmust succeed. - Update DEPLOY-MAP.md bilko-api entry: source =
backend/, Dockerfile =backend/Dockerfile, deploy = GCP Cloud Run viagcp-deploy.yml.
C6 — Proveo Verification (MC #9898 gate, Proveo)
Owner: Proveo (Angie Jones) | Effort: S (2h)
Acceptance criteria:
docker build -t bilko-api ./backendexits 0.docker run --rm -p 8080:8080 bilko-apistarts and responds toGET /healthwith HTTP 200.apps/api-kotlin/directory no longer exists in repo root (renamed per C2).turbo.jsonworkspaces grep returns no match forapi-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