ADR-019: Integration Adapter Registry
# ADR-019 — MarketIntegration Priority:Adapter HR-FirstRegistry
Ratification
**Status:** Accepted
— CEO override**Date:** 2026-05-0911
ratified as architectural decision
Date: 2026-05-13
**Author:** Petter Graff (CodeCraft,CodeCraft — Architecture Lead)
**Decision-maker:** CEO Alem Bašić
—**Mehanik overrideclearance:** issued/tmp/mehanik-cleared-100362
2026-05-09 ("B i onda dokumentuj Opcija C — ali prvo ide Hrvatska ne srbija")
**MC Task:** #100586#100362 (Phase 0' ADR-017ADR RLSConsolidation)
strategy**Cross-references:**
+- ADR-019015 (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 priority)resolved Cross-references:
- edge
ADR-023before§6adapterCEOdispatch)override-2026-05-09`apps/api/src/main/kotlin/no/alai/bilko/country/hr/StorecoveHrFiskEInvoiceAdapter.kt` ("marketreferencepriorityimpl)is-HR → BA → SR")ADR-015-FOUR-JURISDICTION-PLUGIN.md`apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt` (TaxJurisdictionAdapterLifecycleStateenum:onHR,disk)RS,-BA_FED, BA_RS)ADR-016-EINVOICE-ADAPTER.md (HR Storecove Peppol path = Path B, signed 2026-04-24)ADR-019-INTEGRATION-ADAPTER-REGISTRY.md (adapter lifecycle + HR EINVOICE = first to SANDBOX_VERIFIED)- Plan v3 §
36diffPhasetable0'(v2Taskwas RS-first; v3 is HR-first0'4 —CEO override rationale) Plan v3 §4d (HR critical path: Storecove MC #8675 as external dependency)MC #8675 (Storecove account activation — CEO/John action, unblocks Phase 1H.6)MEMORY: project_bilko_hr_competitor_intel_2026-05-10.md (competitive drivers)
Note on numbering: ADR-019-INTEGRATION-ADAPTER-REGISTRY.md (Phase 0', 2026-05-11) covers
adapter governance. This document is a separate ADR-019 variant covering market priority strategy.
Both are accepted. The naming convention adr-019-market-priority-hr-first.md distinguishes
this document from the adapter registry document by suffix, consistent with the file system
evidence requirements of MC #100586.
1. Context
1.1 The v2 Plan Was RS-First
The original v2 multi-market architecture plan (`~/system/specs/bilko-multi-market-architecture-plan.md,plan-v3-2026-05-11.md`
2026-04-21,---
now## superseded)1. prioritizedContext
Serbia### as1.1 theProblem: launchSeven marketIntegration forSurfaces, twoNo reasons:
- Bilko
The Serbian SEF (Sistem e-Faktura) e-invoice mandate was already in forceStorecoveHrFiskEInvoiceAdapter.serialize()had not yet been written
The v2 plan tasked CodeCraftintegrates with SEFexternal outboundsystems adapteracross workseven (Tasksfunctional 1.4domains. andAs 1.5)of as the
Phase 1 priority.
1.2 CEO Override — 2026-05-09
During the ADR-023 routing decision session (2026-05-09), CEO Alem Bašić issued an
explicit market priority override:
"B i onda dokumentuj Opcija C — ali prvo ide Hrvatska ne srbija" (Translated: "B and then document Option C — but Croatia goes first, not Serbia")
ADR-023 §6 records this as: "CEO override 2026-05-09: market priority is HR → BA → SR"
The Plan v3 (2026-05-11,
approvedonly byone CEO)adapter reflectsexists (`StorecoveHrFiskEInvoiceAdapter`). Without a registry and governance
model, adding the second adapter (SEF for RS) and every subsequent adapter will produce:
1. Inconsistent error handling — platform-native exceptions leaking across boundaries
2. No feature-flag mechanism — a broken SEF adapter takes down all RS users
3. Secret sprawl — `STORECOVE_API_KEY` as an env var pattern, but no taxonomy when
there are 7 adapters × 4 markets × 3 environments = up to 84 secrets
4. No observability standard — each adapter invents its own logging and metrics
5. No lifecycle discipline — adapters deployed to production without sandbox verification
### 1.2 Reference Implementation Patterns
`StorecoveHrFiskEInvoiceAdapter.kt` already demonstrates all the patterns this inADR
its §1 executive summary:
"Market priority flipped HR-first (CEO override 2026-05-09, ADR-023 §6)."
1.3 Whyformalises. This ADR
makes those patterns enforceable for all future adapters:
| Pattern | StorecoveHrFiskEInvoiceAdapter | ADR-019 makes it |
| ------------------------------------------------ | --------------------------------------- | ---------------- |
| PII redaction before logging | Lines 24–59 (`sanitizeForLog`) | Mandatory |
| `AdapterException` only (no platform exceptions) | Lines 469–516 (`StorecoveErrorMapper`) | Mandatory |
| Per-adapter Prometheus metrics | Lines 537–540 (`StorecoveMetrics`) | Mandatory |
| Lifecycle state field | Lines 547–548 (`lifecycleState = STUB`) | Mandatory |
| Idempotency key on submit | Lines 591–600 (D5 comment) | Mandatory |
| Credentials NOT required for serialize() | Lines 567–571 | Mandatory |
| Startup credential validation flag | Lines 83–138 | Recommended |
---
## 2. Decision
### 2.1 Seven Adapter Categories
Every external integration belongs to exactly one of the following categories.
Each category is a Kotlin interface in `apps/api/src/main/kotlin/no/alai/bilko/adapter/`.
| Category | Interface | Purpose | Markets |
| -------- | ------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
| 1 | `EInvoiceAdapter` | E-invoice serialization + fiscal platform submission | HR (Storecove), RS (SEF), BA-FED (CPF), BA-RS (UINO) |
| 2 | `CompanyRegistryAdapter` | Company data lookup (name, address, tax status) from government registries | HR (FINA), RS (APR), BA (stub) |
| 3 | `BankStatementAdapter` | Bank statement import (MT940, CAMT.053, PSD2 AISP) | All markets — via Tok Open Banking platform |
| 4 | `ExchangeRateAdapter` | FX rate feed (daily/live) | All markets (ECB primary, HNB for HR, NBS for RS) |
| 5 | `TaxFilingAdapter` | Electronic VAT/CIT return submission to tax authority | HR (ePorezna), RS (ePorezi), BA (TBD) |
| 6 | `FiscalDeviceAdapter` | Fiscal receipt device or cloud fiscal service | HR (Fiskalizacija cloud cert), RS (LPFR chip card), BA (TBD) |
| 7 | `QESSigningAdapter` | Qualified Electronic Signature for invoice signing | HR (FINA QES), RS (stub), BA (stub) |
**Current implementation status:**
- `EInvoiceAdapter`: `StorecoveHrFiskEInvoiceAdapter` (HR, STUB lifecycle)
- All other categories: NOT YET IMPLEMENTED
### 2.2 Common Interface Contract
Every adapter interface extends a common `BilkoAdapter` base:
```kotlin
package no.alai.bilko.adapter
import no.alai.bilko.country.TaxJurisdiction
import no.alai.bilko.einvoice.AdapterLifecycleState
/**
* Base contract for all Bilko integration adapters.
*
* Every adapter implementation MUST:
* 1. Expose [jurisdiction] and [lifecycleState] as first-class properties.
* 2. Throw only [AdapterException] — NEVER platform-native exceptions.
* 3. Pass all log writes through [sanitizeForLog] (defined per-adapter for PII fields).
* 4. Record Prometheus metrics on every external call (see §2.6).
* 5. Not require credentials for read-only / serialization operations.
*/
interface BilkoAdapter {
val jurisdiction: TaxJurisdiction
val lifecycleState: AdapterLifecycleState
val adapterVersion: String // Semantic version string, e.g. "1.0.0"
}
```
### 2.3 AdapterException — Canonical Error Contract
All adapters throw `AdapterException` and nothing else. This exception type is the
single crossing point from adapter space to core service space.
```kotlin
package no.alai.bilko.adapter
import no.alai.bilko.country.TaxJurisdiction
/**
* Canonical adapter error. The CEOONLY 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 wasval capturedmessage: 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 ADR-023scheduled andmaintenance
PlanPLATFORM_INTERNAL_ERROR, v3,// butHTTP neither5xx document— containstransient 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 structuredmapping decisionto recordan with`AdapterErrorCode`. alternativesUse analysis,`UNKNOWN` consequences, and binding implementation
rules. The override must be ratifiedonly as a first-classcatch-all, architectural decision so that:
New team members understand WHY HR is first (competitive, regulatory, and technical drivers)RS deprioritization is explicit and does not require repeated CEO re-confirmationThe Storecove MC #8675 activation is recognized as a critical-path CEO actionThe BA sequencing rationale (after HR, before RS in terms of complexity) is documented
2. Decision
Market launch priority: HR (Croatia) → BA (Bosnia-Herzegovina) → SR (Serbia)
This sequence is CEO-mandated and applies to:
Phase scheduling (Phase 1H before Phase 1S before Phase 1B)Adapter development priority (StorecoveHrFiskEInvoiceAdapterSANDBOX_VERIFIED before SEF)Flyway migration priority (HR Kontni Plan seeded before RS Pravilnik)Frontend MarketContext priority (HR locale, EUR currency, OIB validation before RS/BA)Marketing and GTM (bilko.cloud HR brand before bilko.rs / bilko.company)Securion audit priority (HR-scoped RLS audit before RS/BA)Storecove account activation (MC #8675) is a CEO/John-owned critical-path action
2.1 SR Deprioritization — Explicit Scope
The following RS-focused work items are explicitly DEPRIORITIZED to Phase 1S (post-HR GA):
SEF outbound adapter (SefRsEInvoiceAdapter) — Phase 1S.3SEF inbound adapter (supplier invoice webhook) — Phase 1S.4SEF sandbox certification (5 invoice types, real SEF sandbox) — Phase 1S.5PluginRSfull implementation (PDV 20/10%, PIB validator, APR CoA, RSD formatters) — Phase 1S.1TaxEngineservice withJurisdictionRuleSetinterface — Phase 1S.2RS company registry adapter (APR), exchange rate adapter (NBS), ePorezi filing adapter — Phase 1S.6packages/domain-rsTypeScript plugin work — deprioritized (stub only)bilko.rsdomain registration — open (MC #100124), not required for HR GA
What stays active for RS: ePorezi RS registration (MC decision signed 2026-04-24, started
2026-04-25). Registration process runs independently of code priority — it takes 6+ months and
has no code dependency.
2.2 BA Sequencing — After HR, Before Full SR
BiH (Bosnia-Herzegovina) follows HR in sequence for a specific reason: the BA market has
two sub-jurisdictions (BA_FED — Federation, under UIO-FBiH tax authority; BA_RS —
Republika Srpska entity, under Poreska Uprava RS entity) that require parallel adapters.
This is architecturally more complex than RS (single SEF platform) but the BA market
is strategically closer to the HR market in terms of regulatory readiness:
BA e-invoice mandate is forthcoming (not yet in force as of 2026-05-13)BA SMBs currently use paper or simple e-invoicing; Bilko can enter with less competitive frictionBA demographics and business language overlap with BiH diaspora in Norway (Alem's network)TheTaxJurisdictionenum already definesBA_FEDandBA_RS(V16 applied)
BA sequencing rule: Phase 1B (BiH generalization) begins after Phase 1S kickoff, not
after Phase 1S GA. This allows BA and RS to run in parallel in the later phases.
3. Drivers: Why HR Is First
3.1 Competitive Driver — Minimax HR Is the Threat
The HR competitive map (project_bilko_hr_competitor_intel_2026-05-10.md) identifies
Minimax HR (under SEYFOR group)never as
the primary SMBmapping competitorfor a known status code. See `StorecoveErrorMapper` (lines 469–516
in Croatia.`StorecoveHrFiskEInvoiceAdapter.kt`) Key facts:
SEYFOR (formerly Saop) owns Minimax, Vasco, Opal acrossas theAdriaticreference pattern. ### 2.4 AdapterConfig —50K+DB-LevelusersFeature MinimaxFlaghasEverymodernadaptercloudisUXgated+byFISKan2.0`AdapterConfig`+row.EU-mandateAncomplianceadapter MojaFirma.hrMUST(previouslyNOTthoughtexecuteasany network call if its `AdapterConfig.enabled = false`. This allows disabling athreat)brokenwasadapterconfirmedwithoutasredeployment.HR/timesheet```sqlonly,-- 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) NOTaccountingNULL,(live--probeTaxJurisdiction2026-05-10:enumpaušalvalue:module'HR',='RS',NN'BA_FED',55/2024'BA_RS'evidencija,adapter_typeNOVARCHAR(32)B2BNOTe-faktura,NULL,NO--HR-FISK 2.0)Bilko's differentiation:"Fiken simplicity" vs. Minimax breadth + eRačun/FISK 2.0 gapFirst-mover window: FISK 2.0 mandate is creating a compliance urgency that Bilko can exploit beforeMatches themarket7consolidatescategories:around'EINVOICE',Minimax'COMPANY_REGISTRY',
RS'BANK_STATEMENT', market'EXCHANGE_RATE', has'TAX_FILING',
Moj-eRačun-- /'FISCAL_DEVICE', SEF'QES_SIGNING'
asenabled anBOOLEAN establishedNOT ecosystem.NULL BilkoDEFAULT enteringFALSE,
RSreason afterTEXT,
HR-- GAWhy meansdisabled, 2–3e.g. weeks"MC of#8675 RS delay against a market that is already mature — acceptable given the
HR first-mover opportunity.
3.2 Regulatory Driver — eRačun 2026 Deadline
Croatia mandated eRačun (electronic invoicing) via HR-FISK 2.0 for B2B transactions.
The effective deadline creates an urgency window: businesses that do not have an e-invoice
solution are actively searching for one. Bilko entering the HR market during this window
maximizes TAM capture without requiring marketing spend — regulatory urgency does the
demand generation.
Serbia's SEF mandate is already in force since 2022 — the urgency window has closed.
Businesses that needed a SEF-compliant solution have already adopted one. The Croatia
window is open now.
3.3 Technical Driverpending — Storecove Serialize Already Works
StorecoveHrFiskEInvoiceAdapter.serialize() is implemented and tested offline. The Peppol
path (Option B, CEO-signed 2026-04-24) is confirmed. The only blocked item is submit()
and pollStatus(), which require Storecove account activation (MC #8675 — CEO/John action).
In contrast, the RS SEF path requires:
A dedicated SEF API key (available but requires sandbox registration)A separate certificate for AS4/Peppol via a Serbian IS posrednik (intermediary)More complex XML format (SEF-specific UBL variant) with no existing serializer
The HR path is 60–70% built. The RS path is 20% built. Given equal team velocity, HR GA
is achievable in 4–5 weeks from Phase 1H kickoff; RS GA would take 8–10 additional weeks.
3.4 Banking Partnership Driver — PBZ Alignment
MC #8608 (PBZ Zagreb, Tomislav Premuz) is an active banking partnership discussion framed
as fee income + deposit retention for the Intesa group (per PBZ banking dossier 2026-05-10).
An HR product demo enables the PBZ partnership conversation with a concrete reference
implementation. A RS-first launch does not enableactivated"
this conversation.
The 12-week banking sequencing (W4 PBZ go/no-go, W5–6 parallel Erste+Zaba) is timed to
HR GA (target 2026-06-08). RS partnership discussions are a Phase 1S+B parallel track.
4. Consequences
4.1-- Required Actionswhen 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 (Binding)
market, Theadapter_type)
following);
actions-- areSeed: requiredall byadapters thisstart decisiondisabled
andINSERT cannotINTO beadapter_config deferred(market, toadapter_type, post-HRenabled, GA:
- updated_by)
- VALUES
('HR', 'EINVOICE', false, 'MC #8675 — Storecove account
'QES_SIGNING',activationpending', 'MC-100362'), (CEO/John'HR',action):'COMPANY_REGISTRY',Mustfalse,be'Notcompletedimplementedby—2026-05-25Phaseto1S',maintain'MC-100362'),HR('HR',GA'BANK_STATEMENT',targetfalse,of'Tok2026-06-08.AISPThisintegrationispending',the'MC-100362'),only('HR',external'EXCHANGE_RATE',dependencyfalse,with'ECBvariablefeedleadnottime.configured',Storecove'MC-100362'),account('HR',provides'TAX_FILING',false,STORECOVE_API_KEYand'ePoreznaintegrationSTORECOVE_LEGAL_ENTITY_IDenablingPhase,submit(2 scope', 'MC-100362')and('HR',,pollStatus('FISCAL_DEVICE', false, 'Fiskalizacija cert not configured', 'MC-100362')in('HR',StorecoveHrFiskEInvoiceAdapter.false, - 'FINA
QES
SANDBOX_VERIFIEDPhasepackages/domain-rsTypeScript2deprioritization:scope',The'MC-100362'),('RS',packages/domain-rspackage'EINVOICE',existsfalse,on'SEFdisk as a TypeScript stub. It is NOT wired to the Kotlin runtime. No further work on this package untiladapter Phase 1Skickoff.scope',CodeCraft'MC-100362'),must('BA_FED',not'EINVOICE',routefalse,sprint'CPFcapacityplatform TBD ~2027', 'MC-100362'), ('BA_RS', 'EINVOICE', false, 'UINO platform TBD', 'MC-100362'); -- (Remaining BA/RS adapter rows follow same pattern) -- Admin can enable without redeploy: -- UPDATE adapter_config SET enabled = true, reason = NULL, updated_by = 'MC-8675-DONE' -- WHERE market = 'HR' AND adapter_type = 'EINVOICE'; ``` **Kotlin enforcement pattern:** ```kotlin // In the adapter registry (apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterRegistry.kt) fun requireEnabled(market: TaxJurisdiction, adapterType: String) { val config = adapterConfigRepository.find(market, adapterType) ?: throw AdapterException( code = AdapterErrorCode.NOT_IMPLEMENTED, market = market, retryable = false, rawPayload = "", message = "No AdapterConfig row for ($market, $adapterType) — run Flyway V20" ) if (!config.enabled) { throw AdapterException( code = AdapterErrorCode.NOT_IMPLEMENTED, market = market, retryable = false, rawPayload = "", message = "Adapter ($market, $adapterType) is disabled: ${config.reason}" ) } } ``` ### 2.5 Lifecycle States and Transition Criteria Formalised from `AdapterLifecycleState` enum in `EInvoiceTypes.kt` lines 22–26, and from ADR-016 §2.3. Applies toALLdomain-rswhileadapterPhasecategories.1H```isSTUBin──────────────────►progress.──────────────────► - PRODUCTION
```
SEF tasks parked:Plan v3 Tasks 1.4**STUB** (SEFinitialoutbound)state for all adapters): - Compiles and1.5registers(SEFsuccessfullysandbox-cert)AllarenetworkPARKED.methodsTheythroware`AdapterException(code=NOT_IMPLEMENTED)`not-in the sprint backlog. Any SEF-related MC task opened while Phase 1H is active must be tagged Phase 1S and deferred. BA-FED`serialize()` /BA-RS Kotlin split timing:TaxJurisdictionalready hasBA_FEDandBA_RS(V16 applied).PluginBAFEDandPluginBARSare stub implementations in Phase 1H.3. Full BA implementation waits for Phase 1B kickoff (post-HR GA + Phase 1S kickoff).
4.2 Critical Path: Storecove MC #8675
MC #8675 is the single external dependency that can delay HR GA. The activation sequence:
CEO/John activates Storecove account → receivesSTORECOVE_API_KEY+STORECOVE_LEGAL_ENTITY_IDStorecove provisions Peppol participant ID for Bilko's legal entity (typically 1–3 business days)CodeCraft wiressubmit()+pollStatus()inStorecoveHrFiskEInvoiceAdapter(skeleton already written — activation is uncomment + wire, not a new implementation)Proveo runs 5 sandbox invoice types: B2B outbound, B2G outbound, credit note, cancelled invoice, inbound received — all with real Storecove submission IDslifecycleStateadvances fromSTUBtoSANDBOX_VERIFIEDAdapterConfigrow:(HR, EINVOICE, enabled=true)set in stage DB
If MC #8675 is not activated by 2026-05-25, Phase 1H.6 slips. The rest of Phase 1H
(DI wiring, CountryPlugin interface, PluginHR, Flyway V16) can complete without MC #8675.
A demo with submit() in STUB state is possible — it shows HR VAT rates, EUR currency,
OIB validation, and e-invoice serialization. GA (first paying HR customer) requires
Storecove activation.
Hard deadline: CEO activates MC #8675 no later than 2026-05-25.
4.3 Impact on domain-rs TypeScript Package
The packages/domain-rs TypeScript package exists on disk and compiles to dist/.
It is NOT loaded by the Kotlin runtime — it is a TypeScript-read-only scaffold.
This decision means packages/domain-rs receives no active development during Phase 1H.
The packageoperations may accumulate technical debt if Kotlin PluginRS in Phase 1S diverges from
the TypeScript stub patterns. This is accepted:
The TypeScript packages are frontend/tooling artifacts, not the source of RS business logicRS business logic lives in KotlinPluginRS(Phase 1S.1)packages/domain-rswill be updated in Phase 1S to align withPluginRSimplementation
4.4 IS Posrednik Decision (MC #100273) — HR First
The IS posrednik selection (build vs. Mer/Visma AS4 vs. FINA fallback) is a HR-specific
decision for the FISK 2.0 AS4 submission path. This decision (MC #100273) is on the HR
critical path and must be resolved before Phase 1H.6 (Storecove submit() wiring).
The Storecove Peppol path (Option B, CEO-signed 2026-04-24) uses Storecove as the IS
posrednik — it is not a direct FINA connection. This means:
MC #100273 (IS posrednik decision) is answered by MC #8675 (Storecove = IS posrednik) for the HR FISK 2.0 pathMC #100273 remains open if FINA fallback is considered for non-Storecove scenariosFor Phase 1H: Storecove IS the IS posrednik. MC #100273 can be closed as "Resolved by CEO decision 2026-04-24 (Storecove Peppol Option B)"
4.5 Banking Partnership Parallel Track
PBZ (MC #8608), Erste, and Zaba banking discussions proceed in parallel with Phase 1H
engineering. The banking partnership is not a code dependency — it is a commercial track
that requires an HR product demo (deliverable of Phase 1H) to advance to pilot stage.
The 12-week banking sequencing timeline:
W4 (2026-06-01): PBZ go/no-go decisionW5–6 (2026-06-08): parallel Erste + Zaba outreach (mandatory per competitive intel memo)W8 (2026-06-22): pilot agreementW12 (2026-07-20): LOI or pivot
This timeline is compatible with HR GA target of 2026-06-08. The PBZ go/no-go aligns
with HR GA week.
5. Alternatives Considered
Option A: SR-First (v2 Plan)
Rationale at time of v2: SEF mandate already in force; SEF sandbox certification was
the nearest technical milestone; RS market was assumed to be most revenue-ready.
Why rejected: CEO override 2026-05-09. On reflection, the SR-first rationale was
based on regulatory urgency (SEF in force) but missed the competitive urgency in HR (FISK
2.0 compliance window + Minimax as the primary threat). Additionally, the Storecove
serialize() completion made HR technically closer to done than originally assessed.
The SEF adapter has no existing serializer — it is further from done than HR, not closer.
Option B: Simultaneous HR + SR Launch
Develop PluginHR and PluginRS in parallel, targeting simultaneous GA.
Why rejected: Team capacity and risk. Running two market adapters in parallel while
stabilizing the RLS migration (Phase 2A) and the CountryPlugin DI wiring (Phase 1H)
creates four concurrent workstreams for a small team. The probability of schedule slip
on all four increases nonlinearly. HR-first serializes the risk: prove the CountryPlugin
architecture works for HR, then apply it to RS with confidence.
Option C: BA-First
BiH has strategic importance (CEO personal network, diaspora market in Norway). Target
BA before HR.
Why rejected: BiH e-invoice mandate is not yet in force. The BA market has no
regulatory urgency comparable to HR-FISK 2.0. Additionally, BA requires two sub-jurisdiction
implementations (BA_FED and BA_RS) — more architectural complexity than HR's single
jurisdiction. Starting with the more complex market before the architecture is proven
increases risk. BA follows HR when the CountryPlugin DI pattern is validated.
6. Implementation Notes
6.1 Sprint Backlog Rules (Enforced)
While Phase 1H is active (Weeks 1–3 from 2026-05-11), the following rules apply to the
CodeCraft sprint backlog:
No MC tasks with labelmarket:RSormarket:BAmay enter the active sprintAnydomain-rsTypeScriptworkis automatically deferred to Phase 1SSEF-related tasks are parked (not closed — they will be activated at Phase 1S kickoff)PluginRSandPluginBAFED/PluginBARSstubs (Phase 1H.3) are the ONLY RS/BA items in Phase 1H — they are stubs, not implementations
6.2 Phase 1S Kickoff Gate
Phase 1S (Serbia generalization) kicks off when the following are true:
Phase 1H complete:PluginHRlive in production, StorecoveSANDBOX_VERIFIEDPhase 5H.1 passed: Proveo HR smoke test with real stage DBHR GA target date (2026-06-08) reached or within 1 weekCEO explicit kick-off confirmation (not assumed from Phase 1H completion)
The Phase 1S kickoff is a separate CEO decision — completing Phase 1H does not auto-trigger
Phase 1S. Market conditions may change between 2026-05-11 and 2026-06-08 (e.g., HR serialize works in STUB)
- `AdapterConfig.enabled` is `false`
**SANDBOX_VERIFIED** transition criteria (all must be true):
- Minimum 5 distinct happy-path test cases pass against the real sandbox platform
(not mocked — real submission IDs, real response payloads)
- All test case submission IDs are archived in BookStack as evidence
- Error mapping verified: at least HTTP 400, 401, 409, 429, 503, 5xx all produce
correct `AdapterErrorCode` values (not `UNKNOWN`)
- Proveo sign-off with evidence file path in MC task
- `AdapterConfig.enabled` can be set to `true` after this point
**PRODUCTION** transition criteria (all must be true):
- `SANDBOX_VERIFIED` already achieved
- Securion review of adapter error handling, PII sanitization, and idempotency key
implementation — no critical findings
- 30 consecutive days on stage Cloud Run with:
- Zero `PLATFORM_INTERNAL_ERROR` alerts
- Zero cross-market routing errors
- `bilko_integration_request_total{status="error"}` < 1% of total requests
- CEO approval for production activation
- `AdapterConfig.enabled = true` in production DB (separate row from stage DB)
### 2.6 Secret Taxonomy
Runtime secrets follow the pattern `Bilko/{env}/{market}/{secret-name}`.
**Env first, not market first.** This ensures that all production secrets are under
`Bilko/production/` and can be granted/revoked as a BAunit regulatoryfor announcementenvironment couldpromotion.
reprioritize```
Bilko/
production/
HR/
STORECOVE_API_KEY
STORECOVE_LEGAL_ENTITY_ID
FINA_QES_CERTIFICATE (Phase 2)
EPOREZNA_CLIENT_SECRET (Phase 2)
RS/
SEF_API_KEY (Phase 1S)
LPFR_DEVICE_CERT (Phase 2)
BA_FED/
CPF_API_KEY (Phase 1B — pending platform launch)
BA_RS/
UINO_API_KEY (Phase 1B — pending platform launch)
stage/
HR/
STORECOVE_API_KEY
STORECOVE_LEGAL_ENTITY_ID
RS/
SEF_API_KEY
...
local/
HR/
STORECOVE_API_KEY (developer sandbox credentials only)
...
```
**Secret resolution hierarchy:**
1. Runtime: GCP Secret Manager (current) — accessed via `SecretResolver` interface
2. Break-glass: Vaultwarden (`vault.basicconsulting.no`) — human access only, NOT runtime source
3. Local dev: `.env.local` file (`.gitignore`'d) — NEVER committed
```kotlin
// apps/api/src/main/kotlin/no/alai/bilko/adapter/SecretResolver.kt
/**
* Abstracts secret retrieval behind a testable interface.
*
* Production implementation: GCP Secret Manager.
* Test implementation: environment variables / in-memory map.
*
* Secret path convention: Bilko/{env}/{market}/{secret-name}
*/
interface SecretResolver {
/**
* Resolves a secret value by its canonical path.
*
* @param path e.g. "Bilko/production/HR/STORECOVE_API_KEY"
* @return Secret value, or null if not found.
* @throws AdapterException(AUTH_INVALID_CREDENTIALS) if path exists but value is empty/blank.
*/
fun resolve(path: String): String?
/**
* Convenience method: builds canonical path and resolves.
* @param env "production" | "stage" | "local"
* @param market TaxJurisdiction enum value as string
* @param secretName The specific secret name
*/
fun resolve(env: String, market: String, secretName: String): String? =
resolve("Bilko/$env/$market/$secretName")
}
```
**Vaultwarden is NOT the runtime secret source.** Vaultwarden is the human break-glass
vault for emergency access. Do not write Kotlin code that reads from Vaultwarden at
runtime. GCP Secret Manager is the runtime source.
### 2.7 Observability Mandate
Every adapter MUST emit the following for every network call:
**Structured log line (one per call):**
```
level=INFO market=HR integration=EINVOICE env=production org_id=<uuid>
action=submit status=SUCCESS duration_ms=234 submission_id=<guid>
```
Required fields: `market`, `integration`, `env`, `org_id`. Optional but recommended:
`duration_ms`, `submission_id`, `attempt` (for retries).
**NEVER log:**
- OIB, PIB, JIB (tax IDs)
- IBAN
- `document_data` (invoice XML body)
- `api_key`, `api_secret`
Use `sanitizeForLog()` (pattern from `StorecoveHrFiskEInvoiceAdapter.kt` lines 24–59)
before 1S)any log write that touches a response body.
**Prometheus metrics (one counter per adapter):**
```kotlin
// apps/api/src/main/kotlin/no/alai/bilko/adapter/AdapterMetrics.kt
/**
* Prometheus counter for all adapter network calls.
*
* Labels: market, integration, status (SUCCESS | ERROR | NOT_IMPLEMENTED | TIMEOUT)
*
* Example PromQL for HR e-invoice error rate:
* rate(bilko_integration_request_total{market="HR",integration="EINVOICE",status="ERROR"}[5m])
* /
* rate(bilko_integration_request_total{market="HR",integration="EINVOICE"}[5m])
*/
// bilko_integration_request_total{market, integration, status}
// bilko_integration_request_duration_seconds{market, integration, status}
```
Per-(market, integration) alert rule:
- Error rate > 10% over 5 minutes: PAGE (PagerDuty or Slack alert)
- Error rate > 25% over 1 minute: CRITICAL (adapter auto-disabled via `AdapterConfig`)
### 2.8 Adapter Versioning
Each adapter declares `val adapterVersion: String` (semantic version, e.g., `"1.0.0"`).
6.3The corresponding `CountryPlugin` implementation declares the minimum adapter version
it requires:
```kotlin
// In PluginHR:
companion object {
const val MIN_EINVOICE_ADAPTER_VERSION = "1.0.0"
}
// Startup check in DI.kt:
val adapter = StorecoveHrFiskEInvoiceAdapter()
require(semVer(adapter.adapterVersion) >= semVer(PluginHR.MIN_EINVOICE_ADAPTER_VERSION)) {
"PluginHR requires EInvoiceAdapter >= ${PluginHR.MIN_EINVOICE_ADAPTER_VERSION}, got ${adapter.adapterVersion}"
}
```
Adapters are versioned independently of the `CountryPlugin`. Breaking changes to
an adapter interface (e.g., new required parameter in `submit()`) require a major
version bump and a coordinated plugin + adapter update.
### 2.9 Idempotency Requirements
**All submit-type methods in all adapters MUST include an idempotency key.**
The idempotency key format is adapter-specific, but the value MUST be derived
deterministically from the invoice or entity content — never a random UUID.
| Adapter | Method | Idempotency key derivation |
| -------------------- | ---------- | -------------------------------------------------------------------- |
| EInvoiceAdapter (HR) | `submit()` | `SHA-256(invoice.id + invoice.invoiceNumber)` — matches Storecove D5 |
| EInvoiceAdapter (RS) | `submit()` | `SHA-256(invoice.id + invoice.invoiceNumber)` (same pattern) |
| TaxFilingAdapter | `submit()` | `SHA-256(filing.periodStart + filing.periodEnd + org.taxId)` |
| QESSigningAdapter | `sign()` | `SHA-256(document.contentHash + signer.taxId)` |
**Rationale:** A network timeout after the platform receives the request but before
the response arrives will cause the client to retry. Without idempotency, this creates
a duplicate document. Storecove returns HTTP 409 on duplicate `document_id` (D2 in
`StorecovePayloadBuilder.wrap()` lines 420–436) — the pattern must be replicated.
---
## 3. Implementation Path
| Phase | Task | Deliverable | Status |
| -------- | ------------------------------------------------------------ | --------------------------------------------------------------- | ------------------- |
| Phase 0' | This ADR written to disk | `ADR-019-INTEGRATION-ADAPTER-REGISTRY.md` | DONE |
| Phase 1H | `AdapterException` + `AdapterErrorCode` formalized | `adapter/AdapterException.kt` (already exists — verify package) | Verify existing |
| Phase 1H | `AdapterConfig` Flyway migration (V20) | `V20__adapter_config.sql` | BLOCKED BY Phase 2A |
| Phase 1H | `SecretResolver` interface + GCP impl | `adapter/SecretResolver.kt` + `GcpSecretResolver.kt` | Phase 1H.4+ |
| Phase 1H | `AdapterRegistry` + `requireEnabled()` check | `adapter/AdapterRegistry.kt` | Phase 1H.4+ |
| Phase 1H | Prometheus metrics wired in `StorecoveHrFiskEInvoiceAdapter` | `StorecoveMetrics.kt` (skeleton exists) | Phase 1H.2+ |
| Phase 1S | SEF RS EInvoiceAdapter | `country/rs/SefRsEInvoiceAdapter.kt` | Post-HR GA |
| Phase 1B Kickoff| Gate
CPF PhaseBA-FED 1B+ (BosniaUINO generalization)BA-RS kicksstubs off| `country/ba/Cpf*`, `country/ba/Uino*` | Post-RS GA |
---
## 4. Consequences
### 4.1 Positive
- **Zero platform exception leakage.** `AdapterException` as the only crossing type means
core services have one error handler for all 7 × 4 = 28 potential adapter instances.
- **Hot disable without redeploy.** `AdapterConfig.enabled = false` disables a broken
adapter in parallel< 1 minute (DB write). No restart required. Incident response time drops
from minutes (redeploy) to seconds (DB update).
- **Observability from day one.** Every adapter emits standardized metrics. Error rate
alerts fire before users report issues.
- **Secret hygiene.** Env-first taxonomy (`Bilko/{env}/{market}/{secret}`) makes
environment promotion (stage → production) a structured operation, not an ad-hoc
copy. Break-glass access is separated from runtime access.
### 4.2 Negative
- **Adapter scaffolding cost.** Each new adapter requires implementing the full
interface contract, `AdapterConfig` rows, `SecretResolver` wiring, and Prometheus
metrics. Estimate: 1–2 days for a new adapter in STUB state.
- **AdapterConfig is a deployment dependency.** The application fails at startup if
`adapter_config` rows do not exist. Flyway V20 must run before the application
version that adds `requireEnabled()` checks.
### 4.3 Risks
- **`AdapterConfig` DB unavailable.** If the database is unreachable, `requireEnabled()`
fails, blocking all adapters. Mitigation: cache `AdapterConfig` in-memory at startup
with Phasea 1STTL (notof after):
- minutes.
- Use
Phasecached1S.1state(ifPluginRSfullDBimplementation)ismergedunreachable.to-main**Metrics - cardinality.**
PhaseHigh1H`org_id`stable (no regressionscardinality inHRmetricsmarketlabelsforwould2causeweeksPrometheuspost-GA)memory - issues.
CEOThedecisionobservability mandate specifies `org_id` in log lines, NOT in Prometheus labels. Labels are `market`, `integration`, `status` only — bounded cardinality. --- ## 5. References | Reference | Path | Lines | | ------------------------------------------------ | ------------------------------------------------------------------------------------- | ------- | | `AdapterLifecycleState` enum (onBAdisk)PSD2|/`apps/api/src/main/kotlin/no/alai/bilko/einvoice/EInvoiceTypes.kt`Tok|scope22–26 | | `StorecoveErrorMapper` (OpenerrorCEOmappingDecisionreference)#3,| `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 §10)6 - Task 0'4 acceptance criteria | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md` | 279–290 |
---
## 6. Approval
**Status:** Accepted
**Unblocks:**
- Phase 1H: `AdapterConfig` Flyway V20 migration
- Phase 1H: `SecretResolver` GCP implementation
- Phase 1H: Prometheus metrics wiring in `StorecoveHrFiskEInvoiceAdapter`
- Phase 1S: SEF RS adapter scaffolding (knows the contract to implement against)
- Phase 1B: CPF/UINO BA
e-invoiceadapterregulatorystubsupdate|assessedRole(if|mandateSignannounced,|accelerate)Date
Running| Phase-------------------------------- 1B| and------------------------------------- Phase| 1S---------- in|
parallel| is feasible because PluginBAFED and PluginBARS
do not share code with PluginRS. Two teams (or two sprint lanes within CodeCraft) can
develop them concurrently after the CountryPlugin DI pattern is proven via HR.
7. Approval
Status: Accepted (ratification of CEO override 2026-05-09)
Architecture review: Required from Petter Graff before Phase 1H kickoff
| Architecture Lead (Petter Graff) | ||
| |
| CEO (Alem Bašić) | for ||
8.7. Document History
| Change | ||
|---|---|---|
| ------------ | ------------------------------------------------- |
| 2026-05- | 11 | Petter Graff | | Initial — | #100362)