Based on the arc42 template.

Introduction and Goals

einfache-eRechnung.de (EER) converts traditional PDF invoices into structured e-invoices. German law requires all B2B invoices to use structured electronic formats by January 2027. Most small business owners and sole proprietors — whether craftsmen, freelancers, consultants, or service providers — create invoices in Word or Excel and lack the tools or appetite to adopt new software.

EER solves this by accepting PDF invoices via email, extracting invoice data with an LLM, and returning a ZUGFeRD-compliant e-invoice — also via email. The user changes one thing: the recipient email address. No new software, no training, no workflow disruption. When an uploaded invoice is not yet ready for a valid e-invoice, EER explains typical missing or inconsistent invoice data and guides the user toward correcting the source invoice. This extends the product from pure conversion to guided e-invoice readiness without turning it into accounting software.

We turn your PDF into an e-invoice — you only change the email address.

Requirements Overview

Business Goals

Goal Description

Workflow continuity

Small businesses keep their existing invoice-creation process (Word/Excel/Pages → PDF → email). Only the email recipient changes.

Legal compliance

Generated e-invoices conform to ZUGFeRD 2.5 (EN16931 profile) and satisfy German B2B e-invoicing obligations under UStG/UStDV.

Guided invoice readiness

Users understand whether their existing invoice is formally complete, arithmetically plausible, and ready for e-invoicing before they send it onward.

Market validation

Validate product-market fit through a fake-door pricing page and free trial before investing in billing infrastructure.

Core Workflow

core workflow

Key Functional Requirements

ID Requirement

F-1

Accept PDF invoices as email attachments via Mailgun inbound webhook.

F-2

Extract structured invoice data (seller, buyer, line items, tax, totals) from supported native-text PDFs via layout-aware document interpretation and LLM extraction.

F-3

Generate ZUGFeRD 2.5 e-invoices (EN16931 profile): hybrid PDF with embedded CII XML.

F-4

Return the generated e-invoice to the sender via email.

F-5

Provide a diagnosis-first browser flow for validation failures and keep the edit session as a secondary path for genuine extraction or mapping errors.

F-6

Authenticate users via magic links (passwordless). No separate registration required — first email submission creates the account.

F-7

Enforce time-based access control: 30-day free trial with full functionality, then flat-rate subscription. All tiers produce identical, EN 16931-compliant e-invoices.

Out of Scope

  • Full bookkeeping or accounting features

  • ERP integration or bank synchronization

  • Cross-border VAT automation

  • B2G invoicing (XRechnung / Peppol) — planned for future expansion

Quality Goals

The following quality goals drive architectural decisions. They are ordered by priority. Chapter 10 contains the full quality tree with detailed scenarios.

Priority Quality Goal Scenario

1

Simplicity (#usable)

A small business owner who has never heard of "e-invoicing" sends a PDF invoice to the EER email address and receives a valid e-invoice within 2 minutes — without creating an account first, without reading documentation, and without installing software.

2

Compliance (#suitable)

Every generated e-invoice passes EN16931 schema validation and ZUGFeRD 2.5 conformance checks. An invoice that fails validation is never delivered to the user; instead, the system opens a diagnosis-first correction path.

3

Data privacy (#secure)

All invoice data processing — including LLM extraction — occurs within the EU. No invoice content leaves EU jurisdiction. The system deletes processing artifacts within 30 days.

4

Reliability (#reliable)

When PDF extraction fails or produces incomplete data, the system responds within 60 seconds with an actionable diagnosis or correction path. No invoice is silently lost. Every processing step is tracked via OpenTelemetry spans and persisted as business events.

Stakeholders

Role Contact Expectations

Small business owners and sole proprietors

Primary users

Zero learning curve. Send email, receive e-invoice. Affordable pricing. No forced workflow change.

Tax advisors

Recommenders

Tool they can trust to recommend to clients. Legally compliant output, understandable diagnosis of common formal invoice problems, and transparent processing — no black box.

Development team

Internal

Maintainable codebase. Fast iteration cycles. Architecture that supports incremental feature growth without rewrites.

Mailgun

Email infrastructure provider

Reliable webhook delivery for inbound emails. Stable API for outbound sending.

Mistral AI

LLM provider (EU-hosted)

Consistent extraction quality. EU data residency. Predictable API costs.

Architecture Constraints

The following constraints limit design and implementation decisions. They originate from the technology stack, the small team setup, German regulatory requirements, and agreed coding conventions.

Technical Constraints

ID Constraint Background

TC-1

Kotlin 2.x on JVM 21 (Eclipse Temurin)

Developer expertise. Kotlin’s null safety and conciseness reduce defect rate for a small team.

TC-2

Spring Boot 3.x with Virtual Threads

Mature ecosystem for web, JPA, mail, AI, and security. Virtual threads (spring.threads.virtual.enabled: true) replace reactive programming with simpler blocking code.

TC-3

PostgreSQL 18 as the only database technology

One PostgreSQL cluster hosts separate EER, Clarula, and Metabase logical databases. Product application roles have no sibling-database access. No NoSQL, Redis, foreign data wrapper, or secondary product store.

TC-4

Product-local Flyway chains; Hibernate ddl-auto: validate

EER and Clarula own independent migration locations and schema histories in their logical databases. Each Hibernate persistence unit validates only its product schema at startup.

TC-5

Single deployable modular monolith (no microservices)

One Spring Boot JAR and process. Compile-time product modules and explicit Spring servlet contexts enforce internal boundaries without adding distributed-system operations.

TC-6

Docker-based deployment on a single VPS

Docker Compose orchestrates the backend, PostgreSQL, Caddy, two static Nuxt/Nginx frontend containers, Metabase, and partner demos. Frontend images contain no Node/Nuxt runtime and publish no host ports. No Kubernetes or managed container services.

TC-7

Caddy as reverse proxy

Automatic HTTPS via Let’s Encrypt. Zero certificate management overhead.

TC-8

10 MB max upload size for PDF invoices

Shared Spring multipart settings cap each file and request at 10 MB. A future multi-file upload must introduce feature-owned request limits without widening unrelated upload endpoints.

TC-9

Gradle Kotlin DSL build system

Consistent language (Kotlin) across production code and build scripts.

TC-10

Mistral AI for LLM-based invoice data extraction

EU-hosted AI provider. Selected over OpenAI to satisfy the EU data processing constraint (see LC-2). Uses Spring AI integration.

TC-11

Mustang Project 2.x + PDFBox 3.x for ZUGFeRD generation

Open source libraries for creating and validating ZUGFeRD/Factur-X PDF/A-3 invoices. No commercial PDF licenses.

TC-12

OpenTelemetry Java Agent for observability

Automatic instrumentation of HTTP, JPA, and Spring components. Exports traces, metrics, and logs via OTLP.

TC-13

linux/amd64 as the only target platform

Docker images build for amd64 only. The production VPS runs Linux x86_64.

TC-14

Dev environment provisioned by Terraform + cloud-init

The permanent dev environment is defined in deployment/environment/dev/ and rebuilt from code instead of being patched by hand. Only PostgreSQL data and Caddy state survive host replacement.

TC-15

DATEV Online API behind an environment-selected port

Local and demo operation use a deterministic mock. Sandbox and production require separate DATEV app credentials, exact callback registration, paid master-data:master-clients access, and provider approval. The application contract and minimized domain model must not depend on one environment.

Organizational Constraints

ID Constraint Background

OC-1

Small team

Lean team makes architecture, implementation, and operations decisions. Code quality relies on automation (tests, linting, CI).

OC-2

No dedicated operations team

Infrastructure must be simple and self-managing. Caddy handles TLS automatically. Docker Compose restarts containers on failure. No on-call rotation.

OC-3

Budget-conscious — single VPS, no expensive cloud services

No AWS/Azure/GCP managed services. One Hetzner VPS hosts the entire stack. Cost stays below 50 EUR/month for infrastructure.

OC-4

Open source libraries preferred

Mustang Project, PDFBox, Spring Boot — all open source. Avoids vendor lock-in and license costs.

OC-5

GitHub Actions with dev-first deployment flow

Push to main auto-deploys to dev. Production is manual-only and restricted by the production GitHub Environment allowlist. This protects the customer-facing environment while keeping integration feedback fast.

OC-6

English for all code, comments, commits, and PRs

Technical communication in English. Enables future contributors and aligns with open source conventions.

OC-7

German for all user-facing text

Target market is Germany. UI, emails, error messages, and landing pages use plain German with consistent du tone.

OC-8

Compiler warnings treated as errors

allWarningsAsErrors is set to true in the Kotlin compiler options. No warning suppression.

OC-9

Environment-specific secret management during rollout

Dev deploys already fetch secrets from 1Password at workflow runtime. Prod still uses GitHub Environment secrets until the migration is completed. Documentation and operations must reflect this split accurately.

ID Constraint Background

LC-1

GDPR compliance mandatory

Target market is Germany. All personal data processing follows GDPR. Privacy policy and data processing transparency required.

LC-2

All data processing within the EU

Reason for choosing Mistral AI (EU-hosted) over OpenAI (US-hosted). Invoice content — potentially containing personal data — must not leave EU jurisdiction.

LC-3

Generated e-invoices must comply with EN 16931

European e-invoicing standard. Mustang Project’s validator checks EN 16931 Schematron rules on every generated invoice.

LC-4

ZUGFeRD 2.5 format for the German B2B market

ZUGFeRD 2.5 (= Factur-X 1.09) embeds structured XML (CII) inside a PDF/A-3 document. This is the format German businesses expect.

LC-5

German B2B e-invoicing mandate starting January 2027

Businesses with revenue above 800k EUR must send structured e-invoices. This mandate creates the market demand for EER.

LC-6

No permanent invoice archive; bounded workflow and routing state only

EER stores the source PDF and its application-owned invoice draft only in edit sessions that expire after 30 days. A completed session additionally retains its exact generated ZUGFeRD PDF, verification PDF, and XML for byte-stable delivery until the same expiry; these target artifacts are not editable state. Clarula separately stores encrypted partner authorization and minimized, provenance-bearing mandate routing snapshots, but no complete DATEV responses or general master-data edits.

LC-7

AGB (Terms of Service) required before payment processing

Open point. No real payment processing until AGB are finalized and published. Currently operating in a free trial / waitlist model.

Conventions

Conventions are self-imposed constraints the team agreed on. They guide daily development decisions.

ID Convention Background

CO-1

Package-by-feature, not package-by-layer

Top-level packages reflect business capabilities: email.ingestion, formats.zugferd, invoice.editing. No controllers/, services/, repositories/ packages.

CO-2

Constructor injection only

No @Autowired on fields. Spring beans receive dependencies through their constructor. Makes dependencies explicit and testable.

CO-3

Sealed classes for result types

Business operations return sealed class hierarchies (e.g., Success, ValidationFailure, Error). Exhaustive when expressions catch missing cases at compile time.

CO-4

BaseEntity for all JPA entities

Provides UUID primary key, @Version for optimistic locking, and creation/update timestamps. Every entity extends BaseEntity.

CO-5

Three-tier test segregation

Unit tests (core/src/test/, eer/src/test/, clarula/src/test/): no Spring context where practical, MockK for external dependencies. Product integration tests (eer/src/integrationTest/): isolated EER context and PostgreSQL. Composition integration tests (app/src/integrationTest/): full sibling-context runtime with separate EER and Clarula PostgreSQL Testcontainers. E2E tests (e2e/): Playwright browser automation against the local Docker stack.

CO-6

Spotless + ktlint for formatting; Detekt for static analysis

spotlessApply fixes formatting and spotlessCheck fails on formatting violations. quickCheck runs type-resolved Detekt analysis for production and unit-test sources; staticAnalysis and check cover every Kotlin source set.

CO-7

Kotlin null safety over Optional

Use ?., ?:, and let for nullable types. Avoid !!. Optional is only for Spring framework interop where required.

CO-8

open-in-view: false for JPA

Disables the Open Session in View anti-pattern. @Transactional boundaries start at the controller (entry point) layer, not in services. All lazy loading must happen inside an explicit transaction boundary.

Context and Scope

The EER product (public brand: einfache-eRechnung.de) converts PDF invoices into ZUGFeRD 2.5 e-invoices; the same backend also hosts Clarula capabilities. Users interact exclusively through email and a web UI — no dedicated client software required.

This chapter defines the system boundary and all external communication partners.

Business Context

The business context shows what data the backend product system exchanges with its environment, independent of protocols or infrastructure.

Diagram
Figure 1. Business Context Diagram

External Communication Partners

The following table describes each communication partner and the data exchanged.

Partner Direction Data Exchanged Purpose

Small Business Owner

Bidirectional

PDF invoice (inbound), ZUGFeRD PDF (outbound), correction form data (web UI)

Primary user. Sends invoices via email, receives e-invoices back. Corrects extraction errors through the web portal.

Tax firm representative and DATEV Online APIs

Bidirectional

Direct DATEV authentication and consent; OAuth authorization, organization identity, and minimized mandate master data

Admin-assisted firm onboarding and mandate import. Clarula never receives the representative’s DATEV credentials. Local and demo operation use a deterministic mock behind the same port.

Mailgun

Bidirectional

Inbound: webhook payload with email metadata and PDF attachment. Outbound: email with ZUGFeRD PDF or error notification. Also delivers DOI confirmation and welcome emails for the registration flow.

Email infrastructure. Routes inbound emails to the EER context via webhook. Delivers all outbound emails to users, including transactional and registration emails.

Mistral AI

Bidirectional

Chat API receives extraction prompt plus local document evidence/context (outbound) and returns extracted invoice JSON (inbound).

Semantic extraction provider. Supported native-text PDFs are interpreted locally before the chat call. Scanned or image-only PDFs remain out of scope and fail closed per ADR-17. EU-hosted (data residency).

PostgreSQL

Bidirectional

Separate logical databases for EER invoice/account/widget state and Clarula firm/DATEV state; independent product roles, migrations, and backup artifacts.

Persistent storage for application state. Normal product connections cannot access the sibling database.

New Relic

Outbound

OpenTelemetry traces, metrics, and logs.

Observability. Monitors application health, performance, and errors in production.

GHCR

Inbound

Docker container images for all services.

GitHub Container Registry. Stores and delivers deployment artifacts.

Scope

Inside the system boundary (einfache-eRechnung.de):

  • PDF-to-ZUGFeRD conversion pipeline (extraction, validation, embedding)

  • Email ingestion and response delivery

  • Invoice correction web UI

  • Account management and authentication (magic links)

  • Free website upload API

  • Business event tracking

  • Product-owned marketing, legal, content, and customer application frontends (static Nuxt/Nginx images)

  • Self-hosted registration with double opt-in (DOI) via Mailgun

  • Clarula Firm-scoped DATEV authorization, encrypted token persistence, minimized mandate import, and mandate confirmation in an independent product database

Outside the system boundary:

  • Email transport (Mailgun)

  • Invoice data extraction intelligence (Mistral AI)

  • Monitoring infrastructure (New Relic)

  • Container registry (GHCR)

  • DNS and TLS termination (Caddy reverse proxy)

  • DATEV identity provider and mandate-master-data service

Technical Context

The technical context shows how the backend communicates with its environment: protocols, channels, and infrastructure.

Diagram
Figure 2. Technical Context Diagram

Channel Details

Channel Protocol Authentication Notes

Mailgun → EER (inbound email)

HTTPS POST (webhook)

HMAC-SHA256 signature validation

Mailgun sends multipart/form-data to /webhooks/inbound-email. The EER webhook validates the signature using a shared webhook signing key. Returns 200 OK immediately; processing runs asynchronously.

EER → Mailgun (outbound email)

HTTPS POST (REST API)

HTTP Basic Auth (api:<api-key>)

Sends multipart/form-data to Mailgun Messages API. Attachments include ZUGFeRD PDF. Also delivers DOI confirmation and welcome emails for the registration flow. In local development, replaced by SMTP to Mailpit.

EER → Mistral AI (LLM extraction)

HTTPS POST (REST API)

Bearer token (API key)

Sends structured evidence derived locally from the PDF through Spring AI ChatClient to the configured Mistral model. Six specialized calls return typed JSON that is mapped once into the application-owned invoice model. EU-hosted endpoints ensure data residency compliance.

Clarula <→ DATEV Online APIs

HTTPS (OIDC and REST)

Authorization Code with PKCE; encrypted rotating tokens; X-DATEV-Client-Id

An admin starts the flow under /admin/clarula/firms/{firmId}/datev; the firm representative authenticates at DATEV. /admin/clarula/datev/callback remains admin-authenticated and is dispatched to the Clarula sibling context. master-data:master-clients responses are minimized before persistence. Mock mode replaces this channel locally.

Backend → PostgreSQL

JDBC over TCP

Username/password

Two Spring Data JPA persistence units connect on port 5432 within the Docker bridge network: role eer to invoicex, and role clarula to clarula. PostgreSQL rejects sibling-database connections. No external exposure in hosted environments.

Backend → New Relic (observability)

OTLP/gRPC

API key in api-key header

OpenTelemetry Java Agent exports traces, metrics, and logs to otlp.eu01.nr-data.net:4317.

Internet → Caddy (all web traffic)

HTTPS (ports 80/443)

Automatic TLS via Let’s Encrypt

Caddy terminates TLS and proxies remaining product traffic to each product’s immutable Nuxt/Nginx image on einfache-erechnung.de and clarula.de. Same-origin /api/v1/** plus explicit widget, webhook, and actuator paths route directly to the backend. Admin and Clarula callback paths are backend-owned only on the canonical einfache-eRechnung operator origin. The former app origin retains only already-installed widget bundles and widget APIs during partner migration; analytics.einfache-erechnung.de routes to Metabase.

GHCR → VPS (deployment)

HTTPS (Docker pull)

GitHub token

docker compose pull fetches images from ghcr.io/clarula/clarula/*.

Domain Mapping

The following table maps business-level data flows to their technical channels.

Business Data Flow Technical Channel

User sends PDF invoice

User → email client → Mailgun → HTTPS webhook → EER

User receives ZUGFeRD e-invoice

EER → Mailgun REST API → Mailgun → email delivery → User

PDF extraction to XML

EER → HTTPS → Mistral AI Chat Completions API

Invoice corrections via web UI

User → HTTPS → Caddy → HTTP → backend (einfache-erechnung.de same-origin route)

Visitor tries free website upload

User → HTTPS → Caddy → HTTP → backend (einfache-erechnung.de/api/…​)

Observability data

OpenTelemetry Java Agent → OTLP/gRPC → New Relic EU endpoint

Account registration (DOI)

Visitor → HTTPS → site form → POST /registration/register → Caddy → backend → Mailgun (DOI email)

Newsletter DOI confirmation

Subscriber clicks confirmation link → Caddy → backend (/registration/confirm?token=…​) → account created → welcome email via Mailgun

Solution Strategy

EER converts PDF invoices into ZUGFeRD 2.5 e-invoices through LLM-based data extraction. The architecture optimizes for low adoption friction: German SMBs send a PDF by email and receive a compliant e-invoice back — no new software, no login required. When the source invoice is not ready, the product explains the problem in plain language before asking the user to retry.

The following strategies shape every technical and product decision.

Approach to Quality Goals

Quality Goal Scenario Solution Approach

Usability

A small business owner sends a PDF invoice via email and receives a ZUGFeRD e-invoice within minutes — without registration or training.

Email-based interface eliminates login barriers. Web UI exists only for diagnosis, corrections, and account management. German diagnosis messages guide users through validation failures.

Compliance

Every generated e-invoice passes EN 16931 validation and meets ZUGFeRD 2.5 requirements.

Mustang Project validates and generates XML against the EN 16931 schema. Hybrid PDF generation preserves the original PDF and avoids font corruption. Diagnosis-first handling keeps invalid source invoices out of delivery and explains why they failed. No tier differences in compliance quality.

Data Privacy

Invoice data (VAT IDs, addresses, amounts) stays within the EU at all times.

Mistral AI (EU company, EU servers) processes all LLM extraction. No US-based provider in the data path. Spring AI abstracts the provider for future switches.

Operability

The team monitors and troubleshoots production issues without dedicated ops staff.

OpenTelemetry Java agent exports traces, metrics, and logs to New Relic. Business events recorded both in the database and as OTel spans. Monolith deployment reduces moving parts.

Modifiability

New e-invoice formats (XRechnung, Factur-X) can be added without restructuring the codebase.

ADR-34 places EER-owned invoice semantics and analysis orchestration in :eer package invoice.conversion; ZUGFeRD and future EER output mappings remain edge adapters with independent readiness checks.

Key Solution Strategies

Email as Primary Interface

Users send their PDF invoice to an EER email address. Mailgun receives the email and forwards it as a webhook to the application. The application extracts invoice data, generates the ZUGFeRD e-invoice, and replies to the sender’s email with the result attached.

This removes the biggest adoption barrier for the target audience: small business owners and sole proprietors who already produce PDF invoices in Word or their trade software. They change one email address instead of learning new software.

The web UI handles corrections and account management only — tasks that need a form-based interface.

Traces to: usability goal, vision statement ("you only change the email address").

Native Document Evidence with Parallel Specialized LLM Extraction

For supported native-text PDFs, EER uses local Xberg interpretation as the evidence source. XbergPdfStructuredTextExtractor is the PDF boundary, and ADR-17 keeps layout-aware document evidence local. PDF is the only supported input format; scanned or image-only PDFs fail closed.

The implemented stages are:

  1. XbergPdfStructuredTextExtractor validates the PDF and captures SourceEvidence: hash and size, complete pages containing plain text plus layout-aware Markdown, separately bounded analysis text, and truncation state. The two deterministic renderings complement each other: plain text recovers labels omitted by layout detection, while Markdown preserves table associations. OCR remains disabled and each detected table is rendered once in Markdown.

  2. ParallelInvoiceExtractor runs four Mistral Structured Outputs calls concurrently — header, parties, lines, and payment terms. It then extracts totals and tax with line-item context, followed by notes with all five structured slices as context.

  3. The specialized prompts interpret one complete semantic invoice. Line recognition, implicit quantity, price kind, gross-to-net conversion, amount-only rows, settlement meaning, and semantic totals remain LLM responsibilities.

  4. ExtractedInvoiceObservationMapper maps the semantic provider DTO once to evidence-bound observations with stable IDs. It does not reinterpret layout or claim exact fragments without extractor-provided anchors.

  5. Common validation checks required semantics, ambiguity, and deterministic monetary relationships. Typed analysis outcomes distinguish candidates, non-invoices, unreadable documents, rejected sources, and processing failures.

  6. The ZUGFeRD edge adapter applies EN16931 readiness, maps validated semantics, renders and validates CII XML, compares rendered values with the semantic model, and embeds XML into the matching source PDF only after all gates pass.

This supersedes the previous sequential DocumentSemantics + ExtractedInvoice chain and removes code-side layout interpretation and monetary reconciliation. The EER model keeps one value per invoice concept; the PDF remains immutable evidence. Internally inconsistent source PDFs surface as validation findings and default to source correction or explicit review. The editor is reserved for genuine recognition or mapping errors.

Trade-off: semantic extraction remains non-deterministic, every extractor prompt needs its own benchmarking, and scanned or image-only PDFs remain out of scope and fail closed.

Traces to: usability goal (low-friction conversion for supported documents), modifiability goal (format-neutral pipeline without template maintenance).

Product Isolation inside One Backend Process

The backend remains one BootJar and deployment unit, while Gradle modules, servlet mappings, and Spring application-context boundaries separate product code at construction and runtime. The persistence-free :app root eagerly starts sibling EER and Clarula web contexts behind one parentless bridge of explicitly shared stateless services. :eer owns the established public routes through the default / DispatcherServlet; :clarula owns /admin/clarula/, whose longer servlet mapping wins. Each product owns its DataSource, Flyway chain, JPA persistence unit, and one conventional local transactionManager; the root owns none of them and exposes aggregate product health through /actuator/. One PostgreSQL cluster keeps operations small, but distinct EER and Clarula logical databases and login roles reject normal cross-product connections. This completes ADR-30 and ADR-31 without introducing a second process.

EER-Owned Invoice Semantics and Edge Adapters

ADR-34 establishes the semantic processing boundary inside :eer. Native evidence and specialized extraction map into EER-owned observations, drafts, and commonly validated invoice semantics. The unchanged source PDF remains authoritative evidence for issuer-visible content; semantic claims and recognition corrections retain provenance. Email, website, widget, diagnosis, and correction consume this model rather than generated ZUGFeRD XML.

Target-specific defaults, calculations, schemas, and libraries live behind EER adapters. Mustang and PDFBox remain inside the ZUGFeRD adapter, while a future EER XRechnung adapter would map the same semantics and apply independent readiness rules. The one-time destructive cutover deletes all pre-model correction sessions and removes XML-backed edit state rather than manufacturing source evidence from generated artifacts. Clarula remains isolated in :clarula and does not import the EER invoice model.

Imported Routing Identity Instead of General Master Data Management

The e-invoice conversion product stores no customer master data and no invoice content beyond temporary edit sessions. All data needed to create an e-invoice comes from the source PDF. Edit sessions expire after 30 days; a daily cleanup job deletes them.

The Clarula identity-led partner demo adds one bounded exception defined by PDR: Identity-led client widget. DATEV remains the source of mandate identity and routing targets. A Clarula administrator starts a Firm-scoped DATEV authorization; the authorized firm representative authenticates directly at DATEV. Clarula encrypts the resulting tokens and verifies the DATEV business-partner identity before importing mandate data into the separate Clarula database. The EER Partner registry has no identity or persistence relationship to this Firm.

A DatevApi port isolates authorization, single-use token refresh, revocation, firm verification, and mandate import. Its deterministic mock keeps local and demo flows reproducible. One configurable HTTP adapter targets sandbox or production. Both map into the same minimized snapshot containing mandate labels, available names and addresses, available strong identifiers, provenance, and one simulated target per mandate. Firm administrators select and confirm imported mandates and maintain uploader email grants; imported mandate, identity, and target fields are read-only. Manual replacement data is outside the demo scope.

At submission start, Clarula copies the selected mandate’s required current import values and provenance into an immutable per-submission mandate context. Classification uses that capture only to prove mandate and seller/buyer role before automatic handover, so a later re-import cannot change an in-flight or historical assessment. An incomplete snapshot does not block invitation or upload, but every affected invoice fails closed to firm review and cannot call the DATEV demo port. The imported records never pre-fill or rewrite invoice content.

This keeps recurring invoice fields and general customer-master-data editing outside Clarula while accepting the smaller privacy, provenance, and lifecycle surface required for conservative routing. ADR-29 fixes the OAuth lifecycle, adapter boundary, and same-source re-import semantics; ADR-32 supersedes only its ownership anchor from EER Partner to Clarula Firm. Automatic synchronization, source replacement, and production document handover remain later decisions.

Trade-off: the partner demo stores sensitive routing snapshots and may route more invoices to review when imported DATEV data is incomplete. Users still cannot pre-fill recurring invoice fields; each PDF remains self-contained.

Traces to: data privacy goal, operability goal (bounded state), conservative routing requirement.

Hybrid ZUGFeRD Generation

ZUGFeRD e-invoice generation uses two libraries in sequence:

  1. Mustang Project generates and validates the EN 16931-compliant XML.

  2. PDFBox attaches the XML as an embedded file to the original PDF and sets the required PDF/A-3 metadata.

Mustang’s built-in PDF/A-3 conversion re-renders the entire PDF, which corrupts fonts in many real-world invoices. The hybrid approach preserves the original PDF byte-for-byte and only adds the XML attachment.

GhostScript handles PDF/A-3 color profile conversion when needed.

Traces to: compliance goal, usability goal (preserves original visual appearance).

EU-First Data Processing

All invoice data processing happens within the EU:

  • LLM extraction: Mistral AI (French company, EU data centers)

  • Email routing: Mailgun EU region

  • Application hosting: Hetzner (Germany)

  • Database: PostgreSQL on the same host

The migration from OpenAI to Mistral was driven by documented customer concerns about US data processing and CLOUD Act exposure (see PDR: LLM Provider Migration).

Spring AI’s provider abstraction made this a configuration change rather than a code rewrite.

Traces to: data privacy goal, compliance with GDPR.

Fail-Fast with Diagnosis-First Retry

When validation fails, the system does not silently drop the invoice or return a cryptic error. Instead:

  1. The user receives an email with a specific, German-language explanation of what failed.

  2. The email links to a diagnosis page that explains whether the likely problem is in the source invoice or in recognition.

  3. The default next step is to correct the original invoice and upload a new PDF.

  4. The edit form remains available as an exception path when the visible PDF is correct but extraction or mapping was wrong.

  5. The system generates a new ZUGFeRD e-invoice only after the corrected retry passes validation.

This turns validation failures from dead ends into guided e-invoice readiness checks with a bounded manual-correction fallback.

Traces to: usability goal (actionable errors), compliance goal (invalid invoices never ship), product positioning (explain source-invoice problems without replacing the user’s invoicing tool).

Technology Decisions

Technology Decision Rationale

Kotlin

Primary language (Kotlin 2.x / JVM 21)

Null safety reduces defensive code. Concise syntax (data classes, sealed results, extension functions) keeps the codebase small. Full Spring Boot interop without friction.

Spring Boot

Application framework (3.5.x)

Mature ecosystem covers all needs: web, email, JPA, scheduling, configuration. Spring AI provides a clean abstraction over LLM providers. Large community means problems are well-documented.

Mistral AI

LLM provider via Spring AI

EU company with EU-only data processing. 67% cheaper than GPT-4o-mini. Spring AI starter makes provider switches a configuration change. See PDR: LLM Provider Migration.

PostgreSQL

Relational database (v16)

Reliable, well-understood. Flyway manages schema migrations. JSONB columns store flexible event payloads. Testcontainers provides identical database in tests.

Mustang Project

ZUGFeRD XML generation and validation (v2.22.0)

Only actively maintained open-source ZUGFeRD library on the JVM. Implements the full EN 16931 validation. No viable alternative exists.

PDFBox

PDF manipulation (v3.0.1)

Embeds XML into PDF without re-rendering the document. Preserves original fonts and layout. Apache-licensed, well-maintained.

Caddy

Reverse proxy

Automatic HTTPS via Let’s Encrypt. Simple configuration file. Handles TLS termination and HTTP/2 without manual certificate management.

OpenTelemetry

Observability (Java agent v2.25.0)

Zero-code instrumentation via Java agent. Exports traces, metrics, and logs to New Relic. Business events are both persisted to PostgreSQL and emitted as OTel spans for correlation.

Mailgun

Email sending and receiving

Webhook-based inbound email routing. EU region available. Handles DKIM/SPF/DMARC for deliverability. Switchable — the EmailSender interface abstracts the provider.

Organizational Decisions

Monolith-First

The backend deploys as the backend Spring Boot modular monolith containing all business logic, including registration and DOI. Two independently generated static Nuxt product frontends live under applications/frontend/, with partner demo pages under demos/partner/. The application keeps one process and one deployment unit while ADR-31 supersedes ADR-5’s one-database clause with separate EER and Clarula logical databases and roles in one PostgreSQL cluster. Static frontends, widget bundles, and demos do not constitute a microservices architecture.

For a small team, splitting business logic across services would add operational overhead (networking, deployment orchestration, distributed tracing) without proportional benefit. The package-by-feature structure keeps modules decoupled within the backend monolith. Static frontends consume the explicit /api/v1/ OpenAPI contract and contain no business logic. Splitting the backend into separate services remains possible if scale demands it.

Static Delivery and Explicit Route Ownership

Nuxt is build-time tooling only. Each product image combines one immutable generated release with product-owned unprivileged Nginx. Caddy proxies same-origin /api/v1/ plus an explicit allow-list of backend-owned widget, webhook, actuator, and compatibility paths before forwarding remaining traffic to the matching frontend container. The dedicated operator origin serves its static frontend and proxies only the explicit /api/operator/v1/ owners, including Clarula callbacks. Dynamic diagnosis and correction navigation uses two dedicated client-shell files restricted to HTML GET/HEAD and one token segment. Generated fixed application documents are matched exactly; Clarula has no generic auth fallback. Unknown content, assets, and application paths therefore retain real 404 semantics. Unknown canonical API operations remain backend-owned: authenticated requests receive the backend 404, while unauthenticated requests may receive the API security boundary’s 401 first; neither case can fall back to frontend HTML.

Each frontend, backend, Caddy, and demo keeps an independent immutable image version. Dev recreates only selected components; production promotes the complete runtime. A brief product-specific interruption during frontend replacement is accepted.

Package-by-Feature

Code is organized by business capability, not by technical layer:

applications/backend/
├── app/                  # persistence-free BootJar and servlet composition root
│   └── com.clarula.app/{runtime,security,health}
├── core/                 # product-neutral stateless configuration and contracts
│   └── com.clarula.core/{config,security,web}
├── eer/                  # EER application, persistence, assets, widget, and tests
│   └── com.clarula.backend/{email,invoice,formats,identity,widget,...}
└── clarula/              # Clarula application, persistence, assets, and tests
    └── com.clarula.clarula/{firm,datev,...}

Each package owns its controllers, services, repositories, and domain types. No cross-package database access. infrastructure is the only horizontal package.

ADR-30 defines module ownership; ADR-34 refines EER invoice processing. PDF/LLM analysis lives under :eer package invoice.conversion.analysis.pdf; formats.zugferd contains only target mapping, readiness, validation, and output generation. The architecture still "screams" its domain: a new developer sees product boundaries, invoice conversion, target formats, routing, and email capabilities before JpaConfig or SecurityConfig.

Time-Based Monetization

The pricing model uses time-based access (30-day free trial, then flat-rate subscription) rather than volume limits. All tiers produce identical, EN 16931-compliant e-invoices.

Volume-based pricing penalizes high-volume businesses and lets low-volume users stay free indefinitely — neither outcome aligns with the product’s value delivery. See PDR: Tiering v2 for the full rationale.

Dev-First Delivery, Manual Production Promotion

The release flow now treats dev as the automatic proving ground and production as an explicit promotion step. Every merge to main deploys to the permanent dev environment. Production deploys are triggered manually after the change has been observed in dev.

This protects customer experience without introducing a heavier branching or release-management model. The same application topology and Spring production profile run in dev, so the team validates real deployment behavior before shipping to customers.

Trade-off: one more human step exists between main and prod. This is intentional friction.

Traces to: operability goal, customer-quality protection, small-team operations.

Building Block View

This chapter shows the static decomposition of the backend product system. Level 1 decomposes the overall system into deployment units. Level 2 zooms into the backend’s four Gradle modules and application-context boundaries. Level 3 opens each module separately so package responsibilities and dependencies remain readable.

Cross-cutting implementation rules live in Chapter 8. The package-by-feature organization keeps the Level 3 structures aligned with the source tree.

Level 1: Whitebox Overall System

The system consists of the backend, two independently deployable static product frontends, one dedicated static operator frontend, framework-independent widget bundles, partner demos, and an analytics dashboard. The backend contains all business logic, including registration and double opt-in, in one BootJar and process. EER and Clarula run in eager sibling Spring web contexts loaded from compile-time-independent product modules; the persistence-free :app context composes their servlet mappings and the one server. The Nuxt applications under applications/frontend/ and partner demos under demos/partner/ are stateless companions with no shared database or domain logic. Metabase provides business event dashboards. One PostgreSQL cluster hosts separate logical databases and login roles for einfache-eRechnung and Clarula plus the operational Metabase database.

This decomposition follows ADR-5: Monolith-First Architecture, ADR-30, and ADR-31.

level1 building blocks
Figure 3. Level 1: System Decomposition

Motivation

The system separates by deployment independence and technology fit:

  • backend — Spring Boot modular monolith that owns all business capabilities. :app is the persistence-free composition root and sole BootJar owner. Eager sibling contexts from :eer and :clarula own their controllers, assets, schedulers, migrations, and persistence infrastructure. It remains one process and deployment unit with separate product databases.

  • product frontends — Nuxt generates one static artifact for einfache-eRechnung and one for Clarula. Each product image contains its artifact and unprivileged Nginx configuration; Caddy proxies remaining product traffic to it. Product presentation changes independently from backend releases.

  • operator frontend — Nuxt generates the internal EER/Clarula workspace as a third static artifact. Only admin.clarula.de serves it; Caddy proxies three explicit operator API namespaces to the owning backend sibling and strips credentials before static requests reach Nginx.

  • widget bundles — esbuild produces independent vanilla-JavaScript web components. Their stable script URLs remain outside the Nuxt applications.

  • partner demos — static demo pages for widget and partner flows.

PostgreSQL serves the backend exclusively. The sites and demos are stateless.

Contained Building Blocks (Level 1)

Building Block Responsibility Technology

backend

Converts PDF invoices to ZUGFeRD e-invoices. Handles email ingestion, document interpretation, semantic extraction, XML validation, PDF generation, invoice correction UI, user identity, self-hosted registration with DOI via Mailgun, and business event tracking.

Kotlin 2.x, Spring Boot 3.5, JVM 21

product frontends

Own the complete static product experiences, including marketing content, legal pages, current registration/contact/free-upload interactions, and future customer application routes. Consume the generated product-v1 API client.

Nuxt 4, Vue 3, TypeScript, static HTML/CSS/JS

operator frontend

Owns the static operator login, navigation, EER partner, Clarula firm, DATEV, and mandate presentation. It consumes separate auth, EER, and Clarula operator clients without owning business logic or sharing product transport types.

Nuxt 4, Vue 3, TypeScript, static HTML/CSS/JS

widget bundles

Provide the standard partner upload widget through a stable embed script without importing its implementation into Nuxt.

Vanilla JavaScript Web Components, esbuild

partner demos

Static partner/demo pages for widget flows under demos/partner/.

HTML/CSS/JS

metabase

Business analytics dashboard. Reads business events from PostgreSQL. Internal tool for monitoring conversion rates and error patterns.

Metabase (Docker image)

PostgreSQL

One cluster hosting independently migrated logical product databases. The EER database stores accounts, invoice workflow state, widget partners, and analytics; the Clarula database stores firms, DATEV authorization, imported mandates, and confirmations. Distinct application roles are denied connection to the sibling product database.

PostgreSQL 18

Important Interfaces

Interface Protocol Description

Inbound email webhook

HTTPS POST (multipart/form-data)

Mailgun forwards incoming emails to POST /webhooks/inbound-email. HMAC signature validation. Returns 200 OK immediately; processing runs asynchronously.

Browser product API

HTTPS (JSON and multipart/form-data)

Product frontends call canonical operations under /api/v1/**. The deterministic product-v1 OpenAPI document generates the shared TypeScript client. Temporary legacy paths remain additive aliases.

Free website upload API

HTTPS POST (multipart/form-data)

Anonymous users upload a PDF via the website’s upload form. POST /api/v1/free-upload/convert returns the ZUGFeRD PDF. Rate-limited per IP; /api/free-upload/convert remains a compatibility alias.

Invoice diagnosis and correction UI

HTTPS (static HTML, JSON, and PDF)

The einfache-eRechnung Nginx container serves scoped Nuxt client shells for token-capability routes. The shells use canonical diagnosis/correction APIs; no login is required because the unguessable token grants scoped access.

Portal (account management)

HTTPS (static HTML and JSON)

The einfache-eRechnung frontend owns auth, portal, and plan documents and uses v1 customer-session, portal-summary, logout, plan-overview, and plan-action operations with host-only HTTP-only cookies.

Trial-email subscription results

HTTPS POST (JSON)

Product pages use canonical unsubscribe and resubscribe result operations. The backend continues to handle RFC 8058 one-click POSTs and legacy links.

Account registration (DOI)

HTTPS JSON and browser redirect

Product form submits to POST /api/v1/registration/register; new DOI messages use product-origin GET /api/v1/registration/confirm, which hands off to product welcome/error routes. Legacy registration paths remain aliases.

Operator API

HTTPS JSON and provider callback

admin.clarula.de routes /api/operator/v1/auth/ and /eer/ to EER ownership and /api/operator/v1/clarula/** to Clarula ownership. The dedicated static operator frontend consumes the three contracts; no EER repository is visible to Clarula.

Level 2: Whitebox backend

The backend is one deployed process composed from four Gradle modules. :app is the persistence-free composition root, :core provides product-neutral stateless capabilities, and :eer and :clarula provide the two product applications. The arrows below point from each consumer to an allowed compile-time dependency; their labels also name the corresponding runtime composition. This boundary follows ADR-30: One BootJar with Product-Isolated Spring Child Contexts.

level2 backend
Figure 4. Level 2: Backend Modules and Application Context Boundaries

Motivation

The four-module decomposition makes product ownership enforceable without introducing another process or deployment unit. :eer and :clarula cannot depend on each other, and :core cannot depend on either product. Gradle checks enforce the source dependency direction; disjoint Spring scans and sibling contexts enforce bean visibility at runtime.

:core is a library, not a standalone application context. :app copies an explicit set of its stateless services into a parentless shared-services context, which becomes the parent of both product contexts. The root and bridge expose no repository, entity manager, Flyway instance, DataSource, or transaction manager. Each product context owns its persistence infrastructure and exactly one local transactionManager, as required by ADR-31: Separate Logical PostgreSQL Databases and Roles.

Package-by-feature from ADR-6: Package-by-Feature Organization remains the organization within each product module. The Level 3 whiteboxes refine one module at a time so package responsibilities and dependencies remain readable.

Contained Building Blocks (Level 2)

Module Runtime Boundary Responsibility

:app

Root ServletWebServerApplicationContext

Owns the sole BootJar, embedded servlet server, product-context lifecycle, servlet mappings, container-level security composition, and aggregate health. It contains no product business logic or persistence. See Level 3: :app.

:core

Product-neutral code loaded into the root and shared-services bridge

Provides stateless shared defaults and contracts for time, HTTP clients, JWT processing, operator authentication, and MDC cleanup. It contains no product model or persistence dependency. See Level 3: :core.

:eer

Eager EER sibling WebApplicationContext

Owns einfache-eRechnung conversion, email, identity, correction, widget, EER operator APIs, analytics-event, persistence, migration, asset, scheduler, and test capabilities. It remains the default / dispatcher. See Level 3: :eer.

:clarula

Eager Clarula sibling WebApplicationContext

Owns the Clarula Firm tenant, DATEV authorization and mandate import, Clarula operator APIs, persistence, migrations, and tests. It owns /api/operator/v1/clarula/*. See Level 3: :clarula.

Important Interfaces

Interface Direction Contract

Product context bootstrap

:app:eer, :clarula

:app eagerly creates and closes both sibling contexts from their product-owned module configuration. Failure to initialize either product fails startup of the one backend process.

Shared-services bridge

:app, :eer, :clarula:core

:app creates the parentless bridge from explicitly selected stateless beans. Ordinary parent lookup and autowiring do not expose child beans, and neither sibling can resolve the other. Root composition code accesses selected EER contributions only through the explicit ProductContextRegistry boundary.

Servlet ownership

Container → :app → product context

The root operational servlet owns /actuator/; EER is the default / dispatcher and owns auth/EER operator APIs; the longer /api/operator/v1/clarula/ mapping selects Clarula.

Persistence ownership

Product context → product database

Each product owns one local DataSource, Flyway history, JPA persistence unit, and transaction manager. There is no shared or cross-product transaction boundary.

Level 3: Whitebox :app

:app is the persistence-free composition and operational root at applications/backend/app/. It owns process startup and the one servlet container but delegates every business request to a product context.

level3 app
Figure 5. Level 3: :app Internal Structure

Motivation

:app separates process-wide composition from product behavior. Its component scan is restricted to com.clarula.app, while the runtime package starts each product through an explicit module entry point. The root disables product web, mail, template, AI, task, and persistence auto-configuration so it cannot become a third business or persistence module. This realizes the composition boundary defined by ADR-30.

Contained Building Blocks (Level 3)

Building Block Responsibility Code Location

bootstrap

BackendApplication owns main, the sole executable BootJar, one embedded servlet server, and root-only configuration discovery. It imports product-neutral defaults from :core but defines no product controller or persistence bean.

com.clarula.app

runtime

Creates the parentless shared-services bridge and both eager sibling contexts, forwards application runners after server startup, closes contexts in reverse order, and fails the process if either product fails to start. Registers the root /actuator/, default EER /, and longer Clarula /api/operator/v1/clarula/ dispatcher mappings.

com.clarula.app.runtime

security

Composes one container-level security and CORS chain before servlet dispatch. It combines product-neutral operator authentication from :core with EER-owned customer, API, and widget policy. The EER remember-session bridge supports the operator API before either product dispatcher; authenticated unsafe requests pass the root origin and CSRF boundary.

com.clarula.app.security

health

Publishes aggregate product health through the root Actuator context. A product is healthy only when its sibling context is active and its own DataSource can return a valid connection.

com.clarula.app.health

Important Interfaces

Interface Connected Boundary Contract

Product module entry points

:eer, :clarula

Runtime registration creates the EER context from EerModuleConfiguration and the Clarula context from ClarulaModuleConfiguration. Both contexts start eagerly behind one servlet container and share only the bridge’s explicitly selected stateless beans.

Shared-services context

:core, product contexts

The bridge publishes only explicitly selected stateless beans: Clock, ObjectMapper, RestClient, JWT encoder and decoder, and the optional OperatorAuthTokenService. It exposes no transaction or persistence infrastructure.

Servlet registry

Servlet container, product contexts

Standard longest-path servlet matching routes /api/operator/v1/clarula/ to Clarula and all remaining business paths to EER’s default / dispatcher. /actuator/ remains root-owned.

Root security chain

:core, :eer, both product routes

Operator authentication runs before product dispatch. EER contributes customer/API filters and dynamic widget CORS policy; no EER repository becomes visible to Clarula.

Aggregate health

Product contexts, /actuator/health

The root reports context and product-database reachability without owning either product’s DataSource.

Level 3: Whitebox :core

:core is the product-neutral, persistence-free library at applications/backend/core/. It has no application context of its own; :app loads selected configurations and services into the root and shared-services bridge.

level3 core
Figure 6. Level 3: :core Internal Structure

Motivation

:core contains only capabilities that both products can share without sharing domain or persistence state. The module remains deliberately small: implementation convenience alone does not justify moving code into it. Keeping product entities, repositories, migrations, DataSource configuration, and transaction boundaries out of :core prevents the shared module from becoming a coupling point. These constraints follow ADR-30.

Contained Building Blocks (Level 3)

Building Block Responsibility Code Location

config

Supplies overridable defaults for process-wide Clock and RestClient instances and registers servlet-thread MDC cleanup. Standalone product tests may replace these defaults without importing :app.

com.clarula.core.config

security

Binds common JWT policy, creates the HS256 encoder and decoder, issues and validates the short-lived operator JWT, establishes operator authentication for /api/operator/**, enforces exact-origin/CSRF protection for unsafe operator requests, and defines the narrow optional remember-session refresh contract. It contains no customer account, operator session entity, or repository.

com.clarula.core.security

web

Clears the complete MDC after every servlet request so pooled threads cannot leak diagnostic context between requests.

com.clarula.core.web

Important Interfaces

Interface Consumer Contract

Shared service defaults

:app, standalone product contexts

SharedServicesConfiguration provides replaceable Clock, RestClient, and MDC-cleanup beans. :app adds its root-owned ObjectMapper when it constructs the shared-services bridge.

JWT codec

:app, :eer, :clarula

Encoder and decoder configuration is stateless. Consuming filters enforce their own audience and domain claims.

Operator authentication

:app, both product routes

OperatorAuthFilter, OperatorAuthTokenService, and the authentication entry point define the shared short-lived operator credential boundary described by ADR-13: Admin Identity via Configuration.

Remember-session refresh contract

:eer through :app

OperatorRememberSessionRefresh lets EER refresh its product-owned persistent operator session without moving that persistence into :core. The composed bridge rejects this refresh path for Clarula routes.

Level 3: Whitebox :eer

:eer is the einfache-eRechnung product module at applications/backend/eer/ and the default / sibling web context. It owns all EER business capabilities, product assets, schedulers, asynchronous event processing, tests, and persistence. Four focused structural views keep each diagram within one concern and a bounded number of elements; repeated packages mark interfaces between those concerns, not duplicate implementations.

level3 eer invoice processing
Figure 7. Level 3: :eer Invoice Conversion and Correction
level3 eer identity
Figure 8. Level 3: :eer Identity and Partner Administration
level3 eer widget
Figure 9. Level 3: :eer Partner Widget
level3 eer operations
Figure 10. Level 3: :eer Operations and Persistence

Motivation

The decomposition follows business capabilities rather than technical layers, as required by ADR-6: Package-by-Feature Organization. A developer sees email.ingestion, formats.zugferd, invoice.editing, identity, and widget before framework configuration. infrastructure remains the only horizontal package.

The sibling context scans only EER-owned packages and depends on the stateless contracts in :core. It owns one EER DataSource, Flyway history, JPA persistence unit, and local transactionManager; no Clarula bean, entity, repository, or migration participates in EER injection or persistence. Both siblings share one process environment and classpath, but product-local configuration selects only the owning database credential and resources. This applies ADR-30 and ADR-31.

Detailed Building Block Responsibilities (Level 3)

Building Block Responsibility

email.ingestion

Receives inbound emails from Mailgun, validates webhook signatures, parses email payloads, and dispatches sender metadata and PDF attachments to formats.zugferd.processing. Entry point: POST /webhooks/inbound-email.

invoice.conversion

EER-owned semantic processing boundary defined by ADR-34. All productive PDF-conversion channels depend on it. The implementation binds the PDF hash and size to combined plain and layout-aware Xberg page evidence, runs six specialized LLM extractors in dependency-aware phases, maps the provider DTO once into ObservedInvoice, creates an InvoiceDraft, and applies common semantic and source-fidelity validation. DocumentAnalysisResult preserves invoice candidates with findings, non-invoices, unreadable or rejected sources, and processing failures without forcing them into target XML. No deterministic extraction normalizer, printed-total model, or alternate gross/net representation remains. ADR-17 governs native evidence and ADR-18 governs specialized extraction.

formats.zugferd.processing

Orchestrates the email channel around invoice.conversion: validates the sender account, persists the outcome, creates result or diagnosis edit sessions, updates usage state, and delegates response delivery to email.delivery. Web and widget controllers own their corresponding channel workflows instead of delegating them to invoice.conversion.

formats.zugferd

EER target edge for ZUGFeRD 2.5 EN16931. It checks target readiness, maps ValidatedInvoice into Mustang, deterministically augments unsupported CII writer paths from the same semantic values, verifies rendered values against the draft, runs XSD and Schematron validation, and embeds the resulting XML into the source PDF only when its hash and size still match SourceEvidence. It never owns PDF interpretation or editable state. See LLM Integration, Semantic Invoice Interpretation and Validation, ADR-34, and ADR-1.

email.delivery

Sends all outbound transactional emails: success notifications with ZUGFeRD attachments, validation errors with edit links, magic-link emails, DOI confirmations, partner affiliation requests, partner affiliation confirmation mails for end customers, trial lifecycle notifications, and contact form forwarding. Provider-agnostic interface with Mailgun (production) and SMTP (development) implementations. See Email Architecture for details.

invoice.editing

Manages temporary diagnosis and correction capabilities backed by the original PDF plus a versioned, integrity-checked InvoiceDraft snapshot. It persists draft revisions or optimistic submission reservations before side effects, records successful completion, distinguishes invalid/missing/expired/used lifecycle states, and cleans up sessions after the 30-day TTL. Generated XML is never editable session state.

invoice.editing.ui

Owns diagnosis presentation, source-correction-first editor access, complete correction-form canonicalization, structured validation feedback, submission/delivery state, and corrected result artifacts. JSON/PDF controllers under /api/v1/diagnoses/ and /api/v1/corrections/ use DiagnosisApplicationService, CorrectionApplicationService, CorrectionFormWorkflow, and CorrectionSubmissionService. The product frontend is the only customer HTML transport.

identity

Manages user accounts, passwordless authentication via magic links, plan lifecycle, trial management, customer session and portal-summary APIs, DOI registration, trial-email subscription results, and the contact form. CustomerPlanService and PortalSummaryService supply the v1 APIs consumed by the product frontend. PendingRegistration also carries partner-affiliation request metadata until DOI confirmation, while Account.partnerAffiliationConfirmedAt records the moment a partner confirmed tariff eligibility. The Account entity acts as the aggregate root.

admin

Founder-only authentication and EER partner JSON APIs under /api/operator/v1/auth/ and /api/operator/v1/eer/. Product-neutral operator JWT parsing, exact-origin/CSRF enforcement, and the short-lived admin_session token service live in :core and are composed by the root before servlet dispatch. EER owns magic-link login, partner operations, and remember-session persistence/refresh; the dedicated static operator frontend owns presentation. Clarula owns its separate operator namespace in the sibling context. See ADR-13 and ADR-33.

conversion

Persists anonymous and widget conversion result snapshots, claim state, source attribution, and expiration state. Cleanup remains EER-owned, and corrected anonymous results return through the same claim-and-email workflow.

widget.partner

Owns the server-side partner registry and affiliation-confirmation flow: persisted partner metadata, active/revoked state, one or more exact allowed origins per partner, redirect base URLs, the affiliation notification email address, the direct confirmation endpoint under /partner-affiliation/confirm, and the copy-ready embed snippet contract for /widget/v1/embed.js. Its active (partnerId, origin) mappings drive dynamic widget CORS and server-side origin-to-partner checks. The confirmation token is scoped only to partner affiliation and never grants account or invoice access. See ADR-10.

widget.api / widget.auth

Server-side HTTP API and identity boundary for the partner widget: POST /api/v1/widget/upload converts the PDF anonymously; POST /api/v1/widget/auth delivers the result email for existing accounts or starts DOI for new accounts and issues an active or provisional partner-scoped identity token; POST /api/v1/widget/claim-with-session validates that token and delivers a later same-partner conversion to its fixed account email; GET /api/v1/widget/session-identity resolves the fixed recipient; and GET /api/v1/widget/result restores the claim-token result snapshot after correction redirects. The auth flow includes the optional affiliation-step preflight for partners with a configured Kanzlei mailbox. Only identity-token hashes are stored. The capability cannot authenticate portal or admin routes and cannot download artifacts. Anonymous edit sessions and corrected Conversion results continue through the same claim-and-email path. /api/widget/** remains a compatibility alias. All endpoints are rate-limited per IP.

widget embed

An independently bundled vanilla-JavaScript Web Component is served from the stable /widget/v1/embed.js URL. It implements its own state machine, accessibility, German copy, and canonical widget API calls. The widget keeps its opaque partner-scoped identity token in localStorage with safe fallback. Neither Nuxt application imports the widget implementation. See ADR-11.

business.tracking

Captures domain events (invoice received, processed, sent, errors, trial started) and routes them to three sinks: database persistence, OpenTelemetry spans, and OpenTelemetry metrics. See Observability for the event architecture.

EER infrastructure and persistence

EER-local rate limiting, provider configuration, frontend operational reporting, BaseEntity, DataSource, Flyway history at db/migration, JPA persistence unit, and exactly one local transactionManager. EER alone enables its schedulers and asynchronous event infrastructure. See Persistence and Security.

Important Interfaces

Interface Protocol Description

Inbound email webhook

HTTPS POST (multipart/form-data)

Mailgun forwards incoming emails to POST /webhooks/inbound-email. HMAC signature validation. Returns 200 OK immediately; processing runs asynchronously.

Browser product API

HTTPS (JSON and multipart/form-data)

Canonical operations use /api/v1/** and are published in the deterministic product-v1 OpenAPI contract. Browser Product API Compatibility defines aliases and evolution rules.

Free website upload API

HTTPS POST (multipart/form-data)

Anonymous users upload a PDF through POST /api/v1/free-upload/convert, which returns JSON containing Base64-encoded result PDFs. /api/free-upload/convert remains an alias. The endpoint is rate-limited per IP.

Frontend operational report API

HTTPS POST (JSON)

Static product frontends send anonymous, allow-listed release and failure dimensions to POST /api/v1/operational/frontend-failures. The endpoint emits OpenTelemetry signals and logs but no product analytics or persisted business event.

Invoice diagnosis and correction API

HTTPS (JSON and PDF)

Public token-capability operations under /api/v1/diagnoses/{token} and /api/v1/corrections/{token} expose grouped diagnosis, explicit editor entry, the complete typed field model, draft-backed preview validation, submission and result state, source PDF, and corrected downloads. Preview and submission re-run common and ZUGFeRD readiness gates; successful completion persists exact result artifacts, which downloads serve without draft deserialization or regeneration. The source PDF must be explicitly confirmed before account-backed invalid sessions can enter a correction API.

Static invoice correction UI

HTTPS (static HTML)

The einfache-eRechnung Nginx container serves dedicated Nuxt client shells for exact /diagnosis/{token} and /edit/{token} navigation. The shells call the canonical API; unknown route shapes return 404.

Portal (account management)

HTTPS (JSON plus static HTML)

The static product frontend owns /portal and /portal/plan and reads GET /api/v1/auth/session, GET /api/v1/portal/summary, and /api/v1/plans/**. JWT and remember-session cookies are host-only and HTTP-only.

Trial-email subscription results

HTTPS POST (JSON)

/api/v1/email/unsubscribe and /api/v1/email/resubscribe return typed result states. Unsubscribe also handles RFC 8058 one-click POSTs; legacy HTML links and POST remain available.

Account registration (DOI)

HTTPS JSON and browser redirect

The product form submits to POST /api/v1/registration/register; /registration/register remains an alias. New Mailgun DOI messages link to product-origin GET /api/v1/registration/confirm, which redirects to product-owned welcome or error routes; /registration/confirm remains valid.

Level 3: Whitebox :clarula

:clarula is the Clarula product module at applications/backend/clarula/ and the sibling web context for /api/operator/v1/clarula/*. It depends only on :core and owns the complete Clarula firm, DATEV, web, and persistence boundary.

level3 clarula
Figure 11. Level 3: :clarula Internal Structure

Motivation

The Firm aggregate anchors Clarula tenancy and all DATEV-owned state. It is independent of EER’s Partner registry, so a partner lifecycle or identifier cannot change Clarula authorization or mandate ownership. This applies ADR-32: Clarula Firm as DATEV and Tenant Anchor.

Clarula keeps provider integration behind DatevApi and owns a local persistence boundary. Its sibling context scans only com.clarula.clarula, has one Hikari DataSource, one Flyway history, one JPA persistence unit, and one local transactionManager. It has no compile-time or persistence dependency on :eer, as required by ADR-30 and ADR-31.

Contained Building Blocks (Level 3)

Building Block Responsibility

clarula.firm

Owns the Clarula Firm tenant aggregate and authenticated operator lifecycle under /api/operator/v1/clarula/firms. A firm has a Clarula-local identifier and active state independent of the EER Partner registry. See ADR-32.

clarula.datev

Owns firm-scoped DATEV authorization and token lifecycle in :clarula. DatevApi separates the deterministic mock from the configurable sandbox/production HTTP adapter. The capability encrypts OAuth tokens, serializes single-use refresh on a firm lock, and verifies the DATEV firm identity. It has no compile-time or persistence dependency on the EER Partner aggregate. See ADR-29 and ADR-32.

clarula.datev.mandates

Imports minimized read-only mandate snapshots through a verified firm connection, evaluates routing completeness, retains simulated targets, and stores Clarula-owned confirmations separately from DATEV source data.

Clarula operator API support

Owns Clarula JSON/problem contracts, product-local sensitive-value logging rules, component discovery, and web configuration. Clarula accepts the shared operator JWT and root CSRF boundary but owns no operator or customer identity entity; the static operator app owns presentation.

Clarula persistence

The Clarula sibling owns one Hikari DataSource, one Flyway chain at db/clarula/migration, one JPA persistence unit, and exactly one local PlatformTransactionManager named transactionManager. Startup asserts that the bridge exposes no transaction infrastructure.

Important Interfaces

Interface Protocol Description

Clarula firm and DATEV operator API

JSON, application/problem+json, and browser redirects

The Clarula sibling exclusively owns /api/operator/v1/clarula/*. Firm-scoped operations start DATEV authorization, import mandates, and confirm them; /api/operator/v1/clarula/datev/callback remains backend-owned and protected by the root operator filter. External mode uses DATEV OIDC and master-data:master-clients; mock mode follows the same API and persistence path without external credentials.

DATEV port

Kotlin interface and HTTPS

DatevApi keeps deterministic mock and sandbox or production HTTP adapters behind one Clarula-owned contract for authorization, token refresh, revocation, firm verification, and mandate import. ADR-29 defines this port; ADR-32 replaces only its former EER Partner ownership anchor with Firm.

Clarula database

JDBC

The Clarula role can connect only to the Clarula logical database. Migrations, entities, repositories, and transactions remain local to this sibling context.

Runtime View

This chapter describes how backend product building blocks interact at runtime. Eight scenarios cover the architecturally significant flows: the email-based conversion pipeline, the shared invoice extraction algorithm, validation failure with diagnosis-first recovery, the anonymous free website upload, magic link authentication, account registration with double opt-in, the partner widget state machine, and the Clarula Firm-scoped DATEV mandate import.

The conversion scenarios treat Invoice Extraction Algorithm as a shared sub-flow for PDF interpretation and fail-closed semantic extraction. They also share an outbound provider rate-limiting layer in infrastructure.ratelimit. Before calling external APIs such as Mistral chat or Mailgun, the application enters an in-memory queue when the configured provider budget is exhausted. When a provider still answers with HTTP 429, the client honors Retry-After or provider-specific reset headers and performs exactly one retry before surfacing the failure.

Email-Based Invoice Conversion (Happy Path)

Scope: Core business flow — a registered user sends a PDF invoice by email and receives a ZUGFeRD e-invoice in return.

Trigger: Mailgun delivers an inbound email webhook to POST /webhooks/inbound-email.

Participants: EmailWebhookController, EmailProcessingServiceImpl, PdfToZugferdConversionService, InvoiceEditService, ResponseMailService, Mailgun, PostgreSQL

This scenario treats Invoice Extraction Algorithm as a shared black box so the end-to-end email flow stays at one abstraction level.

runtime 01 email happy path
Table 1. Key interactions
Step Description

Signature check

EmailWebhookController verifies the Mailgun HMAC signature before any processing. Invalid signatures return 401 Unauthorized.

Async handoff

The controller returns 200 OK immediately so Mailgun does not retry. The actual conversion runs on a Spring Executor thread.

Invoice extraction

The shared Invoice Extraction Algorithm validates the PDF, builds immutable evidence, performs six specialized semantic Mistral calls, maps their result into an evidence-bound draft, and fails closed when the document is unsupported or ambiguous. Supported native-text PDFs stay on this path; scanned or image-only PDFs remain out of scope per ADR-17.

Deterministic conversion tail

After extraction, PdfToZugferdConversionService maps the semantic interpretation and runs deterministic common validation. The formats.zugferd adapter then applies EN16931 readiness, renders CII XML with Mustang, compares rendered values with the semantic invoice, and generates the PDF artifacts. The shared boundary emits InvoiceProcessedEvent or InvoiceProcessingFailedEvent for convert-level outcomes, while the email flow owns mail delivery and draft-session side effects.

Outbound rate limiting

The simplified diagram hides RateLimitedClient. The extraction algorithm uses it for Mistral chat calls, and ResponseMailService uses it again before Mailgun. Accepted work can therefore wait briefly in an in-memory queue even after the webhook returned 200 OK.

Edit session

Every processed invoice creates an InvoiceEditSession (status VALID) containing the source PDF and integrity-checked InvoiceDraft snapshot. The edit token is included in the success email so the user can later correct recognition or mapping data if needed.

Response delivery

ResponseMailService also uses RateLimitedClient before calling Mailgun. Successful conversion therefore does not guarantee immediate provider delivery; the response email can briefly wait for the next outbound Mailgun slot.

Invoice Extraction Algorithm

Scope: EER sub-flow that turns a supported native-text PDF into immutable evidence, one semantic application invoice draft, and a target-specific result. Reflects ADR-18 and ADR-34.

Trigger: PdfToZugferdConversionService.convert(pdfBytes, trackingContext) reaches semantic analysis after the caller has accepted an upload or inbound email.

Participants: PdfToZugferdConversionService, PdfInvoiceAnalysisService, XbergPdfStructuredTextExtractor, ParallelInvoiceExtractor, DocumentHeaderExtractor, PartiesExtractor, LineItemsExtractor, TotalsAndTaxExtractor, PaymentTermsExtractor, NotesExtractor, ExtractedInvoiceObservationMapper, common invoice validation, ZUGFeRD EN16931 adapter, Mistral AI

This scenario is the shared black box used by Email-Based Invoice Conversion (Happy Path) and Free Website Upload. Static responsibilities and design rationale live in LLM Integration.

runtime shared invoice extraction
Table 2. Key interactions
Step Description

Evidence extraction

XbergPdfStructuredTextExtractor returns SourceEvidence: SHA-256 and size of the source PDF, complete per-page plain text followed by layout-aware Markdown, separately bounded analysis text, and truncation metadata. Xberg renders detected tables once in Markdown; the plain rendering recovers labels or values that layout detection can omit. Scanned or image-only PDFs fail closed per ADR-17. Generated artifacts are embedded only into PDF bytes that still match this evidence.

Six specialized LLM calls in three stages

ParallelInvoiceExtractor first runs header, parties, semantic line items, and payment terms concurrently. It then interprets totals and tax serially with line-item context, followed by notes with all five prior slices. Every call receives the same bounded evidence; context-dependent invoice meaning belongs to these prompts.

Semantic extraction contract

Line items return one VAT-exclusive unitPrice, optional VAT-exclusive preDiscountUnitPrice, and one lineTotalAmount. Totals return one semantic value for each supported EN 16931 total. The contract has no parallel source, net, gross, or calculated monetary fields.

Evidence-bound mapping

ExtractedInvoiceObservationMapper creates application enums, stable semantic IDs, section provenance, and value fingerprints. Without extractor-provided field offsets, claims remain document-scoped and INTERPRETED; the application does not infer EXACT_FRAGMENT from substring matches.

Deterministic boundary

Kotlin maps, fingerprints, checks arithmetic, and validates targets. It does not decide table meaning, derive an extraction-time unit price from line amount and quantity, or replace contradictory totals.

Common validation and analysis gates

ValidatedInvoice checks shared invoice requirements, ambiguity, and deterministic semantic relationships. Immutable analysis gates such as truncated source context are reconstructed from SourceEvidence for every correction and regeneration; editing cannot clear them.

Typed non-success outcomes

Semantic emptiness or an explicit extractor marker yields NonInvoice. No-readable-text, rejected-source, and processing failures remain separate typed outcomes. Provider failures retain retryability and do not masquerade as validation errors.

ZUGFeRD edge adapter

Only a commonly validated invoice reaches the target adapter. The adapter applies ZUGFeRD EN16931 readiness, renders CII XML with Mustang, compares rendered values with the semantic invoice model, runs XSD/Schematron validation, and returns explicit NotReady findings rather than silently dropping unsupported facts.

Delivery and correction handoff

Successful target output is embedded into the unchanged source PDF. Common or target readiness findings persist the InvoiceDraft, source evidence, findings, and structured errors for diagnosis-first recovery. XML is an output artifact, not correction state.

Validation Failure with Diagnosis-First Recovery

Scope: PDF analysis succeeds, but common invoice validation or ZUGFeRD EN16931 readiness fails. The user reviews diagnosis guidance, corrects the source invoice by default, and enters the editor only for a confirmed recognition or mapping error.

Trigger: PdfToZugferdConversionService returns a draft-backed ValidationFailure.

Participants: EmailProcessingServiceImpl, PdfToZugferdConversionService, InvoiceEditService, InvoiceEditSessionDraftStore, ResponseMailService, static product frontend, DiagnosisApiController, CorrectionApiController, CorrectionFormWorkflow, CorrectionSubmissionService, ZUGFeRD EN16931 adapter, Mailgun

Every diagnosis and correction session uses the draft state defined by ADR-34. The cutover deletes pre-model edit sessions instead of reconstructing source evidence from generated XML.

runtime 02 validation failure edit
Table 3. Key interactions
Step Description

Diagnosis-first recovery

Validation-failure responses route through /diagnosis/{token}. The diagnosis distinguishes source defects from recognition or mapping errors. Source defects require source correction or explicit review; editor entry requires explicit confirmation that the visible PDF is correct.

Error humanization

EN16931 and internal findings map to plain German presentation through ValidationErrorPresenter; rule-specific diagnosis content remains separate from structured validation JSON.

Draft-only correction state

Every email, website, and widget correction capability persists an application draft. The deployment migration deletes existing edit sessions and removes the XML state column because generated XML cannot be converted into trustworthy source evidence.

Draft snapshot integrity and compatibility

The draft lane stores the schema version relationally beside fachliche revision, serialized snapshot, and an integrity hash bound to session identity and source PDF. Compatibility preparation occurs before the request transaction and before JSON binding. A lower-version active snapshot at revision zero is reconstructed from the source PDF without an active database transaction or bound connection. A short compare-and-swap write replaces it only while entity version, old schema, revision zero, snapshot hash, incomplete lifecycle, and original 30-day deadline still match. Corrected, completed, corrupt, future-version, expired, and concurrently changed snapshots are never silently overwritten.

Lossless form handoff

Draft forms preserve nullable values instead of filling absent facts with zero or one. Repeated editable structures carry opaque draft element IDs; existing IDs remain stable, while explicit additions receive new IDs. Removal and additions become evidence-bound revision events.

Recognition provenance

A user revision records every changed semantic field, corrected-value fingerprint, timestamp, revision, and document evidence. Original extraction claims remain immutable but are superseded when computing effective ambiguity. Every user change is one evidence-bound semantic recognition correction; no parallel canonical or source-observation value is created.

Re-validation loop

Every preview rebuilds common validation and immutable source-analysis findings from the revised draft before invoking target readiness. The ZUGFeRD adapter compares rendered XML with the effective semantic draft values. Invalid submissions return the same form plus structured errors as a 422 response.

Durable completion

A changed draft is persisted and optimistic locking is checked before widget or email side effects. An unchanged but valid draft can complete without inventing a revision. Completion atomically records completedAt together with the exact generated ZUGFeRD PDF, verification PDF, and XML; the editor becomes single-use while completed result downloads remain available.

Result handoff

Email sessions send the standard success mail, widget sessions replace the anonymous conversion and become claimable only after their status changes to SUCCESS, and website sessions expose direct downloads. Completed downloads serve the immutable persisted completion artifacts and never deserialize or rerender the draft.

Free Website Upload

Scope: An anonymous user uploads a PDF on the website and receives either a direct-download ZUGFeRD result or a diagnosis-first retry path — without creating an account.

Trigger: POST /api/v1/free-upload/convert with a PDF file upload.

Participants: Caddy, FreeWebsiteUploadController, PdfToZugferdConversionService

This scenario treats Invoice Extraction Algorithm as a shared black box so the website flow stays focused on user-visible behavior.

runtime 03 trial conversion
Table 4. Key interactions
Step Description

Rate limiting

Caddy enforces 5 requests/hour per client IP on POST /api/v1/free-upload/convert and its /api/free-upload/convert compatibility alias. Requests above the threshold receive 429 Too Many Requests before they reach the controller. Inside the application, the shared extraction boundary still uses RateLimitedClient for outbound provider quotas.

Shared conversion pipeline

PdfToZugferdConversionService.convert(pdfBytes, trackingContext) runs the same shared production pipeline used by the email flow. The diagram hides the internals behind Invoice Extraction Algorithm; see LLM Integration for the static architecture and Chapter 6’s dedicated extraction scenario for the runtime sequence. The shared conversion boundary now also emits the common processed/failed analytics events for convert-level outcomes.

Provider retry

When Mistral responds with HTTP 429, the hidden RateLimitedClient honors Retry-After or a provider reset header and retries the provider call once before the conversion fails.

No account persistence

Free website uploads create no account-bound conversion records. Direct-download edit sessions and shared invoice processing events are still created so the anonymous result can be restored and monitored.

Validation failure recovery

Validation failures no longer keep users inside a website-local edit-first warning state. The product frontend receives the existing editToken, redirects to /diagnosis/{token}, and presents source-invoice correction as the default next step. After correction, completion stores the exact generated ZUGFeRD PDF, verification PDF, and XML in the expiring edit session. GET /api/v1/free-upload/result?editToken=…​ restores those immutable bytes without loading or rerendering the draft; /api/free-upload/result remains an alias. The editor remains available only behind the explicit recognition-error confirmation on the diagnosis page.

Timeout

The controller wraps the conversion in a 60-second timeout (virtual thread executor). If the LLM takes too long, the user receives 408 Request Timeout.

Consent required

The endpoint requires an explicit consent=true parameter. Without consent, it returns 400 Bad Request.

Response privacy

Canonical conversion and restoration responses set Cache-Control: no-store and Referrer-Policy: no-referrer because they may contain invoice artifacts or an edit token.

Partner Widget State Machine

Scope: The embeddable partner widget manages anonymous upload, correction, partner-scoped identity reuse, and result delivery by email as a client-side state machine.

Trigger: Browser events, widget API responses, or widget-specific URL parameters on the partner page.

Participants: widget.embed, widget.api, invoice.editing.ui, PostgreSQL, email.delivery

The widget reducer is the single source of truth for visible screens. Events from the browser, /api/widget/* responses, and URL parameters are translated into semantic actions before they reach the reducer. The reducer never receives instructions such as "show result screen" directly.

Main upload and email-delivery flow
[ idle ]
   |
   | SUBMIT_FILE
   v
[ uploading ]
   |
   | UPLOAD_SUCCESS
   v
[ result-ready ]
   |
   | email CTA
   |
   +-- no stored token --> REQUEST_AUTH_PROMPT --> [ auth-required ]
   |                                               |
   |                                               | REQUEST_AUTH(email)
   |                                               v
   |                                         [ auth-pending ]
   |
   +-- stored token ----> REQUEST_SESSION_CLAIM --> [ session-claim-pending ]
                                                        |              |
                                           AUTH_SUCCESS |              | SESSION_INVALID
                                                        v              v
                                                [ auth-pending ]  [ auth-required ]

[ auth-pending ] -- USE_OTHER_EMAIL --> clear partner token --> [ auth-required ]
Validation branch
[ uploading ]
   |
   | UPLOAD_VALIDATION_FAILURE
   v
[ validation-issue ] -- open diagnosis/correction --> product frontend

The result is not claimable and the widget cannot enter authentication until correction changes the persisted conversion status to SUCCESS.
URL entry flow
Correction return:

Partner page URL:
?widget_event=correction_saved&claim_token=...

[ idle ]
   |
   | CORRECTION_SAVED_FROM_URL
   v
[ restoring-result ]
   |
   | GET /api/widget/result
   |
   +-- RESULT_RESTORED ---------> [ result-ready ]
   |
   +-- RESULT_RESTORE_EXPIRED --> [ expired-result ]
   |
   +-- RESULT_RESTORE_ERROR ----> [ temporary-failure ]
Error and reset rules
Upload/API failures:

[ uploading ] -------- UPLOAD_RATE_LIMITED --> [ rate-limited ]
[ uploading ] -------- UPLOAD_ERROR --------> [ temporary-failure ]

[ auth-pending ] ---------- AUTH_EXPIRED -----> [ expired-result ]
[ session-claim-pending ] - AUTH_EXPIRED -----> [ expired-result ]
[ session-claim-pending ] - AUTH_RATE_LIMITED -> [ rate-limited ]
[ session-claim-pending ] - AUTH_ERROR --------> [ temporary-failure ]


Global recovery:

[ rate-limited ] ------ RETRY -----> [ idle ]
[ temporary-failure ] - RETRY -----> [ idle ]

[ result-ready ] ------ RESTART ---> [ idle ]
[ validation-issue ] -- RESTART ---> [ idle ]
[ expired-result ] ---- RESTART ---> [ idle ]
Table 5. State semantics
State Meaning

idle

The widget is ready for a new PDF and has no active conversion context.

uploading

The widget has submitted a PDF to POST /api/widget/upload and waits for a conversion result.

result-ready

A valid ZUGFeRD result exists for the current anonymous claim token. The user sees the invoice summary and can request email delivery. The widget first checks whether a token exists for the current API origin and partner.

validation-issue

Conversion produced a validation failure with diagnosis guidance and an edit token. The result cannot be claimed or delivered. The user can open the diagnosis/correction capability without logging in; successful correction changes the conversion to SUCCESS before the widget restores result-ready.

restoring-result

The partner page was opened after a successful correction. The widget uses the claim token to fetch the current result snapshot before showing any result screen.

auth-required

No usable partner-scoped identity exists. The widget asks for an email address so the anonymous conversion can be claimed and delivered. Definitive session rejection clears only the current partner’s storage key before entering this state.

session-claim-pending

The widget calls POST /api/v1/widget/claim-with-session with the stored Bearer token, current partner ID, and current conversion claim token. Duplicate CTA submissions are impossible while this state is visible.

auth-pending

The delivery request has been accepted. Existing accounts and valid sessions receive the standard success email immediately. New users receive DOI first; confirmation activates their provisional widget identity and then sends the result email. Whenever a stored identity exists, every widget state displays its fixed recipient as “Versand an …”. In this already-delivered state the action says “nicht mehr merken” and clears the future identity without reopening recipient entry for the claimed conversion.

expired-result

The claim token expired. The user must start a new upload.

rate-limited

A widget API endpoint rejected the request with 429. The state keeps the retry-after value when available.

temporary-failure

A transient network or server error occurred. The user can retry from a clean idle state.

runtime 07 partner widget correction return
Figure 12. Correction return flow

For a result that was already claimed, the same correction submission path compares the conversion owner with the account attached to the edit session. Only an exact owner match may replace the artifacts; the corrected result is then sent directly to that account by the standard success email.

runtime 08 partner widget email delivery
Figure 13. First email delivery and identity issuance
runtime 09 partner widget session delivery
Figure 14. Returning delivery with a partner-scoped identity
Table 6. Key interactions
Step Description

Event-only URLs

Widget URL parameters encode flow events only. widget_event=correction_saved becomes CORRECTION_SAVED_FROM_URL; claim_token identifies the anonymous conversion for result restoration. No URL parameter directly sets a screen or grants download access.

Snapshot restore

After a correction redirect, the widget calls GET /api/widget/result?claimToken=…​. This avoids putting invoice summary data into the URL and keeps the browser state reconstructable after a reload.

Origin-partner binding

A preflight may accept an origin mapped to any active partner because it has no partner header. Every actual browser request with Origin resolves its effective partner from the header, conversion, or claim token and rejects an origin mapped only to another partner before conversion, token issuance, delivery, or result access.

Same result screen

A corrected invoice returns to result-ready, the same state used after a directly valid upload. A valid partner-scoped identity can request delivery immediately; otherwise the widget shows the email form.

Conversion-token safety

WidgetResultController returns 404 for unknown or non-widget claim tokens and 410 Gone for expired claim tokens. Expired results move to expired-result; a valid widget identity is retained because the conversion, not the identity, expired.

Identity-token safety

The browser stores one opaque token per API origin and partner ID. The server stores only its SHA-256 hash and requires the request, session, and conversion partners to match. The session grants only delivery to its fixed account email; it grants no portal, admin, correction, or download access.

Storage fallback

Missing or unavailable localStorage keeps the existing email flow. Only definitive 401 widget_session_invalid responses and the explicit “E-Mail-Adresse wechseln” action clear the current partner key. Network errors, 429, and server failures retain it.

Remembered-recipient visibility

On startup, a stored token is resolved through read-only GET /api/v1/widget/session-identity. Active and still-valid provisional DOI sessions return their normalized fixed recipient with Cache-Control: no-store; invalid credentials receive the same uniform 401 rejection as delivery claims. The widget keeps the email only in memory, displays the compact identity row in every state, and never writes the email to localStorage. The action is “wechseln” when it opens and focuses the email form immediately, and “nicht mehr merken” when it only removes the remembered identity while retaining the current idle, processing, or terminal state. Both actions remove only the current API-origin/partner token.

Email split

auth-required renders the email form. session-claim-pending prevents duplicate claims while a remembered identity is checked and disables switching during the in-flight request. auth-pending renders the sent-mail confirmation. Existing accounts and active sessions get the result email immediately; new accounts get DOI first, session activation on confirmation, and then the result.

DATEV Connection and Mandate Import

Scope: A Clarula administrator creates or selects one active Clarula Firm, connects it to DATEV, imports a minimized mandate snapshot, and confirms selected mandates.

Trigger: Authenticated admin operations under /admin/clarula/firms/{firmId}/datev, dispatched exclusively to the Clarula sibling context.

Participants: root operator security filter, Clarula sibling DispatcherServlet, firm, datev, DATEV Online APIs, Clarula PostgreSQL database

runtime datev connection
Figure 15. Connection and firm verification
runtime datev mandate import
Figure 16. Mock or HTTP mandate import

The HTTP adapter fails the complete import when the firm is ambiguous, differs from the stored connection, or a required provider record is malformed. A same-source import preserves confirmations; a firm change requires an explicit replacement flow that is outside this milestone. Adapter-mode changes happen only after the complete Clarula database has been recreated while the backend is stopped. The mock follows the same port, child-servlet route, and Clarula-local persistence path with deterministic data. No EER Partner, table, repository, identifier, foreign key, or transaction participates in this flow. The root operator filter authenticates /api/operator/v1/clarula/** before servlet dispatch; the child receives only the resulting Authentication and the three explicitly bridged stateless beans (Clock, ObjectMapper, and RestClient).

Scope: A registered user authenticates via a passwordless magic link to access the portal.

Trigger: POST /api/v1/auth/magic-link with the user’s email address.

Participants: MagicLinkController, MagicLinkService, AccountRepository, LoginTokenRepository, EmailSender, AuthTokenService, CustomerRememberSessionService, Mailgun

runtime 04 magic link auth
Table 7. Key interactions
Step Description

Enumeration prevention

Whether the account exists or not, the controller returns the same 202 Accepted response. An attacker cannot determine which email addresses are registered.

Token invalidation

When a user requests a new magic link, all pending (unused) tokens for that account are invalidated. Only the latest link works.

Single-use tokens

Each token can be used exactly once. After verification, usedAt is set and the token cannot be reused.

Secure token generation

MagicLinkService generates 32-byte tokens using SecureRandom, Base64url-encoded without padding.

JWT cookie

On successful verification, AuthTokenService creates an HS256-signed JWT. The cookie is host-only, httpOnly, secure, and sameSite=Lax — inaccessible to JavaScript and sent on normal top-level navigation. The canonical verification URL uses the configured product origin, so the cookie belongs to that product host without a Domain attribute.

Remember session

On successful verification, CustomerRememberSessionService creates an opaque 32-byte remember token, stores only its SHA-256 hash in customer_remember_sessions, and sends the raw token in a second host-only cookie. When the short-lived JWT is missing or expired, JwtCookieFilter uses a valid remember session to issue a fresh JWT and rotate the remember token. GET /api/v1/auth/session and GET /api/v1/portal/summary expose the authenticated JSON state. Canonical logout returns 204, revokes the server-side remember session, and clears both cookies; /auth/logout retains its login-page redirect.

Response privacy

Token redirects and account API responses use Cache-Control: no-store and Referrer-Policy: no-referrer. Canonical authentication and authorization failures use application/problem+json instead of HTML redirects.

Account Registration with Double Opt-In

Scope: A visitor registers on the website and, after confirming the double-opt-in email, becomes a registered user with a 30-day free trial. Business processing runs in the backend and Mailgun delivers the email; the static product frontend owns the registration and result pages. No external DOI service is involved.

Trigger: Visitor submits the registration form on the website.

Participants: static Nuxt product frontend, Caddy, RegisterController, RegistrationRequestService, ConfirmController, RegistrationService, AccountRepository, EmailSender, Mailgun

runtime 05 account registration
Table 8. Key interactions
Step Description

Email check

RegistrationRequestService checks that the email is not blank. No further format validation — the DOI email itself validates the address.

Cooldown

If a pending registration for the same email was created less than 2 minutes ago, the service returns success without resending. This prevents abuse while remaining user-friendly.

Token generation

Each pending registration gets a 32-byte secure random confirmation token. New emails link to the configured product origin at /api/v1/registration/confirm?token=<token>; /registration/confirm remains valid for previously sent emails.

DOI confirmation email

Sent via Mailgun using a self-hosted Thymeleaf template (doi-confirmation.html). The visitor must click the confirmation link to proceed.

Token validation

ConfirmController looks up the token in pending_registrations. Invalid and expired tokens redirect to product-owned error routes. Repeated confirmation is idempotent and redirects to the same welcome route.

Idempotent account creation

RegistrationService.createAccountIfNotExists() normalizes the email (lowercase, trim) and checks for an existing account before creating one. Duplicate confirmations are safe.

Initial plan assignment

Website signups and partner-widget signups both start on Plan.FREE_TRIAL with trialStartDate = now and trialEndDate = now + 30 days. Partner-widget signups additionally keep a nullable partner_id as origin and eligibility metadata. Plan.PARTNER is not assigned during initial registration, but it is assigned later when the Kanzlei confirms the requested Zuordnung. That later transition clears the trial window and publishes a second PlanAssignedEvent with the partner context.

Cleanup

A scheduled task (PendingRegistrationCleanupTask) runs daily at 03:00 and deletes unconfirmed pending registrations older than 48 hours.

Response privacy

Confirmation redirects set Cache-Control: no-store and Referrer-Policy: no-referrer, preventing the DOI token from entering browser caches or downstream referrer headers.

Transaction boundary

The @Transactional annotation on the controller method wraps token validation, confirmation marking, and account creation in a single database transaction.

Deployment View

The hosted Clarula platform uses one Terraform-managed Hetzner VPS per environment. Docker Compose orchestrates the backend, idempotent product-database provisioning, one PostgreSQL cluster with isolated EER and Clarula logical databases, three independently versioned static Nuxt/Nginx frontend containers, and operational containers. Caddy terminates TLS, routes shared backend and security paths, and proxies remaining product traffic to the matching frontend container. There is no Kubernetes, Node/Nuxt runtime server, or container orchestrator beyond Compose.

This chapter describes production, the permanent dev environment, component-selective dev delivery, certified composite runtime snapshots, build-free full-runtime production promotion, infrastructure provisioning, the local Nuxt/backend development setup, and credentialed DATEV sandbox validation.

Production Environment

Production runs on one Terraform-managed Hetzner VPS (Debian 13, Nuremberg). Docker Compose runs the shared backend, one PostgreSQL cluster with separate EER and Clarula logical databases, Caddy, two static product frontend containers, one static operator frontend container, two partner-demo containers, and Metabase. Caddy terminates TLS, owns shared backend/security routing, and proxies remaining product traffic to product-owned unprivileged Nginx containers. There is no Nuxt, Node.js, or Hugo process in production.

deployment production

Deployment Units

Unit Version Runtime role

backend

ghcr.io/clarula/clarula/backend:prod-<UTC timestamp>-<run-id> through BACKEND_IMAGE_TAG

Spring Boot modular monolith. Persistence-free :app owns the executable and eagerly composes isolated :eer and :clarula sibling contexts, each with local JPA/Flyway/transaction infrastructure. The base image provides JRE 25, Ghostscript, curl, and the non-root spring user.

Caddy

The shared prod-<UTC timestamp>-<run-id> release through CADDY_IMAGE_TAG

Custom Caddy plus caddy-ratelimit. Both validated environment Caddyfiles and shared route snippets are baked into the promoted image; production Compose selects Caddyfile.prod.

einfache-eRechnung frontend

The shared prod-<UTC timestamp>-<run-id> release through FRONTEND_EER_IMAGE_TAG

Generated Nuxt .output/public/ files plus product-owned unprivileged Nginx configuration.

Clarula frontend

The shared prod-<UTC timestamp>-<run-id> release through FRONTEND_CLARULA_IMAGE_TAG

Separately generated files plus product-owned unprivileged Nginx configuration.

operator frontend

The shared prod-<UTC timestamp>-<run-id> release through FRONTEND_OPERATOR_IMAGE_TAG

Generated operator workspace files plus operator-owned unprivileged Nginx configuration. It has no backend proxy capability and shares only the internal edge network with Caddy.

partner-demo

The shared prod-<UTC timestamp>-<run-id> release through PARTNER_DEMO_IMAGE_TAG

Static third-party integration demonstration in its own Nginx container.

clarula-partner-demo

The shared prod-<UTC timestamp>-<run-id> release through CLARULA_PARTNER_DEMO_IMAGE_TAG

Static Clarula partner demonstration in its own Nginx container.

PostgreSQL

postgres:18-alpine

EER (invoicex), Clarula (clarula), and Metabase logical databases on one attached volume.

product-db-init

postgres:18-alpine

Idempotent one-shot database/role provisioning. Backend startup depends on successful completion; it is not a long-running application service.

Metabase

metabase/metabase:v0.63.0.x

Internal analytics dashboard.

Each component has an independent image version. All three frontend containers connect only to an internal edge network shared with Caddy; they cannot reach PostgreSQL or publish host ports. Each rollout step recreates one Compose service with --no-deps; a production operation sequences all image services and applies Caddy last. Replacing a frontend can briefly interrupt that product; Caddy, the backend, and the other frontend keep running during that step.

Public Routing and Ownership

Origin/path Owner Behavior

einfache-erechnung.de generated public paths

einfache-eRechnung Nginx container

Pre-rendered HTML and static assets. Generated route directories are resolved to index.html.

einfache-erechnung.de/diagnosis/{token} and /edit/{token}

einfache-eRechnung Nginx container

HTML GET/HEAD with exactly one token segment internally rewrites to the dedicated client shell. The browser URL is preserved.

Fixed einfache-eRechnung auth, portal, error, email-result, and /widget/bestaetigt paths

einfache-eRechnung Nginx container

Only the explicitly generated documents are served. There is no family-wide fallback.

clarula.de generated paths

Clarula Nginx container

Only route-manifest files are served. Clarula has no invented auth or token fallback.

Both product origins /api/v1/**

backend

Same-origin browser API. Caddy preserves Spring CORS, cookies, status, and response headers.

Both product origins /api/ and /registration/

backend

Temporary compatibility aliases during the deployed-client migration window.

admin.clarula.de/api/operator/v1/auth/, /eer/, and /clarula/**

backend EER or Clarula sibling according to the explicit namespace

The EER sibling owns authentication and partner operations; the Clarula sibling owns firm, DATEV, and mandate operations, including /api/operator/v1/clarula/datev/callback. Unknown /api/operator/** paths are not broadly proxied.

Remaining declared admin.clarula.de documents and assets

operator Nginx container

Fixed documents and bounded UUID/result shells are served with no-store/noindex security policy; content-addressed assets are immutable. Caddy removes Authorization, Proxy-Authorization, and Cookie request headers and removes Set-Cookie responses on this static hop. Unknown paths return a real 404.

Every product origin /admin and /admin/, plus product-origin /api/operator/

Caddy rejection

Returns 404; former backend-rendered administration and operator APIs are not proxied or redirected on public product origins.

Embeddable /widget/v1/ bundle, /partner-affiliation/, /webhooks/, /actuator/, OpenAPI/Swagger, and explicit backend static resources

backend EER context, except root-owned /actuator/**

EER’s default dispatcher owns established backend routes; the persistence-free root operational dispatcher owns actuator routes. Other backend-owned exceptions remain available according to the host route manifest. The generated customer confirmation document /widget/bestaetigt remains frontend-owned.

Unknown content, marketing, asset, or application paths

Product Nginx container

Real HTTP 404, using the product-owned 404.html where applicable.

api.einfache-erechnung.de

backend

Dedicated backend and Mailgun webhook origin. /admin/** returns 404.

app.einfache-erechnung.de

temporary widget compatibility

Existing partner embeds may load /widget/v1/ and call /api/v1/widget/ or /api/widget/**. Every other path returns 404; new snippets use the canonical product origin.

The former app origin remains only because partner sites have already installed its versioned widget URL. It is not a first-party application origin and can be removed after those partners migrate. Canonical APIs, compatibility APIs, and RFC 8058 subscription posts use the product or api.* origins.

Static Cache Policy

  • Content-addressed /_nuxt/** assets use public, max-age=31536000, immutable; the mutable /_nuxt/builds/latest.json pointer uses no-store.

  • /.well-known/frontend-release.json uses no-store and no-referrer because it is a release pointer.

  • Diagnosis, correction, fixed private application documents, and operator documents use no-store, no-referrer, and noindex, nofollow; the entire operator host is non-indexable.

  • Unknown files are never replaced by a global application shell.

Certified Runtime Promotion

Production promotion is a manual full-runtime workflow operation. After every successful Dev smoke suite, the Dev workflow publishes one certified snapshot containing the seven running OCI digests and their individual source SHAs. The default production dispatch freezes runtime-snapshot:dev and promotes all seven exact digests without rebuilding any runtime component. The production guard resolves the selected snapshot to one immutable digest before mutation, so a later Dev deployment cannot change the promotion source. After production approval, the workflow validates that the snapshot’s immutable cohort tags still resolve to the recorded digests.

The target pulls each component by its recorded digest, uses the cohort ID as a local rollout reference, and deploys all image components with Caddy last. It then verifies health, public routing, and each frontend’s recorded source release. After smoke tests pass, all seven packages and the runtime snapshot receive the same immutable prod-<UTC timestamp>-<run-id> release tag; package-local prod tags then move to those digests. A failed check stops the workflow without automated restoration. Operational recovery uses the same promotion path with a corrective certified Dev runtime. See deployment/FRONTEND_RELEASES.md for the operational sequence.

Edge Rate Limiting

All public product and API origins import the same named per-IP zones. The temporary widget origin imports those zones as well but exposes only widget bundle and API paths. Canonical and compatibility paths appear in one matcher per budget, so switching product host or API alias does not reset the budget.

Operations (canonical + alias) Budget Reason

Contact POST /api/v1/contact, /api/contact

5/hour

Unauthenticated outbound-email spam protection.

Free upload POST /api/v1/free-upload/convert, /api/free-upload/convert

5/hour

Protect document interpretation, extraction, and conversion resources.

Customer magic link and registration

20/5 minutes each

Bound public email-triggering operations while tolerating shared-office bursts.

Operator magic-link submission on exact admin.clarula.de path

20/5 minutes

Enumeration-safe email issuance has a dedicated operator-origin bucket that cannot be multiplied through product origins.

Widget upload

30/5 minutes

Permit short partner-office bursts while bounding sustained conversion abuse.

Widget auth, claim-with-session, and session-identity

30/5 minutes in one shared bucket

First-time email requests and remembered-identity deliveries can occur in short bursts. Sharing the budget across canonical and compatibility paths prevents callers from multiplying the allowance by switching operations, aliases, or public hosts.

Widget result/status reads

300/5 minutes

Permit restore and polling traffic.

Diagnosis editor entry and correction preview/submission

30/5 minutes each

Bound capability mutations and PDF regeneration.

Frontend operational diagnostics

120/5 minutes

Prevent abuse of the anonymous, privacy-minimized diagnostic endpoint.

Caddy returns a no-store RFC 9457 429 rate_limit_exceeded problem before Spring Boot when a budget is exceeded. Application-level outbound provider limits remain separate.

Product Database Provisioning

Every full environment model contains an idempotent product-db-init one-shot service and executes the single repository-managed deployment/scripts/product-database-init.sh implementation. Hosted workflows install that script at /opt/apps/invoicex/scripts/product-database-init.sh before provisioning. Deployment starts PostgreSQL, runs this service to completion, and only then starts or replaces the backend. The job uses the operational invoicex superuser solely to:

  • create or rotate the non-superuser eer and clarula login roles;

  • create the clarula logical database and assign product database ownership;

  • transfer historical EER relations, routines, enum/domain types, and schemas from the former superuser owner to eer so future Flyway migrations can alter them;

  • revoke PUBLIC connection access; and

  • grant eer access only to invoicex and clarula access only to clarula.

The runtime environment renders DATABASE_USER=eer with its dedicated EER_DB_PASSWORD, plus a separate CLARULA_DB_URL, CLARULA_DB_USER=clarula, and CLARULA_DB_PASSWORD. Rendering rejects any equal pair among the EER, Clarula, and operational passwords. Compose passes only an explicit application-variable allowlist to the backend, so the process receives both product credentials but not POSTGRES_PASSWORD or the redundant bootstrap password names. Neither child persistence configuration receives the sibling credential or DataSource. deployment/tests/product-database-isolation-test.sh executes the provisioning model and proves both denied sibling connections.

Persistence and External Services

Only PostgreSQL and Caddy ACME/config state are persistent:

  • /mnt/data/postgres/var/lib/postgresql

  • /mnt/data/caddy-data/data

  • /mnt/data/caddy-config/config

Frontend images are reproducible artifacts in GHCR and are not customer-data backups. The host stores no frontend files or content bind mounts. Host replacement restores PostgreSQL/Caddy state and pulls the component images recorded in runtime configuration and the backup manifest. Production full-state archives contain independently identifiable postgres/invoicex.dump, postgres/clarula.dump, and postgres/metabase.dump files. The EER and Clarula custom-format dumps can be inspected and restored independently; restore commands create each product database with its own owner and never restore one product into the sibling database. Mailgun and Mistral are EU endpoints; New Relic receives OTLP telemetry; GHCR stores all deployable image components. Secrets are rendered from the environment-scoped 1Password vault into a mode-0600 .env file during deployment; each long-running service receives only its explicit Compose environment subset. The production workflow additionally requires the GitHub Environment’s INVOICEX_ADMIN_EMAILS, fixes OPERATOR_ORIGIN to exactly https://admin.clarula.de, and derives the DATEV callback from that validated origin. Compose passes both values explicitly to the backend without printing the allow-list. OpenTelemetry sanitization redacts signed-URL fields and OAuth/OIDC query names including code, state, token, error, error_description, error_uri, nonce, and code_challenge before export.

Dev Environment

Dev is the permanent proving environment for every qualifying merge to main. It uses the same static frontend containers, shared Caddy edge routing, Spring production profile, PostgreSQL, demos, and Metabase topology as production on a separate Terraform-managed Hetzner host.

Origins

Public origin Purpose

dev.einfache-erechnung.de

Canonical einfache-eRechnung frontend container plus same-origin /api/v1/; /admin/ and /api/operator/** return 404.

dev.clarula.de

Canonical Clarula frontend container plus same-origin /api/v1/; /admin/ and /api/operator/** return 404.

admin.dev.clarula.de

Dedicated static operator frontend. Only explicit /api/operator/v1/auth/, /eer/, and /clarula/** namespaces reach the backend; remaining declared documents/assets reach operator Nginx with credentials stripped.

app.dev.einfache-erechnung.de

Temporary compatibility for already-installed /widget/v1/** embeds and their widget API calls only.

api.dev.einfache-erechnung.de

Dedicated backend and Mailgun webhook origin; /admin/** returns 404.

partner-demo.dev.einfache-erechnung.de

Standard partner widget demonstration.

partner-demo.dev.clarula.de

Clarula partner demonstration.

analytics.dev.einfache-erechnung.de

Internal Metabase access.

Every dev host sends X-Robots-Tag: noindex, nofollow. Product route ownership, scoped shells, backend exceptions, cache policy, bounded legacy-widget compatibility, and rate-limit zones match production.

Provisioning and Replaceability

deployment/environment/dev/ provisions the server, firewall, attached volume, and DNS through Terraform. cloud-init.yaml installs Docker and Python, creates the deploy user, prepares /opt/apps/invoicex, and mounts /mnt/data. The host is replaced from code rather than patched manually.

Stateful data survives replacement on the attached volume:

  • /mnt/data/postgres

  • /mnt/data/caddy-data

  • /mnt/data/caddy-config

Frontend images are immutable, reproducible delivery artifacts in GHCR. The host stores no frontend files; a replacement host pulls the selected image tags through the component workflow.

Component Delivery

The dev workflow selects components by changed path. An app-local change packages and activates only that frontend; shared frontend package, generation-script, or lockfile changes select all three. Backend, Caddy, and both partner demos use independent dev-<full-sha> image tags. A frontend deployment recreates only that product’s Nginx container; it does not restart the other frontend, Caddy, or the backend. A brief product-specific interruption is accepted.

The workflow fetches secrets from the invoicex_dev 1Password vault, requires the environment-owned operator allow-list, renders the exact https://admin.dev.clarula.de operator origin and its derived DATEV callback into the mode-0600 .env, preserves unselected component versions, copies the complete environment-owned Compose model and scripts, applies only selected components, and runs production-like route and exact-release smoke checks. The smoke suite POSTs the enumeration-safe operator magic-link endpoint with the exact operator Origin and a reserved address that rendering forbids from the allow-list, so no provider email is sent. After success, it records the seven running immutable tags and OCI digests in one runtime-snapshot:dev-run-<run-id>-<attempt> image. All seven components receive the snapshot’s immutable dev-run-<run-id>-<attempt> cohort tag, and package-local dev tags point to the corresponding certified digests; runtime-snapshot:dev moves last and identifies the authoritative certified combination. A failed deployment or smoke test does not replace that pointer. A manual all deployment bootstraps a replacement host.

Dev is isolated from production by VPS, DNS, GitHub Environment, database, Caddy state, component versions, and telemetry environment name. Caddy uses the same promoted image digest in both environments, but Dev Compose selects the image’s validated /etc/caddy/Caddyfile.dev configuration while production selects Caddyfile.prod. Mailgun account/domain remain shared, with postfach-dev@einfache-erechnung.de routed only to dev. OpenTelemetry URL sanitization redacts OAuth/OIDC query names including code, state, token, error, error_description, error_uri, nonce, and code_challenge before export. Because main auto-deploys, dev is not a stable long-lived QA snapshot.

CI/CD Pipeline

GitHub Actions keeps required checks always reporting while selecting expensive work by path. ADR-14 remains unchanged: main automatically delivers affected components to dev; production requires an explicit full-runtime promotion.

deployment cicd

Pull-request Gates

Gate work Selection Contract

Backend

applications/backend/**

Formatting, unit, integration, Docker build, and native-runtime checks.

Product API contract

Backend main/API contract or generated client changes

checkProductV1OpenApi, checkProductV1ApiCompatibility, and generated TypeScript client drift/tests.

einfache-eRechnung frontend

Product app or shared frontend inputs

Workspace install, generated-client check, product type-check/tests, static generation, static-output inspection, and hardened Nginx image contract.

Clarula frontend

Product app or shared frontend inputs

Same independent contract; no other frontend build is required for an app-local change.

Operator API contracts

Backend or any of the three generated operator clients

Explicit drift and additive-compatibility tasks for operator auth, EER operator, and Clarula operator OpenAPI documents, followed by explicit generated-client drift checks and tests for all three TypeScript clients. The result participates in the always-reporting Build & Test gate.

Operator frontend

Operator app, its three generated operator clients, or shared frontend inputs

Type-check, tests, static generation, declared-route inspection, and hardened operator Nginx image contract including noindex/no-store policy, scoped dynamic shells, and real 404 behavior.

Deployment contracts

Caddy, Compose, scripts, or workflows

Shellcheck, isolated image-component deployment, runtime-snapshot validation and cohort tagging, frontend image hardening, secret-safe environment rendering, all Compose models, hosted and local Caddy configurations, plus Terraform 1.15.0 fmt, backend-free init, and validate for Dev and production.

Product and backend visual

Backend pages/widgets, either product frontend, demos, Caddy, or visual harness

One Docker preparation job builds the backend JAR, both static frontends, the shared backend runtime base, and custom Caddy image. Concurrent desktop, tablet, and mobile screenshot jobs consume those artifacts in isolated Compose projects; a fourth isolated job checks non-screenshot frontend contracts.

Full-stack E2E

Backend, either frontend, retained compatibility sites, demos, Caddy, scripts, or E2E harness

Docker-generated Nuxt/Nginx images behind shared Caddy, backend routes, APIs, scoped shells, bounded legacy-widget compatibility, backend exceptions, 404s, immutable cache headers, and shared edge-rate budgets.

The final Build & Test, Visual Regression Tests, and Full-stack E2E jobs always run and own the required status contexts. Skipped selective jobs count as success; failed or cancelled selected work fails the gate.

Dev Build and Delivery

The dev workflow’s path selector treats these as independent components:

  • backend (plus builder/runtime bases only when their inputs change),

  • einfache-eRechnung frontend,

  • Clarula frontend,

  • operator frontend,

  • Caddy,

  • einfache-eRechnung partner demo, and

  • Clarula partner demo.

A shared frontend package, generation script, API client, workspace manifest, or lockfile selects all three frontend builds. Each frontend image contains only .output/public/, exact release metadata, and its product-owned unprivileged Nginx configuration. CI verifies non-root/read-only execution, scoped shells, privacy and cache headers, real 404 behavior, and absence of Node.js. The release ID fixes Nuxt’s build ID and identifies the source bytes inside the image.

All deployable components use immutable dev-<full-sha> tags in separate .env variables. Build jobs reuse an existing full-SHA tag rather than overwriting it. The deploy job preserves every unselected version and updates only selected services. Caddy packages both validated environment configurations; each Compose model selects its own configuration from the same image digest.

After the complete Dev smoke suite passes, the workflow resolves all seven running OCI digests and publishes runtime-snapshot:dev-run-<run-id>-<attempt>. It adds the same immutable dev-run-<run-id>-<attempt> cohort tag to every component digest, moves each package-local dev tag, and moves runtime-snapshot:dev last. That single snapshot pointer is the authoritative production input; a failed deployment, smoke test, or certification cannot replace it.

Manual Production Promotion

The production workflow is workflow_dispatch only and is additionally constrained by the prod GitHub Environment and a workflow guard to main. There is no component selector: every dispatch promotes the complete certified runtime. A blank input resolves runtime-snapshot:dev; an operator can select a retained immutable dev-run-<run-id>-<attempt> snapshot.

The production guard resolves the selected snapshot to one immutable digest before mutation, so later Dev delivery cannot change the promotion source. The workflow validates the successful Dev certification and main-reachable deployment-control revision before approval, then revalidates the immutable cohort tags after approval. It builds no runtime image and creates no intermediate candidate aliases. The target pulls each component by its recorded digest and deploys backend, all three frontends, both demos, and Caddy in that order. The image deploy script verifies each component, then public smoke tests verify the complete route contract and all three recorded frontend source releases.

After smoke tests pass, every component package and the runtime-snapshot package receive the same immutable prod-<UTC timestamp>-<run-id> tag. The production host records that shared tag and package-local prod pointers move to the active digests. A failed check stops the workflow; operators recover through a new promotion of a corrective certified Dev runtime.

Detailed commands and retention rules are in deployment/FRONTEND_RELEASES.md.

Engineering Documentation Publication

The engineering-pages.yml workflow publishes a static engineering observatory to the gh-pages branch. Changes under docs/architecture/ or docs/pages/ render the complete arc42 document with its diagrams and update the landing page automatically from main without running the paid benchmark. A manual dispatch additionally runs the real LLM benchmark with the development Mistral credential and retains all historical benchmark runs. The landing page is served at the GitHub Pages root, architecture documentation at /architecture/, and benchmark reports at /llm-benchmark/. The workflow migrates the former root-level benchmark site into that namespaced path on its first publication.

Cleanup

  • GHCR: ten immutable dev-<SHA> source builds, ten certified dev-run- cohorts, and ten timestamped successful prod-20 release tags per component and runtime-snapshot package. Package-local moving dev and prod tags protect active manifests.

  • Host Docker images: unused images older than 72 hours after verified deployments.

  • Volumes and customer data are never part of image or release cleanup.

Infrastructure Provisioning

Dev and production infrastructure use environment-local Terraform modules and cloud-init as their source of truth. Both environments follow the same replaceable-host pattern: Terraform provisions the Hetzner server, firewall, persistent volume, and DNS records; cloud-init installs Docker and Python, creates the deploy user, prepares /opt/apps/invoicex, and mounts /mnt/data.

Component Current State Location

Dev server, firewall, persistent volume, DNS records

Terraform (hetznercloud/hcloud) provisions invoicex-dev, the attached Hetzner volume, and the dev, app.dev, api.dev, analytics.dev, and partner-demo.dev records under einfache-erechnung.de plus dev, admin.dev, and partner-demo.dev under clarula.de.

deployment/environment/dev/

Dev host bootstrap

cloud-init.yaml installs Docker, creates the deploy user, prepares /opt/apps/invoicex, and mounts /mnt/data.

deployment/environment/dev/cloud-init.yaml

Production server, firewall, persistent volume, DNS records

Terraform (hetznercloud/hcloud) provisions invoicex-prod, the attached Hetzner volume, the customer-facing production A records including admin.clarula.de, and the Google Workspace verification, SPF, and DKIM records for einfache-erechnung.de and clarula.de. Mailgun retains the einfache-erechnung.de MX records.

deployment/environment/prod/

Production host bootstrap

cloud-init.yaml installs Docker, creates the deploy user, prepares /opt/apps/invoicex, and mounts /mnt/data.

deployment/environment/prod/cloud-init.yaml

Terraform state for Hetzner-managed infrastructure

Dev and production use shared remote backends on Hetzner Object Storage with bucket versioning and lockfile-based locking under ADR-15.

deployment/environment/dev/, deployment/environment/prod/, ADR-15

Local operator secret resolution

Local Terraform and hcloud commands for dev and production run through each environment’s with-hcloud-env.sh, which resolves HCLOUD_TOKEN from shared 1Password first and uses .env.local only as an override or fallback. Backend Object Storage credentials follow the same shared-secret model.

deployment/environment/dev/with-hcloud-env.sh, deployment/environment/prod/with-hcloud-env.sh

Terraform static validation

Pull-request CI pins Terraform 1.15.0, checks recursive formatting, combines each partial backend block with its committed non-secret backend.hcl in a temporary validation copy, initializes with -backend=false and readonly provider locks, and validates without credentials or state access. This version supports the hcloud provider-defined txt_record function used by production DNS records.

.github/workflows/ci.yml, deployment/environment/dev/.terraform.lock.hcl, deployment/environment/prod/.terraform.lock.hcl

Mailgun account, domain, and routes

Mailgun is managed manually. Dev and production share one account and domain; routes direct postfach-dev@ to dev and postfach@ to production.

No current Terraform root

The dev and production servers are treated as replaceable infrastructure. Terraform plus cloud-init rebuild each host, while the attached Hetzner volume preserves the only state that must survive rebuilds: PostgreSQL data and Caddy’s ACME/TLS state. Static frontend images are reproducible immutable GHCR artifacts, not Terraform resources or persistent customer data; component workflows pull the selected image tags after replacement.

Each infrastructure directory keeps its own environment-local variables, wrapper scripts, and non-secret overrides. Under ADR-15, shared Terraform state is not part of that self-containment boundary anymore: Hetzner-managed infrastructure moves to a shared remote backend instead of one module-local state file per operator. There is no shared Terraform module spanning dev and prod, which limits blast radius inside the shared Hetzner project.

Local Development Environment

local-development/docker-compose.yml runs the two Nuxt product applications and the dedicated Nuxt operator application against one shared Spring Boot backend. Caddy keeps API and backend-exception routing ahead of each frontend and deliberately disables edge throttling locally. Product APIs remain on the product origins; operator APIs use the separate local operator origin.

deployment local

Local Origins

URL Owner

http://localhost

einfache-eRechnung Nuxt app; canonical local product origin.

http://clarula.localhost

Clarula Nuxt app.

http://admin.localhost

Dedicated operator Nuxt app plus the explicit auth, EER, and Clarula operator API namespaces.

http://partner-demo.localhost

Standard partner widget demo.

http://partner-demo.clarula.localhost

Clarula partner demo.

http://localhost:8025

Mailpit UI.

Both product origins proxy /api/v1/, the embeddable-widget bundle, webhooks, actuator endpoints, and explicit backend-static exceptions to the same backend. They return 404 for /admin/ and /api/operator/. Only http://admin.localhost proxies the explicit /api/operator/v1/auth/, /eer/, and /clarula/ namespaces; remaining requests reach the operator Nuxt development server. Unknown product and operator routes remain 404 responses.

Start and Hot Reload

./start-local.sh
./start-local.sh --metabase --otel
./start-local.sh --no-build

The script builds and mounts applications/backend/build/libs/backend.jar, starts Gradle bootJar --continuous, and restarts only the backend when the JAR changes. The three Node 26.5 containers install the pinned workspace dependencies into separate named volumes and run Nuxt dev servers with source mounts. Ctrl+C stops the Compose project.

Backend JDWP remains available on port 5005. PostgreSQL is exposed on 5432, optional Metabase on 3000, and optional OpenTelemetry Collector ports on 4317/4318. Before the backend starts, product-db-init creates local eer and clarula roles, provisions the clarula database, revokes sibling connection access, and performs the idempotent ownership transition for historical EER objects. The local backend uses eer for invoicex and clarula for clarula; the development passwords are non-secret Compose defaults. Local runtime configuration sets both INVOICEX_PRODUCT_ORIGIN and INVOICEX_BASE_URL to http://localhost, while OPERATOR_ORIGIN is http://admin.localhost. The local backend receives an explicit non-secret INVOICEX_ADMIN_EMAILS=admin@example.com allow-list. DATEV_ENVIRONMENT=mock is the default: /clarula/firms in the operator app creates a Clarula-local firm and executes the complete connection, encrypted-token, import, and confirmation path, while authorization consent and mandate data remain local and deterministic. DATEV_REDIRECT_URI is http://admin.localhost/api/operator/v1/clarula/datev/callback, derived from the same canonical local operator origin used by hosted environments. DATEV Sandbox Validation describes the credentialed HTTP-adapter check.

Visual Regression Environment

applications/visual-tests/update-screenshots.sh keeps screenshot generation inside the pinned Playwright container. A normal update or check builds a production-like backend runtime plus the static frontend and custom Caddy artifacts, creates fresh project-scoped Compose state with both product databases, runs Playwright, and tears the stack down. The explicit --up, --reuse, and --down lifecycle retains those containers and build outputs for repeated local screenshot updates. Reuse is rejected when runtime source inputs or built artifacts changed after --up. Developers run a normal hermetic --check after incremental updates. Screenshot updates select only the visual specifications; checks can select visual and non-screenshot contract specifications independently.

DATEV Sandbox Validation

The deterministic mock adapter is the explicit operating mode for local development and automated tests. Sandbox validation exercises the same admin, connection, token, import, persistence, and confirmation path with the HTTP adapter. It is a manual credentialed check because DATEV authentication requires an interactive authorized user.

Configuration

For a confidential DATEV sandbox application, register the exact Dev callback URI https://admin.dev.clarula.de/api/operator/v1/clarula/datev/callback (production uses https://admin.clarula.de/api/operator/v1/clarula/datev/callback) subscribed to master-data:master-clients. Start the backend with:

DATEV_ENVIRONMENT=sandbox
DATEV_CLIENT_ID=<sandbox client id>
DATEV_CLIENT_SECRET=<sandbox client secret>
DATEV_REDIRECT_URI=<exact registered callback URI>
DATEV_TOKEN_ENCRYPTION_PASSWORD=<environment secret of at least 32 characters>
DATEV_TOKEN_ENCRYPTION_SALT=<stable random hexadecimal salt of at least 16 bytes>

The requested scopes are openid, datev:master-data:master-clients:read, and datev:master-data:org-info:read. Do not add document-write permissions to this validation. Hosted deployments read DATEV_ENVIRONMENT and environment-specific 1Password field references from GitHub environment variables. Dev permits mock or sandbox; production permits mock or production. The deployment workflow resolves those references through its existing 1Password service account only for sandbox or production; unset credentials are accepted only while the explicit mode is mock. Hosted rendering always derives DATEV_REDIRECT_URI from the validated environment-specific OPERATOR_ORIGIN. In mock mode, Compose omits the four credential/encryption values instead of injecting empty strings while still passing that canonical callback. The runtime renderer rejects absent or weak external encryption material and any separately supplied callback that conflicts with the derived value. Secrets belong in the environment secret store and never in repository files, shell history, screenshots, or issue comments. Only the backend receives the rendered DATEV values; PostgreSQL receives an explicit database-only environment. OpenTelemetry URL sanitization redacts OAuth/OIDC code, state, token, error, error_description, error_uri, nonce, and code_challenge query values before traces are exported by hosted deployments.

Adapter Mode Transition

The application does not persist adapter-mode provenance and does not support an in-place transition between mock, sandbox, and production. Every DATEV_ENVIRONMENT change therefore requires a destructive reset of the complete Clarula database. This removes all Clarula firms, OAuth tokens, imported mandates, routing identities, targets, confirmations, and authorization attempts; EER remains untouched.

Run the transition as one supervised maintenance operation. Record the old mode, target mode, cutover time, and operator, and prevent concurrent deployments or backups. On production, acquire the shared operation lock from /opt/apps/invoicex and keep that shell open through the database reset:

cd /opt/apps/invoicex
mkdir .production-operation-lock || {
  echo "A production deployment, backup, or transition is already active" >&2
  exit 1
}
trap 'rmdir .production-operation-lock' EXIT HUP INT TERM
  1. While the backend still uses the old mode, revoke every real provider grant. For sandbox or production, disconnect each firm through the admin UI. If local ciphertext cannot be used for revocation, revoke the grant through DATEV before continuing. Confirm that no external connection remains:

    docker compose exec -T postgres psql -X -v ON_ERROR_STOP=1 -U invoicex -d clarula -Atc \
      "SELECT count(*) FROM firm_datev_connections;"

    The result must be 0. The deterministic mock has no external grant, so this revocation step may be skipped when leaving mock.

  2. Stop the backend. From /opt/apps/invoicex on a hosted VM, or local-development in a local checkout, drop and recreate the complete Clarula database and verify that it contains no application tables:

    docker compose stop backend
    docker compose exec -T postgres psql -X -v ON_ERROR_STOP=1 -U invoicex -d postgres \
      -c "DROP DATABASE IF EXISTS clarula WITH (FORCE);"
    docker compose run --rm product-db-init
    
    test "$(docker compose exec -T postgres psql -X -v ON_ERROR_STOP=1 -U invoicex -d clarula -Atc \
      "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';")" = "0"
  3. On production, release the operation lock only after the reset. Keep the backend stopped until the deployment workflow starts it:

    trap - EXIT HUP INT TERM
    rmdir .production-operation-lock
  4. Change DATEV_ENVIRONMENT, credentials, and encryption material through the environment’s normal secret and deployment workflow. Confirm that the registered callback matches the URI derived from that environment’s fixed operator origin. Do not start the old runtime against the empty database.

  5. Deploy the backend normally. Flyway creates the Clarula schema from V1, after which the health check must be UP and the firm list must be empty.

  6. Recreate and reconnect every intended Clarula firm in the new mode before importing mandates.

A Clarula dump created before the recorded cutover must never be restored into the new mode. It may be used only for a complete rollback with its matching runtime configuration; otherwise restore the other product data selectively and recreate Clarula empty. A rollback to another adapter mode repeats the same revocation and reset procedure. Any local or ephemeral database that applied the unreleased earlier checksum of Clarula migration V1 must also be recreated once, even when its adapter mode does not change.

Validation Flow

  1. Log into the operator workspace on the environment’s dedicated admin origin.

  2. Create or open the target Clarula firm under /clarula/firms and select DATEV & Mandate.

  3. Start Mit DATEV verbinden.

  4. Let an authorized sandbox firm user authenticate and grant access directly at DATEV.

  5. Verify that the callback shows the expected DATEV firm reference, granted scopes, and expiry without displaying tokens.

  6. Trigger Mandate importieren.

  7. Verify that imported names, addresses, strong identifiers, completeness, provenance, and demo targets are read-only.

  8. Confirm one complete and one incomplete mandate.

  9. Restart the backend and repeat the import to prove encrypted token persistence, refresh rotation, idempotent mandate upsert, and confirmation preservation.

  10. Disconnect and verify that both provider tokens are revoked before the local connection disappears.

Record only pass/fail, environment, immutable application revision, DATEV request correlation identifiers, mandate counts, and sanitized internal error codes. Do not record tokens or complete provider payloads.

Production Switch

Production later uses DATEV_ENVIRONMENT=production, production app credentials, a production callback URI, and separate encryption material. The Clarula Firm domain model, isolated database, child-context route, and DatevApi contract do not change. Before switching, follow the complete clean-state procedure above; revoking existing external grants is required but does not replace the full database reset.

Production vs. Dev vs. Local Comparison

Aspect Production Dev Local Development

Host topology

Terraform-managed Hetzner VPS

Separate Terraform-managed replaceable VPS

Developer Docker Compose project

Static frontends

Three immutable static Nuxt/Nginx OCI images behind Caddy: two product apps and one operator app

Same frontend-container topology as production

Product dev servers plus the operator app’s separate development entry point

Frontend runtime

Product-owned Nginx; no Node, Nuxt, or Hugo process

Product-owned Nginx; no Node, Nuxt, or Hugo process

Node is development-only for hot reload

Shared backend

Independent prod-* image, Spring production

Independent dev-* image, Spring production

Mounted bootJar, Spring local, JDWP

Route ownership

Caddy owns shared backend/security routes; product Nginx owns public static routes; admin.clarula.de alone owns operator APIs/documents; the former app host is limited to installed widget compatibility

Same, with admin.dev.clarula.de, dev names, and global noindex

Product origins proxy product APIs and explicit backend exceptions; admin.localhost alone owns operator APIs/documents

Database

One PostgreSQL 18 cluster on an attached persistent volume; separate invoicex and clarula logical databases and roles

Same logical database/role isolation on a separate attached persistent volume

Same logical database/role isolation on a named volume with non-secret local passwords

Caddy

Immutable environment image, ACME state on attached volume, edge rate limits

Immutable environment image, separate ACME state, same rate limits

Immutable local image, plain HTTP, no edge throttling

Email

Mailgun postfach@…​

Shared Mailgun account and domain, separate postfach-dev@…​ route

Mailpit

Observability

New Relic production namespace + Metabase

New Relic dev namespace + Metabase

Optional local OTel collector + optional Metabase

Delivery

Manual full-runtime promotion; exact tested frontend image digests promoted from dev

Path-selected automatic component delivery from main

./start-local.sh

Version state

Seven independent prod-* image variables

Same with dev-* image tags

Local images, source mounts, and named dependency volumes

Secrets

invoicex_prod 1Password vault at workflow runtime, including a separate Clarula database password

invoicex_dev 1Password vault at workflow runtime, including a separate Clarula database password

Git-ignored local application config and explicit non-secret Compose defaults, including the local operator origin and allow-list

Cross-cutting Concepts

This chapter documents concepts and patterns that apply across multiple building blocks. Each concept describes how the system works, not what its components are.

cross cutting overview

Domain Model

Neither product maintains an accounting ledger or long-lived invoice master data. EER owns a bounded semantic conversion model, while Conversion and InvoiceEditSession persist only artifacts, evidence, draft snapshots, and lifecycle state needed for delivery, claims, diagnosis, correction, and expiry. Clarula owns a separate firm, mandate, and DATEV-routing domain and cannot import EER domain code.

ADR-34 establishes the EER-owned, format-neutral invoice model. The unchanged source PDF remains authoritative for issuer-visible content; source evidence, extracted observations, editable drafts, and commonly validated invoice semantics remain distinct. ZUGFeRD and future EER target artifacts never become canonical semantic state. The semantic LLM DTO maps directly into this model without a second monetary interpretation layer. Active correction sessions persist application-owned draft snapshots; completed sessions additionally retain their exact immutable result artifacts until expiry.

EER Format-Neutral Invoice Processing Model

The model is a non-JPA domain model owned by :eer package invoice.conversion:

  • SourceEvidence preserves immutable PDF identity, complete page evidence, bounded analysis text, and truncation state.

  • ObservedInvoice carries semantic claims linked to evidence. Each invoice concept has one value; there are no parallel source, net, gross, printed, or calculated fields.

  • InvoiceDraft carries editable semantics plus extraction and correction provenance. Repeated semantic elements use stable UUIDs.

  • InvoiceChange binds each corrected value fingerprint to source evidence, advances one contiguous draft revision, and supersedes ambiguity only for the effective value.

  • ValidatedInvoice represents common semantic consistency, not target readiness or legal validity.

DocumentAnalysisResult represents invoice candidates with findings, safe non-invoice classifications, accepted but automatically unreadable documents, permanent source rejection, and transient processing failure. EER target adapters apply independent mapping and conformance rules. Every correction session persists a versioned, integrity-checked draft snapshot; the one-time destructive cutover deletes all pre-model sessions rather than reconstructing missing source evidence. Snapshot compatibility is relational metadata checked before JSON binding. Obsolete uncorrected drafts can be reconstructed from the stored PDF outside a database transaction and installed only by a lifecycle-, TTL-, hash-, schema-, revision-, and version-guarded compare-and-swap, while completed downloads no longer require a draft reader.

Core Entities

The system uses persistent entities for EER identity, partner attribution, edit-session state, and the independent Clarula firm/DATEV model. EER entities extend BaseEntity; Clarula entities compile in a separate module and extend ClarulaBaseEntity. Both conventions provide UUID primary keys, optimistic locking via @Version, and automatic audit timestamps without creating a shared persistence type.

The diagrams are separate because there is no cross-product entity relationship, foreign key, shared persistence superclass, or persistent identifier mapping. In particular, an EER Partner and a Clarula Firm are independent records even when they represent the same real-world tax firm. This separation follows ADR-31 and ADR-32.

EER Entities
domain model eer
Figure 17. EER Persistent Entity Relationships
Account

Represents a registered user. Owns the plan, trial dates, conversion counter, and optional partner reference. Account.partner records partner origin or confirmed affiliation metadata; it is not by itself a partner-managed tariff. Account.partnerAffiliationConfirmedAt records when a partner confirmed tariff eligibility during or before the trial. Only plan = PARTNER makes the account partner-managed. Partner-origin registrations therefore start like direct registrations on Plan.FREE_TRIAL with a regular 30-day trial window while keeping the partner reference for attribution and future eligibility. Package: identity.accounts

PendingRegistration

Stores DOI requests before an account exists. For partner-widget registrations it additionally keeps the optional full name or company name for the requested Kanzlei-Zuordnung, plus the state of the one-time partner confirmation token until the request is confirmed, expires, or is ignored. Package: identity.registration

Partner

Represents a registered widget partner with display name, a non-empty set of exact allowed origins, redirect URL, active state, and affiliation notification address. Origins are persisted in partner_allowed_origins and can be shared only through explicit mappings. Package: widget.partner

Conversion

Persists bounded processing artifacts and delivery state for email, website, and widget conversions. It may retain original and generated PDFs, CII XML, extracted fields, validation evidence, and an expiring anonymous claim token. Optional Account and Partner relationships record ownership and widget source without turning the record into an accounting invoice aggregate. Package: conversion.persistence

WidgetIdentitySession

Represents a persistent, partner-scoped capability for sending later widget conversion results to one account email. Only a SHA-256 token hash is persisted. Sessions are bound to the exact PendingRegistration and technically expiring before DOI, then account-bound and indefinite by default after activation; revokedAt, expiresAt, and lastUsedAt support revocation and policy checks. The entity never grants application login or browser download access. Package: widget.auth

LoginToken

A single-use magic link token with expiration. References one Account. Package: identity.authentication

InvoiceEditSession

Stores the original PDF, validation state, schema-versioned InvoiceDraft snapshot, fachliche revision, and integrity hash. The schema version is relational metadata and is evaluated before snapshot deserialization. An obsolete active revision-zero snapshot may be rebuilt from the PDF; corrected or future-version snapshots fail closed. On successful completion the session also stores the exact generated ZUGFeRD PDF, verification PDF, and XML. These immutable delivery artifacts are never editable state and allow downloads without historical draft deserialization or regeneration. The draft-only cutover deletes older XML-backed sessions because their generated XML cannot provide trustworthy source evidence. Also carries email threading metadata so response emails appear in the same thread. Anonymous widget edit sessions additionally keep the widget claim token so corrected results can update the original anonymous Conversion and continue through the standard claim-and-email flow. Package: invoice.editing.session

Clarula Entities
domain model clarula
Figure 18. Clarula Persistent Entity Relationships
Firm

Represents one Clarula tenant with a stable Clarula-local identifier, display name, and active lifecycle. It has no EER Partner reference and possession of its UUID grants no authority. Package: com.clarula.clarula.firm in :clarula

FirmDatevConnection

Stores one firm-scoped DATEV authorization as encrypted access and single-use refresh tokens plus granted scopes, provider subject, expiry, status, and the verified DATEV business-partner reference. It never grants Clarula login and contains no mandate data. Package: com.clarula.clarula.datev in :clarula

DatevFirmImportSnapshot and DatevImportedMandateEntity

Represent the current minimized, read-only DATEV import for one Clarula firm. The snapshot records source firm, version, and import time; DatevMandateRoutingIdentityEntity retains normalized routing evidence, while DatevMandateDemoTarget stores the simulated target associated with each mandate. Provider responses outside this allow-list are not persisted. Package: com.clarula.clarula.datev.mandates in :clarula

DatevMandateConfirmation

Records the Clarula admin and time that an available imported mandate was confirmed. It is separate from imported fields, survives same-source re-import, and cannot transfer across a source-firm change. Package: com.clarula.clarula.datev.mandates in :clarula

For field-level details, see the entity source files — they are the single source of truth.

BaseEntity Convention

Every EER JPA entity extends BaseEntity; every Clarula JPA entity extends ClarulaBaseEntity. Keeping two equivalent product-owned base classes prevents a persistence dependency through :core while enforcing the same four properties in each persistence layer:

  • UUID primary key — generated in application code with UUID.randomUUID(), never sequential

  • Optimistic locking@Version prevents lost updates without database locks

  • Audit timestampscreatedAt and modifiedAt set automatically

  • Proxy-safe equals/hashCode — equality requires the same Hibernate class and UUID; hash codes use the Hibernate class

Aggregate roots use companion factories where creation must enforce lifecycle defaults; provider snapshot entities may be constructed directly inside their owning services. JPA’s no-arg construction remains an internal persistence concern. See the Persistence section for a code example.

Sealed Result Types

The system models expected outcomes as sealed classes. Kotlin’s exhaustive when expressions force callers to handle every case at compile time.

Two primary result types drive the processing pipelines:

EmailProcessingResult

Models all outcomes of the email-based pipeline — from successful conversion through silent rejection to retryable infrastructure failures.

DocumentAnalysisResult and ConversionResult

DocumentAnalysisResult is the target-neutral semantic outcome: candidate with immutable draft and findings, non-invoice, unreadable document, rejected source, or processing failure. The ZUGFeRD application adapter maps that outcome to ConversionResult for email, website, and widget delivery. ConversionResult therefore carries target-specific XML/PDF artifacts plus the draft needed for diagnosis; it is not the shared semantic model.

This pattern provides compile-time safety: adding a new outcome variant causes build failures everywhere that when expression does not handle it yet.

Business Events

BusinessEvent is the abstract base for all domain-significant occurrences. Each event carries a timestamp, correlation ID (from MDC or generated), and optional user email. The event type derives automatically from the class name.

Events flow through Spring’s ApplicationEventPublisher to three independent sinks:

business event sinks

This fan-out design keeps event producers decoupled from consumers. Adding a new sink (e.g., webhook notifications) requires only a new @EventListener — no changes to existing code.

Persistence

Schema Ownership

Each product owns an independent Flyway history in a separate logical PostgreSQL database. Hibernate validates each product persistence unit against its own database and never modifies either schema (ddl-auto: validate). A Clarula migration cannot resolve an EER table, and the database roles are denied connection to the sibling database. Failure of either required Flyway chain or either Hibernate validation fails startup of the shared backend process.

Database runtime: one PostgreSQL 18 cluster with logical databases invoicex (EER), clarula, and metabase.

Schema Layout

The product databases have distinct schemas, histories, ownership roots, and foreign-key graphs:

schema layout

accounts is the central EER identity entity. Both login_tokens and invoice_edit_sessions reference it via foreign key with ON DELETE CASCADE. firms is the independent Clarula tenant root; all current Clarula DATEV state reaches one firm exclusively through Clarula-local foreign keys. There is no cross-database foreign key, join, view, foreign data wrapper, or shared identifier mapping between partners and firms.

The EER analytics schema is write-only by design. Application features never read from analytics.business_events — it serves observability and reporting only. An ArchUnit test (AnalyticsBoundaryArchTest) enforces this boundary.

Table Purpose

accounts

User identity: email, plan, trial dates, conversion counter

login_tokens

Passwordless magic link tokens with expiration and single-use tracking

invoice_edit_sessions

Original PDF, integrity-checked InvoiceDraft snapshot with relational schema version, validation and temporary capability state, plus immutable completion PDF/XML artifacts retained until session expiry

firms

Clarula-owned tenant identity, display name, and active lifecycle

firm_datev_connections

Encrypted firm-scoped DATEV authorization, verified source binding, scopes, expiry, and reconnect state

datev_firm_import_snapshots, datev_imported_mandates, datev_mandate_routing_identities, datev_mandate_demo_targets

Clarula-local current minimized read-only mandate import plus provenance and completeness evidence

datev_mandate_confirmations

Clarula-owned confirmation separate from imported DATEV values

analytics.business_events

Append-only event log with JSON payloads (max 64 KB each)

For column-level details, see the product-owned Flyway migrations — they are the single source of truth.

Database Roles and Provisioning

Compose starts an idempotent product-db-init job before the backend. The operational PostgreSQL superuser creates the eer and clarula login roles, creates the Clarula database, migrates ownership of existing EER relations, routines, enum/domain types, and schemas from the historical superuser to eer, and revokes public database connection privileges. Among application roles, only eer receives CONNECT on invoicex and only clarula receives CONNECT on clarula; the operational superuser retains explicit cluster access. The backend receives both credential sets because it remains one process, but each set is bound only inside its owning application context. The operational superuser remains available to provisioning, Metabase, and backup automation and is not configured as either product DataSource.

EER migrations live at eer/src/main/resources/db/migration and Clarula migrations at clarula/src/main/resources/db/clarula/migration. Each eager sibling context creates and runs only its local Flyway bean; the persistence-free root and shared-services bridge expose none. Deleting historical EER migrations is prohibited; the removed unshipped DATEV V121 never becomes part of the EER chain. The two flyway_schema_history tables and version sequences remain independent.

Optimistic Locking Retries

BaseEntity uses @Version to detect concurrent updates. Spring Framework 7 retry support is enabled through @EnableResilientMethods. Retry-safe transaction entry points use the composed @RetryableTransactional annotation when a concurrent update can be resolved by reading the latest state and repeating the operation. It combines Spring’s @Retryable with @Transactional(propagation = REQUIRES_NEW), limits retries to one for recognized Spring, JPA, or Hibernate optimistic-locking exceptions, and uses no retry delay. The retry advice surrounds the transaction advice, so every attempt gets a fresh transaction and persistence context. Retry must not be applied globally or around irreversible side effects such as sending email or calling an external API.

Entity Conventions

EER entities extend BaseEntity; Clarula entities extend the compile-time-independent ClarulaBaseEntity with the same UUID, version, and audit semantics. Domain-created entities normally use companion object factory methods:

@Entity
class LoginToken : BaseEntity() {
    lateinit var token: String
    // ...

    companion object {
        fun create(account: Account, token: String, expiresAt: Instant): LoginToken =
            LoginToken().apply {
                this.account = account
                this.token = token
                this.expiresAt = expiresAt
            }
    }
}

This pattern keeps entity construction logic centralized and supports JPA’s no-arg constructor requirement without exposing it to callers. Repository-level atomic inserts are reserved for database-enforced claims such as single-use token consumption with ON CONFLICT DO NOTHING.

Security

Authentication Flow

EER uses passwordless authentication via magic links. No passwords exist in the system.

auth flow

Customer API operations use /api/v1/auth/magic-link, /api/v1/auth/verify, /api/v1/auth/session, and /api/v1/auth/logout; the pre-existing operations retain /auth/ compatibility aliases. Magic-link emails use eer.product.origin, successful verification redirects to /portal, and token errors redirect to product-owned /auth/error/ routes. Backend-rendered customer login, portal, error, and plan routes remain available during migration.

Security Layers

Layer Implementation

Authentication

Customer JWTs and remember tokens are stored in host-only, HTTP-only, Secure, SameSite=Lax cookies; neither cookie has a Domain attribute. JwtCookieFilter decodes the short-lived JWT on each request and sets the Spring SecurityContext. If the JWT is missing or expired, the filter can use a valid customer remember-session cookie to mint a fresh JWT and rotate the remember token. Operator authentication uses separate host-only admin_session and admin_remember_session cookies on admin.clarula.de, both with HttpOnly, Secure, SameSite=Lax, and Path=/, validated globally by OperatorAuthFilter before product servlet dispatch (see Operator Authentication Boundary).

Token management

Firm-scoped DATEV OAuth access and single-use refresh tokens are encrypted at rest in the Clarula database, bound to the verified DATEV firm reference, and never logged or rendered. Connection replacement, refresh, import persistence, and disconnect are serialized with a pessimistic Clarula Firm row lock; unreadable, missing, rejected, or ambiguously consumed refresh tokens require reconnection. LoginToken entity: single-use, 15-minute expiry. Customer remember sessions: opaque high-entropy tokens in an HTTP-only, Secure, SameSite=Lax cookie; only SHA-256 token hashes are stored server-side in customer_remember_sessions, bound to an Account, revocable via revoked_at, and rotated on refresh. Admin remember sessions use separate opaque tokens in admin_remember_sessions, bind to the configured admin email, and re-check the current operator.admin.emails allow-list before minting a fresh admin JWT. Successful admin verification stores a hash of the magic-link token’s unique identifier on the new session so every encoding of the link can be claimed only once. The admin remember token remains stable and is reissued on refresh to renew its browser lifetime; this lets logout and re-authentication revoke the same server-side session after use from another client. Partner widget identity sessions use independent 256-bit opaque tokens in partner-origin localStorage; only SHA-256 hashes are stored in widget_identity_sessions, bound to one account email and one partner, with revoked_at, nullable expires_at, and last_used_at. Provisional sessions are additionally bound to the exact PendingRegistration and expire with DOI until confirmation activates them; active sessions have no default product expiry. Partner affiliation confirmation tokens are opaque, hash-only, single-use, and bounded to the current trial window. Invoice edit-session UUIDs remain URL capabilities with a 30-day lifetime; completed_at records successful use so diagnosis/editor access cannot be reopened while result downloads remain available. JWT auth tokens: 30-minute TTL. Expired JWT cookies are cleared automatically. Admin tokens carry an invoicex-admin audience and role=ADMIN claim; customer, remember, and widget identity sessions never include or grant admin authority.

Rate limiting

Two layers: Caddy reverse proxy with the caddy-ratelimit plugin for per-IP throttling before requests reach Spring Boot, plus application-level outbound rate limiting in infrastructure.ratelimit for providers such as Mistral and Mailgun. Edge rules place each canonical public API and compatibility alias in the same named budget: auth, registration, contact, free upload, and the standard widget. Widget auth, claim-with-session, and session-identity share one budget. Diagnosis editor entry, correction preview/submission, and the anonymous frontend operational-diagnostic endpoint have explicit budgets too. The same customer zones are imported on every public product and API host; the temporary widget origin imports them for its widget-only routes as well, preventing host- or alias-based bypass. The exact operator origin separately imports a dedicated POST /api/operator/v1/auth/magic-link budget.

Webhook security

Mailgun inbound webhooks validated via HMAC signature verification.

CORS

First-party contact, registration, and free-upload APIs accept configured einfache-eRechnung and Clarula origins on both canonical and legacy paths. Authenticated product APIs are same-origin. The standard widget API resolves the request origin against cached active (partnerId, origin) mappings. Preflight accepts an origin mapped to any active partner, while every actual widget request with Origin requires that origin to be mapped to its effective partner; absent Origin preserves non-browser behavior. The shared dynamic policy covers /api/v1/widget/ and /api/widget/ and permits Authorization and X-Partner-Id. It caches mappings for 60 seconds and retains Access-Control-Max-Age: 3600. CORS limits browser callers but is not an authentication boundary, so origin-to-partner validation is enforced before conversion, token issuance, delivery, or result access. The local profile remains permissive for development.

API authorization failures

Canonical /api/v1/** requests receive application/problem+json with HTTP 401 or 403; they never redirect to backend HTML. This applies to customer session, portal summary, plan overview, and every plan action. Diagnosis and correction operations are deliberately public capability endpoints: possession of the unguessable edit token grants access, while malformed, missing, expired, used, and unconfirmed states return typed problems. Compatibility protected paths on the product origin retain the portal login redirect during migration.

Sensitive response policy

All /api/operator/ responses plus canonical product API responses and token/account responses, including operator and partner-affiliation verification links, send Cache-Control: no-store, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer. Edge-generated rate-limit problems use no-store and no-referrer too. Product Nginx applies the same policies to release metadata, token client shells, and private fixed documents; it also applies no-store to /_nuxt/builds/latest.json. Content-addressed /_nuxt/ assets alone receive the one-year immutable policy. Problem instances contain no query strings, and edit-capability instances use route templates rather than token-bearing request paths. Controllers and application services log token-validation categories only; edit tokens, token prefixes, and token-bearing URLs are never logged or placed in MDC. Frontend Nginx access logs and the temporary widget-origin edge access log are disabled; future logging must skip token paths.

Frontend operational reports

POST /api/v1/operational/frontend-failures is anonymous and same-origin; it is not registered as a cross-origin API. The frontend omits credentials, uses a no-referrer fetch policy and keepalive, and sends only product, immutable release, bounded failure kind, status, route template, and the optional generated operation ID. The backend rejects unknown fields and invalid operation/template pairs. It never reads a concrete URL, token, query, body, exception, stack, filename, email address, or invoice value from a report. Customer JWT/remember-session authentication and identity MDC enrichment are disabled for this endpoint even if a nonconforming caller supplies a cookie.

Email unsubscribe

Trial reminder body links open product-origin /email/unsubscribe result pages. Their separate RFC 8058 List-Unsubscribe URL posts to /api/v1/email/unsubscribe, which remains backend-handled without JavaScript. Legacy GET result links and POST /email/unsubscribe remain active.

Link-scanner safety

Partner affiliation links under /partner-affiliation/confirm confirm directly on GET and then render a success, used, expired, or invalid result page. The token is opaque, stored hashed server-side, single-use, and bounded to the current trial window. This flow intentionally optimizes for low-friction manual approval and accepts the residual risk of automated link opening.

Container security

Non-root Docker user in production images.

DATEV Connection Boundary

A DATEV connection belongs to one active Clarula Firm; it never authenticates a Clarula customer or administrator and never references an EER Partner. The Clarula administrator only initiates assisted onboarding. A firm representative enters credentials and grants consent directly at DATEV.

Aspect Enforcement

Authorization attempt

Authorization Code with PKCE S256, high-entropy state and nonce, exact callback URI, and an admin-scoped server session. The callback revalidates the initiating admin, ten-minute attempt lifetime, state, nonce, issuer, signature, required timestamps, audience, authorized party, and provider subject. OpenTelemetry redacts callback code, state, token, error, error_description, error_uri, nonce, and code_challenge query values before trace export.

Operator mutations

Connect, import, confirm, and disconnect JSON operations require the authenticated operator cookies, exact https://admin.clarula.de Origin, and a signed CSRF header bound to the stable remember session and operator identity. Invalid or missing protection fails before business or provider calls.

Token storage

Access and rotating refresh tokens use authenticated encryption before PostgreSQL persistence. External adapter modes require environment-provided client credentials and encryption material. Raw credentials, codes, tokens, callback URLs, and provider bodies never enter UI, logs, or analytics.

Firm binding

org-info must resolve one unambiguous DATEV business partner. Its stable reference is stored with the connection and retained import snapshot, and every returned master client must carry the same business-partner ID. Reconnect cannot switch firm while retained state exists; an explicit future replacement flow is required. Adapter-mode changes are operationally isolated by recreating the complete Clarula database before restart.

Token lifecycle

Access tokens refresh on demand. A Clarula-database lock serializes every firm connection mutation and the single-use refresh-token exchange. Terminal or ambiguous refresh failure marks the connection reconnect-required. Reconnect can replace locally unreadable ciphertext for the same source; disconnect discards unreadable ciphertext with an audit warning. Access and refresh ciphertext are handled independently so every readable provider token is revoked before replacement or local deletion. A transaction rollback after token rotation triggers best-effort revocation of the newly issued pair.

Adapter boundary

DatevApi has one deterministic mock and one endpoint-configured HTTP adapter. Mock, sandbox, and production are mutually exclusive operating modes, not feature enablement. The mock authorization controller is absent outside mock mode.

Context and database isolation

/api/operator/v1/clarula/* is dispatched only to the Clarula sibling context. That context scans only com.clarula.clarula, inherits only the persistence-free shared-services bridge, and uses the clarula database role, which is refused connection to the EER database. The shared process and root security filter remain trusted boundaries, as documented in ADR-30 and ADR-31.

See ADR-29 and ADR-32.

Partner Widget Identity Boundary

A partner widget identity session is a bearer capability for one operation: send a widget conversion owned by the same partner to the fixed email of the bound account. It is deliberately separate from customer and admin authentication.

Aspect Enforcement

Issuance

POST /api/v1/widget/auth returns a new high-entropy token only after the email-delivery flow has been accepted. Token-bearing auth responses, read-only identity responses, and successful session-claim responses containing the fixed recipient email use Cache-Control: no-store. Existing accounts receive an active session. Unknown emails receive a provisional session bound to the exact DOI registration; it remains unusable for delivery until that registration creates the account, but GET /api/v1/widget/session-identity may return its fixed email while DOI is pending. /api/widget/** remains a compatibility alias.

Storage

The browser stores the raw token in partner-origin localStorage under a versioned key containing API origin and partner ID. PostgreSQL stores only SHA-256 token_hash; raw tokens are never logged or persisted server-side.

Scope

GET /api/v1/widget/session-identity and POST /api/v1/widget/claim-with-session require the Bearer token and X-Partner-Id; the claim endpoint additionally requires the conversion claim token. Identity lookup accepts active sessions and unexpired provisional sessions bound to an unconfirmed DOI registration. A browser origin must be explicitly mapped to the request partner. Delivery still requires an active account, enforces request partner == session partner == conversion partner, and always uses session.account.email as recipient. Caller-supplied email addresses are not accepted on either path.

Revocation and expiry

revoked_at invalidates a session. Nullable expires_at supports technical expiry; provisional sessions use the DOI TTL and clear it on activation. Unknown, malformed, revoked, expired, inactive-partner, and partner-mismatched tokens receive the same 401 widget_session_invalid response.

Capability limits

The token cannot establish a Spring SecurityContext, mint or refresh JWTs, open the portal, access admin routes, access correction sessions, or download result artifacts. Result delivery remains the standard success email.

Browser threat model

Any script executing on the partner origin can read localStorage. The server therefore treats CORS and the storage key as convenience boundaries only. Partner binding, fixed-recipient delivery, edge rate limiting, and uniform rejection are mandatory controls. The widget resolves and displays the backend-confirmed fixed recipient in every state so users can detect a wrong identity on shared devices, but keeps that email only in memory. The contextual “wechseln” or “nicht mehr merken” action clears only the current API-origin/partner token; definitive session rejection does the same automatically.

Operator Authentication Boundary

The operator API (/api/operator/**) uses a completely separate authentication pipeline from the customer portal. https://admin.clarula.de is the only operator origin; all product-origin /admin and /api/operator requests return 404 at Caddy.

Aspect Implementation

Operator identity

Allow-list in operator.admin.emails. No Account entity and no customer role lookup. Login tokens are signed JWTs whose unique identifier hashes are claimed once when a persistent EER-owned remember session is created. The session stores only token hashes and the operator email for refresh/revocation — see ADR-13.

Login entry point

The static /login document submits to POST /api/operator/v1/auth/magic-link; verification remains a backend-owned GET /api/operator/v1/auth/verify. The endpoint checks the allow-list, atomically claims the token identifier, establishes the session, and redirects to a token-free operator document. The exact submission path has its own operator-origin edge budget and always returns the same accepted response.

Session cookies

admin_session short-lived JWT cookie and admin_remember_session persistent opaque-token cookie are host-only for admin.clarula.de, with HttpOnly, Secure, SameSite=Lax, and Path=/. The JWT uses the separate invoicex-admin audience and role=ADMIN claim. The stable remember token is stored server-side only as a SHA-256 hash and is revoked on logout or re-authentication.

API and CSRF protection

Caddy forwards only /api/operator/v1/auth/, /eer/, and /clarula/** on the operator host. The root OperatorAuthFilter validates the short-lived JWT before servlet dispatch and can invoke the EER-owned remember-session bridge without exposing EER persistence to Clarula. A read-only session bootstrap returns a signed CSRF token bound to the stable remember session and operator identity. Every authenticated unsafe request requires that token and exact Origin: https://admin.clarula.de; pre-authentication magic-link submission is token-exempt but still requires the exact origin. Customer cookies grant no operator privileges.

Static boundary

Remaining declared operator documents and assets reach the unprivileged operator Nginx container. Caddy removes Cookie, Authorization, and Proxy-Authorization request headers and removes Set-Cookie responses on that hop. Nginx has no backend proxy capability or backend network access, disables access logging, and returns a real 404 for undeclared paths.

Response and indexing policy

Operator documents, APIs, auth responses, and callback results use no-store, no-referrer, noindex, nofollow, and restrictive CSP/frame/permissions policy. Only content-addressed assets use immutable caching. The operator app loads no product analytics and publishes no robots or sitemap documents.

Audit logging

Login, denial, logout, refresh/revocation, EER partner mutation, Clarula firm lifecycle, DATEV connection/import, and mandate confirmation events carry the operator identity, product, stable resource identifier, outcome, and correlation identifier without tokens or sensitive payloads.

Browser Product API Compatibility

Canonical Contract

All JSON and multipart operations called by product frontends or embeddable widgets have canonical paths below /api/v1/. The version identifies the first major browser contract, not an implementation release. Compatible changes within v1 are additive. A breaking change requires a new major path and a migration period in which both majors remain available.

The product-v1 contract includes authentication, customer session, portal summary, registration, contact, plan, trial-email subscription, free-upload, diagnosis, correction, frontend operational reporting, and standard-widget operations. It excludes backend-rendered HTML, admin routes, webhooks, actuator endpoints, and the runtime OpenAPI endpoints themselves. The diagnosis and correction APIs use the existing edit-session token as a capability. The einfache-eRechnung Nginx container serves only the two scoped static client shells for /diagnosis/{token} and /edit/{token}; Caddy keeps the API routes backend-owned and no customer-facing backend HTML route remains. Partner-affiliation confirmation remains a backend route until its owning UI flow migrates. Registration and magic-link confirmations remain backend-handled token operations, but their canonical v1 handlers redirect to product-owned result routes.

Canonical path Temporary compatibility alias

/api/v1/auth/magic-link, /api/v1/auth/verify, and /api/v1/auth/logout

Matching /auth/** REST operations

/api/v1/registration/register and /api/v1/registration/confirm

/registration/register and /registration/confirm

/api/v1/contact

/api/contact

/api/v1/free-upload/**

/api/free-upload/**

/api/v1/operational/frontend-failures

None. This anonymous, cookie-free endpoint is used only by generated product frontends.

/api/v1/diagnoses/{token} and its editor-entry and source-PDF operations

None. Legacy app-host diagnosis links redirect to the static product route.

/api/v1/corrections/{token} and its preview, submission, result, source-PDF, and result-download operations

None. Legacy app-host correction links redirect to the static product route.

/api/v1/auth/session and /api/v1/portal/summary

None. The product frontend owns /portal.

/api/v1/plans/**

/api/plans/** for compatibility catalog/action requests; plan HTML is product-owned.

/api/v1/email/unsubscribe and /api/v1/email/resubscribe

/email/unsubscribe and /email/resubscribe; static result documents own GET, while RFC 8058 POST remains backend-handled.

/api/v1/widget/**

/api/widget/**

The widget script URL is an embed contract, not an API alias. /widget/v1/embed.js remains stable while its internal requests use canonical API paths.

The pre-production DATEV spike operations under /api/v1/datev-widget/** were retired as an approved reset before implementation of the Clarula partner demo.

Contract Generation

Springdoc builds the product group from Spring MVC handlers marked as product API controllers and filters it to /api/v1/**. The Gradle generation task boots a test application with Testcontainers, requests the grouped document, sorts every object key, and writes a deterministic JSON document. It does not call a deployed or production backend.

cd applications/backend
./gradlew generateProductV1OpenApi      # build/openapi/product-v1.json
./gradlew updateProductV1OpenApi        # update openapi/product-v1.json
./gradlew checkProductV1OpenApi         # detect contract drift
./gradlew checkProductV1ApiCompatibility

checkProductV1ApiCompatibility uses OpenAPI Diff and fails on incompatible v1 changes. It accepts OPENAPI_BASELINE_FILE for an explicit baseline, otherwise compares with the merge-base selected by GITHUB_BASE_REF or the parent commit. The first commit that introduces the contract has no earlier baseline and therefore compares the generated document with itself.

A v1 change is incompatible when it removes a path, operation, accepted input, response, or schema value; narrows an existing type; or makes an existing optional request field mandatory. New optional fields, new response fields, and new operations are additive. Compatibility aliases remain outside the product-v1 document so they cannot become the source contract accidentally.

Generated TypeScript Client

applications/frontend/packages/api-client/ generates OpenAPI path and schema types from the committed contract and wraps them with openapi-fetch. Both Nuxt applications depend on this package, and workspace type-checking compiles it with each product. Generation and drift checks use only repository files:

cd applications/frontend
npm run generate:api-client
npm run check:api-client
npm run typecheck

Generated files are committed so product builds do not depend on a running backend. A backend contract change is complete only when the OpenAPI document and generated TypeScript source are updated together. The generated correction schema contains the complete current editor field graph, including repeatable line items, parties, delivery, references, allowances, charges, tax breakdowns, payment data, and attachment metadata. Frontend code must import these generated transport types instead of declaring parallel diagnosis or correction DTOs.

Failure Contract

Framework, security, and unexpected failures use application/problem+json with RFC 9457 fields. The documented Problem schema adds a stable code; Caddy emits the same representation with rate_limit_exceeded when it rejects a request at the edge. Expected domain outcomes that already use typed response variants, such as an invalid PDF, invalid unsubscribe token, or expired widget claim, retain their documented status-specific JSON bodies during the compatibility window.

Edit-capability operations distinguish malformed (400, edit_token_invalid), missing (404, edit_session_not_found), expired (410, edit_session_expired), completed (409, edit_session_used), and unconfirmed editor entry (409, editor_confirmation_required) states. Invalid correction submissions use 422 correction_validation_failed and extend the problem document with the effective semantic form and structured rule, field, ambiguity, and evidence information. Result downloads return 409 correction_not_completed until the persisted completion marker exists.

Unauthenticated and unauthorized requests below /api/v1/ return JSON 401 and 403 responses without redirecting to an HTML login page. Canonical API responses and legacy token handoffs set Cache-Control: no-store, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer so account data and URL tokens do not enter browser caches, content-sniffing contexts, or referrer headers. Problem instances use matched route templates for edit capabilities and therefore never echo the token. Legacy protected routes retain their redirect behavior until their consumers migrate.

The operational frontend-failure operation accepts only the generated FrontendFailureReportRequest schema. API reports must pair a registered route template with its stable operation ID; navigation reports omit the operation ID. Unknown fields, concrete capability paths, query strings, invalid statuses, and unregistered API pairs fail with 400 invalid_frontend_failure_report or the generic malformed-request problem before any operational event is emitted.

Error Handling

Strategy

EER combines two error handling approaches:

  • Fail-fast for unexpected conditions — throw exceptions. An NPE or missing configuration should crash immediately, not produce silent corruption.

  • Sealed result types for expected outcomes — EmailProcessingResult and ConversionResult model every possible pipeline outcome as an explicit type. The compiler enforces exhaustive handling.

Error Humanization

Technical error messages from third-party libraries are not user-facing. Two components translate them into plain German:

MustangErrorHumanizer

Converts Mustang Project exceptions (arithmetic mismatches, validation failures) into actionable German messages. For example, an ArithmeticException about "Payable total in XML is 3609.10, but calculated total is 134.29" becomes:
"Der Rechnungsbetrag (3.609,10 EUR) stimmt nicht mit der berechneten Summe (134,29 EUR) ueberein."

EN16931ErrorMessages

Maps 120+ Schematron BR-codes (BR-01 through BR-IP-10) to German translations. Uses plain language: "Die Rechnungsnummer fehlt" instead of "[BR-02]-An Invoice shall have an Invoice number." Falls back to a simplified English message when no German translation exists.

Browser API Failures

Canonical /api/v1/** framework, security, unexpected, and edge rate-limit failures use the RFC 9457 application/problem+json representation. The response contains type, title, status, detail, and instance; backend failures and Caddy’s 429 rate_limit_exceeded response also expose a stable code for UI decisions. Authentication and authorization failures return JSON 401 and 403 responses instead of redirecting to the backend login page. Expected domain outcomes with established typed payloads remain status-specific response variants during the compatibility window. For example, unsubscribe and resubscribe return unsubscribed, resubscribed, or invalid result states without turning an invalid link into an infrastructure failure. The product-v1 OpenAPI document includes the shared problem schema and failure responses for every operation. Canonical API responses use Cache-Control: no-store, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer. Problem instance values omit query strings; edit-capability instances additionally use the matched route template, such as /api/v1/corrections/{token}, instead of echoing the path token.

Error Communication

Error emails include actionable advice, not just status codes. Each error type maps to a specific email template:

Error Type User Communication

Validation errors (fixable)

Error list + diagnosis link with source-invoice-first guidance. The browser editor stays behind an explicit confirmation step.

Validation errors (structural)

Error list with specific field names and per-rule diagnosis content from eer/src/main/resources/diagnosis/{code}.html

Extraction failure

Explanation + request to check the original PDF

Technical failure

Apology + request to retry later

Unregistered sender

Silent rejection (no email sent, HTTP 406)

The static diagnosis and correction client shells use the same token-backed InvoiceEditSession through canonical JSON/PDF APIs; no customer-facing Thymeleaf transport remains. The v1 APIs return stable problem codes for malformed, missing, expired, completed, and unconfirmed capabilities. Preview responses always contain the effective semantic form plus structured validation feedback; invalid submissions return the same information as a 422 correction_validation_failed problem. A successful submission persists completedAt; editor and diagnosis operations then return edit_session_used, while result and PDF-download operations remain available until session cleanup. For email-origin validation failures, both /edit/{token} and /api/v1/corrections/{token} reject editor access until the user explicitly confirms that the visible PDF already contains the correct information.

Email Architecture

Dual Provider Pattern

The EmailSender interface abstracts away the email transport. AbstractEmailSender centralizes composition logic (subjects, templates, attachments, inline resources), and two provider-specific subclasses handle transport:

email architecture
  • Local development — SmtpEmailSender sends via SMTP to a local Mailpit instance for visual inspection.

  • Production — MailgunEmailSender sends via the Mailgun REST API.

Automated-Response Loop Prevention

Public support, invoice ingestion, and internal staff notifications use separate recipient addresses. The internal notification recipient is configured per environment, has no application autoresponder, and must not be routed to an invoice webhook. Dev and production additionally use distinct invoice inboxes.

All application-generated messages carry Auto-Submitted and X-Auto-Response-Suppress headers. Invoice ingestion acknowledges but does not answer messages marked as automated, messages from delivery-system accounts such as mailer-daemon, or messages from configured protected system mailboxes. The protected list includes both production and dev inboxes so one environment cannot start an automatic response chain with the other even when provider headers are lost. These application checks complement rather than replace exact-recipient Mailgun routes.

Email Threading

Response emails appear in the same thread as the user’s original email. EmailThreadingInfo carries the original Message-ID and Subject, which map to In-Reply-To and References headers on outgoing messages. Reply subjects follow the pattern Re: Original Subject — short suffix (e.g., "Re: Invoice #42 — E-Rechnung ist fertig").

Templates

All email content renders through Thymeleaf templates. GermanFormatters provides locale-specific number and date formatting for template expressions (German decimal separators, date formats).

For the partner-widget channel, the outbound email set now includes two additional privacy-sensitive messages:

  • the DOI confirmation email for a new widget user after they requested Kanzlei-Zuordnung, and

  • the partner affiliation request email sent only after DOI confirmation, and

  • the customer confirmation email sent after the Kanzlei approved the Zuordnung.

The partner email is intentionally minimal: it includes only full name or company name, email address, request context, and the one-time confirmation link. It never includes invoice files, extracted invoice data, conversion results, usage history, subscription details, or account links.

The follow-up customer email confirms the approved Kanzlei by name, states that no further action is required, and repeats the privacy boundary: only name or company name plus email address were shared.

Trial reminder emails deliberately carry two links for the same signed preference token:

  • The visible email link targets product-origin /email/unsubscribe, where the static Nuxt page calls the canonical JSON result operation. Its resubscribe action uses /api/v1/email/resubscribe.

  • The RFC 8058 List-Unsubscribe header targets product-origin /api/v1/email/unsubscribe. Caddy proxies that path to Spring, so mailbox providers can send the required one-click POST without JavaScript.

The backend also retains GET /email/unsubscribe, GET /email/resubscribe, and POST /email/unsubscribe for previously sent messages. Token-bearing responses are non-cacheable, suppress referrers, and never write raw tokens to logs or analytics events.

Observability

Three-Signal Architecture

The OpenTelemetry Java agent (2.25.0) auto-instruments the Spring Boot application. All three signal types — traces, metrics, and logs — export to New Relic EU.

Business Event Pipeline

Business events flow through Spring’s ApplicationEventPublisher to three independent sinks. Each sink runs in its own error boundary: one sink failing does not affect the others.

event pipeline
Sink Behavior

PersistBusinessEvents

Saves the event as a JSON payload to PostgreSQL. Uses Propagation.REQUIRES_NEW so persistence succeeds even if the caller’s transaction rolls back.

TrackBusinessEventsInOtel

Adds an event to the current OTel span with typed attributes (event type, correlation ID, user email).

EmitBusinessEventMetrics

Emits an invoice.events.count counter per event type. Subclasses can add histogram metrics (e.g., processing duration).

Context Propagation

Every business event carries a correlationId (from SLF4J MDC or auto-generated) and optionally a userEmail (also from MDC). This ensures trace correlation across synchronous processing, async event handling, and log output.

Operational Diagnostics and Product Analytics

Operational diagnostics and product analytics have separate purposes, data contracts, and consent behavior.

Signal Purpose Boundary

Frontend operational diagnostics

Detect broken static releases, client navigation, and product API calls.

Anonymous, allow-listed technical dimensions only. Reports go to OpenTelemetry and application logs. They never enter analytics.business_events and do not depend on marketing consent.

Product analytics

Measure visits, campaigns, and product interactions.

einfache-eRechnung retains its existing Plausible and consent-controlled Google Ads behavior on public pages. Auth, portal, email-token, diagnosis, and correction routes load no product analytics. Clarula loads no product analytics.

Business events

Record server-side domain outcomes such as invoice processing and trial lifecycle events.

Published by backend business capabilities through the existing event pipeline. A frontend failure is not a business event.

Frontend Release and Failure Diagnostics

Every generated product document exposes product and frontend-release meta elements. The same values are available at the fixed static path /.well-known/frontend-release.json:

{
  "product": "einfache-erechnung",
  "release": "4f7d9e29d6e14eb89f96a0a1f37dc2f4"
}

Support and deployment smoke checks can therefore identify a static artifact even when its client bundle cannot start. The edge must serve this mutable pointer and Nuxt’s /_nuxt/builds/latest.json with Cache-Control: no-store; content-addressed Nuxt assets keep their immutable cache policy.

Hydrated routes report navigation failures and failed generated-client operations to same-origin POST /api/v1/operational/frontend-failures. The request uses credentials: omit, fetch referrer policy no-referrer, and keepalive. The client drops unknown routes, deduplicates identical reports for 60 seconds, and sends at most 20 reports per document from a map capped at 50 keys. Transport failure is ignored so diagnostics cannot cause another product failure.

The payload contains only these fields:

Field Constraint

product

einfache-erechnung or clarula.

release

Immutable build identifier, 7 to 128 allow-listed characters; unknown is invalid.

failureKind

navigation, api_http, or api_network.

status

400 to 599 for an HTTP failure; 0 when navigation or network failure has no HTTP response.

routeTemplate

A declared page template or generated OpenAPI schema path. Capability values remain placeholders such as {token}.

operationId

Stable generated OpenAPI operation identifier for API failures; absent for navigation failures.

Client code never forwards a concrete URL, path parameter, query string, request or response body, exception, stack, filename, email address, or invoice content. The backend rejects unknown fields, unknown API operation/template pairs, concrete sensitive routes, invalid statuses, and oversized dimensions. It records one frontend.failure span event, one frontend.failures counter increment, and a minimized log entry. The product request sends no cookies. JwtCookieFilter and MdcLoggingFilter deliberately exclude the endpoint so a nonconforming caller cannot attach customer identity by supplying credentials.

Testing

Three-Tier Strategy

Tier Scope Tools Location

Unit

Pure logic, no framework

JUnit 5, MockK, AssertJ

core/src/test/, eer/src/test/, and clarula/src/test/

Integration

Spring context + database

Spring Boot Test, Testcontainers (PostgreSQL 18)

Product tests in eer/src/integrationTest/; composed-runtime tests in app/src/integrationTest/

E2E

Full website + app stack in browser

Playwright (Docker-based)

e2e/

Integration Test Infrastructure

EER integration and LLM fixtures start only the EER PostgreSQL 18 Testcontainer and product context, so product behavior can be tested without the composition root or Clarula. Composed-runtime fixtures in :app start separate worker-scoped EER and Clarula containers and bind different JDBC URLs. Each sibling context runs only its local Flyway chain, and either eager startup failure must fail the process.

MultiContextRuntimeBoundaryIntegrationTest starts the production composition on a random port and proves default EER and explicit Clarula dispatch, authenticated route exclusivity, the persistence-free root and bridge, sibling bean invisibility, separate migration histories, exactly one conventional local transaction manager per product, unqualified transactional rollback, product-local writes, scheduler/async ownership, health detail, eager failure, and both Hikari pools closing with the root process. DatevAdminIntegrationTest exercises real HTTP through the root security filter and Clarula sibling servlet, including firm lifecycle, DATEV callback session state, action tokens, encrypted token persistence, import, confirmation, firm scoping, concurrent refresh, and disconnect. Compile-time dependency verification and forbidden-source-import checks enforce app → core/eer/clarula and eer/clarula → core, with no product-to-product or product-to-:app imports.

Visual Regression Testing

Playwright screenshot tests run entirely in Docker for pixel-identical rendering across developer machines and CI. The DevDataSeeder creates deterministic EER data in the local profile so screenshots produce stable baselines. The Clarula DATEV visual fixture creates its independent firm through /admin/clarula/firms; it no longer discovers an EER partner row. PDF.js iframes are masked because they render non-deterministically.

Test Fixtures

EER test utilities live in eer/src/testFixtures; composition-specific two-database fixtures remain in app/src/integrationTest so product tests do not depend on the BootJar project.

Deployment Database Contract

deployment/tests/product-database-isolation-test.sh starts the same PostgreSQL provisioning job used by Compose, simulates historical EER-owned objects, and verifies that the eer role can migrate them after ownership transfer. It then proves both positive and negative access: each product role connects to its own logical database and PostgreSQL refuses both sibling-database connections. Compose model validation and runtime-environment rendering tests require the separate Clarula credential in every hosted environment.

Configuration

Profile Strategy

Profile Purpose

default

Root server/security/health defaults plus product-local persistence and provider defaults

local

Developer machine: permissive CORS, SMTP email via Mailpit, DevDataSeeder active, relaxed rate limits

production

Deployed application: restrictive CORS, Mailgun email, secure cookies, strict rate limits

Typed Configuration

Grouped configuration uses @ConfigurationProperties data classes with startup validation. A few isolated legacy URL and email values still use @Value; new grouped settings do not extend that pattern. The EER sibling scans EER property classes; the compile-time-independent Clarula sibling explicitly enables only ClarulaDataSourceProperties and DatevProperties. The root scans only composition configuration and imports product-neutral :core configuration for shared stateless services and operator JWT parsing. Root runtime-defaults.yml owns server, management, operator, and release settings. The composition imports it together with eer-defaults.yml and clarula-defaults.yml from the owning product modules; the EER test bootstrap uses the same import manifest without requiring the app or Clarula modules.

This pattern provides type safety, IDE autocompletion, and startup-time validation — a misconfigured property fails the application on boot, not at runtime.

Browser Origins

eer.product.origin is the product/customer origin used for magic links, DOI confirmations, portal registration links, plan links, and trial-email preference links. It must be an absolute HTTP(S) origin without a path, query, or fragment. INVOICEX_PRODUCT_ORIGIN configures it and falls back to the existing INVOICEX_WEBSITE_URL during deployment migration.

backend.origin and eer.base-url retain the stable INVOICEX_BASE_URL product-origin input for EER correction/diagnosis links, partner-affiliation confirmations, and the default widget embed URL. OPERATOR_ORIGIN configures operator links, cookies, JSON APIs, and mock authorization on the dedicated operator origin: exactly https://admin.clarula.de in production, https://admin.dev.clarula.de in Dev, and http://admin.localhost locally. Hosted rendering rejects a missing, cross-environment, or noncanonical operator origin. Product origins no longer forward /admin or /api/operator paths. Partner redirect origins retain their own configuration. The former app host is never emitted by current application configuration and remains only as bounded compatibility for already-installed widgets. CLARULA_PARTNER_DEMO_ALLOWED_ORIGIN and CLARULA_PARTNER_DEMO_REDIRECT_BASE_URL supply historical placeholder names required by immutable migration V115; migration V120 removes that retired demo partner again. They can be removed only after a tested B120-or-later Flyway baseline initializes new databases and every supported database and restorable backup has already passed V115. The product-local eer.cors.allowed-origins binding retains the deployed INVOICEX_CORS_ALLOWED_ORIGINS environment name; the internal prefix rename does not change the runtime contract.

Static Frontend Release Identity

Each Nuxt build resolves one immutable release identifier at build time. FRONTEND_RELEASE_ID is the preferred input and should be the immutable deployed artifact ID or full source revision. GIT_SHA remains a compatible fallback for build environments that already provide it. Configured values must identify an immutable artifact and use 7 to 128 ASCII letters, digits, dots, underscores, or hyphens; unknown, blank values, and unsafe characters fail the build. Deployment configuration must not supply a mutable branch or ref name.

When neither variable exists, the build derives source-<sha256> deterministically from the product app, shared frontend packages, generation scripts, and workspace manifests. This content digest keeps local and test generation reproducible and prevents an unidentified production artifact. The resolved value is compiled into every HTML document and /.well-known/frontend-release.json; it is not runtime configuration and cannot change after artifact generation.

The static metadata path and Nuxt’s /_nuxt/builds/latest.json are release pointers rather than immutable assets. Each product’s Nginx configuration returns Cache-Control: no-store for both while retaining long-lived immutable caching for content-addressed Nuxt assets.

Secrets Management

Environment variables inject secrets at deployment time. No secrets appear in configuration files or version control.

Key secrets: JWT_SIGNING_KEY, MISTRAL_API_KEY, MAILGUN_API_KEY, MAILGUN_WEBHOOK_SIGNING_KEY, EER_DB_PASSWORD, CLARULA_DB_PASSWORD, DATEV_CLIENT_SECRET, and DATEV_TOKEN_ENCRYPTION_PASSWORD.

Secret handling differs by environment today:

  • Local development uses the git-ignored application-local.yml for personal overrides such as MISTRAL_API_KEY.

  • Dev deployment loads secrets from 1Password at GitHub Actions runtime. The only long-lived bootstrap secret left in the GitHub dev environment is OP_SERVICE_ACCOUNT_TOKEN.

  • Prod deployment loads secrets from 1Password at GitHub Actions runtime. The only long-lived bootstrap secret left in the GitHub prod environment is OP_SERVICE_ACCOUNT_TOKEN.

Both hosted vaults provide distinct product-role passwords at eer-postgres/password and clarula-postgres/password; deployment renders them as EER_DB_PASSWORD/DATABASE_PASSWORD and CLARULA_DB_PASSWORD without exposing either to the PostgreSQL container’s long-running environment.

Product Database Configuration

The EER sibling binds spring.datasource. from the stable DATABASE_URL, DATABASE_USER, and DATABASE_PASSWORD variables and creates a Hikari pool named eer. Deployed configuration uses the non-superuser eer role and keeps the compatible invoicex logical database name. The Clarula sibling independently binds clarula.datasource. from CLARULA_DB_URL, CLARULA_DB_USER, and CLARULA_DB_PASSWORD and creates a Hikari pool named clarula. The production profile has no default values for either Clarula URL or credential, so a missing secret fails startup. The root and shared-services bridge bind neither product DataSource and expose no Flyway, entity manager, or transaction manager.

CLARULA_DB_MAXIMUM_POOL_SIZE and CLARULA_DB_MINIMUM_IDLE tune only the Clarula pool. The default maximum is 10 and the default minimum idle count is zero.

DATEV Adapter Configuration

clarula.datev.environment selects exactly one implementation mode: mock, sandbox, or production. This is not a feature flag; the admin capability is always available. mock is deterministic and requires no DATEV credentials. The two external modes share one HTTP adapter and select environment-specific OIDC and master-data:master-clients endpoints. They fail startup when client credentials are missing or when the non-default token-encryption password is shorter than 32 characters or its hexadecimal salt is shorter than 16 bytes. Hosted workflows select the mode with the environment-specific DATEV_ENVIRONMENT variable and resolve configured *_OP_REF field references from 1Password only for external modes.

DATEV_REDIRECT_URI must match the DATEV app registration exactly. Hosted deployment derives it from the validated OPERATOR_ORIGIN plus /api/operator/v1/clarula/datev/callback; a separately supplied conflicting value is rejected. Production therefore uses https://admin.clarula.de/api/operator/v1/clarula/datev/callback, and Dev sandbox uses https://admin.dev.clarula.de/api/operator/v1/clarula/datev/callback. Sandbox and production use separate client credentials and encryption material. The adapter mode is runtime configuration and is not persisted with connections or imports. Changing DATEV_ENVIRONMENT requires revoking real provider grants, stopping the backend, and recreating the complete Clarula database first; application code does not support or clean up mixed-mode state.

Admin Configuration

The AdminProperties class (operator.admin.emails) defines which email addresses may access the admin area. This is the single source of truth for admin identity — no database table, no role column on Account.

operator:
  admin:
    emails:
      - founder1@example.com
      - founder2@example.com

The stable INVOICEX_ADMIN_EMAILS environment name populates operator.admin.emails. Operator authentication and both products' operator controllers are always registered; the allow-list determines who can authenticate. Hosted deployment requires the environment-owned INVOICEX_ADMIN_EMAILS GitHub Environment variable, validates it as a nonempty comma-separated list, writes it only to the mode-0600 runtime environment file, and passes it explicitly to the backend. The renderer and smoke suite never print the addresses. Adding or removing an admin requires a config change and restart. This is intentional friction for a team of three (see ADR-13).

LLM Integration

Architecture

All productive PDF-to-ZUGFeRD callers depend on invoice.conversion.PdfToZugferdConversionService. Email processing, free website upload, and widget upload traverse the same target-neutral analysis pipeline before the ZUGFeRD adapter runs. ADR-18 defines the specialized extraction strategy; ADR-34 defines the semantic handoff.

The implemented stages are:

  1. Native source evidence — XbergPdfStructuredTextExtractor produces SourceEvidence for supported native-text PDFs: SHA-256 and byte size, complete page text, bounded analysis text, and truncation metadata. Scanned or image-only PDFs fail closed per ADR-17.

  2. Specialized three-stage LLM interpretation — ParallelInvoiceExtractor coordinates six Mistral Structured Outputs calls:

    1. DocumentHeaderExtractor — invoice number, document code, dates, currency, and references.

    2. PartiesExtractor — seller, buyer, and special parties with identity, address, tax, and contact values.

    3. LineItemsExtractor — semantic invoice lines, including billed quantity, unit, VAT-exclusive prices and amounts, VAT, adjustments, and context-dependent line inclusion.

    4. PaymentTermsExtractor — payment means, account details, references, mandate values, and settlement wording.

    5. TotalsAndTaxExtractor — one coherent semantic totals model and VAT breakdown, using semantic line items as context.

    6. NotesExtractor — source free text not represented by the five prior slices. Header, parties, line items, and payment terms run concurrently via async and awaitAll. Totals and tax then runs serially, followed by notes. A stage-1 failure cancels siblings and skips later stages.

  3. Evidence-bound semantic mapping — the extractor returns RawExtractedInvoice. ExtractedInvoiceObservationMapper maps it once to ObservedInvoice, creating stable semantic element IDs, application enums, section provenance, and value fingerprints. It does not reinterpret PDF layout or create alternative monetary values.

  4. Application draft and common validation — ObservedInvoice.toDraft() retains the semantic interpretation unchanged. Common validation checks required semantics, unresolved ambiguity, and deterministic monetary relationships. DocumentAnalysisResult distinguishes invoice candidates, non-invoices, unreadable documents, rejected sources, and processing failures.

  5. Target adapter — commonly valid semantics reach the ZUGFeRD EN16931 adapter. It performs target readiness, Mustang mapping, CII rendering, comparison with the semantic invoice values, and XSD/Schematron validation. PDF embedding starts only after these gates pass and only when the PDF still matches SourceEvidence.

The runtime sequence lives in Invoice Extraction Algorithm.

Native PDF Evidence

Xberg 1.0.0-rc.29 produces two no-OCR renderings per page. A plain-text rendering comes first so labels and address blocks omitted by layout detection remain visible; layout-aware Markdown follows so table row and column associations remain available. The application labels both as renderings of the same page, and prompts must not duplicate facts or invoice lines that occur in both. Xberg’s separate reading-order post-processing option stays disabled because it did not change Markdown output across the 34-PDF benchmark corpus. The RT-DETR and TATR layout models are revision- and checksum-pinned in both immutable backend base images, and XBERG_CACHE_DIR points Xberg to those preloaded files. CI runs a Coolblue extraction in the final backend image and reruns the native extraction regressions in the builder image, both without network access. This prevents request-time model downloads while preserving native-text-only, no-OCR behavior.

llm integration

Design Decisions

LLM-owned semantic interpretation

ADR-18 uses six focused extractors. Context-dependent decisions such as line recognition, implicit quantity, price kind, included rows, gross-to-net interpretation, and settlement meaning belong in prompts. Kotlin does not reproduce those decisions with layout heuristics.

Raw DTO as a mandatory boundary

RawExtractedInvoice prevents provider DTOs from becoming application or target state. Mapping to ObservedInvoice binds the semantic values to immutable evidence before workflows consume them.

One semantic value per concept

Line items return unitPrice, preDiscountUnitPrice, and lineTotalAmount; totals return BT-106 through BT-115 semantics directly. There are no parallel source, net, gross, or calculated variants. The original wording remains in SourceEvidence, not in duplicate invoice fields.

Prompt-side interpretation with format-neutral mapping

Prompts request normalized dates, country codes, decimals, recognized document and tax categories, and complete line and total semantics. The observation mapper translates extractor codes into application enums. ZUGFeRD-specific defaults are not introduced into the shared core.

PDF as immutable evidence

Complete page text and source identity remain immutable. A value occurrence somewhere in the PDF is insufficient for EXACT_FRAGMENT; exact provenance requires reliable field offsets from the extractor. Current semantic claims are document-scoped and INTERPRETED. User recognition corrections retain the original claim and add a source-bound, value-fingerprinted revision event.

No Kotlin unit-price inference

Deterministic code never creates an extraction-time unit price through lineAmount / (quantity ?: 1). Amount-only services, credits, explicit quantities, included rows, and VAT-inclusive prices require contextual prompt interpretation. If the meaning remains unresolved, the semantic field is null and validation fails closed.

No silent total reconciliation

The application does not replace contradictory invoice totals with sums calculated from lines. Deterministic code may check arithmetic and compare rendered target values with the semantic model. Contradictions route to diagnosis, source correction, or firm review.

Immutable analysis gates

Facts derivable from SourceEvidence, such as analysis-text truncation, are reconstructed on initial conversion, correction preview, submission, and completed artifact regeneration. A user edit cannot clear a source-analysis risk.

Typed non-invoice and infrastructure handling

An explicit extractor marker or semantically empty extraction can yield NonInvoice. Provider failures become InvoiceExtractionException with retryability. Artifact-generation failures remain technical failures; they are not routed to an editor that cannot repair them.

Provider-aware outbound rate limiting

All six calls share the Mistral budget through infrastructure.ratelimit.RateLimitedClient. The wrapper paces requests, queues callers instead of rejecting normal load, and performs the single reset-aware retry on HTTP 429 using Retry-After. Mistral and Mailgun have separate service budgets.

Provider-agnostic extractor interface

Spring AI ChatClient and Structured Outputs isolate extractor code from the configured model provider. A provider change still requires per-extractor schema and benchmark verification.

Target-owned Mustang use

Mustang is used only after common validation inside formats.zugferd. The adapter maps ValidatedInvoice, renders CII XML, compares rendered monetary values with the semantic model, and validates target conformance. XML parsers remain for target-output presentation and characterized output tests, not for correction state or a shared semantic handoff.

Mock extraction for development

A mock ParallelInvoiceExtractor returns a deterministic Reifen-style semantic invoice in local and visual-test profiles. Its line values and totals are internally coherent so readiness and rendered-value checks exercise the production boundary.

Invoice Semantic Interpretation and Validation

ADR-34 separates immutable source evidence, LLM-owned semantic interpretation, application-owned invoice state, deterministic validation, and target adaptation. The application does not keep parallel source, net, gross, or calculated variants of the same invoice field.

Active Boundary

The active sequence is:

  1. XbergPdfStructuredTextExtractor creates immutable SourceEvidence from the PDF.

  2. ParallelInvoiceExtractor returns one semantic RawExtractedInvoice assembled from six specialized Structured Outputs calls.

  3. ExtractedInvoiceObservationMapper maps the provider DTO and evidence to ObservedInvoice without reinterpreting layout semantics.

  4. ObservedInvoice.toDraft() creates revision zero with the same semantic values.

  5. Common validation checks required values, ambiguity, and deterministic monetary relationships.

  6. Target adapters apply target-specific readiness, mapping, rendering, and conformance checks without mutating the draft.

Architecture tests prohibit direct DTO-to-target mapping and keep provider, target, UI, and persistence types outside the invoice model.

Semantic Interpretation Ownership

The LLM extractors interpret the complete invoice context. This includes deciding which blocks are invoice lines, whether a price is VAT-exclusive or VAT-inclusive, the meaning of amount-only rows, implicit quantity and unit, credit-note signs, included transparency rows, VAT categories, settlement state, and document totals. The line extractor returns VAT-exclusive unitPrice, VAT-exclusive preDiscountUnitPrice, and lineTotalAmount as invoice semantics. The totals extractor returns one coherent set of EN 16931 totals and tax buckets.

The provider contract and application model contain one value per business concept:

  • InvoiceLine.quantity and unitCode represent the billed quantity and semantic unit.

  • InvoiceLinePrice.netUnitPrice represents BT-146.

  • InvoiceLinePrice.preDiscountNetUnitPrice and discountAmount represent BT-148 and BT-147.

  • InvoiceLine.netAmount represents BT-131.

  • InvoiceTotals represents BT-106 through BT-115 directly.

The raw PDF wording remains available only through SourceEvidence. Without reliable extractor-provided offsets, claims use document-scoped evidence and INTERPRETED provenance; substring matches never become EXACT_FRAGMENT evidence. Unknown or contradictory semantics remain null, UNCLASSIFIED, or explicit findings rather than parallel candidate values.

Deterministic Code Boundary

Kotlin code may:

  • map provider fields to application types;

  • assign stable UUIDs to repeated semantic elements;

  • calculate fingerprints and correction provenance;

  • verify exact relationships such as pre-discount price minus discount equals net unit price;

  • check totals and rendered target values;

  • map validated semantics to a target format.

Kotlin code must not:

  • decide table or layout meaning;

  • infer an extraction-time unit price from line amount and quantity;

  • keep separate VAT-inclusive and VAT-exclusive variants of one semantic price;

  • replace contradictory invoice totals with calculated totals;

  • insert target-specific defaults into the EER semantic model.

Gross-to-net conversion is therefore prompt-owned semantic interpretation. Deterministic validation may verify the resulting values but does not reconstruct a competing interpretation.

User Revisions

The correction form maps directly to InvoiceDraft, never through generated XML.

  • Nullable semantic facts remain nullable; rendering a form does not turn absence into zero or one.

  • Existing repeated elements retain opaque draft UUIDs. Added elements receive new UUIDs; removals are recorded as semantic field changes.

  • When a user changes a price basis and leaves the line amount untouched, the form mapper may recalculate that line amount from the explicit corrected inputs. This is deterministic correction arithmetic, not source interpretation.

  • Every changed semantic field receives one source-evidence-bound InvoiceChange with revision, timestamp, corrected-value fingerprint, and evidence reference.

  • Replaying an unchanged preview creates neither a revision nor a correction event.

Target Adaptation

ZUGFeRD-only requirements belong to formats.zugferd readiness and mapping. The adapter maps BT-148 and BT-147 as a price-level allowance, maps line and document adjustments, renders CII XML, compares rendered monetary values with the semantic model, and runs XSD/Schematron validation. A missing, contradictory, or unsupported target fact produces NotReady; it is never silently defaulted or discarded to make XML validate. Other output adapters must implement their own readiness from the same ValidatedInvoice rather than consuming ZUGFeRD XML.

EN16931 BT Field Coverage Inventory

This document records the current EER support state for every official EN16931 Business Term (BT) from the Factur-X / ZUGFeRD 2.5 EN16931 workbook. It is an IST-state reference, not a branch-local execution sheet.

Scope And Column Definitions

The table uses these columns:

  • BT: official EN16931 business term identifier from the workbook, including scheme-related subfields such as BT-29-1.

  • Business Term: workbook label.

  • Card.: official cardinality.

  • Upload Preview: field is surfaced in the current free-upload / widget preview response (InvoicePreviewFieldsMapper).

  • LLM Extraction: field is part of the structured parallel extraction contract (ExtractedInvoice plus JSON schemas).

  • Expert UI: field is surfaced somewhere in the expert correction UI. This includes intentionally read-only fields; see Note where that distinction matters.

  • UI Gap: current expert-UI coverage rating. No = current UI coverage is sufficient for the present product scope, Partial = only partly surfaced or truthfully handled, Yes = still missing.

  • Verification Report: field is rendered in the current verification report PDF/HTML.

  • Generated XML: field is emitted by the current XML generation paths. Derived means Mustang computes the value from other source fields instead of taking it from a dedicated BT input.

  • Note: caveats such as read-only behavior, first-item-only handling, draft-backed round trips, target-adapter limitations, or intentional exclusions.

Status values:

  • Yes: supported in the current stage.

  • Partial: only partly supported, ambiguous, defaulted, repeated-item-limited, or only supported in one path.

  • No: not supported in the current stage.

  • Derived: not sourced directly; computed from other supported fields.

Method sources:

  • Official BT list: docs/research/ZF25_EN/Schema/3_Factur-X_1.09_EN16931/Factur-X_1.09_EN16931.xlsx

  • LLM contract: eer/src/main/kotlin/*/ExtractedInvoice.kt and eer/src/main/resources/prompts/parallel/schemas/.schema.json

  • Application invoice model: eer/src/main/kotlin/**/invoice/conversion/model/InvoiceModel.kt

  • Extraction handoff: eer/src/main/kotlin/**/invoice/conversion/analysis/pdf/llm/ExtractedInvoiceObservationMapper.kt

  • ZUGFeRD target adapter: eer/src/main/kotlin/**/formats/zugferd/generation/ZugferdEn16931Adapter.kt

  • Draft correction mapper: eer/src/main/kotlin/**/invoice/editing/ui/form/InvoiceDraftFormMapper.kt

  • Edit UI: applications/frontend/apps/einfache-erechnung/app/components/correction/**

  • Verification report: InvoiceTemplateMapper.kt and templates/pdf/verification-report.html

Upload Preview stays intentionally narrow. It reflects only the current free-upload / widget preview surface, not the full product field model.

BT Business Term Card. Upload Preview LLM Extraction Expert UI UI Gap Verification Report Generated XML Note

BT-1

Invoice number

1..1

Yes

Yes

Yes

No

Yes

Yes

BT-2

Invoice issue date

1..1

Yes

Yes

Yes

No

Yes

Yes

BT-3

Invoice type code

1..1

No

Yes

Yes

No

No

Yes

BT-5

Invoice currency code

1..1

No

Yes

Yes

No

Partial

Yes

Verification report hardcodes currency to EUR.

BT-6

VAT accounting currency code

0..1

No

No

Yes

No

No

No

Expert UI now keeps this field visible as read-only. No current extractor or writer currently handles VAT accounting currency.

BT-7

Value added tax point date

0..1

No

No

Yes

No

No

No

Expert UI now keeps this field visible as read-only. No current extractor or writer currently handles VAT point dates.

BT-8

Value added tax point date code

0..1

No

No

Yes

No

No

No

Expert UI now keeps this field visible as read-only. No current extractor or writer currently handles VAT point date codes.

BT-9

Payment due date

0..1

No

Yes

Yes

No

Yes

Yes

BT-10

Buyer reference

0..1

No

Yes

Yes

No

No

Yes

BT-11

Project reference

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps BT-11 as the project ID and repeats it as the mandatory CII technical project name.

BT-12

Contract reference

0..1

No

Yes

Yes

No

No

Yes

Initial conversion and draft correction both preserve contract references.

BT-13

Purchase order reference

0..1

No

Partial

Yes

No

No

Partial

LLM schema keeps seller vs buyer order references, but exact BT-13 vs BT-14 classification depends on invoice labeling.; Current writers support purchase-order style references, but BT-13 vs BT-14 classification still depends on source labeling.

BT-14

Sales order reference

0..1

No

Partial

Yes

No

No

Yes

LLM schema keeps seller vs buyer order references, but exact BT-13 vs BT-14 classification still depends on invoice labeling.; Both XML writer paths now persist seller order references.

BT-15

Receiving advice reference

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter augments Mustang output with the EN 16931 CII receiving-advice reference and validates the result.

BT-16

Despatch advice reference

0..1

No

Yes

Yes

No

No

Yes

BT-17

Tender or lot reference

0..1

No

Yes

Yes

No

No

Yes

BT-18

Invoiced object identifier

0..1

No

Yes

Yes

No

No

Yes

BT-18-1

Scheme identifier

0..1

No

Yes

Yes

No

No

Yes

BT-19

Buyer accounting reference

0..1

No

No

Yes

No

No

No

Editor exposes buyer accounting reference, but Mustang 2.23.0 exposes no header-level buyer-accounting-reference write path, so current XML writers cannot persist it truthfully.

BT-20

Payment terms

0..1

No

Yes

Yes

No

Partial

Yes

Verification report hardcodes payment terms to a default sentence.; Edit-save now has explicit emitted-XML and parse-back verification for payment terms in the direct-debit settlement path.

BT-21

Invoice note subject code

0..1

No

Yes

Yes

No

Yes

Yes

Expert UI still surfaces only the first note subject code, but the verification report now renders every document note as its own repeated group.

BT-22

Invoice note

1..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve invoice notes. The verification report renders every document note as its own repeated group.

BT-23

Business process type

0..1

No

Yes

Yes

No

No

Yes

BT-24

Specification identifier

1..1

No

Yes

Yes

No

No

No

Expert UI surfaces this target-context field read-only when available. Current target writers still do not emit an editable value.

BT-25

Preceding Invoice reference

1..1

No

Yes

Yes

No

Yes

Yes

BT-26

Preceding Invoice issue date

0..1

No

Yes

Yes

No

Yes

Yes

BT-27

Seller name

1..1

No

Yes

Yes

No

Yes

Yes

BT-28

Seller trading name

0..1

No

Yes

Yes

No

No

Yes

BT-29

Seller identifier

0..n

No

Partial

Yes

No

Partial

Partial

LLM contract, expert UI, and verification report currently surface only one scheme-backed seller identifier. Additional seller identifiers are still outside the current contract and XML generation remains partial overall.

BT-29-1

Seller identifier identification scheme identifier

0..1

No

Partial

Yes

No

Partial

Partial

LLM contract, expert UI, and verification report currently surface only one scheme-backed seller identifier. Additional seller identifiers are still outside the current contract and XML generation remains partial overall.

BT-30

Seller legal registration identifier

0..1

No

Yes

Yes

No

No

Yes

BT-30-1

Scheme identifier

0..1

No

Yes

Yes

No

No

Yes

BT-31

Seller VAT identifier

0..1

Partial

Yes

Yes

No

Yes

Yes

Upload preview exposes one seller tax identifier field and does not distinguish VAT vs tax number.

BT-32

Seller tax registration identifier

0..1

Partial

Yes

Yes

No

Yes

Yes

Upload preview exposes one seller tax identifier field and does not distinguish VAT vs tax number.

BT-33

Seller additional legal information

0..1

No

Yes

Yes

No

No

Yes

BT-34

Seller electronic address

0..1

No

Yes

Yes

No

No

Yes

BT-34-1

Scheme identifier

1..1

No

Yes

Yes

No

No

Yes

BT-35

Seller address line 1

0..1

No

Yes

Yes

No

Yes

Yes

BT-36

Seller address line 2

0..1

No

Yes

Yes

No

No

Yes

BT-37

Seller city

0..1

No

Yes

Yes

No

Yes

Yes

BT-38

Seller post code

0..1

No

Yes

Yes

No

Yes

Yes

BT-39

Seller country subdivision

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter augments the seller postal address with CountrySubDivisionName and validates the resulting XML.

BT-40

Seller country code

1..1

No

Yes

Yes

No

Yes

Yes

BT-41

Seller contact point

0..1

No

Yes

Yes

No

No

Yes

BT-42

Seller contact telephone number

0..1

No

Yes

Yes

No

No

Yes

BT-43

Seller contact email address

0..1

No

Yes

Yes

No

Partial

Yes

Verification report renders the party email field, not a dedicated contact email field.

BT-44

Buyer name

1..1

Yes

Yes

Yes

No

Yes

Yes

BT-45

Buyer trading name

0..1

No

Yes

Yes

No

No

Yes

BT-46

Buyer identifier

0..1

No

Yes

Yes

No

No

Yes

BT-46-1

Scheme identifier

0..1

No

Yes

Yes

No

No

Yes

BT-47

Buyer legal registration identifier

0..1

No

Yes

Yes

No

No

Yes

BT-47-1

Scheme identifier

0..1

No

Yes

Yes

No

No

Yes

BT-48

Buyer VAT identifier

0..1

No

Yes

Yes

No

Yes

Yes

BT-49

Buyer electronic address

0..1

No

Yes

Yes

No

No

Yes

BT-49-1

Scheme identifier

1..1

No

Yes

Yes

No

No

Yes

BT-50

Buyer address line 1

0..1

No

Yes

Yes

No

Yes

Yes

BT-51

Buyer address line 2

0..1

No

Yes

Yes

No

No

Yes

BT-52

Buyer city

0..1

No

Yes

Yes

No

Yes

Yes

BT-53

Buyer post code

0..1

No

Yes

Yes

No

Yes

Yes

BT-54

Buyer country subdivision

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter augments the buyer postal address with CountrySubDivisionName and validates the resulting XML.

BT-55

Buyer country code

1..1

No

Yes

Yes

No

Yes

Yes

BT-56

Buyer contact point

0..1

No

Yes

Yes

No

No

Yes

BT-57

Buyer contact telephone number

0..1

No

Yes

Yes

No

No

Yes

BT-58

Buyer contact email address

0..1

No

Yes

Yes

No

Partial

Yes

Verification report renders the party email field, not a dedicated contact email field.

BT-59

Payee name

1..1

No

Yes

Yes

No

No

Yes

BT-60

Payee identifier

0..1

No

Yes

Yes

No

No

Yes

BT-60-1

Scheme identifier

0..1

No

Yes

Yes

No

No

Yes

BT-61

Payee legal registration identifier

0..1

No

Yes

Yes

No

No

Yes

Expert UI surfaces this field from the application draft. Draft round trips preserve the identifier before target generation.

BT-61-1

Scheme identifier

0..1

No

Yes

Yes

No

No

Partial

Expert UI surfaces this field from the application draft. The target adapter intentionally omits the scheme because Mustang-generated PayeeTradeParty/SpecifiedLegalOrganization/ID@schemeID currently triggers FX-SCH-A-000031 during EN 16931 validation.

BT-62

Seller tax representative name

1..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps the application-owned tax-representative group after Mustang rendering and validates the resulting XML.

BT-63

Seller tax representative VAT identifier

1..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps the VAT identifier in the application-owned tax-representative group.

BT-64

Tax representative address line 1

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps the tax-representative postal address.

BT-65

Tax representative address line 2

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps the tax-representative postal address.

BT-66

Tax representative city

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps the tax-representative postal address.

BT-67

Tax representative post code

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps the tax-representative postal address.

BT-68

Tax representative country subdivision

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps the tax-representative postal address.

BT-69

Tax representative country code

1..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter requires and maps the tax-representative country code.

BT-70

Deliver to party name

0..1

No

Yes

Yes

No

No

Yes

BT-71

Deliver to location identifier

0..1

No

Yes

Yes

No

No

Yes

BT-71-1

Scheme identifier

0..1

No

Yes

Yes

No

No

Yes

BT-72

Actual delivery date

0..1

No

Yes

Yes

No

No

Yes

If XML validation reports that no explicit delivery/service date signal survived as BT-72, invoice period, or line period, the current conversion path may fall back to the invoice issue date.

BT-73

Invoicing period start date

0..1

No

Yes

Yes

No

No

Yes

Initial conversion and draft correction both preserve invoice period start dates.

BT-74

Invoicing period end date

0..1

No

Yes

Yes

No

No

Yes

Initial conversion and draft correction both preserve invoice period end dates.

BT-75

Deliver to address line 1

0..1

No

Yes

Yes

No

No

Yes

BT-76

Deliver to address line 2

0..1

No

Yes

Yes

No

No

Yes

BT-77

Deliver to city

0..1

No

Yes

Yes

No

No

Yes

BT-78

Deliver to post code

0..1

No

Yes

Yes

No

No

Yes

BT-79

Deliver to country subdivision

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter augments the ship-to postal address with CountrySubDivisionName and validates the resulting XML.

BT-80

Deliver to country code

1..1

No

Yes

Yes

No

No

Yes

BT-81

Payment means type code

1..1

No

Yes

Yes

No

No

Yes

BT-82

Payment means text

0..1

No

Yes

Yes

No

No

Partial

The application draft preserves BT-82 absence without injecting default text and carries explicit values to the target adapter. Characterized parity-writer behavior keeps overall target coverage partial.

BT-83

Remittance information

0..1

No

Yes

Yes

No

No

Yes

Initial conversion and draft correction both preserve payment reference / remittance information.

BT-84

Payment account identifier

1..1

No

Yes

Yes

No

Yes

Yes

BT-85

Payment account name

0..1

No

Yes

Yes

No

Partial

Yes

Verification report uses seller name as account name instead of the dedicated payment account name field.

BT-86

Payment service provider identifier

0..1

No

Yes

Yes

No

Yes

Yes

BT-87

Payment card primary account number

1..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter emits only the visible PAN digits and fails closed if more than the PCI-safe first six plus last four digits are exposed.

BT-88

Payment card holder name

0..1

No

Yes

Yes

No

No

Yes

The ZUGFeRD adapter maps an explicit card-holder name together with BT-87.

BT-89

Mandate reference identifier

0..1

No

Yes

Yes

No

No

Yes

Edit-save now has explicit emitted-XML and parse-back verification for direct-debit mandate references.

BT-90

Bank assigned creditor identifier

0..1

No

Yes

Yes

No

No

Yes

Edit-save now has explicit emitted-XML and parse-back verification for direct-debit creditor references.

BT-91

Debited account identifier

0..1

No

Yes

Yes

No

No

Yes

BT-92

Document level allowance amount

1..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document allowance amounts. The verification report renders each document allowance as its own repeated group.

BT-93

Document level allowance base amount

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-93; the ZUGFeRD adapter maps it to BasisAmount.

BT-94

Document level allowance percentage

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-94; the ZUGFeRD adapter maps it to CalculationPercent.

BT-95

Document level allowance VAT category code

1..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document allowance VAT categories. The verification report renders each document allowance as its own repeated group.

BT-96

Document level allowance VAT rate

0..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document allowance VAT rates. The verification report renders each document allowance as its own repeated group.

BT-97

Document level allowance reason

0..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document allowance reasons. The verification report renders each document allowance as its own repeated group.

BT-98

Document level allowance reason code

0..1

No

Yes

Yes

No

Yes

Yes

The semantic draft preserves explicit reason codes and the ZUGFeRD adapter maps them through Mustang.

BT-99

Document level charge amount

1..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document charge amounts. The verification report renders each document charge as its own repeated group.

BT-100

Document level charge base amount

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-100; the ZUGFeRD adapter maps it to BasisAmount.

BT-101

Document level charge percentage

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-101; the ZUGFeRD adapter maps it to CalculationPercent.

BT-102

Document level charge VAT category code

1..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document charge VAT categories. The verification report renders each document charge as its own repeated group.

BT-103

Document level charge VAT rate

0..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document charge VAT rates. The verification report renders each document charge as its own repeated group.

BT-104

Document level charge reason

0..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve document charge reasons. The verification report renders each document charge as its own repeated group.

BT-105

Document level charge reason code

0..1

No

Yes

Yes

No

Yes

Yes

The semantic draft preserves explicit reason codes and the ZUGFeRD adapter maps them through Mustang.

BT-106

Sum of Invoice line net amount

1..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic BT-106. Mustang renders the value from line semantics, and readiness requires the rendered result to match the application model.

BT-107

Sum of allowances on document level

0..1

No

Yes

Yes

No

Partial

Derived

The LLM returns semantic BT-107. Mustang renders it from document allowances, and readiness compares the result with the application model.

BT-108

Sum of charges on document level

0..1

No

Yes

Yes

No

Partial

Derived

The LLM returns semantic BT-108. Mustang renders it from document charges, and readiness compares the result with the application model.

BT-109

Invoice total amount without VAT

1..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic BT-109. Mustang renders it from lines and document adjustments, and readiness compares the result with the application model.

BT-110

Invoice total VAT amount

0..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic BT-110 and VAT buckets. Mustang renders the target value, and readiness compares it with the application model.

BT-111

Invoice total VAT amount in accounting currency

0..1

No

No

Yes

No

No

No

No current writer populates VAT totals in accounting currency.

BT-112

Invoice total amount with VAT

1..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic BT-112. Mustang renders it from tax basis and VAT, and readiness compares the result with the application model.

BT-113

Paid amount

0..1

No

Yes

Yes

No

Partial

Yes

The LLM interprets settlement state and returns semantic BT-113; initial conversion and draft correction preserve it.

BT-114

Rounding amount

0..1

No

Yes

Yes

No

No

Yes

The LLM returns explicit semantic rounding and the target adapter maps it without deriving a replacement.

BT-115

Amount due for payment

1..1

Partial

Yes

Yes

No

Yes

Derived

The LLM returns semantic BT-115. Mustang renders the payable amount from the semantic totals, and readiness compares the result with the application model.

BT-116

VAT category taxable amount

1..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic VAT buckets. Mustang renders the target bucket, and readiness compares it with the application model.

BT-117

VAT category tax amount

1..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic VAT buckets. Mustang renders the target bucket, and readiness compares it with the application model.

BT-118

VAT category code

1..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic VAT categories; the target adapter maps them and verifies the rendered buckets.

BT-119

VAT category rate

0..1

No

Yes

Yes

No

Yes

Derived

The LLM returns semantic VAT rates; the target adapter maps them and verifies the rendered buckets.

BT-120

VAT exemption reason text

0..1

No

Yes

Yes

No

Yes

Yes

Initial XML writer now mirrors tax-breakdown exemption reasons onto matching exempt line items so Mustang emits BT-120 in generated XML. The verification report renders each tax breakdown as its own repeated group.

BT-121

VAT exemption reason code

0..1

No

Yes

Yes

No

Yes

No

Expert UI now keeps this field visible as read-only. Mustang 2.23.0 does not emit header-level ApplicableTradeTax/ExemptionReasonCode for BG-23, so truthful write-back remains blocked. The verification report renders each tax breakdown as its own repeated group.

BT-122

Supporting document reference

1..1

No

Yes

Yes

No

Yes

Yes

The ZUGFeRD adapter maps supporting-document metadata as CII type 916 references. The verification report renders each document as its own repeated group.

BT-123

Supporting document description

0..1

No

Yes

Yes

No

Yes

Yes

The ZUGFeRD adapter maps the supporting-document description. The verification report renders each document as its own repeated group.

BT-124

External document location

0..1

No

Yes

Yes

No

Yes

Yes

The ZUGFeRD adapter maps an explicit external location as URIID. The verification report renders each document as its own repeated group.

BT-125

Attached document

0..1

No

No

Yes

No

Yes

No

Expert UI surfaces attachment-backed entries read-only from the application draft. Attachment payload editing and target generation remain intentionally out of scope. The verification report renders each attachment as its own repeated group.

BT-125-1

Attached document Mime code

1..1

No

No

Yes

No

Yes

No

Expert UI surfaces attachment MIME types read-only from the application draft. Attachment payload editing and target generation remain intentionally out of scope. The verification report renders each attachment as its own repeated group.

BT-125-2

Attached document Filename

1..1

No

No

Yes

No

Yes

No

Expert UI surfaces attachment filenames read-only from the application draft. Attachment payload editing and target generation remain intentionally out of scope. The verification report renders each attachment as its own repeated group.

BT-126

Invoice line identifier

1..1

No

Yes

Yes

No

Yes

Yes

Initial conversion and draft correction both preserve explicit line IDs through stable application element identifiers.

BT-127

Invoice line note

0..1

No

Yes

Yes

No

No

Yes

Edit-save now roundtrips the first line-level IncludedNote as BT-127; note subject codes remain a separate gap.

BT-128

Invoice line object identifier

0..1

No

Yes

Yes

No

No

Yes

BT-128-1

Invoice line object identifier scheme identifier

0..1

No

Yes

Yes

No

No

Yes

BT-129

Invoiced quantity

1..1

No

Yes

Yes

No

Yes

Yes

BT-130

Invoiced quantity unit of measure

1..1

No

Yes

Yes

No

Yes

Yes

BT-131

Invoice line net amount

1..1

No

Yes

Yes

No

Partial

Derived

The LLM returns semantic BT-131 directly. Mustang renders it from the line semantics, and readiness requires the rendered amount to match the application model.

BT-132

Referenced purchase order line reference

0..1

No

Yes

Yes

No

No

Yes

BT-133

Invoice line Buyer accounting reference

0..1

No

Yes

Yes

No

No

No

Expert UI now keeps this field visible as read-only. Mustang 2.23.0 exposes no public setter, so current writer paths still cannot emit it truthfully.

BT-134

Invoice line period start date

0..1

No

Yes

Yes

No

No

Yes

BT-135

Invoice line period end date

0..1

No

Yes

Yes

No

No

Yes

BT-136

Invoice line allowance amount

1..1

No

Yes

Yes

No

Yes

Yes

The verification report renders each line-level allowance as its own nested repeated group.

BT-137

Invoice line allowance base amount

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-137; the ZUGFeRD adapter maps it to BasisAmount.

BT-138

Invoice line allowance percentage

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-138; the ZUGFeRD adapter maps it to CalculationPercent.

BT-139

Invoice line allowance reason

0..1

No

Yes

Yes

No

Yes

Yes

The verification report renders each line-level allowance as its own nested repeated group.

BT-140

Invoice line allowance reason code

0..1

No

Yes

Yes

No

Yes

Yes

Expert UI surfaces this field and draft-backed target generation preserves it. The verification report renders each line-level allowance as its own nested repeated group.

BT-141

Invoice line charge amount

1..1

No

Yes

Yes

No

Yes

Yes

The verification report renders each line-level charge as its own nested repeated group.

BT-142

Invoice line charge base amount

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-142; the ZUGFeRD adapter maps it to BasisAmount.

BT-143

Invoice line charge percentage

0..1

No

Yes

Yes

No

Yes

Yes

The semantic LLM contract and draft preserve BT-143; the ZUGFeRD adapter maps it to CalculationPercent.

BT-144

Invoice line charge reason

0..1

No

Yes

Yes

No

Yes

Yes

The verification report renders each line-level charge as its own nested repeated group.

BT-145

Invoice line charge reason code

0..1

No

Yes

Yes

No

Yes

Yes

Expert UI surfaces this field and draft-backed target generation preserves it. The verification report renders each line-level charge as its own nested repeated group.

BT-146

Item net price

1..1

No

Yes

Yes

No

Yes

Yes

BT-147

Item price discount

0..1

No

Yes

Yes

No

No

Yes

The LLM returns semantic BT-147. The adapter maps it as the price-level allowance paired with BT-148 and validates the net-price relationship.

BT-147-01

Indicator for price allowance

0..1

No

No

Yes

No

No

Derived

The target adapter emits the price-allowance indicator when semantic BT-147 and BT-148 are present.

BT-147-02

Indicator for price allowance, value

1..1

No

No

Yes

No

No

Derived

The target adapter derives the indicator value from the semantic price discount.

BT-148

Item gross price

0..1

No

Yes

Yes

No

No

Yes

BT-148 is the VAT-exclusive price before discount. The adapter emits GrossPriceProductTradePrice together with BT-147.

BT-149

Item price base quantity

0..1

No

Yes

Yes

No

No

Yes

The LLM and draft carry semantic price-base quantity; the adapter maps it through Mustang BasisQuantity.

BT-149-1

Item price base quantity

0..1

No

Yes

Yes

No

No

Yes

The semantic LLM contract captures price-base quantity and the current adapter emits it.

BT-150

Item price base quantity unit of measure code

0..1

No

Yes

Yes

No

No

Partial

The adapter emits the price-base unit when it equals BT-130; a distinct BT-150 unit fails readiness because Mustang exposes no separate writer API.

BT-150-1

Item price base quantity unit of measure code

0..1

No

Yes

Yes

No

No

Partial

The semantic LLM contract captures a dedicated price-base unit. The current adapter supports it only when it equals BT-130 and otherwise fails closed.

BT-151

Invoiced item VAT category code

1..1

No

Yes

Yes

No

No

Yes

Extraction and draft correction preserve explicit line-level VAT categories, including non-rate-derived categories such as L; the target adapter maps the draft directly without a CII parse-back round trip.

BT-152

Invoiced item VAT rate

0..1

No

Yes

Yes

No

Yes

Yes

BT-153

Item name

1..1

No

Yes

Yes

No

Yes

Yes

BT-154

Item description

0..1

No

Yes

Yes

No

Yes

Yes

BT-155

Item Seller’s identifier

0..1

No

Yes

Yes

No

No

Partial

The characterized writer emits seller item identifiers without a forbidden schemeID; active target-adapter support remains partial.

BT-156

Item Buyer’s identifier

0..1

No

Yes

Yes

No

No

Partial

The characterized writer emits buyer item identifiers without a forbidden schemeID; active target-adapter support remains partial.

BT-157

Item standard identifier

0..1

No

Yes

Yes

No

No

Yes

BT-157-1

Scheme identifier

1..1

No

Yes

Yes

No

No

Yes

BT-158

Item classification identifier

0..n

No

Partial

Yes

Partial

Partial

Partial

LLM contract, expert UI, and verification report currently support only one classification entry; additional classifications are still not surfaced truthfully.

BT-158-1

Scheme identifier

1..1

No

Partial

Yes

Partial

Partial

Partial

LLM contract, expert UI, and verification report currently support only one classification entry; additional classifications are still not surfaced truthfully.

BT-158-2

Scheme version identifer

0..1

No

Partial

Yes

Partial

Partial

Partial

LLM contract, expert UI, and verification report currently support only one classification entry; additional classifications are still not surfaced truthfully.

BT-159

Item country of origin

0..1

No

Yes

Yes

No

No

Yes

BT-160

Item attribute name

1..1

No

Yes

Yes

No

Yes

Yes

Expert UI surfaces item attributes read-only and preserves them through draft correction. The verification report renders each item attribute as its own nested repeated group.

BT-161

Item attribute value

1..1

No

Yes

Yes

No

Yes

Yes

Expert UI surfaces item attributes read-only and preserves them through draft correction. The verification report renders each item attribute as its own nested repeated group.

BT-162

Seller address line 3

0..1

No

Yes

Yes

No

No

Yes

BT-163

Buyer address line 3

0..1

No

Yes

Yes

No

No

Yes

BT-164

Tax representative address line 3

0..1

No

Yes

Yes

No

No

No

Expert UI surfaces this application-draft field read-only, but current writer paths still cannot emit it truthfully.

BT-165

Deliver to address line 3

0..1

No

Yes

Yes

No

No

Yes

Reading Guide

  • Expert UI answers only whether a field is surfaced somewhere in the expert editor at all. Use Note to distinguish editable fields from intentionally read-only ones.

  • Large clusters of Expert UI = Yes plus UI Gap = No plus Generated XML = No/Partial mark the main gap between what the editor can show and what the current write-back model can persist truthfully.

  • Large clusters of UI Gap = Yes/Partial mark fields where the expert UI is still missing or only partly exposed even if downstream mapper support may exist.

  • Large clusters of LLM Extraction = Yes plus Generated XML = Partial mark information that the initial conversion pipeline can extract, but at least one XML path still drops or weakens.

  • Derived in Generated XML is expected for many totals and tax-breakdown rows because Mustang computes them from line items and document-level adjustments.

Analytics Reports

Reports as Code

All analytics report definitions live in the repository as SQL views, managed via Flyway migrations. Metabase visualizes these views but owns no query logic. This makes reports version-controlled, code-reviewable, and visible to AI agents. See ADR-9: Analytics Reports as Code via SQL Views for the full decision rationale.

analytics reports

Naming Convention

Prefix Purpose Example

report_

User-facing report consumed by a Metabase question

analytics.report_conversion_funnel

metric_

Reusable building block consumed by other views

analytics.metric_daily_active_accounts

Documentation Standard

Every view must include a SQL comment block and a COMMENT ON VIEW statement. The comment block sits at the top of the migration file. COMMENT ON VIEW makes the description visible in Metabase’s data reference.

-- Business Question: How many invoices reach each processing stage per day?
-- Source Events:    InvoiceReceivedEvent, InvoiceProcessedEvent
-- Grain:            One row per event type per day
-- Caveats:          Counts exclude test accounts (email LIKE '%@test.%')

CREATE VIEW analytics.report_daily_processing AS
SELECT ...;

COMMENT ON VIEW analytics.report_daily_processing IS
  'Daily invoice processing counts by event type. Excludes test accounts.';

Read-Side Boundary

AnalyticsBoundaryArchTest enforces that application code never reads from analytics.*. Views are exclusively for Metabase consumption. The write path is unchanged: the business.tracking package writes events via PersistBusinessEvents.

This separation means schema changes to business events force a view migration in the same PR — broken reports surface at build time, not when someone opens a dashboard.

InvoiceProcessedEvent is emitted centrally inside PdfToZugferdConversionService.convert(…​) once the shared conversion pipeline returns an accepted outcome (success or validation_failure). The event includes the submission origin (email, website, or widget) and widget partner metadata when present, so cross-channel invoice-volume reports read one event type instead of combining controller-specific upload events. A schema-upgrade reconstruction uses the separate internal reanalysis origin so its additional paid, nondeterministic pipeline run is visible without being misattributed as a new channel submission.

InvoiceProcessingFailedEvent complements that success-path event for conversions that do not reach an accepted outcome. PdfToZugferdConversionService.convert(…​) emits it for shared failure reasons such as invalid_pdf, not_an_invoice, and technical_error, again with the applicable submission or reanalysis origin and optional widget partner metadata. Timeout handling stays at the entry-point boundary because the conversion service does not observe wrapper-level timeout exceptions. This lets upload monitoring read one success event type plus one failure event type across all channels and distinguish internal reanalysis.

For widget uploads, the accepted upload event remains anonymous at first. The event therefore carries a stable conversionId, not a guessed user identity. That ID is the existing Conversion.id, created before the widget upload enters PdfToZugferdConversionService.convert(…​) and passed through the tracking context. Later widget events reuse that same conversionId once an account is known:

  • WidgetClaimSucceededEvent identifies which account and email claimed the upload and marks that the standard result email was sent.

  • WidgetClaimFailedEvent captures claim or result-email delivery failures such as expired, missing, or already-claimed conversions.

This gives analytics two complementary views:

  • funnel and partner activity from the anonymous upload to result-email delivery

  • attribution reports that join the later known user back to the original upload via conversionId

Business events in the diagnosis and validation-email flow stay intentionally sparse. They may include rule-code aggregates and counters, but they must not persist recovery tokens, full URLs, raw validator messages, invoice content, invoice numbers, party names, tax IDs, addresses, or bank data inside analytics.business_events.

Adding a New Report

  1. Create a Flyway migration in applications/backend/eer/src/main/resources/db/migration/. Name the view analytics.report_<name> (or analytics.metric_<name> for a building block).

  2. Add a SQL comment block and COMMENT ON VIEW to the migration (see Documentation Standard).

  3. Point a Metabase question to the view: SELECT * FROM analytics.report_<name>. Metabase owns only the visualization layer (chart type, axes, filters).

  4. Never write SQL directly in Metabase. If a report needs a change, update the view in a new Flyway migration and review it in a pull request.

Architecture Decisions

This chapter documents the significant architecture decisions for the Clarula monorepo and its products. Each decision shaped the system’s structure, technology choices, or operational approach in ways that a new team member would question without context.

Decisions are recorded as lightweight ADRs (Architecture Decision Records). Product-level decisions with business rationale live in separate Product Decision Records (PDRs).

Decision Overview

ID Decision Status Date

ADR-1

Hybrid ZUGFeRD Generation (XML + PDF Attachment)

Implemented

2025-Q4

ADR-2

OCR + LLM Extraction over Rule-Based Mapping

Superseded by ADR-18

2025-Q4

ADR-3

Migration from OpenAI to Mistral AI

Implemented

2026-02-25

ADR-4

Email-Based Interface as Primary Channel

Active

2025-Q3

ADR-5

Monolith-First Architecture

Partially superseded by ADR-30 and ADR-31

2025-Q3

ADR-6

Package-by-Feature Organization

Active; refined by ADR-30 and ADR-34

2025-Q3

ADR-7

Time-Based Monetization over Volume-Based

Implemented

2026-03-15

ADR-8

LLM Returns JSON, Code Renders XML and Computes Totals

Superseded by ADR-18

2026-04-18

ADR-9

Analytics Reports as Code via SQL Views

Accepted

2026-04-18

ADR-10

Cross-Domain Redirect via Server-Side Partner Registry

Accepted

2026-04-18

ADR-11

Embeddable Widget as Vanilla JS Web Component

Accepted

2026-04-18

ADR-12

Anonymous Conversion Persistence with TTL

Accepted

2026-04-18

ADR-13

Admin Identity via Configuration, Not as a Customer Role

Partially superseded by ADR-33

2026-04-18

ADR-14

Dev-First Deployment Flow with Manual Production Promotion

Accepted

2026-04-20

ADR-15

Shared Remote Terraform State via Hetzner Object Storage

Accepted

2026-04-24

ADR-16

Format-Neutral Invoice Conversion Pipeline

Superseded by ADR-34

2026-05-01

ADR-17

Native Document Interpreters with Xberg, PDFBox, and Apache POI

Accepted; refined by ADR-34

2026-05-12

ADR-18

LLM-Only Semantic Extraction with Specialized Parallel Calls

Partially superseded by ADR-22 and ADR-34

2026-05-16

ADR-19

Per-Environment Terraform State Bucket for Production

Accepted

2026-05-21

ADR-20

Defer GitHub Merge Queue until Required CI is Enforced

Accepted

2026-05-22

ADR-21

Partner Affiliation Confirmation via One-Time Email Token

Accepted

2026-05-24

ADR-22

Diagnosis-First Validation Failure Routing

Partially superseded by ADR-34

2026-05-26

ADR-23

Widget Result Delivery by Standard Success Email

Amended

2026-07-10

ADR-24

Clarula Monorepo Structure and Neutral Backend Naming

Partially superseded by ADR-25, ADR-26, and ADR-30

2026-07-09

ADR-25

Static Nuxt Product Frontends

Partially superseded by ADR-26 and ADR-33

2026-07-12

ADR-26

Static Frontend OCI Images behind Shared Edge Caddy

Partially superseded by ADR-27 and ADR-28

2026-07-13

ADR-27

Promotion-Only Production Delivery

Partially superseded by ADR-28

2026-07-13

ADR-28

Certified Composite Runtime Snapshot Promotion

Partially superseded by ADR-33

2026-07-13

ADR-29

Partner-Scoped DATEV Connection and Mandate Import Port

Partially superseded by ADR-32 and ADR-33

2026-07-14

ADR-30

One BootJar with Product-Isolated Spring Child Contexts

Partially superseded by ADR-33

2026-07-15

ADR-31

Separate Logical PostgreSQL Databases and Roles

Accepted

2026-07-15

ADR-32

Clarula Firm as DATEV and Tenant Anchor

Partially superseded by ADR-33

2026-07-15

ADR-33

Dedicated Static Operator Frontend on admin.clarula.de

Accepted

2026-07-16

ADR-34

EER-Owned Format-Neutral Invoice Model as Conversion Core

Accepted

2026-07-16

ADR-1: Hybrid ZUGFeRD Generation (XML + PDF Attachment)

Status

Implemented (2025-Q4). Later extended with optional Ghostscript-based PDF/A-3 conversion.

Context

ZUGFeRD 2.5 e-invoices embed structured XML inside a PDF/A-3 document. Mustang Project — the only actively maintained open-source ZUGFeRD library on the JVM — provides ZUGFeRDExporterFromA3 to handle this. However, Mustang’s PDF/A-3 conversion re-renders the entire PDF, which destroys font ToUnicode mappings in many real-world invoices. German umlauts (ä, ö, ü) become placeholder characters in the output.

This is not a niche problem. Most small-business invoices use system fonts or embedded fonts whose Unicode mappings do not survive re-rendering.

Decision Drivers

  • Original PDF must remain visually intact (users check the output)

  • ZUGFeRD XML must pass EN 16931 validation

  • No commercial PDF library licenses (budget constraint)

Considered Options

Option Pros Cons

Mustang end-to-end

Single library handles everything. Strict PDF/A-3 output.

Re-renders PDFs. Corrupts fonts with German umlauts. Visual output differs from the original.

Hybrid: Mustang (XML) + PDFBox (attach)

Preserves original PDF byte-for-byte. No font corruption. Faster (no re-rendering).

Output is not strict PDF/A-3 until an optional Ghostscript step runs. Two libraries instead of one.

Commercial PDF library (iText)

Full PDF/A-3 pipeline with font preservation.

License cost incompatible with budget. Vendor dependency for a core function.

Decision

Two-step hybrid approach:

  1. Mustang Project generates and validates the EN 16931-compliant XML.

  2. PDFBox attaches the XML as an embedded file to the original PDF and sets the required ZUGFeRD metadata entries.

The original PDF passes through unchanged. Ghostscript handles PDF/A-3 color profile conversion as a separate, optional step.

Consequences

Positive
  • Original fonts, layout, and visual appearance remain intact.

  • Processing is faster — no PDF re-rendering.

  • Code is simpler — Mustang handles only what it does well (XML generation and validation).

Negative
  • Without the Ghostscript step, the output is technically not strict PDF/A-3. All tested ZUGFeRD readers (Quba, ZUGFeRD Community viewer, DATEV) accept it regardless.

  • Two libraries to maintain instead of one.

ADR-2: OCR + LLM Extraction over Rule-Based Mapping

Status

Superseded by ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls (2026-05-16). Previously superseded in part by ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI for supported native-text documents and by ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals for typed JSON extraction. Historical OCR: mistral-ocr-2512. Chat: mistral-small-2603.

Context

InvoiceX must extract structured invoice data (seller, buyer, line items, tax, totals) from arbitrary PDF layouts. Traditional approaches use OCR to extract text, then apply rule-based field mapping or template matching. These approaches break when PDF layouts vary — and every invoice tool produces different layouts.

Decision Drivers

  • Must handle any PDF layout without template maintenance

  • German invoices legally contain all required fields (§ 14 UStG) — the data is always present

  • A small team cannot maintain a growing library of layout-specific extraction rules

  • Extraction quality must be good enough that most invoices need no user correction

Considered Options

Option Pros Cons

OCR + rule-based extraction

Deterministic output. No per-invoice API cost. Works offline.

Requires layout-specific templates. Breaks on unknown layouts. High maintenance effort.

OCR + ML model (custom-trained)

Adapts to new layouts after training. No API dependency.

Needs training data. High upfront investment. The team cannot maintain an ML pipeline alongside the core product.

OCR + LLM extraction (two-step)

Works with any PDF layout. No template maintenance. OCR handles visual interpretation; LLM handles semantic extraction. Reads full document context.

Non-deterministic LLM output. Per-invoice API cost. Depends on external API. Potential hallucination.

Decision

Use a two-step extraction pipeline with Mistral AI for the first production generation:

  1. OCR step: MistralDocumentAiOcrClient sends the PDF to Mistral’s OCR API (POST /v1/ocr, model mistral-ocr-2512). The OCR model returns page-structured markdown text with headers, content, and footers per page.

  2. LLM step: MistralXmlInvoiceLLMClient feeds the OCR text plus a ZUGFeRD schema prompt to Mistral’s chat model via Spring AI ChatClient. The LLM generates the CII XML.

This ADR captures the original baseline. The current supported native-text PDF path has since moved to local Xberg evidence plus typed JSON chat passes; see ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI and ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals.

Mustang Project validates every LLM output against EN 16931 rules. Failed validations trigger the edit-and-retry flow.

Consequences

Positive
  • Historically worked with all PDF types — native text, scanned, any layout.

  • Zero template maintenance. Adding a new invoice format requires no code changes.

  • Separating OCR from LLM lets each model focus on its strength: visual interpretation vs. semantic extraction.

  • The OCR step produces human-readable markdown, useful for debugging extraction issues.

Negative
  • Non-deterministic: the same PDF may produce slightly different XML on repeated runs.

  • Per-invoice API cost (two API calls: OCR + chat, ~0.01 EUR total per extraction).

  • External API dependency: Mistral downtime blocks invoice processing.

  • LLM may hallucinate values not present in the PDF. Validation catches structural errors, but semantic errors (wrong amount) require user review.

  • Superseded in part by ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI and ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals for supported native-text documents: provider OCR is no longer the preferred default path there, and the LLM no longer emits XML directly.

ADR-3: Migration from OpenAI to Mistral AI

Status

Implemented (2026-02-25). Documented in PDR: LLM Provider Migration.

Context

InvoiceX initially used OpenAI for LLM-based invoice extraction. Customers raised concerns about US data processing during early user interviews. Invoice PDFs contain sensitive business data: VAT IDs, addresses, bank details, revenue figures.

The EU Data Privacy Framework allows US data transfers, but its legal foundation remains fragile — two predecessors (Safe Harbor, Privacy Shield) were invalidated by the CJEU. The US CLOUD Act further compounds the risk: US providers must hand over data stored anywhere when served with a warrant.

Decision Drivers

  • Customer trust: users must feel confident sending invoice data to the service

  • GDPR compliance without relying on a legally fragile adequacy decision

  • Cost (OpenAI GPT-4o-mini was 3x more expensive than Mistral equivalents)

  • Spring AI must support the target provider

Considered Options

Option Pros Cons

Stay with OpenAI

Largest model ecosystem. Best tooling. Most documentation.

US-hosted. CLOUD Act exposure. Customer trust concerns. Higher cost.

Migrate to Mistral AI

EU company (France). EU-only data centers. 67% cheaper. Spring AI starter available.

Smaller model ecosystem. Fewer model size options. Less community tooling.

Self-hosted open-source model

Full data control. No API dependency. No per-invoice cost.

Requires GPU infrastructure. High ops overhead. The team cannot justify maintaining ML infrastructure alongside the core product.

Decision

Migrate to Mistral AI (mistral-small-2603 model). Spring AI’s provider abstraction made this a configuration change: swap the starter dependency and API key.

Consequences

Positive
  • 100% EU data processing. No data leaves EU jurisdiction.

  • 67% cost reduction per extraction compared to GPT-4o-mini.

  • Removes CLOUD Act exposure and customer trust concerns.

  • Migration required only a dependency and configuration change thanks to Spring AI.

Negative
  • Smaller model ecosystem. Fewer model variants for future use cases.

  • Less community tooling and fewer examples compared to OpenAI.

ADR-4: Email-Based Interface as Primary Channel

Status

Active. Web trial upload added as secondary channel.

Context

InvoiceX targets small business owners and sole proprietors with established invoicing processes. These users create invoices in Word, Excel, or trade-specific software, save as PDF, and email them to customers. They are not tech-savvy and resist adopting new software.

Decision Drivers

  • Zero learning curve for the target audience

  • Must work from any device and any email client

  • Must integrate into the existing workflow (create PDF → email it)

  • No app installation or browser-specific requirements

Considered Options

Option Pros Cons

Web upload interface (primary)

Full UX control. Rich feedback. Interactive validation.

Requires users to open a browser, navigate to a site, and upload a file. Extra steps break the existing workflow.

Email-based interface (primary)

Fits the existing workflow. User changes one thing: the recipient address. Works from any device, any email client. Zero learning curve.

Limited UX control. Async workflow (user waits for response email). Error handling requires email + web handoff.

Desktop application

Full offline capability. Rich UI.

Requires installation. Users resist new software. Platform-specific maintenance. Does not fit the target audience.

Decision

Email is the primary interface. Users send their PDF invoice to an InvoiceX email address and receive the e-invoice back via email.

The web UI handles only tasks that require a form-based interface:

  • Correcting extraction errors (edit session)

  • Account management (pricing tier, email preferences)

  • Trial upload for first-time visitors from the marketing website

Consequences

Positive
  • Zero learning curve. Users already know how to send email.

  • Works on every device with an email client — phones, tablets, desktop, webmail.

  • Integrates into the existing workflow: create PDF → send email → done.

Negative
  • Limited UX control: cannot show progress indicators, interactive validation, or rich formatting in email.

  • Async workflow: user sends email, then waits for a response. No instant feedback.

  • Error recovery requires a handoff from email to web (magic link to edit session).

ADR-5: Monolith-First Architecture

Status

Partially superseded by ADR-30: One BootJar with Product-Isolated Spring Child Contexts for internal build and runtime boundaries and by ADR-31: Separate Logical PostgreSQL Databases and Roles for database topology; one backend process and deployment unit remain active.

Context

InvoiceX is maintained by a small team. The team handles architecture, implementation, operations, and customer support. Microservices would add inter-service communication, distributed tracing, deployment orchestration, and failure modes that a small team cannot justify operating.

Decision Drivers

  • Small team: cannot operate distributed systems

  • Budget: single VPS, no managed container orchestration

  • Speed: fast iteration without coordination overhead

  • Must remain splittable if team size or scale demands it later

Considered Options

Option Pros Cons

Microservices

Independent deployment. Independent scaling. Technology diversity per service.

Distributed system complexity. Network failure modes. Deployment orchestration. Distributed tracing required. Small team cannot justify the operational overhead.

Monolith

Single deployment unit. Shared database. Simple debugging. Fast local development. One process to monitor.

Vertical scaling only. Coupling between capabilities if boundaries erode. All-or-nothing deployment.

Modular monolith (formal modules)

Monolith benefits with enforced module boundaries.

Additional build complexity. Over-engineered for current team size.

Decision

The core application (backend) runs as a single Spring Boot application with one process, one database, and one deployment unit for all business logic.

Static companions run alongside it: the two Nuxt-generated product frontends under applications/frontend/ plus partner demo pages under demos/partner/. They are stateless and minimal in scope — they don’t share the backend’s database or contain business logic. This is not a microservices architecture; it is a monolith with static companions. ADR-25: Static Nuxt Product Frontends defines their frontend and deployment boundary.

Registration and double opt-in (DOI) are handled self-hosted within the backend, using Mailgun for email delivery.

A previous Go microservice (subscribe-api) and the external Brevo dependency were removed in April 2026 (#319). They are no longer part of the system and only mentioned here for historical context.

Package-by-feature structure (ADR-6: Package-by-Feature Organization) keeps capabilities decoupled within the backend. Splitting into separate services remains possible by extracting packages into independent applications.

Consequences

Positive
  • Simple deployment: docker compose up runs the entire application.

  • Shared database: no distributed transactions, no eventual consistency.

  • Fast debugging: one log stream, one process, local breakpoints.

  • Low infrastructure cost: runs on a single Hetzner VPS.

  • Fewer external dependencies: no third-party DOI service required.

Negative
  • Scaling is vertical only: larger VPS, not more instances. Sufficient for current load.

  • All capabilities deploy together. A bug in email delivery blocks invoice processing deployment.

  • Internal boundaries depend on developer discipline, not compile-time enforcement.

ADR-6: Package-by-Feature Organization

Status

Active. Refined by ADR-30: One BootJar with Product-Isolated Spring Child Contexts for product-module ownership and by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core for EER invoice semantics and target adapters.

Context

The codebase needs a structure that communicates business purpose and supports future modularization. Traditional Java/Kotlin projects organize by technical layer (controllers/, services/, repositories/), which scatters related business logic across the directory tree.

Decision Drivers

  • Architecture should scream the domain, not the framework

  • Related code should live together (cohesion)

  • Must support extracting a capability into a separate service later

  • A new developer should understand the business from the package tree

Considered Options

Option Pros Cons

Package-by-layer (controllers/, services/, repositories/)

Familiar to most Java developers. Easy to find all controllers or all repositories.

Scatters related business logic. A change to "email ingestion" touches files in three different packages. Does not communicate business purpose.

Package-by-feature (email.ingestion/, formats.zugferd/, invoice.editing/)

Groups related code. Architecture communicates domain. Supports future extraction into services. High cohesion within each package.

Cross-cutting code (security, persistence config) needs a shared infrastructure package. Less familiar to developers coming from layer-based projects.

Decision

Top-level packages named by business capability:

applications/backend/
├── app/                  # composition root, servlet ownership
├── core/                 # product-neutral stateless contracts
├── eer/
│   └── com.clarula.backend/{email,invoice,formats,identity,widget,...}
└── clarula/
    └── com.clarula.clarula/{firm,datev,...}

Each package owns its controllers, services, repositories, and domain types. No cross-package database access. infrastructure is the only horizontal package.

Subsequent Refinement

ADR-30: One BootJar with Product-Isolated Spring Child Contexts places EER and Clarula in sibling Gradle modules and application contexts. Package-by-feature remains mandatory inside each product; :core contains only genuinely product-neutral stateless capabilities and no product domain.

ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core assigns EER invoice semantics, source evidence, analysis orchestration, common validation, and target ports to :eer package invoice.conversion. The PDF/Xberg/LLM implementation is an EER input adapter behind that boundary, while formats.zugferd owns only target readiness, mapping, validation, and output generation. invoice.editing owns EER draft persistence and correction mapping. Clarula keeps its independent firm, mandate, routing, and DATEV capabilities in :clarula and cannot import EER invoice-domain code. This does not introduce a generic top-level domain, shared, or technical-layer package.

Consequences

Positive
  • A new developer sees formats.zugferd and email.ingestion before JpaConfig.

  • Related code lives together: controller, service, repository, and domain types in one package.

  • Each package can be extracted into a separate service by moving it and adding an API boundary.

Negative
  • Cross-cutting concerns (security filters, JPA configuration, global exception handling) must live in the infrastructure package.

  • Developers unfamiliar with package-by-feature may initially look for a controllers/ directory.

ADR-7: Time-Based Monetization over Volume-Based

Status

Implemented (2026-03-15). Documented in PDR: Tiering v2 — Time-Based Access.

Context

The initial pricing model used volume-based limits: Free (10 invoices/month), Standard (50/month), Pro (250/month). Two problems emerged during early validation:

  1. High-volume users were penalized. A small business owner processing 40+ invoices per month hit the Free tier limit quickly and faced a steep jump to Standard. The pricing penalized exactly the users who needed the product most.

  2. Low-volume users never converted. A freelancer sending 3 invoices per month stayed in the Free tier indefinitely. Volume limits created no urgency to convert.

Decision Drivers

  • Fair pricing across all business sizes

  • Create urgency for conversion (trial expiry > soft usage limits)

  • Simple communication: one price, unlimited use

  • Predictable costs for the user — no per-invoice anxiety

Considered Options

Option Pros Cons

Volume-based limits (status quo)

Pay-per-use fairness. Low-volume users pay less.

Penalizes high-volume users. Low-volume users never convert. Complex tier communication ("How many invoices do I process per month?").

Time-based access (trial + subscription)

Fair for all business sizes. Simple communication. Trial creates urgency. Predictable cost.

No revenue from users who churn after trial. High-volume users get more value per euro (acceptable: they are the best advocates).

Per-invoice pricing

Perfect pay-per-use alignment.

Unpredictable costs for users. Creates "should I convert this one?" friction. Complex billing infrastructure.

Decision

Switch to time-based access:

  • 30-day free trial with full functionality (no volume limits during trial).

  • Flat-rate subscription at 9.90 EUR/month (Standard) after trial expiry.

  • All tiers produce identical, EN 16931-compliant e-invoices.

Consequences

Positive
  • Fair pricing: a small business owner with 40 invoices/month pays the same as a sole proprietor with 5.

  • Simple communication: "30 days free, then 9.90 EUR/month — unlimited invoices."

  • Trial expiry creates natural conversion urgency.

  • No per-invoice cost anxiety for users.

Negative
  • Low-volume users who only need 2-3 invoices per month may churn after the trial.

  • High-volume users extract more value per euro — acceptable because they generate word-of-mouth.

ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals

Status

Superseded by ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls (2026-05-16). Previously implemented 2026-04-18 and superseded the XML-direct extraction approach in ADR-2: OCR + LLM Extraction over Rule-Based Mapping.

Context

The current extraction pipeline (ADR-2: OCR + LLM Extraction over Rule-Based Mapping) originally instructed the LLM to produce ZUGFeRD CII XML directly from OCR text. The prompt has grown to ~200 lines covering 13 critical XML validity rules, three numbered steps for net/gross detection and conversion, payment-means mapping, tax-ID search, Kleinunternehmer handling, mandatory-field rules for delivery and due dates, and an embedded XML skeleton with [PLACEHOLDER] markers. The LLM must in one pass: classify the document, identify line items, classify net vs. gross prices, compute per-line and document totals, and emit syntactically valid XML.

A production failure on a Hetzner Cloud invoice surfaced two problems that point to deeper structural issues, not isolated prompt bugs:

  1. The LLM picked the wrong table on a multi-table invoice page (a service summary table instead of the per-product line items) and emitted line totals from the wrong source. The extracted source text for the same PDF is correct, so the failure is in semantic extraction, not text recognition.

  2. Per-line and document totals occasionally fail arithmetic consistency checks even when individual values are extracted correctly. The LLM is performing arithmetic in natural language while simultaneously generating XML.

Attempts to fix the first problem by adding more disambiguation rules to the prompt have so far not produced a verified fix, and each addition increases the risk of regressions on invoice types that already work (Tennis, Brutto-Lines, Reifen, Kleinunternehmer). The current architecture couples three concerns inside one LLM call: semantic extraction, arithmetic, and XML serialization. Mistral’s own prompting guidance and Anthropic’s long-context guidance both recommend against asking an LLM to perform arithmetic, and recommend separating extraction from formatting.

Decision Drivers

  • Extraction must be reliable enough that the Hetzner-class bug cannot recur from a prompt regression

  • Arithmetic errors must be impossible by construction, not caught by validation after the fact

  • Prompt complexity must stay low enough for a single developer to reason about and modify safely

  • The change should not increase per-invoice API cost or latency materially

  • The XML output must remain valid against EN 16931 and the ZUGFeRD 2.5 schema

Considered Options

Option Pros Cons

Keep current architecture, fix prompt iteratively

No code changes. No risk to other extraction paths. Familiar to the team.

Each prompt fix risks regressing other invoice types. Arithmetic bugs remain possible by construction. Prompt complexity already exceeds the threshold where Mistral’s own guidance predicts failures.

LLM returns JSON, code renders XML and computes totals

LLM has one job (extract structured facts). Arithmetic happens in Kotlin and is unit-testable. XML serialization is deterministic and schema-valid by construction. Prompt becomes substantially shorter (no XML skeleton, no [PLACEHOLDER] rules, no arithmetic verification step). Mistral and OpenAI both support structured outputs natively, so JSON conformance is enforced server-side, not by prompt.

Requires a new JSON schema (one-time effort) and a Kotlin JSON→CII renderer. Existing tests must be ported. Possible regression on invoice types currently passing.

Two-stage LLM (stage 1 extracts JSON, stage 2 generates XML from JSON)

Keeps LLM in both extraction and formatting roles. Easy to introduce incrementally.

Doubles API cost and latency. Stage 2 still has the arithmetic problem unless stage 1 already computes totals. Adds a moving part without removing the original failure mode.

Decision

Refactor the LLM boundary so that every model response is typed JSON, and move XML rendering and arithmetic into Kotlin:

  1. Local evidence step: XbergPdfStructuredTextExtractor derives StructuredDocumentEvidence from the source PDF and keeps both the legacy documentContext text view and richer layout cues local to the application.

  2. Structural LLM step: MistralJsonInvoiceLLMClient performs a first typed chat call that maps StructuredDocumentEvidence to DocumentSemantics.

  3. Invoice JSON step: the same client performs a second typed chat call that combines documentContext with those structural hints and returns an ExtractedInvoice JSON object with seller, buyer, line items (description, quantity, unit price, tax rate, net/gross flag), payment terms, and tax breakdown lines — but no computed totals.

  4. Arithmetic step: A pure Kotlin InvoiceTotalsCalculator computes per-line totals, tax base sums, tax amounts, and grand total from the extracted line items. This is unit-testable without API calls.

  5. Rendering step: MustangInvoiceMapper and ExtractedInvoiceZugferdConversionService render valid CII XML from the ExtractedInvoice plus the calculated totals, followed by XML normalization and PDF embedding.

  6. Validation: MustangXmlValidationService continues to validate the rendered XML against EN 16931, unchanged.

The prompt shrinks to instructions for extraction only, with 3–5 few-shot examples covering the failure modes that currently require defensive rules (per-product tables vs. service summaries, brutto-line receipts, Kleinunternehmer cases).

Consequences

Positive
  • Arithmetic bugs become structurally impossible: the LLM never produces a total.

  • The prompt loses the entire embedded XML skeleton, the [PLACEHOLDER] substitution rules, the 13 XML validity rules, and the arithmetic verification step. The remaining instructions are pure extraction guidance, well within the range where Mistral’s prompting guidance predicts reliable behavior.

  • JSON schema enforcement happens server-side via Structured Outputs, removing an entire class of "LLM produced invalid XML" failures.

  • The Kotlin renderer and calculator are unit-testable in milliseconds without LLM API calls. Test coverage of edge cases becomes cheap.

  • Few-shot examples become tractable: 3–5 JSON examples are far more compact than 3–5 XML examples and sit better within the prompt context window.

  • The extra DocumentSemantics pass gives the final extraction prompt layout-aware section hints without reintroducing XML generation into the model.

Negative
  • One-time effort: define ExtractedInvoice and DocumentSemantics, write InvoiceTotalsCalculator, wire MustangInvoiceMapper into the deterministic tail, and port existing prompt knowledge into the new typed stages.

  • Risk of regression on currently-passing invoice types (Tennis, Brutto-Lines, Reifen, Kleinunternehmer). All *LlmTest cases must pass against the new pipeline before rollout.

  • Loss of any "free" XML structure the LLM was implicitly handling — e.g. uncommon BG-25 line item attributes — must be handled explicitly in the renderer.

  • Adds Kotlin code that must be maintained against future ZUGFeRD/EN 16931 schema revisions. Currently the LLM absorbs schema knowledge from the prompt; future schema updates require renderer changes instead of prompt changes.

Neutral
  • The dedicated OCR provider call disappears on the supported native-text PDF path.

  • The production semantic flow now uses two bounded chat calls instead of one large XML-producing call: first DocumentSemantics, then ExtractedInvoice.

Implemented Shape

The accepted direction is now implemented in the following production shape:

  1. Typed evidence: StructuredDocumentEvidence and DocumentSemantics narrow the LLM responsibilities before ExtractedInvoice is produced.

  2. Calculator: InvoiceTotalsCalculator computes totals deterministically, including reconciliation against printed monetary anchors.

  3. Renderer: MustangInvoiceMapper, ZugferdXmlNormalizer, MustangXmlValidationService, and ZUGFeRDGenerator form the deterministic rendering and validation tail.

  4. Prompt + Few-Shots: The active extraction prompt focuses on typed invoice facts, not XML syntax or arithmetic.

  5. Regression: testUnit, testIntegration, quickCheck, and testLlm remain the verification path for changes to the semantic extraction flow.

ADR-9: Analytics Reports as Code via SQL Views

Status

Accepted (2026-04-18).

Context

We track business events in a dedicated analytics schema in Postgres (see BusinessEventEntity, business.tracking package). Reports and dashboards are built in Metabase, which connects directly to Postgres and stores its question and dashboard definitions in its own application database.

This split has a structural problem: the SQL queries that define our reports live only inside Metabase. They are not in the repository, not reviewable in pull requests, not testable, and not visible to AI coding agents. Concretely:

  • When a BusinessEvent payload changes, we have no way to detect which Metabase question breaks until someone opens the dashboard.

  • Refactoring an event type or renaming a payload field requires manually clicking through Metabase questions to find references.

  • Agents cannot answer "what reports do we have?" or "which event drives the conversion funnel?" without a human exporting Metabase state.

  • Onboarding a new team member to our analytics requires a Metabase tour, not a git log and a directory listing.

The business.tracking.persistence package is already write-only by architecture rule (enforced by AnalyticsBoundaryArchTest). Application code does not read from the analytics schema. This means the read path is fully owned by Metabase today, which is exactly the part that is invisible to the repository.

We have ~9 event types in production (InvoiceReceivedEvent, InvoiceProcessedEvent, InvoiceProcessingFailedEvent, etc.) and a growing number of Metabase questions whose query logic is not under version control.

Decision Drivers

  • Report definitions must be reviewable in pull requests, the same way code is reviewed.

  • AI agents must be able to read, modify, and propose changes to report logic without manual export.

  • When a business event schema changes, the impact on reports must be visible at the same place the event is changed.

  • Metabase remains the visualization tool — we do not want to replace it.

  • The solution must not introduce a new tool or runtime dependency unless clearly justified (YAGNI).

  • Operational overhead must stay low: we are a single-VPS deployment with limited DBA time.

Considered Options

Option Pros Cons

Keep status quo (queries in Metabase only)

No work. No risk.

Reports stay invisible to repo and agents. Schema-change impact remains undetectable until dashboards break. Documented problem persists.

SQL Views in the repository, Metabase only visualizes (proposed)

Report definitions live next to event definitions. Versioned, reviewable, testable. Metabase questions become trivial SELECT * FROM analytics.report_xyz. Schema changes force view updates in the same PR (compile-time-like guarantee via Flyway). No new tool. Agents can read and modify reports.

Schema changes to views are heavier than to plain SQL: views must be `DROP CASCADE`d and recreated. Materialized views need a refresh strategy. Risk that Metabase users still write ad-hoc SQL against raw tables and bypass the curated layer.

Materialized views only (always materialize)

Best query performance. Decouples Metabase load from event write load.

Refresh strategy required for every report (cron, trigger, or manual). REFRESH MATERIALIZED VIEW CONCURRENTLY requires a unique index. Adds operational complexity that most reports do not need at our current data volume.

dbt (data build tool)

Industry standard for analytics-as-code. Lineage graph, tests, auto-generated docs.

New tool, new runtime, new deployment artifact. Overkill for our current report count and team size. Re-evaluate when we have >50 reports or a dedicated data role.

Export Metabase questions to repo via API (sync script)

Keeps Metabase as authoring tool. Repo is auto-updated mirror.

Metabase remains source of truth — review happens after the fact. Generated SQL is harder for agents to reason about than hand-written views. Sync drift is a permanent risk.

Metabase Serialization (official YAML export/import)

Officially supported. Round-trippable.

Binds reports to Metabase’s YAML format, which mixes SQL with visualization config. Harder for agents to parse than plain SQL. Vendor lock-in on the report definition layer.

Decision

We will define analytics reports as SQL views in the analytics schema, managed via Flyway migrations in the same repository as the event definitions that feed them. Metabase questions become trivial reads against these views (SELECT * FROM analytics.report_xyz) and own only the visualization layer (chart type, axes, filters, colours).

Specifically:

  1. Plain views by default. A new view is a regular CREATE VIEW analytics.report_<name> in a Flyway migration. No materialization unless a measured performance problem justifies it.

  2. Materialized views by exception. When a report’s query latency on the live Metabase dashboard exceeds an acceptable threshold (initial heuristic: >2 seconds), it is converted to a materialized view in a follow-up migration, with a documented refresh strategy (cron via pg_cron or a Spring scheduled job).

  3. Naming convention. Views are prefixed report_ for user-facing reports and metric_ for reusable building blocks consumed by other views. Example: analytics.report_conversion_funnel, analytics.metric_daily_active_accounts.

  4. Location. Migrations live in applications/backend/src/main/resources/db/migration/ alongside existing Flyway scripts. View DDL is the same migration mechanism as the rest of the schema.

  5. Read-side boundary stays. AnalyticsBoundaryArchTest continues to forbid application code from reading analytics.*. Views are exclusively for Metabase.

  6. Documentation. Each view starts with a SQL comment block describing: the business question it answers, the events it consumes, the grain (one row per …), and any known caveats. COMMENT ON VIEW is used so the description is also visible in Metabase’s data reference.

The migration of existing Metabase questions and the closing of analytics gaps is tracked in a separate GitHub epic.

Consequences

Positive
  • Report logic is versioned, code-reviewed, and visible to agents.

  • Renaming or changing a BusinessEvent payload field forces a view migration in the same PR — broken reports become a build-time concern, not a "next time someone opens the dashboard" concern.

  • Onboarding to analytics becomes "read `db/migration/V*report*.sql`" instead of "tour Metabase".

  • Metabase questions become uniform and trivial, reducing the maintenance surface inside Metabase.

  • COMMENT ON VIEW plus the migration history give us free, durable documentation of our analytics surface.

Negative
  • View schema changes are heavier than plain query edits: dependent views require CASCADE drops and explicit recreation in the migration.

  • Materialized views, when introduced, need a refresh mechanism we currently do not operate. First materialized view will pay the cost of choosing and setting up that mechanism.

  • Discipline required: nothing in Postgres prevents a Metabase user from writing an ad-hoc query against analytics.business_events and bypassing the view layer. Convention must be documented and enforced socially or via Metabase user permissions.

  • JSON payloads in business_events.payload_json must be projected into typed columns inside the views. Each event type effectively gets its own typed unwrap view, adding boilerplate.

Neutral
  • No change to event persistence. business.tracking continues to write events as today, but shared conversion outcome events are now emitted centrally from PdfToZugferdConversionService.convert(…​) instead of being split across channel-specific callers.

  • No new runtime dependency. Postgres and Flyway already in use.

  • Metabase remains in use for visualization, dashboards, and access control.

  • ADR-6: Package-by-Feature Organization (Package-by-Feature Organization) — business.tracking owns events; this ADR extends ownership to the read views derived from those events.

  • AnalyticsBoundaryArchTest — enforces that application code does not read the analytics schema; this ADR confirms the read path belongs to Metabase via the curated views.

ADR-10: Cross-Domain Redirect via Server-Side Partner Registry

Status

Accepted (2026-04-18). Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.

Context

The partner widget runs on third-party domains (e.g. kanzlei-mueller.de) but must convert anonymous uploads into authenticated sessions through the canonical einfache-erechnung.de product origin. After magic-link confirmation, the user must land back on the partner page that started the flow — both for UX continuity and so the tax advisor sees their branded experience preserved.

The naive solution — letting the widget pass an arbitrary redirect_url query parameter to our backend — creates an open redirect vulnerability. Any third party could craft a magic-link URL that redirects to a phishing site after authentication.

Decision Drivers

  • Security: No open redirects. We must control the allow-list of redirect targets.

  • Simplicity: Tax advisors should not need to configure redirect URLs per page.

  • Operability: Adding or revoking a partner must be a single admin action.

  • Auditability: We must know which partner originated every conversion.

Considered Options

Option Pros Cons

Server-side Partner registry with partner_id token

Closed allow-list. Single source of truth. Audit trail per partner. Revocation is one DB update.

Requires admin UI. Each new partner needs onboarding.

Signed redirect_url query parameter (HMAC)

No registry needed. Self-contained tokens.

Key rotation is painful. Lost keys cannot be revoked without breaking active sessions. Still need allow-list of base domains.

Hardcoded partner allow-list in config

Trivial to implement. No DB.

Every new partner requires a deployment. Conflicts with admin self-service goal.

Decision

Introduce a Partner entity with a stable, opaque partner_id (UUID). The widget embeds partner_id in every API call. The backend resolves partner_id to the partner’s configured redirect_base_url and appends a fixed path suffix (/erechnung-erstellt).

  • Widget API contracts carry partner_id as a header (X-Partner-Id).

  • Magic-link tokens persist the originating partner_id so the post-confirmation redirect resolves server-side from the registry, not from a user-controlled URL.

  • Unknown or revoked partner_id returns HTTP 404 — never falls back to the default flow.

Consequences

Positive
  • No open redirect vulnerability. Redirect targets are server-controlled.

  • Revoking a partner takes one admin action and breaks all in-flight flows immediately.

  • Per-partner conversion analytics are trivial (just join on partner_id).

  • Partner onboarding is decoupled from deployments.

Negative
  • Requires an admin UI to manage partners (covered by the same Epic).

  • Adds one indirection (partner_idredirect_base_url) on every widget request — negligible at expected volume.

  • Widgets cannot redirect to arbitrary deep links within a partner site. Acceptable: the post-conversion landing page is part of the partner’s onboarding contract.

ADR-11: Embeddable Widget as Vanilla JS Web Component

Status

Accepted (2026-04-18). Amended 2026-07-14 to serve new embeds from the canonical product origin while retaining the former app origin only for already-installed widgets. Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.

Context

Tax advisors will embed our widget on their websites with a single <script> tag. Their sites run on heterogeneous stacks: WordPress, Wix, Jimdo, custom HTML, sometimes legacy IE-era CMSes. Many of these sites already load jQuery, Bootstrap, or other UI frameworks that may conflict with global CSS or JS.

The widget must:

  • Load fast (sub-100 KB ideally).

  • Not pollute the host page’s global scope.

  • Not be broken by the host page’s CSS resets.

  • Be embeddable with a one-liner the partner copies from the admin UI.

Decision Drivers

  • Embedding simplicity: One <script> tag, zero configuration beyond partner_id.

  • Style isolation: Host CSS must not bleed into the widget, and vice versa.

  • No framework lock-in for partners: Cannot require the partner site to load React/Vue.

  • Maintainability: The widget evolves independently from the partner sites.

Considered Options

Option Pros Cons

Vanilla JS Web Component with Shadow DOM

Native browser API. Shadow DOM isolates styles bidirectionally. Tiny bundle. No runtime dependency. Works with any host stack.

Requires modern browsers (acceptable: tax advisors and their clients are on evergreen browsers).

React/Preact bundle

Familiar component model.

Bundle size 40-150 KB just for the framework. CSS isolation requires extra work (CSS-in-JS, scoped class names). Risk of version conflicts if host page also loads React.

Iframe embed

Perfect isolation. Trivial to implement.

Cross-origin file uploads via iframe are awkward (postMessage choreography). Drag-and-drop UX is degraded. Resizing iframe to content is fragile.

Decision

Build the widget as a vanilla JavaScript custom element (<einfache-erechnung-widget>) using Shadow DOM for style isolation. Source lives in applications/backend/src/main/widget/ (TypeScript). A Gradle task buildWidget runs esbuild via npm and emits the bundle to src/main/resources/static/widget/v1/embed.js. The artifact ships in the same bootJar as the rest of the application — one deployment, one version (consistent with ADR-5 Monolith-First).

The embed snippet is:

<script src="https://einfache-erechnung.de/widget/v1/embed.js" async></script>
<einfache-erechnung-widget partner-id="..." accent-color="#005FCC"></einfache-erechnung-widget>

Versioning is path-based (/widget/v1/). Breaking changes require /widget/v2/ and a partner migration window. Partner sites that already use https://app.einfache-erechnung.de/widget/v1/embed.js remain supported during a coordinated migration window. That former origin exposes only /widget/v1/, /api/v1/widget/, and the /api/widget/** compatibility alias; it no longer owns admin, webhooks, customer pages, or general APIs. New snippets must never use it.

Consequences

Positive
  • Bundle size stays small (target < 30 KB gzipped). Native APIs do the heavy lifting.

  • Shadow DOM guarantees no CSS conflicts in either direction.

  • Widget works on any modern host stack without dependencies.

  • TypeScript source is colocated with the backend that serves it — one repo, one build, one deploy.

Negative
  • Adds npm/node as a build-time dependency (only for the widget bundle, not for the application runtime).

  • Custom elements need polyfills for IE11 — explicitly out of scope. Tax advisors confirm their target users are on modern browsers.

  • Path-based versioning means we must support old /widget/v1/embed.js indefinitely (or coordinate partner migration).

ADR-12: Anonymous Conversion Persistence with TTL

Status

Superseded in part by ADR-23: Widget Result Delivery by Standard Success Email (2026-05-26). Accepted 2026-04-18. Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.

Context

The widget lets a visitor upload a PDF, see the e-invoice generated, and only then asks for an email address to download. Between upload and authentication, the conversion exists without an owning Account. After magic-link confirmation, the result must attach to the new (or existing) account so the user can download it.

This anonymous-then-authenticated handoff is unique to the widget. The existing email channel always has an owner from the first byte (the sender address).

Decision Drivers

  • Privacy: Hold anonymous PDFs no longer than necessary. GDPR data minimization.

  • Reliability: Must survive a backend restart between upload and download.

  • Simplicity: Should reuse existing Conversion infrastructure where possible.

  • Operability: Cleanup must not require manual intervention.

Considered Options

Option Pros Cons

Persist Conversion with account_id = NULL and a claim_token, TTL 1 hour

Same entity as the rest of the system. Survives restarts. Cleanup is a scheduled job. Token-based claim is simple.

Adds a nullable foreign key on Conversion. Cleanup job is one more moving part.

In-memory cache (Caffeine) with TTL

Trivial. Auto-evicts.

Loses data on restart. Loses data on second instance (when we eventually scale out).

Separate AnonymousConversion entity, copied to Conversion on claim

Clean domain separation. No nullable FK.

Duplicated schema. Double the persistence code. Two tables to back up.

Decision

Allow Conversion.account_id to be nullable. Add claim_token (random 256-bit) and claim_token_expires_at (upload time + 1 hour) columns. A scheduled task runs every 15 minutes and deletes expired anonymous conversions (where account_id IS NULL AND claim_token_expires_at < NOW()).

When the user submits an email, the original decision issued a magic-link that carried claim_token (alongside partner_id from ADR-10). On confirmation, the backend originally:

  1. Resolves or creates the Account for the email.

  2. Sets Conversion.account_id to that account.

  3. Keeps claim_token and claim_token_expires_at so the widget can still resolve the conversion by claim token.

  4. Logs the user in and redirects per ADR-10 with a short-lived download token.

ADR-23: Widget Result Delivery by Standard Success Email supersedes the confirmation and delivery part: the widget uses the claim token to trigger the standard success email and does not issue download tokens. Its separate partner-scoped identity token may remember the confirmed destination account for later email deliveries, but never grants direct download or portal access.

Consequences

Positive
  • Reuses existing Conversion table and processing pipeline.

  • Survives restarts and (later) scale-out.

  • Cleanup is automatic, observable via metrics on the scheduled task.

  • GDPR-friendly: anonymous PDFs disappear within 1 hour 15 minutes worst case.

Negative
  • Conversion.account_id becomes nullable — existing queries must be reviewed (ArchUnit-style invariant: no user-facing query may return rows with account_id IS NULL).

  • If a user takes longer than 1 hour to click the magic link, the conversion is gone. Acceptable: they can re-upload.

  • The cleanup task is a new operational concern (alerting on failure required).

ADR-13: Admin Identity via Configuration, Not as a Customer Role

Status

Partially superseded by ADR-33: Dedicated Static Operator Frontend on admin.clarula.de for operator origin, routes, and cookie path; identity decision accepted (2026-04-18). Drives implementation of PDR: Partner Embeddable Widget for Tax Advisors.

Context

Managing partners requires an authenticated UI under /admin/** that only the InvoiceX team can access. At the time of writing there are three admins: the three founders. This count will not exceed a handful in the foreseeable future.

The temptation is to model admins as Account rows with a role = ADMIN flag. That conflates two different domains: customers (paying users of the e-invoice service) and operators (us, running the platform). A bug in customer-facing code could elevate or expose admin privileges.

Decision Drivers

  • Security: Admin and customer roles must not share a privilege-escalation surface.

  • Simplicity / YAGNI: Three admins do not justify a full role/permission system.

  • Auditability: It must be obvious who can administer the system without querying the database.

  • Reversibility: If we ever need DB-backed admins (10+ admins, granular permissions), we should not have painted ourselves into a corner.

Considered Options

Option Pros Cons

Allow-list in configuration (operator.admin.emails)

Admins are explicit in deployment config. No DB schema. No accidental privilege escalation via customer flow. Trivial to audit.

Adding/removing admins requires a config change and restart. Acceptable at our scale.

Account.role enum with CUSTOMER / ADMIN

One auth pipeline. Self-service admin creation possible later.

Customer code paths can reach admin checks. Higher attack surface. Premature for three users.

Separate Admin entity with own credentials

Clean domain separation in DB.

Duplicates magic-link infrastructure. More code, no current benefit.

Decision

Admins are a configuration-driven allow-list:

operator:
  admin:
    emails:
      - founder1@example.com
      - founder2@example.com
      - founder3@example.com

Authentication reuses the existing magic-link infrastructure but:

  • Operator authentication and both products' admin controllers are always registered; there is no admin feature flag. The configured email allow-list is the access boundary.

  • The admin login lives at /admin/login (separate controller, separate template).

  • Caddy exposes all /admin/** paths, including Clarula callbacks, only on the canonical einfache-eRechnung operator origin and returns 404 on other public origins. This keeps host-only admin cookies and OAuth session state on one host.

  • The magic link verifies that the email is on the allow-list before issuing a session.

  • Successful verification atomically stores a hash of the magic-link token’s unique identifier on the new remember session. Alternate encodings or repeated verification cannot create another session.

  • Successful admin auth issues a separate short-lived cookie (admin_session, scoped to path /admin) with its own JWT signing claim.

  • Successful admin auth also issues a persistent opaque remember-session cookie (admin_remember_session, scoped to path /admin). Only token hashes are stored server-side in admin_remember_sessions, bound to the admin email, and the current allow-list is checked again before refresh.

  • The admin remember token stays stable and is reissued on refresh to renew its browser lifetime. Stability ensures that logout or re-authentication can revoke the same server-side session even if another client used the token first.

  • The product-neutral OperatorAuthFilter validates the JWT globally for /admin/**; only non-Clarula EER admin routes may mint a fresh JWT through the EER-owned remember-session bridge.

  • Customer cookies and customer remember sessions grant no admin privileges and vice versa.

When the team grows past ~10 admins or we need granular permissions, this ADR is superseded by a DB-backed Admin entity. Migration is straightforward: read the same emails from a table instead of config.

Consequences

Positive
  • Zero risk of customer-side bug elevating to admin (different cookies, different filters, different domain).

  • Allow-list is visible in deployment config — audit is grep-able.

  • Admin identity remains separate from customer accounts and roles, even though admin remember-session revocation needs a small dedicated persistence table.

  • Magic-link UX is consistent across customer and admin, including persistent browser login and single-use verification.

Negative
  • Admin changes require a config update and restart. Friction is intentional.

  • Two parallel auth pipelines must be maintained (customer + admin).

  • Admin and customer remember-token rotation differ intentionally: admin tokens prioritize reliable revocation while customer tokens retain rotation with a short grace period.

  • No admin self-service. Acceptable: founders add each other manually.

ADR-14: Dev-First Deployment Flow with Manual Production Promotion

Status

Accepted (2026-04-20).

Context

InvoiceX now has a permanent dev environment at dev.einfache-erechnung.de that runs the same Docker Compose topology and Spring production profile as the customer-facing production system.

Earlier, changes on main flowed straight toward production. That kept delivery simple, but it also meant the main integration branch was one automation step away from shipping directly to customers. For a small team with no dedicated ops role, that gives very little room to observe deployment behavior before customer impact.

We need a release policy that keeps feedback fast while protecting the production experience.

Decision Drivers

  • Customer protection: production quality must not depend on every merge being release-ready for customers.

  • Operational simplicity: avoid adding a complex release-branch or staging-process bureaucracy.

  • Fast feedback: deployed code should still be exercised automatically in a production-like environment.

  • Traceability: it must remain obvious which commit is running in dev and which commit was promoted to prod.

Considered Options

Option Pros Cons

Auto-deploy main to production

Fastest path from merge to customer-visible release. Minimal human steps.

No buffer between integration and customer impact. Operational mistakes or bad merges hit customers immediately.

Auto-deploy main to dev, promote to prod manually

Keeps feedback fast in a production-like environment. Adds a deliberate gate before customer impact. Works with the new permanent dev environment.

Adds one manual release step. Dev is not a stable long-lived QA snapshot because later merges replace it.

Introduce release branches or a separate staging branch

Stronger separation between integration and release candidates.

More branch and merge overhead than the current team size justifies. Easy to forget, harder to keep simple.

Decision

We will use a dev-first deployment flow:

  • Every qualifying merge to main auto-deploys to the permanent dev environment.

  • Production deploys are manual-only.

  • The production GitHub Environment restricts allowed deployment refs to main or prod-* tags.

  • Successful production deploys create a timestamped prod-* tag for release traceability.

Dev is the automatic proving ground for integrated changes. Prod is an explicit promotion decision.

Consequences

Positive
  • Customers are protected from every merge becoming an implicit release.

  • The team still gets automatic deployment feedback on every integrated change.

  • Dev runs closer to prod than a local-only workflow because it uses the deployed containers, Caddy, Mailgun routing, and production Spring profile.

  • Release intent becomes clearer: shipping to prod is a conscious act.

Negative
  • Releasing now requires a human to dispatch the prod workflow.

  • Dev cannot be treated as a stable hand-tested environment because merges to main overwrite it.

  • Two deployment workflows must stay aligned enough that dev remains a trustworthy proving ground for prod.

ADR-15: Shared Remote Terraform State via Hetzner Object Storage

Status

Accepted (2026-04-24). Source: #426.

Context

deployment/environment/dev/ currently relies on a local terraform.tfstate on one workstation. That was acceptable for bootstrapping the dev environment quickly, but it ties infrastructure operations to one laptop and gives the team no shared locking, recovery, or handover model.

Local dev-infra commands now use with-hcloud-env.sh and shared 1Password-backed secret resolution instead of ad-hoc local setup. We need a shared Terraform state approach that fits the same small-team operating model without adding disproportionate process or tooling overhead.

Decision Drivers

  • Remove the single-operator dependency on one local state file

  • Protect state integrity with a shared locking model

  • Keep recovery practical via versioned state history

  • Stay within the current Hetzner-centered infrastructure footprint

  • Align local operator workflow with the wrapper and shared-secret pattern from #425

Considered Options

Option Pros Cons

Keep local state per operator

No backend setup. Familiar Terraform default.

Single-laptop dependency. No team-safe locking. Weak recovery and handover. Easy to drift or overwrite state accidentally.

HCP Terraform / Terraform Cloud

Mature remote backend, locking, history, and team controls. No S3-compatibility caveat.

Adds another SaaS dependency, a different operator workflow, and more platform surface than the current team size needs.

Hetzner Object Storage via Terraform s3 backend

Shared remote state within the current vendor footprint. Low cost. Supports versioned recovery. Fits the existing Hetzner-focused ops model.

S3-compatible support is best-effort, not first-party AWS S3. Locking semantics must be validated with the exact Terraform version and backend config. Bucket and credentials need manual bootstrap outside managed state.

Decision

We will store Terraform state for Hetzner-managed infrastructure in a shared remote backend on Hetzner Object Storage via Terraform’s s3 backend.

We adopt this pattern first for deployment/environment/dev/ and treat it as the default for future Hetzner infrastructure modules unless a later ADR supersedes it. The backend must enable bucket versioning for recovery, use_lockfile = true for shared-state locking, and separate Object Storage credentials from HCLOUD_TOKEN. Local operators must use the shared wrapper-based secret flow, with 1Password as the primary source and .env.local only as an override or fallback. The state bucket and initial backend credentials are bootstrap infrastructure and stay outside the Terraform state they protect.

Consequences

Positive
  • Terraform operations no longer depend on one engineer’s laptop.

  • Shared locking reduces accidental concurrent state changes.

  • Bucket versioning gives a practical recovery path after state corruption or accidental deletion.

  • The operator workflow stays aligned with ADR-14’s small-team, low-bureaucracy deployment model and the wrapper-based secret flow.

Negative
  • The state bucket becomes critical infrastructure and a higher-value target.

  • Credential compromise could expose infrastructure metadata or allow state tampering.

  • Hetzner Object Storage is only S3-compatible, so locking and recovery behavior must be tested and Terraform versions must be pinned deliberately.

  • Bootstrap, credential rotation, and break-glass recovery remain procedural work outside the managed Terraform state.

ADR-16: Format-Neutral Invoice Conversion Pipeline

Status

Superseded by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-15). Previously accepted (2026-05-01); retained as the historical decision that introduced the format-neutral pipeline direction.

Context

The current conversion flow is centered in invoice.conversion with formats.zugferd as a supporting adapter package. It still assumes PDF input for the productive route, but the semantic slice already separates local evidence construction, structural semantics, invoice extraction, fail-closed interpretation, and deterministic output generation. A benchmark run on 2026-05-01 across 26 PDFs and 78 attempts reached 75.59% populated-field exactness while generated XML still passed EN16931 validation in 100% of runs.

The dominant failures are semantic, not syntactic: payment means, credit-note detection, line-item grouping, references outside expected header regions, and allowance / charge / prepayment interpretation. Future scope broadens both sides of the pipeline: besides PDF, the system should accept Word and Excel, and besides ZUGFeRD it should support XRechnung and the ZUGFeRD / Factur-X family. A ZUGFeRD-specific package and a PDF-specific evidence model would hard-wire the wrong center of gravity.

Source-visible monetary values remain authoritative. Validation may prove internal consistency of generated output, but not faithfulness to the source document.

Decision Drivers

  • Support multiple input and output formats without creating N x M conversion paths

  • Share one semantic invoice-understanding core across all formats

  • Preserve source-visible monetary values as authoritative

  • Localize failures by pipeline stage and fail closed on ambiguity

  • Keep output-specific mapping, validation, and rendering isolated

  • Stay extensible as benchmark corpus and format support grow

Considered Options

Option Pros Cons

Extend formats.zugferd with more adapters

Lowest migration effort. Reuses the current package and pipeline.

Keeps ZUGFeRD as the conceptual center. PDF assumptions leak into future formats. Shared semantics stay coupled to one output family.

Separate pipeline per input/output pair

Each path can be optimized for one source and one target format.

Creates N x M duplication. Benchmark learnings fragment across pipelines. Shared semantic fixes must be repeated.

Format-neutral pipeline with canonical evidence

One shared semantic core for all inputs and outputs. Input and output remain adapter concerns. Benchmark learnings accumulate in one place.

Adds new stages, intermediate models, orchestration rules, and migration effort.

Custom-trained document model pipeline

Could eventually be optimized for the benchmark corpus.

High investment in labeling, training, evaluation, and operations. Not aligned with current team size and stage.

Decision

Adopt a format-neutral invoice.conversion feature package with five high-level stages:

  1. Input Interpreters — build CanonicalEvidence from format-specific adapters such as pdf, word, and excel. CanonicalEvidence is an abstract evidence tree, not a PDF-only page/block model.

  2. Semantic Document Parsing — turn CanonicalEvidence into richer format-specific evidence and then into typed semantic observations. The productive PDF slice currently uses XbergPdfStructuredTextExtractor to derive StructuredDocumentEvidence, a first typed chat pass to derive DocumentSemantics, and a second typed chat pass to derive ExtractedInvoice. SemanticIndex remains the handoff object owned by invoice.conversion; the current implementation stores ExtractedInvoice, DocumentSemantics, and StructuredDocumentEvidence there together with the summary fields downstream interpreters need.

  3. Specialized Interpreters — run concern-specific interpreters against the SemanticIndex, referenced evidence, and full-document fallback to produce ObservedInvoice fragments. Initial concerns are document type, parties/references, line items, payments, and taxation/adjustments.

  4. Validators — combine provenance checks, source-conflict checks, ambiguity detection, monetary reconciliation, and format-readiness validation. Monetary reconciliation preserves source-visible totals as authoritative and derives only the structured values needed by the target format, especially net amounts, tax basis amounts, and tax amounts for gross-priced source documents. If multiple semantic interpretations remain plausible, or exact reconciliation to authoritative monetary anchors is impossible, the pipeline must fail closed into validation failure / correction flow.

  5. Output Generators — generate target formats from validated input only. output.cii serves the ZUGFeRD / Factur-X family. output.xrechnung serves XRechnung-specific output.

Each observed field must preserve provenance: source format, source anchor, extractor stage, and ambiguity flags. The current implemented slice preserves this incrementally by carrying StructuredDocumentEvidence and DocumentSemantics alongside ExtractedInvoice in the semantic handoff, even though not every field is yet normalized into a separate provenance graph. Deterministic code is responsible for normalization, provenance handling, monetary reconciliation, validation, and output rendering. LLMs remain responsible for bounded semantic interpretation, not XML or arithmetic. The orchestrator must enforce bounded retry and adjudication budgets.

Implementation should combine Ports & Adapters at the input/output edges, a Pipeline orchestrator through the core, Strategy-based interpreters per concern/format, and Specification-style validators.

Algorithm Flow

adr16 algorithm flow

Module Dependencies

adr16 module dependencies

Consequences

Positive
  • Shared semantic understanding removes N x M coupling between input and output formats.

  • PDF, Word, and Excel can evolve as input adapters without duplicating semantic rules.

  • ZUGFeRD / Factur-X and XRechnung stay isolated at the output boundary.

  • Fail-closed validation prevents monetarily consistent but semantically wrong outputs from being emitted.

  • New archetypes can be handled by extending semantic parsing, specialized interpreters, and validators instead of rewriting one universal prompt.

Negative
  • The pipeline gains new moving parts: canonical evidence, semantic parsing, specialized interpreters, validators, and output generators.

  • End-to-end latency and API cost increase compared to a single-pass chat approach because the productive semantic slice now performs a structural and a final extraction pass.

  • The team must define authoritative-value roles, provenance rules, source-conflict rules, and orchestration budgets.

  • Existing references to formats.zugferd as the core conversion boundary must be migrated in code and architecture documentation.

ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI

Status

Accepted (2026-05-12) and implemented (2026-05-15) with local PDF document-text extraction for supported native-text PDFs; migrated from Kreuzberg to MIT-licensed Xberg in 2026-07. Refined by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-16) for input-adapter ownership and on 2026-07-17 to combine complementary plain and layout-aware page renderings; the interpreter technology and fail-closed scope remain active. Source: Issue #483: Replace Mistral OCR with local PDF text extraction.

Context

The former extraction path assumed provider OCR as the first step (ADR-2: OCR + LLM Extraction over Rule-Based Mapping), even though EER does not support scanned PDFs. The implemented replacement must preserve page-structured evidence for prompts and semantic parsing while preparing future Word and Excel inputs. A generic text-only converter loses too much positional meaning, while unclear or restrictive licenses would create unnecessary product risk for a core input capability.

Decision Drivers

  • Preserve layout-aware evidence for prompts and downstream semantic checks

  • Remove OCR cost and dependency from the supported native-text PDF path

  • Keep the input side compatible with future Word and Excel adapters

  • Prefer permissive JVM-native libraries with predictable operational footprint

  • Fail closed for unsupported scanned or image-only documents

Considered Options

Option Pros Cons

Keep provider OCR as the universal document reader

Handles scanned PDFs and keeps one extraction shape across inputs.

Over-scoped for EER, adds external latency and cost, fits future Word/Excel poorly, and keeps layout evidence tied to provider output.

Use a generic cross-format extraction framework or converter

One facade for many formats. Fast to prototype.

Either loses too much layout semantics, adds native/runtime complexity, or introduces unclear/restrictive licensing for a core capability.

Use application-local format interpreters: Xberg now for PDF text extraction, PDFBox for PDF validation/canonical evidence, Apache POI for future Office formats

Keeps page-structured evidence local, stays JVM-native, and aligns with ADR-16: Format-Neutral Invoice Conversion Pipeline input interpreters without routing supported PDFs through provider OCR.

Requires multiple adapters and our own heuristics for prompt evidence, reading order, blocks, and tables. Scanned PDFs stay unsupported.

Decision

Adopt native, format-specific input interpreters as the main evidence source.

  • Supported native-text PDF extraction derives two complementary page renderings locally through XbergPdfStructuredTextExtractor before the semantic chat call: plain text first and layout-aware Markdown second.

  • Both Xberg configurations use page extraction with OCR disabled. The Markdown configuration additionally enables layout detection and layout-based rendering. The application combines only PageContent.content; it does not append PageContent.tables, because Markdown already renders each detected table once.

  • Plain text recovers labels and address blocks that layout detection can omit or fragment. Markdown preserves row and column associations that plain output loses. Prompts treat them as two renderings of the same page and must not duplicate facts or lines.

  • Xberg’s separate reading-order post-processing option remains disabled. Enabling it changed neither native Markdown nor layout-aware Markdown output across the 34-PDF benchmark corpus and added no evidence quality.

  • PdfCanonicalEvidenceInterpreter keeps Apache PDFBox as the PDF validation and canonical-evidence boundary, and auxiliary plain-text extraction also remains local inside the application process.

  • Provider OCR is removed from the supported native-text PDF path. The outbound provider dependency remains only on the semantic chat step.

  • Future Word and Excel input interpreters will use Apache POI when those formats enter implementation scope.

  • Generic conversion tools may be used for benchmarking or research, but not as the primary production dependency for semantic evidence extraction.

  • Image-only or scanned PDFs remain out of scope and must fail closed instead of triggering a new OCR path by default.

Evidence Configuration Characterization

The 2026-07 Xberg 1.0.0-rc.29 characterization ran each candidate against all 34 benchmark PDFs with OCR disabled. All files remained native-text extractions.

Configuration Corpus result Finding

Plain page content alone (former production configuration)

34/34 extracted

Preserved labels and address blocks but discarded native table structure and separated some visible labels from their values. Retained only as the first half of the combined evidence.

Markdown with native table extraction

34/34 extracted

Rendered Xberg’s native tables once, but did not recover all visually structured headers and line-item tables in the Coolblue and Der Spiegel fixtures.

Markdown with layout detection and reading order

34/34 extracted

Preserved the required Coolblue and Der Spiegel header associations and reconstructed their line-item tables. This evidence shape was selected, except for reading order.

Markdown with layout detection, without reading order

34/34 extracted

Produced byte-identical output to the preceding candidate across the corpus. Selected as the table-preserving half of the combined evidence; plain text precedes it to recover omitted labels.

Layout inference requires the RT-DETR and TATR ONNX models. The immutable backend builder and runtime bases preload revision c6bf493e2f7b0b9a29a5870da9880c14e20ff0a3 from xberg-io/layout-models and verify these content hashes during the image build:

  • RT-DETR 3bf2fb0ee6df87435b7ae47f0f3930ec3dc97ec56fd824acc6d57bc7a6b89ef2

  • TATR c11f4033da75e9c4d41c403ef356e89caa0a37a7d111b55461e7d5ba856bb6b6

XBERG_CACHE_DIR points to the image-owned, read-only model directory. CI verifies the model hashes and runs a Coolblue extraction as the unprivileged user in the final backend image with networking disabled. It also repeats the complete native extraction regression suite offline in the builder image, so a missing model fails before deployment rather than downloading during a conversion request.

A representative offline Linux arm64 builder-container probe used the one-page Coolblue fixture. Plain extraction took 0.70 seconds and reached about 311 MiB RSS. Layout-aware extraction took 1.45 seconds cold and about 0.46 seconds warm; RSS stabilized around 1,000 MiB after the second extraction, with a 1,007 MiB peak across six extractions. The measured steady-state memory increase over plain extraction was about 689 MiB. Production x86_64 measurements may differ, so these values are capacity-planning baselines rather than an SLA.

Consequences

Positive
  • The supported PDF path removes one external OCR API call, reducing latency, cost, and provider coupling.

  • Combined page-structured plain text and layout-aware Markdown become an internal contract EER can tune for prompt design and semantic parsing without appending Xberg’s table collection a second time.

  • Future Word and Excel support fits the same format-neutral pipeline without forcing everything through OCR or markdown.

  • Xberg’s MIT license reduces license risk compared with the former Kreuzberg v4 ELv2 dependency.

Negative
  • Local PDF semantics now span more than one interpreter technology (Xberg for structured text, PDFBox for canonical evidence and auxiliary text, POI for future Office input).

  • Xberg still returns text-oriented page content rather than a perfect semantic structure; EER must own prompt evidence shaping, heuristics, and benchmark quality.

  • Each supported PDF is interpreted in both plain and layout-aware modes. Layout inference still dominates runtime and adds about 190 MiB of immutable model data plus a higher CPU and memory cost than plain extraction alone.

  • ADR-2: OCR + LLM Extraction over Rule-Based Mapping is superseded in part: provider OCR no longer defines the preferred path for supported native-text documents.

Scope after ADR-34

ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core keeps the native interpreter choice active but moves target-neutral PDF/LLM extraction behind an application-owned analysis port instead of leaving it under the ZUGFeRD output package. Failing closed means that image-only PDFs do not enter automatic semantic extraction or target handover; the Clarula flow may still accept a technically valid PDF and route it to REVIEW_REQUIRED under its approved product contract.

ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls

Status

Partially superseded by ADR-22: Diagnosis-First Validation Failure Routing (2026-05-26) for direct edit-screen recovery and by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-15) for the semantic contract, intermediate representation, application handoff, deterministic-code boundary, and direct ZUGFeRD rendering path. The six-call extraction topology, retry, and immutable-evidence decisions accepted on 2026-05-16 remain active; this ADR still supersedes ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals and the remaining LLM-extraction parts of ADR-2: OCR + LLM Extraction over Rule-Based Mapping.

Context

The current extraction pipeline (ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals) splits work between two sequential LLM calls (DocumentSemantics, ExtractedInvoice) and a sizeable amount of deterministic Kotlin: InvoiceTotalsCalculator computes line totals, tax bases and grand totals; ZugferdXmlNormalizer and StandardInvoiceInterpreter patch and reconcile the extracted facts before XML rendering.

Two structural problems have emerged from running this pipeline against real-world German invoices:

  1. Deterministic normalization cannot scale to the variance of real PDFs. Date formats, currency notations, country names, payment-term phrasings, tax-rate presentations, and table layouts vary across thousands of invoicing tools. Each new edge case adds a special-case branch to the normalizer. The code grows without converging.

  2. Code-side arithmetic conflicts with the product promise. EER is a conversion service: the resulting ZUGFeRD XML must reflect what is printed on the PDF, not what the code recomputes. When our recomputed totals disagree with the PDF, the source of truth is the PDF, not our calculator. Today’s calculator can silently produce a "correct" XML that disagrees with the user’s actual invoice.

The semantic interpretation of arbitrary PDF layouts is inherently a language task. LLMs handle this variance well; deterministic code does not. The sequential two-call design also leaves latency on the table: the document semantics pass blocks the extraction pass even though most fields are independent.

Decision Drivers

  • Faithfulness to the source PDF: extracted values must match what the user sees

  • Bounded prompt complexity: each prompt must stay simple enough to reason about and to keep LLM error rates low

  • Reasonable latency: parallel work where independence allows it

  • Complete diagnostic picture: users must see all findings at once, with recovery routed by ADR-22: Diagnosis-First Validation Failure Routing rather than one error per retry

  • Clear separation between user-correctable validation errors and infrastructure failures

  • Maintainability: a single developer must be able to add a new fact category without touching unrelated code

Considered Options

Option Pros Cons

Keep ADR-8 architecture, harden normalizer

No structural change. Existing tests stay relevant.

Normalizer grows unbounded with each new layout variant. Code-computed totals continue to diverge from PDF reality. Sequential calls remain the latency floor.

Single large LLM call returning the full invoice

One prompt, one round-trip, simple wiring.

Prompt complexity becomes the bottleneck. One mistake in any field corrupts the entire response. Few-shot examples for all fact categories blow up the context window.

Specialized LLM calls with no broad code-side interpretation or reconciliation (this ADR)

Each prompt stays focused. Failures are isolated to one fact category. Independent structured calls reduce wall-clock latency. The PDF remains immutable evidence. Kotlin is limited to mapping, provenance, deterministic consistency checks, target assembly, and validation.

Total token cost increases because the full PDF text is sent to every call. Cross-call consistency must be validated explicitly.

Decision

Replace the sequential two-call extraction with six specialized LLM calls in three stages. Header, parties, line items, and payment terms execute concurrently against the full text from XbergPdfStructuredTextExtractor. Totals and tax then receives the same text plus line-item context, followed by notes with all five structured slices as gap-filling context.

The six calls and their responsibilities:

Call Responsibility

DocumentHeaderExtractor

Document type (invoice/credit note), invoice number, issue date, delivery date or period, currency, buyer reference, order reference, contract reference.

PartiesExtractor

Seller and buyer: legal name, address, country code, VAT ID, tax number, contact details.

LineItemsExtractor

Complete semantic invoice lines: inclusion, description, billed quantity and unit, VAT-exclusive BT-146 and BT-148 prices, BT-147 discount, BT-131 line amount, adjustments, VAT, periods, and references. The prompt interprets VAT-inclusive source layouts and amount-only rows.

TotalsAndTaxExtractor

One coherent semantic model for BT-106 through BT-115, VAT breakdowns, and document-level adjustments, using semantic line items as context.

PaymentTermsExtractor

IBAN, BIC, due date, payment terms text, SEPA mandate references, discount terms, payment payee (when different from seller).

NotesExtractor

Free-text document notes (Kleinunternehmer-Hinweise, delivery notes, legal disclaimers, footers) preserved verbatim from the PDF, excluding facts already represented by the five structured slices.

Each call returns typed JSON via Mistral Structured Outputs. The prompts contain interpretation instructions plus lexical formatting requirements such as ISO dates, ISO country codes, decimal numbers, and recognized tax categories. Context-dependent meaning belongs to the prompts. This includes line recognition, implicit quantity, included rows, amount-only services and credits, net-versus-VAT-inclusive interpretation, and gross-to-net conversion.

The combined provider DTO contains one semantic value per invoice concept. It has no parallel source, net, gross, or calculated monetary fields. Kotlin maps these values to the application-owned model, binds provenance, checks deterministic relationships, and maps validated semantics to targets. It never creates an extraction-time unit price through line amount divided by a defaulted quantity.

After all six calls return, the pipeline maps them to ObservedInvoice and InvoiceDraft, then applies:

  1. Structured-output validation for each call’s JSON contract.

  2. Common semantic validation of required values, ambiguity, and deterministic relationships.

  3. Target readiness and conformance validation, including XSD and EN 16931 Schematron validation for ZUGFeRD.

All validation errors from all three stages are collected into a single result. Under ADR-22: Diagnosis-First Validation Failure Routing, findings go to diagnosis first: source defects require source correction and resubmission, while only recognition or mapping errors may open the editor exception path. If all stages pass, the XML is embedded into the source PDF and returned.

Infrastructure failures (LLM timeouts, provider 5xx after retry, rate-limit exhaustion that the RateLimitedClient cannot drain) are not validation errors. They take the regular exception path with a user-facing "please try again" response. Each LLM call retries once on failure; a second failure aborts the entire conversion.

The first four calls execute concurrently using Kotlin coroutines (async/awaitAll). A stage-1 failure cancels its siblings and skips both later calls. Otherwise totals and tax runs serially with TotalsAndTaxContext, followed by notes with NotesContext. The existing RateLimitedClient handles Mistral budget queuing for all six calls transparently.

Consequences

Positive
  • The PDF remains immutable evidence, while the application model contains one semantic interpretation. A target cannot silently replace or reconcile contradictory values.

  • Each prompt covers one fachliche concern and stays short enough to reason about. Few-shot examples per call become cheap.

  • Failure isolation: a regression in the parties prompt cannot break line-item extraction.

  • Four independent concerns execute concurrently; wall-clock latency is their slowest call plus the context-aware totals and notes calls, rather than six sequential calls.

  • ZugferdXmlNormalizer, StandardInvoiceInterpreter, and InvoiceTotalsCalculator are removed from the active pipeline. The deterministic surface area for layout-specific bugs collapses.

  • Adding a new fact category means adding one specialized class and one prompt, with no risk to the existing calls.

  • Users always see the complete list of diagnosable findings on the first attempt instead of discovering one finding per retry.

Negative
  • Token cost per conversion increases: the full PDF text is sent to six calls instead of two. Expected to remain within an acceptable per-conversion cost envelope for the current free and trial workload; will be revisited if cost becomes material.

  • Cross-call consistency must be checked explicitly by common and target validation. Drift surfaces as a diagnosis finding instead of being silently reconciled.

  • Four concurrent stage-1 calls put more pressure on the Mistral budget. RateLimitedClient will queue more often during traffic spikes; totals and notes add two serial latency steps.

  • The team gives up code-side layout interpretation and calculated replacement totals. If semantic extraction is wrong but internally consistent, target validation may not detect it; benchmark coverage and diagnosis remain essential.

  • Existing *LlmTest benchmarks must be rewritten against six specialized extractors instead of one combined extraction.

Neutral
  • The StructuredDocumentEvidence / DocumentSemantics distinction from ADR-8: LLM Returns JSON, Code Renders XML and Computes Totals goes away. The Xberg-extracted text is the only intermediate representation.

  • Kotlin does not infer semantics from arbitrary source text. It maps, fingerprints, checks deterministic relationships, and validates targets.

Scope after ADR-22 and ADR-34

The specialized typed LLM calls, retry behavior, infrastructure-error handling, immutable source evidence, and prohibition on silent document-total reconciliation remain active. ADR-22: Diagnosis-First Validation Failure Routing supersedes only the original direct-to-editor recovery: all findings are diagnosed first, and the editor may revise a draft only for recognition or mapping errors. ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core introduces SourceEvidence, application-owned invoice states, and a semantic-only provider contract. It supersedes deterministic gross-to-net conversion, parallel monetary fields, normalization pipelines, and ExtractedInvoice as the direct application-to-ZUGFeRD handoff. Extractor DTOs map once into application-owned observations. Kotlin validates but does not reinterpret PDF layout, infer missing unit prices, or repair contradictory invoice semantics. Target-specific defaults, calculations, and code mappings remain behind output adapters.

ADR-19: Per-Environment Terraform State Bucket for Production

Status

Accepted (2026-05-21). Refines ADR-15.

Context

ADR-15 established Hetzner Object Storage as the shared remote backend for Terraform state and standardized one bucket, invoicex-tf, with environments separated by S3 key prefix (terraform/dev/…​, terraform/prod/…​). That decision targeted "shared across operators," not "shared across environments," and dev was the only consumer at the time.

Standing up the new production environment under deployment/environment/prod/ forced the question that ADR-15 left open. Hetzner Object Storage access keys are project-scoped and Hetzner does not yet expose bucket-policy controls strong enough to enforce per-prefix isolation cryptographically. A leaked dev S3 credential would therefore be able to read or tamper with the production state object stored under the prefix in the shared bucket. The same blast-radius argument was just applied to the production SSH key (invoicex-prod-deploy, separate from the dev Hetzner_invoiceX); the S3 state layer needs the matching treatment.

Production has never run terraform apply against the shared bucket, so the cost of moving prod to its own bucket is creation only — no state migration.

Decision Drivers

  • Limit blast radius if production or dev S3 credentials leak independently

  • Keep the isolation posture between dev and prod consistent across SSH, secrets, and state

  • Avoid state migration cost by deciding before production state exists

  • Stay within the s3-on-Hetzner-Object-Storage pattern and bootstrap-outside-Terraform principle established by ADR-15

Considered Options

Option Pros Cons

Keep one bucket, isolate by S3 key prefix

Matches ADR-15 as originally written. One bucket to monitor.

Any S3 credential in the Hetzner project can read or write across prefixes today; isolation depends on operator discipline, not on enforced ACL or policy. Inconsistent with the SSH-key isolation we just adopted.

Per-environment bucket (invoicex-tf-prod for prod, invoicex-tf stays for dev)

Hard name-level isolation. Bootstrap script can apply per-bucket versioning, lifecycle, and access-block. Parallel to the SSH-key isolation already in place. No state migration because prod is unprovisioned.

Two buckets to monitor instead of one. ADR-15 needs the cross-reference. Dev remains in the shared-bucket pattern until separately retrofitted.

Per-environment Hetzner project

Strongest isolation; separates billing, API tokens, and Object Storage credentials at the project boundary.

Significant operational lift well beyond the current bootstrap. Touches token rotation, DNS-zone ownership, and CI secrets across multiple repos. Defer until the team is larger than one operator.

Decision

Production Terraform state moves to its own Hetzner Object Storage bucket, invoicex-tf-prod. Dev keeps invoicex-tf for now; retrofitting dev to invoicex-tf-dev is deferred because it requires terraform init -migrate-state against the live dev state and coordinated updates to dev README and any CI workflows that reference the bucket name.

Bucket creation and post-creation hardening live in deployment/environment/prod/bootstrap-state-bucket.sh, idempotent and driven from 1Password (op://invoicex_prod/terraform-state-s3/{access_key_id,secret_access_key}). The script enables versioning and sets a lifecycle rule expiring noncurrent state versions after 90 days. It deliberately does not enable AWS S3 Public Access Block because Hetzner Object Storage currently rejects regular private PutObject calls with that setting enabled; the working dev state bucket also runs without it. Hardening calls that Hetzner Object Storage does not implement degrade to warnings, not failures, so the script stays usable as Hetzner’s S3 surface evolves.

deployment/environment/prod/backend.hcl is updated to point at the new bucket; everything else in the backend block (key, region, endpoint, use_lockfile) stays unchanged.

The bucket and its credentials remain bootstrap infrastructure outside the Terraform state they protect, per ADR-15.

Consequences

Positive
  • A leaked dev S3 credential can no longer reach the production state object.

  • The state-bucket lifecycle (creation, versioning, lifecycle, access-block) is scripted, reviewable in a PR, and re-runnable.

  • Bootstrap posture for production is now symmetric across SSH, secrets resolution, and remote state.

Negative
  • Two state buckets to monitor and operate instead of one.

  • Dev remains in the legacy single-bucket pattern until a follow-up migrates it to invoicex-tf-dev.

  • Isolation today is name-based; until Hetzner Object Storage exposes bucket policies, an attacker with project-level S3 credentials can still cross buckets within the project. The protection is meaningful (no accidental prefix leakage, credential-segmentation by environment in 1Password) but not cryptographic.

ADR-20: Defer GitHub Merge Queue until Required CI is Enforced

Status

Accepted (2026-05-22).

Context

GitHub merge queues can protect a busy integration branch from breakage by testing a pull request together with the latest target branch and any queued pull requests before the merge lands. They are most useful when many independently approved pull requests compete for the same protected branch.

InvoiceX currently uses main as the integration branch. Qualifying pushes to main auto-deploy to the dev environment, while production promotion remains manual per ADR-14. The default branch ruleset currently prevents deletion and non-fast-forward updates, but it does not yet require pull requests or required status checks. The relevant GitHub Actions workflows run on pull_request; required checks in a merge queue would also need to run on merge_group, otherwise queued merges would wait for checks that never report.

Some workflows are path-filtered and comparatively expensive, especially full-stack E2E and visual-regression checks. Making those checks required without a deliberate required-check strategy can block documentation-only or unrelated pull requests with permanently pending checks.

Decision Drivers

  • Keep main protected without adding CI complexity before the basic branch policy exists

  • Avoid duplicate or unnecessary CI runs for a repository with modest merge throughput

  • Preserve fast Dependabot and feature-branch feedback while preventing broken integrated changes

  • Keep the dev-first deployment flow simple and traceable

Considered Options

Option Pros Cons

Enable merge queue now

Tests queued changes against the latest main state before merge. Reduces stale-branch merge races once multiple PRs are ready at the same time.

Requires updating all required workflows for merge_group first. Adds another CI run per queued merge. Gives limited benefit while no required checks or pull-request rule are enforced.

Require up-to-date branches without merge queue

Simple GitHub setting and familiar workflow.

Forces authors to update branches and re-run checks manually. Slower than merge queue under Dependabot or high PR concurrency.

Defer merge queue and first establish required branch checks

Keeps process simple now. Lets us define a small, reliable required-check set before adding queue semantics. Avoids path-filtered pending-check traps.

main can still receive sequentially approved PRs whose combined result was not tested by a merge queue.

Decision

We will not enable GitHub merge queue on main yet.

Before enabling it, we will first establish the baseline branch policy for main:

  • require pull requests for changes to main

  • define the required status checks explicitly

  • ensure every required GitHub Actions workflow also runs on merge_group

  • avoid making path-filtered workflows required unless they have a non-skipped reporting strategy

When PR throughput or Dependabot churn makes stale-branch merges a recurring problem, we will revisit merge queue with conservative settings:

  • squash merge method, matching the repository setting

  • build concurrency of 1 or 2

  • maximum merge group size of 1 initially

  • required checks limited to the stable core CI set

Consequences

Positive
  • The current GitHub rules stay understandable while the repository has modest merge concurrency.

  • We avoid a queue that fails because required checks do not run on merge_group.

  • We can design required checks deliberately before coupling them to merge queue behavior.

Negative
  • Sequential PR merges can still interact badly if the combined result is not tested before the second merge lands.

  • Dependabot batches may still require manual sequencing when several dependency updates are ready at once.

Neutral
  • The dev deployment workflow remains push-driven from main; merge queue adoption would not change the production-promotion model from ADR-14.

Compliance

Merge queue must stay disabled for main until the ruleset contains explicit required checks and each required workflow supports the merge_group event.

ADR-21: Partner Affiliation Confirmation via One-Time Email Token

Status

Accepted (2026-05-24). Drives implementation of PDR: Partner Affiliation Verification for Partner-Origin Trials.

Context

Partner widgets are publicly reachable. Users who register through a partner page start a regular 30-day trial, but only real clients of the partner Kanzlei should be moved into the partner tariff after the Kanzlei confirms the requested affiliation.

The tax advisor must confirm the client relationship without receiving invoice data, conversion results, usage history, or account access. The first pilot partner accepts an email-based confirmation flow and needs full name or company name plus email address to identify the mandate.

Decision Drivers

  • Keep the tax advisor a recommender, not an account manager

  • Notify the partner only after explicit user request and confirmed email ownership

  • Minimize disclosed data and avoid partner access to user accounts or invoices

  • Avoid building a partner dashboard before partner-channel demand is proven

  • Make confirmation auditable while keeping partner effort minimal

Considered Options

Option Pros Cons

One-time email token sent to configured partner address

Low partner effort. Fits existing email architecture. No dashboard required. Keeps confirmation narrowly scoped.

Email is a bearer channel. Requires high-entropy tokens, expiry, and single use.

Partner dashboard with authenticated login

Stronger control and better future client-management foundation.

Adds partner identity, onboarding, support, and UI scope before demand is proven. Conflicts with v1 non-goal of partner management.

Manual founder/admin confirmation

Smallest technical surface. No partner-facing confirmation endpoint.

Does not scale. Creates operational bottleneck and weak audit trail between user request and partner answer.

Decision

Use a one-time, expiring email token for partner affiliation confirmation.

The widget collects the user’s full name or company name only after the user explicitly requests Kanzlei-Zuordnung. The system sends the partner notification only after the user confirms email ownership through DOI. The notification goes to the partner-specific affiliation email address configured in the admin portal.

The token is opaque, high entropy, stored hashed server-side, single-use, and scoped only to confirming the affiliation request. Opening the confirmation link with GET confirms the affiliation directly and renders a success, used, expired, or invalid result page.

When the affiliation is confirmed, the existing account is moved from Plan.FREE_TRIAL to Plan.PARTNER. The transition clears trialStartDate and trialEndDate, records partnerAffiliationConfirmedAt, and publishes a dedicated PlanAssignedEvent for the partner-tariff assignment.

Partner-origin metadata remains attribution and eligibility metadata. It does not grant the partner access to invoices, conversion results, usage history, billing state, or account settings.

Consequences

Positive
  • Partners can confirm affiliation with one email action and no dashboard onboarding.

  • The disclosed data is limited to full name or company name, email address, and request context.

  • The mechanism fits the existing monolith, partner registry, and email delivery architecture.

  • Confirmed users are switched into the partner tariff immediately without a separate back-office step.

Negative
  • Whoever controls the partner mailbox can use the confirmation link until it expires.

  • Wrong or stale partner notification addresses can disclose personal data to the wrong recipient.

  • Automated link opening by mail clients or scanners can trigger the confirmation, because the action happens on GET.

  • Future partner self-service or revocation workflows may require a stronger authenticated partner area.

ADR-22: Diagnosis-First Validation Failure Routing

Status

Partially superseded by ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core (2026-07-15) for the canonical correction state. Diagnosis-first routing accepted on 2026-05-26 remains active, supersedes the direct edit-screen recovery in ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls, and continues to implement PDR: Diagnose-first Correction Flow.

Context

Validation failures currently lead users toward the browser editor. That default suggests that users should repair structured XML data, even when the real problem is a missing or inconsistent source invoice. For hybrid invoices this is risky because the visible PDF and the structured invoice data can diverge.

The product decision establishes source-invoice correction as the default path. The manual editor remains necessary, but only for genuine recognition or mapping errors where the visible PDF is already correct.

Decision Drivers

  • Preserve consistency between visible invoice PDF and structured data

  • Keep the user’s existing invoice tool as the default correction place

  • Provide one shared validation-error entry point for email, website upload, and widget flows

  • Keep the manual editor available without making it the primary CTA

  • Make validation errors actionable without exposing validator rule codes first

  • Support product analytics for diagnosis, retry, and editor-exception behavior

Considered Options

Option Pros Cons

Keep edit-first routing

Lowest implementation effort. Reuses existing /edit/{token} flow.

Keeps the wrong product default. Encourages PDF/XML divergence. Does not explain source-invoice problems clearly.

Add diagnosis text inside the editor

Reuses current route and form. Lets users continue editing when needed.

The full editor still dominates the page. The source-invoice path remains secondary.

Add dedicated /diagnosis/{token} before the editor

Gives validation failures their own user journey. Keeps source correction primary and editor entry deliberate. Works for email, website, and widget origins.

Adds another token-based page and route. Requires updating emails, website, widget, and runtime documentation.

Render diagnosis only inline per channel

Can optimize each channel’s UI independently.

Duplicates diagnosis logic. Leaves email without a durable detailed page. Makes analytics and future changes inconsistent.

Decision

All user-visible validation failures that have an edit session token will route first to a dedicated diagnosis page at /diagnosis/{token}. Email templates, website upload validation states, and partner widget validation states will link to this diagnosis page instead of linking directly to /edit/{token}.

The diagnosis page is owned by invoice.editing.ui and reuses the InvoiceEditSession token model, persisted source PDF, versioned application draft, validation status, and validation errors. It presents grouped, user-action-oriented validation errors and makes source-invoice correction the primary path. The existing /edit/{token} route remains available as the manual recognition-error path, but the diagnosis page must require an explicit user choice before linking to it.

Exact customer-facing copy, examples, and legal boundary wording follow the PDR and remain subject to legal/product copy review before release.

Consequences

Positive
  • Users see validation failures as a guided checklist instead of a full invoice editor.

  • Source-invoice correction becomes the architecture default across email, website upload, and widget flows.

  • The editor remains useful for recognition errors without becoming a hidden invoice-authoring tool.

  • The shared route gives product analytics one consistent place to track diagnosis views, help expansion, retry intent, and editor exceptions.

Negative
  • Validation-failure handling now spans more UI surfaces and must keep route contracts consistent.

  • The existing runtime scenario "Validation Failure with Edit-and-Retry" must be rewritten to diagnosis-first behavior.

  • Tests must cover both the diagnosis route and the still-supported editor exception path.

Scope after ADR-34

Diagnosis-first navigation, source correction as the default, token capabilities, and the editor exception path remain unchanged. After the correction cutover, every session persists a versioned application-owned invoice draft instead of using generated ZUGFeRD XML as editable state. The cutover deletes existing XML-backed sessions because their generated artifacts cannot reconstruct trustworthy source evidence. XML parsing remains only at target-output boundaries, never in the correction path.

ADR-23: Widget Result Delivery by Standard Success Email

Status

Accepted (2026-05-26), amended (2026-07-10). Supersedes the direct-download parts of ADR-12: Anonymous Conversion Persistence with TTL.

Context

The partner widget originally planned an upload-first flow where users authenticated with a widget session and then downloaded generated PDFs directly from the partner page. That design required download tokens, polling, and download endpoints in addition to the existing email delivery path.

The product still needs double opt-in for unknown email addresses. At the same time, existing accounts must not receive a magic login link just because they entered an email in a partner widget. The system already has a proven result-delivery path: ResponseMailService.sendSuccessMail(…​) sends the clean ZUGFeRD PDF, verification PDF, and edit link.

The initial implementation deliberately forgot the entered email after every conversion. Returning users therefore had to enter the same address repeatedly on the same partner page and browser. This avoidable prompt can be removed without restoring browser downloads or granting access to the customer portal.

Decision Drivers

  • Keep DOI for new users before the first result is sent.

  • Avoid logging existing accounts in from a third-party widget email entry.

  • Reuse the established success email and attachment naming behavior.

  • Remove repeated email entry on the same partner page and browser.

  • Keep remembered identity revocable and strictly partner-scoped.

  • Keep anonymous upload persistence and TTL from ADR-12: Anonymous Conversion Persistence with TTL.

Considered Options

Option Pros Cons

Require the email address for every conversion

No persistent browser identity state.

Returning users repeat the same step for every invoice.

Remember a partner-scoped identity and continue delivering by email

Removes repeated email entry while retaining DOI, standard email delivery, revocation, and a narrow capability boundary.

A bearer token exists in partner-origin browser storage and can be read by scripts on that origin.

Restore widget sessions and direct downloads

Best same-page result UX.

Adds download authorization, polling, and a substantially larger security surface.

Decision

The widget delivers results only through the standard success email and does not expose direct result downloads. It may remember a narrowly scoped widget identity for future email deliveries from the same partner:

  1. If the email belongs to an existing account, POST /api/v1/widget/auth claims the conversion, sends the result email, creates an active WidgetIdentitySession, and returns its opaque token.

  2. If the email is unknown, POST /api/v1/widget/auth starts DOI and returns a provisional token. Only its hash is stored, and the provisional session is bound to that exact PendingRegistration. Successful DOI confirmation links only those sessions to the newly created account and removes their technical confirmation expiry.

  3. The widget stores the token in partner-origin localStorage, keyed by API origin and partner ID. It stores neither the email address nor invoice data.

  4. On a later conversion, POST /api/v1/widget/claim-with-session accepts the token as a Bearer credential and the current partner ID. The backend requires request partner == session partner == conversion partner, resolves the fixed account email, claims the conversion, and sends the standard result email.

  5. On startup, GET /api/v1/widget/session-identity resolves active tokens and unexpired provisional DOI tokens to their normalized fixed recipient. The widget displays a compact “Versand an …” row in every state but keeps the email only in memory; localStorage still contains only the opaque token. The action says “wechseln” where another address can be entered immediately and “nicht mehr merken” where it only clears the remembered identity. Both clear only the current API-origin/partner token.

  6. Unknown, malformed, revoked, technically expired, inactive-partner, and partner-mismatched sessions return 401 widget_session_invalid. The widget clears only that partner’s stored token and falls back to the email form.

  7. Active sessions have no product-facing expiry by default. The persisted model retains nullable expires_at, revoked_at, and last_used_at fields for technical expiry, revocation, and later account-policy checks.

A widget identity session is not an application login. It cannot create a Spring Security context, mint customer or admin JWTs, access the portal, access correction pages, or download invoice artifacts. Its only authority is to send a widget conversion belonging to the same partner to the email of its fixed account. The widget does not poll for authentication state.

Consequences

Positive
  • Returning users can request later partner-widget results without entering their email again.

  • Result delivery still reuses the same email, attachments, edit links, and DOI rules as other widget deliveries.

  • Tokens are high entropy, stored only as SHA-256 hashes server-side, partner-bound, revocable, and independently auditable.

  • Existing accounts still receive no magic login link from widget email entry.

  • New accounts still pass DOI before a provisional session becomes usable.

Negative
  • Partner-origin scripts and XSS can read localStorage; server-side partner checks and the narrow email-only authority are therefore mandatory.

  • On a shared browser, the remembered identity sends later widget results to the previously confirmed mailbox until a user chooses another email or clears browser storage.

  • Session persistence and a dedicated claim endpoint add state and tests compared with asking for the email every time.

Neutral
  • The anonymous conversion TTL still bounds how long unclaimed uploads are retained.

  • The correction UI remains outside the widget and uses claim_token for return-state restoration. After claim, the attached edit session may replace the result only for the same account and redelivers the corrected artifacts by standard email.

  • Analytics continue to treat WidgetClaimSucceededEvent as result-email delivery rather than download completion.

ADR-24: Clarula Monorepo Structure and Neutral Backend Naming

Status

Partially superseded by ADR-25: Static Nuxt Product Frontends for product frontend paths, ADR-26: Static Frontend OCI Images behind Shared Edge Caddy for frontend runtime packaging, and ADR-30: One BootJar with Product-Isolated Spring Child Contexts for the backend’s internal subproject and context structure; neutral backend location and monorepo naming remain active.

Date

2026-07-09

Context

The repository started as the codebase for einfache-eRechnung.de and therefore used product-specific names such as website/, application/invoice-processor/, and InvoiceX in operational paths. Clarula is now the umbrella brand and may host multiple products, while the backend remains a single Spring Boot monolith shared by those products.

The old structure made the einfache-eRechnung product look like the default product and made the Clarula site look like an add-on. It also coupled the backend’s repository location and Docker image names to one capability instead of the shared monolith.

Decision

Use a Clarula-oriented monorepo layout with neutral deployment-unit names:

  • applications/backend/ for the shared Spring Boot monolith.

  • applications/visual-tests/ for Docker visual regression tests across product frontends and retained backend pages.

  • applications/frontend/apps/clarula/ for the complete Clarula product frontend.

  • applications/frontend/apps/einfache-erechnung/ for the complete einfache-eRechnung.de product frontend.

  • demos/partner/ for static partner demo pages.

  • Docker service and image names use neutral units such as backend, backend-base, backend-builder-base, and caddy; ADR-26: Static Frontend OCI Images behind Shared Edge Caddy later packages each static product frontend as its own OCI image.

  • Published container images live under the Clarula GHCR project namespace ghcr.io/clarula/clarula/*.

The backend remains a monolith and keeps the existing package-by-feature structure. Technical legacy identifiers that affect production state, such as the PostgreSQL database name, 1Password vault names, and INVOICEX_* configuration variables, may remain until a separate operational migration is justified.

Consequences

  • Repository navigation now reflects Clarula as the umbrella context and treats product sites equally.

  • CI, local development, E2E, visual tests, deployment Compose files, and agent guidance must use the new paths.

  • Future products should be added under explicit product/site/demo names instead of becoming implicit top-level defaults.

  • Historical product decisions may keep old names when they describe past decisions, but current instructions should prefer the new layout.

ADR-25: Static Nuxt Product Frontends

Status

Partially superseded by ADR-26: Static Frontend OCI Images behind Shared Edge Caddy for hosted delivery topology and ADR-33: Dedicated Static Operator Frontend on admin.clarula.de for retaining backend-rendered admin presentation; static Nuxt generation and product boundaries remain active.

Date

2026-07-12

Context

The repository now supports two products, einfache-eRechnung and Clarula, on one shared Spring Boot backend. Their marketing sites are separate Hugo applications, while customer-facing application pages such as login, portal, diagnosis, and correction are rendered by the backend with Thymeleaf. This splits each product experience across technologies and deployment units and couples product presentation, branding, and frontend releases to the shared backend.

Both products need a continuous experience from marketing content and demos into their complete applications. They must remain independently deployable, while all business logic stays in the shared backend monolith. The team creates and maintains content as repository files with AI agents and does not need a runtime CMS. Runtime server-side rendering is not required.

Decision Drivers

  • Provide one continuous marketing and application experience per product and origin.

  • Keep product presentation and frontend releases independent from the shared backend.

  • Reuse frontend foundations without coupling the two products into one deployment artifact.

  • Preserve static delivery, low operational overhead, and search-engine-readable marketing content.

  • Keep all business logic in the Spring Boot monolith in accordance with ADR-5: Monolith-First Architecture.

  • Establish explicit, generated, and evolvable contracts between frontends and backend.

Considered Options

Option Pros Cons

Keep Hugo marketing sites and backend-rendered Thymeleaf applications

Lowest migration effort. Marketing pages remain static.

Splits each product experience across technologies and deployments. Keeps customer-facing presentation and branding coupled to backend releases.

Keep Hugo for marketing and add separate client-side applications

Retains the existing content sites. Separates application UI from the backend.

Creates two frontend stacks per product. Shared navigation, design, demos, and conversion flows require coordination across applications.

Build one runtime-configured multi-brand frontend

Maximizes apparent code reuse and produces one frontend application.

Couples product releases and encourages product-specific conditionals throughout routing, content, and presentation. A defect or deployment affects both products.

Build one static Nuxt application per product in a shared workspace

Unifies marketing and application UI per product, supports static content and interactive application routes, and allows selective reuse with independent deployments.

Introduces Node.js and Nuxt as build-time technologies and requires migration from Hugo and Thymeleaf. Dynamic application routes need explicit static fallback routing.

Use Nuxt with runtime SSR

Supports request-time rendering and server-side data loading.

Adds stateful frontend runtime processes and operational failure modes without a current requirement.

Decision

Create a shared frontend workspace under applications/frontend/ with two independently buildable applications:

applications/frontend/
├── apps/
│   ├── einfache-erechnung/
│   └── clarula/
└── packages/
    └── ... shared technical foundations

Each application uses Nuxt and Vue and owns the complete customer-facing product experience: marketing pages, repository-managed content, demos, registration and authentication UI, and product application routes. Product content, branding, navigation, and product-specific components remain inside the owning application. Shared packages are limited to capabilities used by both products, such as API clients, UI foundations, authentication integration, analytics integration, and test support.

Nuxt generates static HTML, JavaScript, and assets at build time. Production does not run a Node.js or Nuxt server. Marketing, content, and legal pages are pre-rendered as complete HTML; interactive application routes use client-side Vue code and may require JavaScript. Content remains versioned in the repository, without a CMS.

Caddy serves each product’s static output directly under its product origin:

Caddy proxies backend operations on both origins under /api/v1/ to the shared Spring Boot backend. All browser-facing backend APIs, including authentication operations, use this prefix. The backend publishes an OpenAPI contract, and the frontend build generates TypeScript clients from it. Version v1 denotes the first major contract generation; compatible changes within v1 remain additive, while breaking changes require a new major API path and a migration period. Webhooks, actuator endpoints, and backend-rendered admin routes are not browser-facing product APIs and do not need this prefix.

The two frontend applications and the backend remain separate, independently versioned deployment artifacts. Independent deployment requires the backend to remain compatible with currently deployed frontend versions during contract migrations.

Customer-facing Thymeleaf views and their product assets will leave the backend as their flows migrate. The backend-rendered admin area remains in Spring Boot and is outside this frontend migration. Embeddable partner widgets also remain outside the Nuxt applications and continue to follow ADR-11: Embeddable Widget as Vanilla JS Web Component. Detailed product identity, JWT audience, and cross-product account rules require a separate decision when product identity requirements are settled.

This decision refines ADR-5: Monolith-First Architecture: the Spring Boot application remains the single business-logic monolith, while its stateless companions become complete product frontends instead of Hugo-only marketing sites. It supersedes the sites/clarula/ and sites/einfache-erechnung/ target layout from ADR-24: Clarula Monorepo Structure and Neutral Backend Naming; the remaining neutral backend and monorepo naming decisions stay active.

Consequences

Positive
  • Each product presents one continuous experience under one origin, without a visible marketing-to-application domain transition.

  • Marketing content, demos, and application flows share routing, layouts, components, and analytics within the owning product.

  • Frontend releases no longer require a backend release, and one product frontend can deploy without deploying the other.

  • Static output avoids additional frontend runtime processes and keeps marketing pages directly indexable.

  • Same-origin API access reduces CORS and cookie complexity.

  • OpenAPI-generated clients make frontend/backend contracts explicit and reduce manually duplicated transport types.

  • The shared workspace enables reuse without creating one multi-brand deployment artifact.

Negative
  • Both Hugo sites and all customer-facing Thymeleaf flows must be migrated.

  • The team must maintain a Node.js/Nuxt build toolchain and Nuxt-specific test infrastructure.

  • Caddy needs explicit fallback rules for dynamic client-side routes while preserving real 404 responses for unknown content routes.

  • Independent deployments require additive API evolution, compatibility checks, and coordinated removal of obsolete API versions.

  • Direct static hosting through Caddy requires atomic artifact deployment and rollback handling.

  • Some duplication between product applications is intentional to preserve product ownership and deployment independence.

Neutral
  • Thymeleaf remains a backend dependency while the admin area remains server-rendered.

  • Product sessions are naturally separated by their origins, but the persistence model for users shared across products remains undecided.

  • Embeddable widgets retain their separate framework-independent architecture and release contract.

ADR-26: Static Frontend OCI Images behind Shared Edge Caddy

Status

Partially superseded by ADR-27: Promotion-Only Production Delivery

Date

2026-07-13

Context

ADR-25: Static Nuxt Product Frontends established two independently generated static Nuxt product frontends and direct file delivery through Caddy. The implementation packages .output/public/ as archives, installs versioned release directories on each host, bind-mounts them into Caddy, and keeps product-specific file routing in the shared edge configuration.

The deployment platform otherwise delivers immutable OCI images. Host-installed frontend files create a second release mechanism, add host backup and symlink state, and place product-specific cache, fallback, and 404 behavior in shared Caddy configuration. We want each frontend artifact to contain both its generated files and its product-owned web-server policy while retaining one shared security edge and a simple full-runtime production release.

Decision Drivers

  • Package files and web-server configuration in one immutable, portable artifact.

  • Keep product-specific routing, cache, privacy-header, and 404 policy with the owning frontend.

  • Preserve shared TLS, rate limiting, backend route ownership, and bounded legacy-widget compatibility at one trusted edge.

  • Promote the exact frontend image tested in dev to production without rebuilding it.

  • Keep operations appropriate for one VPS and a small team; brief frontend interruption is acceptable.

  • Avoid a Node.js or Nuxt runtime server.

Considered Options

Option Pros Cons

Keep host release directories served directly by Caddy

Fewest runtime processes. Atomic symlink activation without container restart.

Separate non-OCI release mechanism. Frontend files and product-specific server policy are split across host state and shared Caddy.

Send every product-origin request through a frontend container

Edge Caddy needs only host dispatch. Each product origin appears fully self-contained.

Duplicates backend route and security policy, adds another backend proxy hop, and gives frontend containers unnecessary backend network access.

Use one static frontend container per product behind shared edge Caddy

One OCI artifact per product. Product web-server policy remains local while shared backend/security routing remains centralized. Uses the existing Compose and GHCR operating model.

Adds two Nginx processes and image patching. Container replacement can briefly interrupt one product.

Decision

We will package each generated Nuxt frontend as a separate OCI image with an app-local unprivileged Nginx server:

  • frontend-einfache-erechnung

  • frontend-clarula

Each final image contains only .output/public/ and the product’s Nginx configuration. It runs as a numeric non-root user on an internal port with a read-only root filesystem, no Linux capabilities, no secrets, and no published host port. Frontend request logging is disabled because diagnosis and correction URLs contain bearer capability tokens.

Nginx owns product-specific generated-file resolution, scoped client-shell rewrites, compression, cache and privacy headers, and real 404 responses. It does not proxy backend requests and has no database network path.

Shared Caddy remains the sole public edge. It owns TLS, host aliases, dev-wide search-engine exclusion, shared rate limits, explicit backend/admin/widget/webhook/callback routes, and the temporary compatibility routes for already-installed widgets. After those routes, Caddy reverse-proxies remaining traffic to the matching frontend container and removes credentials before forwarding static requests.

Dev builds immutable dev-<full-sha> frontend images and verifies them through the deployed topology. Production promotion resolves both tested image digests and creates prod-<full-sha> from those same digests; production does not rebuild the frontends. Production promotion and rollback deploy the complete runtime, with both frontends before Caddy, and verify health, release metadata, and public routing. A short interruption during replacement is accepted; we will not add blue/green deployment until an availability requirement justifies it.

This decision supersedes only the direct-Caddy file delivery, host release directory, and no-product-web-server portions of ADR-25: Static Nuxt Product Frontends and ADR-24: Clarula Monorepo Structure and Neutral Backend Naming. Static Nuxt generation, independent product artifacts, the /api/v1/ contract, and the shared Spring Boot business monolith remain active.

Consequences

Positive
  • Frontend files and product server policy form one immutable deployment artifact.

  • Host replacement and restore no longer manage frontend directories, checksums, or symlinks.

  • Product routing policy remains in the product-owned image.

  • Dev and production use the same tested image manifests.

  • Caddy remains a small shared security boundary instead of duplicating backend routing in both products.

Negative
  • Production runs two additional Nginx processes and must patch their base image.

  • A production rollout replaces all runtime image components, even when only one changed.

  • Rollback depends on retaining the full-runtime image version in every GHCR package or on the host.

  • Nginx and Caddy routing boundaries need container-level and full-stack contract tests.

Neutral
  • Nuxt remains build-time tooling; there is still no Node.js or runtime SSR process.

  • The single VPS remains the availability boundary.

  • ADR-14’s automatic dev delivery and manual production promotion remain unchanged.

ADR-27: Promotion-Only Production Delivery

Status

Accepted

Date

2026-07-13

Context

ADR-26 introduced full-runtime production promotion, named rollback, component restoration after deployment failure, and complete-runtime restoration after smoke failure. The first production cutover to the two static frontend containers is complete. The rollback branches and their retained-state handling make the production workflow harder to understand and maintain, while the current team has no requirement for automated rollback.

Production must remain manual under ADR-14, and frontend production bytes must still match the images tested in dev. We need the smallest production workflow that preserves those constraints.

Decision Drivers

  • Keep the manual production path easy to understand and operate.

  • Remove one-time cutover and unused recovery branches.

  • Preserve exact-digest frontend promotion from dev.

  • Preserve full-runtime version alignment and Caddy-last deployment order.

  • Fail visibly instead of running additional mutation after a failed check.

Considered Options

Option Pros Cons

Keep named and automatic rollback

Shortens recovery when every retained image and database schema remain compatible.

Retains workflow inputs, previous-version capture, reverse deployment, verification branches, and image-retention assumptions.

Keep automatic restoration but remove named rollback

Preserves failure recovery while reducing one operator input.

Still retains most rollback state and branching in the deployment path.

Use one promotion-only path

Removes rollback-specific inputs and state. Every production change follows the same tested path.

A failed deployment can leave mixed component versions or a failed target version in runtime configuration. Recovery requires an operator and another promotion.

Decision

The production workflow will only promote a complete runtime from a main-reachable, dev-tested revision. It will deploy backend, both frontends, both demos, and Caddy in that order, then verify health, public routing, and frontend release metadata.

We will remove named rollback, previous-version recording, automatic restoration, host-local image handling, and all completed first-cutover branches. The component deployment script will stop on failure without restoring the previous image version. Operational recovery will use the same promotion workflow with a corrective, dev-tested revision.

This decision supersedes ADR-26 only where ADR-26 requires named rollback or automatic restoration. ADR-26’s static frontend image model, exact-digest promotion, complete-runtime production release, and Caddy-last order remain active.

Consequences

Positive
  • Production has one operation, one version-selection model, and one deployment sequence.

  • The workflow no longer carries temporary Hugo/Caddy cutover state.

  • Failed checks remain visible and do not trigger additional automated production mutation.

Negative
  • A failed component deployment can leave runtime configuration on the requested version.

  • A failure after earlier components have deployed can leave a mixed runtime until the next successful promotion.

  • Recovery can take longer and requires an available operator.

  • Database migrations must remain compatible with the chosen corrective deployment.

Neutral
  • Immutable prod-* image tags remain for traceability and reproducible promotion.

  • Production remains manual-only under ADR-14.

  • Public smoke checks and the production operation lock remain unchanged.

ADR-28: Certified Composite Runtime Snapshot Promotion

Status

Partially superseded by ADR-33: Dedicated Static Operator Frontend on admin.clarula.de for the six-image snapshot; the certified composite promotion model remains accepted.

Date

2026-07-13

Context

Dev deploys changed runtime components independently, so its tested runtime can contain images from several source commits. The previous production workflow required both frontends from one exact dev commit but rebuilt backend, Caddy, and demos during production promotion. This prevented promotion after selective dev builds and meant production did not run the complete image combination tested on dev.

We need a manual production operation that promotes the tested dev combination without rebuilding runtime components and still identifies every successful production release as one human-readable set.

Decision Drivers

  • Promote exactly the six image digests tested together on dev.

  • Preserve component-selective automatic dev delivery.

  • Make a production dispatch without optional input select the current certified dev runtime.

  • Avoid runtime image builds during the production operation.

  • Identify one production cohort across all image packages and host-recovery metadata.

  • Prevent a later dev deployment from changing an active production promotion.

Considered Options

Option Pros Cons

Keep one source revision and rebuild non-frontend images in production

One Git SHA labels the release. Existing backend and demo build flow remains.

Selective dev builds block unrelated promotions. Four production images differ operationally from the dev-tested artifacts.

Resolve six moving dev tags during production

Simple package-level convention. No additional manifest artifact.

Tag reads are not atomic. A concurrent dev deployment can produce a combination that never passed one smoke test. Moving tags do not preserve component provenance.

Promote one certified composite runtime snapshot

Preserves selective builds, freezes all six digests, and promotes the tested combination without rebuilding it.

Adds a runtime-snapshot OCI artifact, release-finalization logic, and composite provenance instead of one source SHA.

Decision

After each successful dev deployment and full public smoke test, the dev workflow will record all six running immutable image tags, resolve their OCI manifest digests, and publish one certified runtime-snapshot OCI image. The snapshot records each component’s image, digest, and source SHA plus the dev workflow run and deployment-control revision. All six component digests and the runtime snapshot receive the same immutable dev-run-<run-id>-<attempt> cohort tag. The package-local moving dev tags identify the images in the certified combination; the single runtime-snapshot:dev tag moves last and is the authoritative current-dev pointer. A failed dev deployment or smoke test does not publish a new certified pointer.

Dev and production operations are serialized within their own environments. A production dispatch defaults to runtime-snapshot:dev, resolves it to one immutable digest, and validates the referenced successful Dev certification on main before mutation. After production approval, it revalidates the snapshot’s cohort tags and pulls each component by the recorded digest. The host uses the snapshot’s dev-run-<run-id>-<attempt> ID as a temporary local rollout reference while deploying the complete runtime with Caddy last. Later Dev deployments cannot change the selected bytes. An operator may explicitly select a retained immutable snapshot only when its deployment-compatibility schema matches the current production workflow. Snapshot schema version 2 marks the separate EER/Clarula database boundary and rejects older snapshots before production secrets, SSH, or host mutation. Dev certification also requires the running backend to advertise product-databases-v1 through operational info, so a selective manual run cannot stamp stale pre-boundary backend bytes as schema 2. Production does not rebuild backend, Caddy, either frontend, or either demo. Caddy therefore packages both validated environment configurations in one image; Compose selects the active environment configuration at runtime.

After successful production smoke tests, the workflow will add the same immutable prod-<UTC timestamp>-<production run ID> tag to all six component digests and to the runtime snapshot. It will then record that release ID in the production runtime environment and move each package-local prod pointer. The runtime-snapshot manifest and OCI digests are authoritative; dev, prod, and the shared release tag are readable pointers. OCI registries cannot update seven package tags atomically, so a failed finalization is recovered through a new production dispatch rather than a same-run retry.

This decision supersedes ADR-26 and ADR-27 only where they require one source revision, frontend-only exact-digest promotion, or production builds for other runtime components. Their static frontend boundary, complete-runtime production deployment, Caddy-last order, manual production gate, and no-automatic-rollback policy remain active.

Consequences

Positive
  • Production runs the complete image combination that passed the dev smoke suite.

  • Selective dev builds no longer prevent later production promotion.

  • Production spends no time building runtime images and cannot introduce build-time byte differences.

  • Shared timestamped tags make release cohorts readable across packages.

  • The runtime snapshot preserves per-component source and digest provenance.

Negative
  • A successful dev runtime is a composite release rather than one Git commit.

  • Tag finalization spans several packages and can fail partially; operators must start a new promotion to complete the environment pointers.

  • Cohort validation, digest pulls, and release finalization add registry operations to each promotion.

  • Exact image promotion does not make secrets, database state, or environment-specific runtime values identical.

Neutral
  • Production rollout remains sequential and can leave mixed runtime versions after a failed component, as accepted by ADR-27.

  • PostgreSQL and Metabase remain separately pinned operational images outside the six-component runtime snapshot.

  • Retained old image sets support audit and host restoration, not guaranteed database-compatible rollback. Production promotion rejects snapshots from an older deployment-compatibility schema.

ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port

Status

Partially superseded by ADR-32: Clarula Firm as DATEV and Tenant Anchor for DATEV and Clarula tenant ownership and by ADR-33: Dedicated Static Operator Frontend on admin.clarula.de for the operator surface; the port, OAuth, token, import, and adapter decisions remain accepted.

Date

2026-07-14

Context

The Clarula partner demo needs DATEV mandate identity before it can classify invoices as incoming or outgoing and route them conservatively. A firm login and firm self-service do not exist yet, but the existing Clarula admin can assist onboarding. The removed DATEV spike proved an advisor-level OAuth connection, encrypted token persistence, and token refresh against accounting:documents; it did not provide the master-client identity required by the approved Clarula flow.

The implementation must remain reproducible without DATEV credentials, support sandbox validation, and later switch to production without changing the Clarula domain model.

Decision Drivers

  • Keep DATEV as the source of mandate identity.

  • Reuse the existing Partner as the firm, admin, and future embed anchor.

  • Never expose DATEV credentials to Clarula; authenticate directly at DATEV.

  • Keep local development and the partner demo reproducible.

  • Minimize persisted DATEV fields and fail closed when source identity is ambiguous.

  • Keep mandate import separate from later document handover.

Decision

Clarula stores one DATEV connection per Partner. A Clarula administrator starts an OAuth 2.0/OpenID Connect authorization-code flow with PKCE for an active partner, and an authorized firm representative authenticates directly at DATEV. The callback remains under /admin/**, revalidates the admin session, and binds the short-lived authorization attempt to the initiating admin, partner, state, nonce, and PKCE verifier. Connect, import, confirmation, and disconnect mutations additionally require a per-admin-session action token.

Access and single-use refresh tokens are encrypted at rest. The connection stores the granted scopes, provider subject, expiry, and the stable DATEV business-partner reference verified through org-info. A connection is usable only when its firm reference still matches the retained import snapshot; disconnect does not remove that source binding. Connection replacement, refresh-token use, import persistence, and disconnect are serialized with a stable partner-row database lock; missing, unreadable, rejected, or ambiguously consumed refresh tokens require reconnection. Reconnect cannot silently switch DATEV firm.

The business capability depends on a DatevApi port for authorization, refresh, revocation, firm verification, and mandate import. A deterministic mock adapter is the default local and demo implementation. One HTTP adapter supports sandbox and production through environment-specific endpoints and credentials. The operating mode is mandatory adapter configuration, not a feature flag; the admin capability is always present. The mode is deliberately absent from the persisted domain model. Before an operator changes it, real provider grants must be revoked, the backend stopped, and the complete Clarula database recreated; no runtime compatibility or mixed-mode cleanup path exists. The mock authorization controller exists only in mock mode.

The HTTP adapter reads master-data:master-clients, requires every master client to carry the verified DATEV business-partner ID, maps only approved firm, name, address, VAT/tax-number, provenance, and stable-reference fields, and discards complete provider responses after normalization. Malformed, cross-firm, or ambiguous provider data fails the whole import.

Each partner owns one current, minimized import snapshot. Imported mandate, identity, and demo-target fields are read-only. Clarula-owned mandate confirmation is stored separately. A same-source re-import updates normalized fields and preserves confirmations; a changed DATEV firm fails closed and requires an explicit future replacement flow. Missing mandates remain visible as unavailable instead of being silently reassigned or deleted.

The DATEV document-handover port remains separate and uses a deterministic demo adapter until its production contract is decided.

Consequences

Positive
  • Admin-assisted onboarding works before a firm account area exists.

  • Mock, sandbox, and production use one application contract.

  • Tokens, imported identity, and confirmations have explicit ownership and provenance.

  • A wrong DATEV login cannot silently replace another firm’s imported mandates.

  • The reproducible partner demo has no mandatory DATEV runtime dependency.

Negative
  • Clarula persists highly sensitive OAuth tokens and selected mandate identity fields.

  • Key recovery currently means reconnecting; automated encryption-key rotation is deferred.

  • Firm-wide long-term DATEV grants and automatic synchronization remain unresolved.

  • DATEV firm changes require an explicit replacement workflow rather than automatic reconciliation.

  • Adapter-mode changes require a destructive, manually supervised reset of the complete Clarula database.

ADR-30: One BootJar with Product-Isolated Spring Child Contexts

Status

Partially superseded by ADR-33: Dedicated Static Operator Frontend on admin.clarula.de for the Clarula admin servlet mapping; the sibling-context model remains accepted.

Date

2026-07-15

Context

The backend currently places einfache-eRechnung (EER) and Clarula business capabilities in one Gradle project and one Spring ApplicationContext. That keeps operations simple, but package conventions alone cannot prevent one product from importing the other product’s code, discovering its Spring beans, or using its persistence infrastructure. Clarula now has its own tenant, DATEV, and document-routing model, while EER retains its invoice-conversion, customer-account, and partner-affiliation model. The products need stronger construction and runtime boundaries without introducing a distributed system.

A focused runtime proof established that one embedded servlet server can eagerly host sibling DispatcherServlet application contexts, each with a conventional local transaction manager, while a persistence-free parent exposes only shared stateless beans.

Decision Drivers

  • Preserve one backend artifact, process, deployment, and operational failure domain for the small team.

  • Make EER-to-Clarula source dependencies fail at compile time.

  • Prevent component scanning and dependency injection from crossing product boundaries accidentally.

  • Let each product use normal Spring Data JPA, Flyway, and @Transactional conventions without qualifiers or a routing transaction manager.

  • Keep public path ownership explicit and testable.

  • Retain a path to separate processes if security, scale, or operations later require it.

Considered Options

Option Pros Cons

Keep one Gradle project and one application context

Lowest migration effort. Standard Spring Boot auto-configuration.

Package rules remain discipline-based. Product beans, repositories, and transaction infrastructure remain mutually visible.

Build one BootJar from product subprojects and sibling application contexts

Adds compile-time dependencies and runtime bean/persistence boundaries while preserving one process and deployment.

Requires explicit bootstrap, servlet mappings, scanning, and per-child auto-configuration.

Deploy one Spring Boot application per product

Provides process, classpath, credential, scaling, and failure isolation.

Adds another runtime, health surface, deployment sequence, and inter-process integration before the team needs them.

Decision

The backend becomes a Gradle multi-project build with four subprojects:

applications/backend/
├── app/       # composition root and sole BootJar owner
├── core/      # product-neutral, persistence-free shared capabilities
├── eer/       # einfache-eRechnung business application
└── clarula/   # Clarula business application

Only :app applies the Spring Boot executable packaging and produces backend.jar. The dependency graph is directional:

app     -> core, eer, clarula
eer     -> core
clarula -> core
core    -> no product subproject

:eer and :clarula must not depend on each other or on :app. :app contains only startup, product-context registration, shared servlet/container infrastructure, and route ownership; it must not become a third business module or mediate direct product calls. :core contains only product-neutral, stateless code and contracts that both products genuinely share; it contains no product domain entity, repository, migration, DataSource, JPA configuration, or transaction boundary. Shared implementation convenience alone is not a reason to move code into :core. Gradle dependency assertions and architecture tests enforce these rules in addition to package conventions from ADR-6: Package-by-Feature Organization.

At runtime, :app starts one root ServletWebServerApplicationContext and one embedded servlet server. The root eagerly creates two sibling child WebApplicationContext instances and registers one DispatcherServlet for EER and one for Clarula. The root is the composition and servlet-registration context; it scans no product controller, JPA entity, or repository and defines no DataSource, EntityManagerFactory, Flyway, or PlatformTransactionManager bean. Only explicitly listed persistence-free operational endpoints may remain root-owned.

Each product child owns and scans only its product configuration and packages. A child can resolve product-neutral beans from the parent, but the parent cannot resolve child beans and neither sibling can resolve the other sibling’s beans. Both children start eagerly; failure to initialize either child fails startup of the one backend process.

Each product child has exactly one local conventional PlatformTransactionManager bean named transactionManager, one local JPA persistence unit, and one local Flyway migration boundary. Unqualified @Transactional therefore resolves within the owning child. There is no shared, routing, chained, or cross-product transaction manager. ADR-31: Separate Logical PostgreSQL Databases and Roles defines the corresponding database boundary.

Every externally reachable business path belongs to exactly one product child through the servlet-mapping registry in :app. EER is the default / dispatcher so its established public routes remain unchanged; Clarula’s longer /admin/clarula/ mapping wins by standard servlet matching, and the root operational servlet owns /actuator/. This deliberate mapping overlap is safe only because product controller scans are disjoint and route-exclusivity tests cover both positive and negative dispatch. Edge host routing is not a substitute for backend path ownership. Unknown product handler paths remain 404 after the applicable security boundary, and moving or versioning a public path remains an explicit API compatibility change rather than an incidental consequence of component scanning.

The boundary is verified at both levels:

  • Compile-time checks reject forbidden Gradle project dependencies and product-package imports.

  • Runtime tests prove one server, two eager sibling contexts, root and bridge persistence freedom, sibling bean invisibility, separate Flyway histories, one local conventional transaction manager per product, unqualified product-local rollback, scheduler/async ownership, exclusive path dispatch, aggregate health, eager failure, and pool shutdown.

Completed implementation

The implementation now realizes the symmetric target. :app contains the BackendApplication composition root, the root operational DispatcherServlet, global security/filter composition, product health, and eager product-context registration; it defines no DataSource, EntityManagerFactory, Flyway, or PlatformTransactionManager. :eer owns the existing EER controllers, services, entities, repositories, Flyway history, templates, static resources, widget build, schedulers, and product tests. :clarula continues to own the complete firm/DATEV capability and its independent persistence boundary.

Both product contexts are eager sibling GenericWebApplicationContext instances behind one parentless shared-services bridge. The bridge publishes only product-neutral stateless services (Clock, ObjectMapper, RestClient, JWT codec, and the optional short-lived operator token service); it contains no product or persistence bean. Root auto-configuration explicitly excludes product mail, template, OpenAPI, AI, task, dispatcher, and persistence infrastructure; EER excludes its inert auto-configured dispatcher and actuator info endpoint. The EER DispatcherServlet is the default / servlet, the longer /admin/clarula/ mapping selects Clarula, and /actuator/ is handled by the persistence-free root operational servlet. Startup failure in either product closes any context already created and fails the one process; normal root shutdown closes both Hikari pools and the EER-owned asynchronous event executor. A root ApplicationRunner delegates ordered product ApplicationRunner and CommandLineRunner beans after the servlet server has started, preserving Boot runner timing and failure semantics for local seeding and future startup tasks. Spring application lifecycle events do not propagate from the root into either sibling; a product must use the delegated runner contract or an explicit composition bridge rather than assuming it receives the root ApplicationReadyEvent. Internal composition and product-boundary names now use backend, EER, Clarula, and operator terminology. Compatibility identifiers remain unchanged where external consumers or deployed state depend on them: the invoicex EER database, INVOICEX_* environment variables, JWT issuer/audiences, public routes, backend.jar, and API/widget contracts.

This is modular-monolith isolation, not a hard security boundary. Both products still share one JVM, heap, operating-system identity, artifact, startup, and failure domain; the process also receives both products' database credentials. Reflection, deliberately hostile code, shared-process compromise, or resource exhaustion can cross the child-context boundary. A requirement for adversarial, regulatory, credential, or failure isolation must trigger a decision for separate processes and deployment identities.

Consequences

Positive
  • Product source dependencies become visible and enforceable in Gradle.

  • Spring bean discovery, JPA repositories, migrations, and transactions stay local to one product.

  • Both products retain conventional Spring transaction semantics without bean qualifiers.

  • Deployment, observability, local startup, and production promotion still operate one backend artifact and process.

  • Explicit servlet ownership prevents accidental route collisions and frontend fallbacks from hiding missing backend routes.

Negative
  • Bootstrap and test infrastructure become more complex than standard single-context Spring Boot auto-configuration.

  • Cross-cutting framework configuration must be classified deliberately as root-safe or product-local.

  • A startup failure, memory leak, or process crash in one product still affects both products.

  • Sharing a capability now requires an intentional :core contract instead of a direct product import.

  • The route-ownership registry and boundary tests must evolve with every public endpoint.

Neutral
  • Product capabilities still deploy together and cannot scale independently.

  • Package-by-feature remains the organization inside each product subproject.

  • Static product frontends and the certified composite runtime promotion model are unchanged.

ADR-31: Separate Logical PostgreSQL Databases and Roles

Status

Accepted

Date

2026-07-15

Context

ADR-30: One BootJar with Product-Isolated Spring Child Contexts separates EER and Clarula at Gradle and Spring-context boundaries while retaining one backend process. Keeping both products in one PostgreSQL database would leave tables, privileges, migrations, joins, foreign keys, and restore operations inside one persistence boundary. It would also let a repository or ad hoc query bypass the product boundary even when Spring beans are isolated.

The team still operates one VPS and does not need separate PostgreSQL servers. It does need product-owned schema evolution, least-privilege access, and the ability to back up or restore one product without treating the other product’s data as the same unit.

Decision Drivers

  • Make cross-product persistence access fail through database authorization, not only code review.

  • Give EER and Clarula independent migration histories and recovery units.

  • Preserve one PostgreSQL installation and the current low operational overhead.

  • Keep every business transaction local to one product.

  • Avoid distributed transaction coordination and hidden cross-product data models.

Considered Options

Option Pros Cons

Separate schemas in one logical database

Simple connection management. PostgreSQL foreign keys and joins remain available.

Grants are easier to weaken accidentally, one connection can still address both schemas, and backup/restore remains database-wide by default.

Separate logical databases and roles in one PostgreSQL cluster

Strong namespace and connection boundary, independent migrations and dumps, one PostgreSQL runtime to operate.

No native cross-database foreign keys or joins. The shared cluster remains one physical availability and capacity boundary.

Separate PostgreSQL instances

Stronger process, resource, superuser, and failure isolation.

Adds runtime, backup, monitoring, patching, and capacity overhead that the current threat and load model do not justify.

Decision

Each environment provides two logical PostgreSQL databases in the existing PostgreSQL cluster: one owned by EER and one owned by Clarula. Their concrete names are environment configuration, not a cross-product contract.

Each product uses distinct credentials and a distinct PostgreSQL login role. A product role receives connection and object privileges only for its own database. No EER application or migration role receives Clarula privileges, and no Clarula application or migration role receives EER privileges. Operational superuser or backup credentials may cover the cluster, but they are never application credentials and are not exposed to either product context. Production may later split each product’s migration owner from its runtime role without changing this decision.

The root application context consumes neither credential set and creates no persistence beans. The EER sibling binds only EER connection properties; the Clarula sibling binds only Clarula connection properties. Each child creates its own DataSource, JPA persistence unit, conventional local transactionManager, and Flyway instance as defined by ADR-30: One BootJar with Product-Isolated Spring Child Contexts.

Flyway migrations are product-owned:

  • EER migrations ship from :eer, execute only against the EER database, and maintain an EER-local schema history.

  • Clarula migrations ship from :clarula, execute only against the Clarula database, and maintain a Clarula-local schema history.

  • A migration must not create or query objects in the sibling database.

  • Failure of either required migration fails startup; successful histories and version numbers remain independent.

Cross-database foreign keys, joins, views, dblink, foreign data wrappers, and XA/JTA transactions are prohibited. No transaction may claim atomicity across both databases. If a future product workflow must exchange data, it must use an explicit application contract, carry stable values rather than foreign keys, and define idempotency, ordering, and compensation without a distributed transaction. That integration requires its own architecture review.

Staged implementation

The 2026-07-15 production slice provisions logical databases invoicex and clarula with distinct non-superuser roles eer and clarula in every Compose environment. An idempotent one-shot service creates/rotates the roles, creates the Clarula database, revokes public and sibling connection access, and transfers historical EER object ownership from the former operational superuser so the eer role can continue its Flyway chain. Clarula starts a new migration history at db/clarula/migration/V1; the unshipped EER DATEV V121 is removed instead of copied or marked applied. The complete EER history is packaged in :eer at db/migration; it runs only through the EER sibling context’s DataSource and role. Production full-state backups carry separate invoicex.dump and clarula.dump artifacts and restore each with its product owner. The production-to-dev synchronization archive deliberately omits Clarula; it transfers only EER and Metabase, then clears Dev-local Clarula tenant state before restart.

Backups and restores treat the products as separate recovery units. Automation creates separately identifiable EER and Clarula backup artifacts and can restore and verify either logical database independently. A cluster-wide physical backup may supplement this approach, but it does not replace product-specific logical backups and restore tests. No cross-product invariant may require a coordinated point-in-time restore.

Automated access tests run with the real product roles and prove both sides of the boundary:

  • each role can connect to, migrate as configured, and read/write its own database;

  • each role is refused connection or object access to the sibling database;

  • each product integration-test context starts with only its own migration locations and persistence unit; and

  • backup verification identifies and restores one product database without loading the other.

Consequences

Positive
  • Accidental cross-product SQL, ORM relations, and migrations fail at the database boundary.

  • Product schemas can evolve and be restored independently.

  • Local JPA and transaction conventions remain simple in each sibling context.

  • One PostgreSQL cluster preserves the current deployment and patching model.

  • A later process split can reuse the same database and credential boundaries.

Negative
  • Cross-product joins and foreign keys are unavailable, even for reporting or operational convenience.

  • Shared reference data may need intentional duplication or an explicit integration contract.

  • Backup, restore, provisioning, and test automation must handle two logical databases and credential sets.

  • A workflow that touches both products cannot use one atomic transaction and must expose partial-failure behavior.

Neutral
  • PostgreSQL remains one physical failure, capacity, maintenance, and superuser boundary.

  • The shared backend process holds both credential sets, so role separation limits ordinary connections but is not protection against full process compromise.

  • Metabase or future reporting access must be granted separately per product; application roles remain write/read scoped to their own database.

ADR-32: Clarula Firm as DATEV and Tenant Anchor

Status

Partially superseded by ADR-33: Dedicated Static Operator Frontend on admin.clarula.de for the implemented Clarula admin UI path; the Firm tenant and ownership decision remains accepted.

Date

2026-07-15

Context

The Partner aggregate was introduced for the EER embeddable-widget registry, redirect allow-list, origin attribution, and partner-tariff affiliation flow. ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port later reused that aggregate as the Clarula tax firm, DATEV connection owner, imported-mandate owner, admin workspace anchor, and future embed anchor. That reuse avoided a second concept while both products shared one application context and database.

Clarula now has an independent tenant lifecycle and persistence boundary under ADR-30: One BootJar with Product-Isolated Spring Child Contexts and ADR-31: Separate Logical PostgreSQL Databases and Roles. A Clarula tax firm owns DATEV authorization, mandates, uploader grants, and document submissions; an EER partner represents a distribution and affiliation relationship. Keeping one aggregate for both meanings would couple Clarula tenant identity to EER commercial and widget lifecycles and would require cross-database ownership.

Decision Drivers

  • Give Clarula one product-owned aggregate root for tax-firm tenancy and data ownership.

  • Keep EER partner attribution, affiliation, and tariff behavior independent from Clarula.

  • Preserve the DATEV port, token, source-verification, and minimized-import decisions from ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port.

  • Avoid an unvalidated cross-product identity or customer-master-data mapping in the partner-demo MVP.

  • Make tenant-scoped authorization and persistence references unambiguous.

Considered Options

Option Pros Cons

Continue using EER Partner as the Clarula tenant

Reuses existing records, admin pages, and identifiers.

Creates a cross-product aggregate and database dependency. Partner affiliation and Clarula tenancy retain incompatible meanings and lifecycles.

Introduce Clarula Firm with an MVP Partner-to-Firm mapping

Separates the domain names while retaining an explicit relationship to existing partners.

Adds mapping ownership, synchronization, conflict, deletion, and authorization rules before a cross-product use case has been validated.

Introduce independent Clarula Firm with no MVP mapping

Gives Clarula a clean tenant boundary and leaves future integration reversible.

May duplicate limited organization metadata and cannot reuse an EER partner identifier automatically.

Decision

Clarula owns a Firm aggregate in the :clarula subproject and Clarula database. Each Firm has a stable Clarula identifier and a Clarula-owned lifecycle independent of EER and DATEV; connecting, disconnecting, or importing from DATEV does not create, activate, deactivate, or reactivate it. Firm is the tenant and ownership anchor for Clarula state, including DATEV connections, minimized import snapshots, imported mandates, mandate confirmations, uploader grants, firm-scoped widget configuration, and document submissions. Every tenant-scoped Clarula operation resolves and authorizes a Firm before accessing that state. Possession of a firm identifier alone grants no authority.

The DATEV decisions in ADR-29: Partner-Scoped DATEV Connection and Mandate Import Port remain active with Firm replacing Partner as owner:

  • Clarula stores one DATEV connection per Firm.

  • Authorization attempts, encrypted tokens, provider subject, verified DATEV business-partner reference, and current import snapshot bind to that Firm.

  • Connection replacement, refresh-token use, import persistence, and disconnect serialize on a stable firm-owned database lock.

  • DatevApi, deterministic mock operation, sandbox/production HTTP adaptation, source verification, minimized read-only imports, separate Clarula confirmations, and the separate document-handover port remain unchanged.

The DATEV business-partner reference is external provenance for a Firm; it is not an EER Partner identifier and does not create a cross-product relationship.

Partner remains owned by EER for its existing widget registry, redirect, origin-attribution, affiliation, and tariff use cases. It does not anchor Clarula tenancy, DATEV state, mandates, uploader access, or submissions. Clarula code and persistence do not reference EER Partner entities, repositories, tables, or identifiers.

The partner-demo MVP has no Partner-to-Firm mapping. It introduces no mapping table, cross-database foreign key, shared UUID convention, lookup adapter, synchronization job, or implicit match by name, domain, email, or DATEV reference. The same real-world tax firm may therefore have independent EER Partner and Clarula Firm records with no asserted relationship. Any one-time data migration, if required, must not establish a durable mapping.

If a later validated use case needs shared commercial identity, onboarding transfer, or single sign-on, it must define an explicit integration with source ownership, consent, lifecycle, conflict, deletion, and authorization rules. It must not restore a shared persistence aggregate.

Implementation

The first production migration creates firms and all firm-owned DATEV/import tables only in the Clarula database. The authenticated UI at /admin/clarula/firms creates, lists, deactivates, and reactivates firms; DATEV workspaces are nested beneath the selected firm. The former EER partner detail no longer links to DATEV, and the Clarula module has no compile-time import, repository lookup, table reference, foreign key, or identifier mapping to EER Partner. No data migration maps historical partner records to firms: migration V120 already removed the spike data, and the replacement EER V121 was not retained in the shipped migration chain.

Consequences

Positive
  • Clarula tenant ownership is explicit in code, transactions, database grants, and authorization checks.

  • EER partner lifecycle changes cannot silently alter Clarula DATEV or mandate ownership.

  • Clarula can evolve firm onboarding and self-service without changing partner-tariff behavior.

  • The DATEV adapter and security work from ADR-29 remain reusable with a clearer aggregate boundary.

  • Avoiding an MVP mapping removes synchronization and false-identity risks.

Negative
  • Existing Partner-based Clarula implementation and demo fixtures must move to Firm ownership.

  • Limited organization metadata may exist independently in both products.

  • Operators cannot navigate from an EER partner to a Clarula firm through a product mapping in the MVP.

  • A future cross-product commercial relationship will require a deliberate integration rather than a local foreign key.

Neutral
  • Firm is a tenant aggregate, not a new authentication mechanism; admin and future firm-user authorization remain separate concerns.

  • DATEV remains the source of imported mandate identity and routing evidence, while Clarula owns confirmations and uploader grants.

  • Historical ADRs and PDRs retain the term Partner where it accurately records the earlier decision.

ADR-33: Dedicated Static Operator Frontend on admin.clarula.de

Status

Accepted

Date

2026-07-16

Context

The backend currently renders two administrative areas with Thymeleaf: EER partner administration under /admin/ and Clarula firm, DATEV, and mandate administration under /admin/clarula/. Both areas use the operator identity from ADR-13: Admin Identity via Configuration, Not as a Customer Role, but their presentation remains coupled to backend releases and the canonical einfache-eRechnung origin. The public product frontends are already independent static Nuxt applications under ADR-25: Static Nuxt Product Frontends and ADR-26: Static Frontend OCI Images behind Shared Edge Caddy.

The founders need one redesigned internal workspace behind one login while EER and Clarula remain separate products with separate backend modules and databases. The workspace must not consume paths on either public product origin. Moving presentation must not move business rules, authorization, transactions, DATEV OAuth state, or persistence into the frontend, and a unified screen must not restore the cross-product aggregate rejected by ADR-32: Clarula Firm as DATEV and Tenant Anchor.

Decision Drivers

  • Provide one coherent operator workflow and one login for both products.

  • Remove administrative HTML, CSS, and browser interaction code from the backend.

  • Keep public product origins free of operator routes and cookies.

  • Preserve EER and Clarula domain, API, transaction, and database ownership.

  • Retain static frontend delivery and the one-backend modular monolith.

  • Keep cookie authentication, DATEV OAuth callbacks, and unsafe mutations same-origin and explicitly protected.

Considered Options

Option Pros Cons

Keep backend-rendered administration

No additional runtime image or browser API migration. Existing authentication and callbacks remain unchanged.

Prevents the requested redesign and keeps presentation coupled to backend releases.

Add administration to each public product frontend

Presentation follows product ownership and adds no third frontend application.

Duplicates the operator shell, splits one session across origins, couples internal tools to public releases, and complicates DATEV OAuth and cookie handling.

Create one operator frontend under an existing product path

One login and one deployable admin application with a small origin migration.

Continues to occupy a public product origin and makes the shared operator workspace appear EER-owned.

Create one operator frontend on admin.clarula.de

One workspace and origin, independent presentation releases, no operator routes on public product origins, and same-origin APIs and callbacks.

Adds one runtime image and requires a coordinated cookie, magic-link, API, edge-routing, and DATEV callback migration.

Decision

We will add a third static Nuxt application, applications/frontend/apps/operator-admin, to the existing frontend workspace. It will generate one independently versioned OCI image containing static files and an unprivileged Nginx configuration; production will run no Node.js or Nuxt server. The application will provide one operator shell, login experience, navigation, and technical UI foundation with separate EER and Clarula feature modules. Shared presentation must not introduce shared Partner/Firm transport types or infer a relationship between them. A combined dashboard may compose separately authorized product summaries in the browser, but the backend will not add cross-product joins, repositories, transactions, or aggregate endpoints for display convenience.

https://admin.clarula.de will become the only operator origin. Caddy will route explicit operator APIs and provider callbacks on that host to the backend and forward declared operator documents and assets to the operator frontend image. Only declared route families with bounded dynamic segments may use a client-shell rewrite; unknown paths return a real 404 instead of a global application fallback. The public einfache-eRechnung and Clarula origins will not serve, proxy, or redirect /admin paths after the coordinated cutover; those paths will return 404. The former routes are not a compatibility API, and in-flight short-lived login and OAuth attempts will be allowed to expire before removal.

All browser operations for the new application will use dedicated, versioned operator APIs; they will not enter the product-v1 contract. Backend servlet ownership is explicit and non-overlapping:

  • /api/operator/v1/auth/** belongs to the EER sibling for operator login, verification, session bootstrap, logout, and the existing remember-session persistence.

  • /api/operator/v1/eer/** belongs to the EER sibling for partner administration.

  • /api/operator/v1/clarula/** belongs to the Clarula sibling for firm, DATEV, and mandate administration, including the DATEV callback.

The root servlet registry will enforce the same longest-path ownership; Caddy routing is not a substitute for backend dispatch boundaries. EER partner operations remain implemented and transacted in :eer; Clarula firm, DATEV, and mandate operations remain implemented and transacted in :clarula. The backend will publish separate generated operator contracts and clients for authentication, EER, and Clarula so transport types retain their ownership even though one frontend consumes all three. Changes within operator v1 remain additive; a breaking change requires a new major contract and a migration period because frontend and backend artifacts deploy independently. The root composition layer and :core will not gain product business logic or persistence. Backend APIs will return JSON or application/problem+json, including 401 and 403, and will not redirect API requests to HTML.

The configuration allow-list, operator/customer identity separation, short-lived operator JWT, and persistent remember-session model from ADR-13: Admin Identity via Configuration, Not as a Customer Role remain active. EER will continue to own magic-link issuance and verification, remember-session persistence and refresh, session bootstrap, and logout behind the operator auth contract. The product-neutral root filter will validate the short-lived JWT before servlet dispatch but will remain persistence-free; Clarula will not access EER session persistence. The backend verification endpoint will consume the one-time magic-link token and redirect to a token-free frontend URL. Login tokens will not enter frontend state, browser-visible errors, analytics, referrers, or request logs, and magic-link submission will remain enumeration-safe and receive a dedicated edge rate limit.

The admin_session and admin_remember_session cookies will be host-only for admin.clarula.de, HTTP-only, Secure, and SameSite=Lax; because the host is operator-only, their path will be /. Caddy will remove cookie and authorization headers before forwarding static requests to Nginx; the operator Nginx container will have no backend proxy capability or backend network access. Static files and the login shell may be fetched without authentication, but every administrative datum and mutation requires backend authorization. A read-only auth bootstrap operation will return a signed CSRF token bound to the stable operator remember session and current operator identity; it will not enable CORS, so another origin cannot read the token. Before business dispatch, the root security filter will reject every authenticated unsafe operator request whose CSRF header is missing or invalid, or whose Origin is absent or differs from https://admin.clarula.de. The CSRF token remains valid across short-lived JWT expiry so the frontend can use it for remember-session refresh; logout, re-authentication, remember-session revocation, or remember-session expiry invalidates it. Pre-authentication magic-link submission is exempt from the CSRF token because it has no authenticated ambient authority, but it still requires the exact operator Origin, enumeration-safe behavior, and edge rate limiting. The backend-owned one-time verification request establishes the session before redirecting to the token-free frontend URL.

DATEV authorization will continue to use server-held state, nonce, PKCE verifier, initiating operator, firm binding, and expiry. Its registered callback will move to the operator API on admin.clarula.de and remain backend-owned; the backend will redirect only the sanitized outcome to a frontend route. Provider codes, tokens, state, and internal correlation values will not enter static documents, client logs, analytics, or frontend state.

The operator host will be non-indexable and use no product analytics. Operator HTML, APIs, auth responses, and callback-result responses will use no-store, Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow, and a restrictive content-security policy; content-addressed static assets may retain immutable caching. Static Nginx access logging will be disabled, while Caddy and backend observability will omit or redact token-bearing request targets. The backend will emit privacy-minimized audit events for login, denial, logout, refresh, revocation, EER partner mutations, Clarula firm lifecycle changes, DATEV connection and import actions, and mandate confirmations. Events will include operator identity, product, stable resource identifier, outcome, and correlation identifier, but no token, provider body, or sensitive request payload. The new image will join the certified composite runtime snapshot from ADR-28: Certified Composite Runtime Snapshot Promotion; the certified runtime therefore grows from six to seven application images.

This decision partially supersedes ADR-13: Admin Identity via Configuration, Not as a Customer Role for the operator origin, routes, and cookie path; ADR-25: Static Nuxt Product Frontends for retaining backend-rendered admin presentation; ADR-28: Certified Composite Runtime Snapshot Promotion for the six-image snapshot; ADR-30: One BootJar with Product-Isolated Spring Child Contexts for the Clarula /admin/clarula/* servlet mapping; and ADR-32: Clarula Firm as DATEV and Tenant Anchor for the implemented Clarula admin UI path. Their identity, static product frontend, promotion, modular-monolith, and tenant-ownership decisions remain active.

Consequences

Positive
  • Operators get one coherent workspace and session for both products.

  • Public product origins no longer expose administrative routes or cookies.

  • Admin presentation and backend business capabilities can evolve independently through explicit contracts.

  • Product module, transaction, and database boundaries remain enforceable.

  • The new design is not constrained by the existing backend templates.

Negative
  • Deployment, patching, certification, smoke testing, and recovery gain a seventh application image.

  • The origin cutover invalidates existing operator sessions, bookmarks, magic links, and unfinished DATEV authorization attempts.

  • Both product areas share one frontend release and can be affected by one shell defect.

  • Cookie-authenticated JSON mutations require explicit CSRF and origin protection.

  • The publicly retrievable static shell cannot itself protect data; security depends on backend authorization and correct edge routing.

Neutral
  • The clarula.de subdomain is the operator entry point, not a change in EER domain or data ownership.

  • The backend remains one BootJar and process, and the products retain separate sibling contexts and logical databases.

  • EER continues to own remember-session persistence behind the product-neutral auth contract until scale or role requirements justify a separate identity decision.

  • Thymeleaf may remain in the backend for non-admin rendering after the administrative templates are removed.

Compliance

  • Frontend dependency checks keep EER and Clarula feature modules and generated transport types separate.

  • Backend architecture and database-isolation tests continue to reject cross-product imports, repositories, and transactions.

  • Contract tests verify versioned operator APIs, JSON authorization failures, CSRF enforcement, and product route ownership.

  • Edge tests prove that admin.clarula.de owns operator documents, APIs, cookies, and callbacks while /admin returns 404 on all product origins.

  • Deployment tests verify the unprivileged static image, stripped credentials, security headers, release metadata, and inclusion in the certified runtime snapshot.

ADR-34: EER-Owned Format-Neutral Invoice Model as Conversion Core

Status

Accepted

Date

2026-07-16

Supersedes

ADR-16: Format-Neutral Invoice Conversion Pipeline

Partially supersedes

ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls for its intermediate representation, normalization ownership, and ZUGFeRD-shaped handoff, and ADR-22: Diagnosis-First Validation Failure Routing for XML-backed correction state. Their extraction strategy and diagnosis-first routing remain active.

Context

The EER conversion pipeline assembled ExtractedInvoice under formats.zugferd, applied EN 16931-oriented normalizers to that type, mapped it directly to Mustang, and later treated generated ZUGFeRD XML as correction and delivery state. Although implemented in Kotlin, that handoff was not format-neutral: it mixed source observations, interpreted values, EN 16931 terms, and fields shaped for one renderer. It also made the EER invoice.conversion capability depend on a model owned by a target-format package.

EER needs one stable semantic interpretation for current ZUGFeRD EN16931 generation and possible future XRechnung generation without generating and reparsing ZUGFeRD XML first. Those targets overlap but differ in mandatory fields, syntax, validation, and transport metadata. Using either target schema or Mustang’s object model as application state would leak target concerns and lose source evidence needed for diagnosis and correction.

ADR-30: One BootJar with Product-Isolated Spring Child Contexts now places EER and Clarula in isolated sibling application contexts and Gradle modules. EER owns invoice conversion, customer correction, email, website, and partner-widget workflows under :eer; :core contains no product domain. This decision therefore establishes an EER-owned model, not a cross-product shared model. Clarula cannot import it under the accepted module dependency graph; any future genuinely shared invoice-analysis contract requires a separate decision and evidence that it is product-neutral.

ADR-16: Format-Neutral Invoice Conversion Pipeline anticipated a format-neutral pipeline but prescribed intermediate structures that were not retained when ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls replaced the extraction flow. This decision preserves that strategic intent within the current EER boundary.

Decision Drivers

  • Give every invoice concept exactly one semantic value.

  • Preserve source-visible facts, evidence, ambiguity, and user corrections independently of generated artifacts.

  • Keep target schemas, libraries, and transport metadata at architecture edges.

  • Add an EER XRechnung adapter later without an XML-to-XML conversion path.

  • Keep context-dependent PDF interpretation in the specialized LLM calls and deterministic validation in Kotlin.

  • Remove generated XML as normal correction state.

  • Preserve the product isolation and persistence ownership established by ADR-30: One BootJar with Product-Isolated Spring Child Contexts and ADR-31: Separate Logical PostgreSQL Databases and Roles.

Considered Options

Option Pros Cons

Keep ZUGFeRD XML or Mustang Invoice as application state

Lowest migration effort.

Couples extraction and correction to one target and library. XML round-trips can discard unsupported source facts.

Use an EN 16931 schema model plus a source-evidence envelope

Strong common basis for ZUGFeRD EN16931 and XRechnung.

Makes an external standard the owner of the EER domain vocabulary and still couples interpretation to target terms.

Maintain one extraction and correction model per output

Each target can optimize independently.

Duplicates extraction, evidence, and correction; results can diverge for the same PDF.

Use an EER-owned semantic model with edge adapters

One evidence-bound semantic interpretation serves EER workflows while output targets remain replaceable.

Requires a deliberate migration and independent target conformance tests.

Decision

The invoice.conversion capability in applications/backend/eer owns the format-neutral invoice model and the transition from source evidence to EER invoice semantics. The unchanged source document remains authoritative for what the issuer stated. The semantic model is EER’s authoritative structured processing and correction state, but neither extraction nor a recognition correction proves that a defective source invoice became legally valid. Generated XML and PDFs are target artifacts, not authoritative invoice semantics.

The model has four normative states:

SourceEvidence

Immutable PDF identity, hash, byte size, complete page evidence, bounded analysis text, and truncation state. It contains no normalized or inferred replacement values.

ObservedInvoice

Potentially incomplete or ambiguous semantic claims produced by extraction. Every material claim remains bound to SourceEvidence and records its interpretation origin. Provider DTOs and Structured Output schemas map once into this state but do not own it.

InvoiceDraft

The editable semantic aggregate used by diagnosis and correction. It uses invoice concepts such as parties, lines, price basis, taxes, totals, settlement, references, and notes rather than XML paths, BT numbers, Mustang types, or frontend form types. Each concept has one value; parallel printed*, source/calculated, gross/net, or canonicalized variants are prohibited. Extracted claims and user corrections retain provenance. A draft may remain incomplete so diagnosis and correction can operate before target generation.

ValidatedInvoice

A typed result after common semantic-consistency and source-fidelity validation. It is not a legal-validity assertion and does not imply readiness for every output; each target adapter applies its own readiness and conformance rules.

A target-neutral DocumentAnalysisResult wraps these states for EER workflow handoff. It distinguishes an invoice candidate with draft and findings, a safely classified non-invoice, an accepted but automatically unreadable source such as an image-only PDF, a permanently rejected source such as an invalid PDF or limit violation, and a transient processing failure. Invalid candidates remain available for diagnosis instead of being discarded or forced into target XML. Stable standards such as ISO currencies and countries or UNECE units are allowed; XML structures, ZUGFeRD profile identifiers, XRechnung syntax, transport metadata, and account data are not.

adr34 eer invoice core

The following ownership and processing rules apply:

  • :eer owns the model, semantic analysis orchestration, common validation, correction snapshots, and target ports. Nothing is moved to :core merely for implementation convenience.

  • Native Xberg interpretation remains local and offline. Complementary plain text and layout-aware Markdown from ADR-17: Native Document Interpreters with Xberg, PDFBox, and Apache POI become one immutable per-page evidence representation before semantic extraction starts.

  • Specialized LLM calls return one complete semantic invoice interpretation. Context-dependent decisions such as line recognition, price kind, implicit quantity, amount-only rows, VAT-inclusive values, and settlement meaning belong to prompts.

  • Deterministic Kotlin code maps types, records provenance, performs arithmetic and consistency checks, and applies target rules. It does not reinterpret PDF layout or derive a missing unit price from line amount and quantity.

  • Every invoice concept has one semantic value. A target may calculate structural syntax values, but it never replaces or reconciles contradictory monetary values; contradictions fail closed.

  • Defaults and code mappings required only by one output belong to that output adapter.

  • ZUGFeRD generation remains governed by ADR-1: Hybrid ZUGFeRD Generation (XML + PDF Attachment) and maps to Mustang only inside formats.zugferd. If Mustang exposes no writer API for an EN 16931 term, the adapter may deterministically augment the rendered CII XML from the same semantic value. The complete artifact must then pass XSD, Schematron, semantic rendered-value, and source-PDF identity checks.

  • A future EER XRechnung adapter maps the same model directly and validates its CIUS independently.

  • Diagnosis and correction operate only on versioned InvoiceDraft snapshots. Recognition corrections never rewrite SourceEvidence, repair the original PDF, or silently reconcile source contradictions.

  • The relational session metadata owns snapshot schema compatibility. A correction-request preflight checks it before JSON deserialization. An obsolete, active revision-zero snapshot is reanalyzed from its integrity-bound source PDF through the current conversion pipeline without an active database transaction or bound connection, then installed by a short compare-and-swap write that rechecks lifecycle, TTL, version, schema, revision, and hash. Corrected, completed, expired, corrupt, future-version, or concurrently changed snapshots are never silently replaced.

  • Preview and submission re-run common analysis and target readiness gates. Successful completion persists the exact generated ZUGFeRD PDF, verification PDF, and XML for the remaining session lifetime; later downloads serve these immutable artifacts without deserializing or rerendering the draft.

  • API DTOs, persistence records, and frontend forms map to commands and views; the internal model is not a public transport contract.

ExtractedInvoice remains only as the provider-facing Structured Outputs DTO. ExtractedInvoiceObservationMapper immediately transfers its values into the EER model. The legacy DTO-to-Mustang, canonicalization, deterministic extraction-normalizer, gross-to-net, and printed-totals validator paths are removed. Gross-to-net interpretation occurs in the line-item prompt, while the original PDF remains bound through SourceEvidence.

The correction cutover is intentionally destructive. Migration V123 intentionally deletes all existing pre-model edit sessions, removes XML correction state, and requires every new session to single-write a versioned, integrity-checked InvoiceDraft snapshot. Draft changes and submission reservations are persisted before external side effects. Completion atomically stores its marker and exact result artifacts, which remain delivery state rather than editable semantics. Only successful conversions are deliverable or claimable, and an already claimed widget result can be corrected only by the same account.

Consequences

Positive
  • EER extraction, email, website, widget, diagnosis, and correction use one semantic state.

  • Source evidence and semantic claims survive generation and correction without lossy XML or library round-trips.

  • ZUGFeRD and future EER targets can evolve behind independent adapters.

  • Ambiguity and source contradictions remain visible instead of being silently normalized away.

  • The model remains inside the product boundary that owns its workflows and persistence.

Negative
  • Extraction, mapping, orchestration, edit sessions, previews, validation, and tests must migrate together.

  • Breaking snapshot-model changes require an explicit relational schema-version bump; obsolete uncorrected sessions incur the cost and nondeterminism of one fresh analysis, recorded under the distinct reanalysis analytics origin.

  • Completed artifacts duplicate bounded target bytes for the remainder of the 30-day edit-session lifetime so downloads do not depend on historical domain readers or generator behavior.

  • The one-time destructive cutover invalidates all existing pre-model edit capabilities, which are accepted as test-only data at this migration boundary.

  • Every target adapter needs independent readiness, mapping, and conformance tests.

  • The team must prevent the model from becoming a union of every target field.

Neutral
  • The backend remains one deployed modular monolith with isolated EER and Clarula child contexts.

  • ADR-18: LLM-Only Semantic Extraction with Specialized Parallel Calls still governs specialized LLM extraction and retry behavior.

  • This decision does not select an XRechnung syntax, DATEV contract, or cross-product shared model.

  • Clarula remains isolated in :clarula and does not depend on EER invoice-domain code.

Compliance

  • The model imports no Mustang, XML/DOM/JAXB, Spring AI, Jackson, JPA, frontend, Clarula, or DATEV API types.

  • Architecture and Gradle boundary tests reject dependencies from the model toward target adapters and reject EER/Clarula cross-imports.

  • Target adapters do not mutate snapshots or write target defaults back into the model.

  • No step recomputes document totals to replace contradictory semantic totals.

  • Correction contract tests cover draft → API view → correction command → revised draft without an XML round-trip.

  • A versioned golden JSON fixture detects structural snapshot changes that lack an explicit schema-version decision. Snapshot tests prove unsupported versions are rejected before JSON binding, reanalysis holds no database transaction or connection and uses a guarded compare-and-swap, obsolete revision-zero sessions are rebuilt from the source PDF, and completed downloads remain byte-stable without loading the draft.

  • Source-evidence tests prove that semantic claims and user corrections remain bound to the immutable PDF identity.

  • ZUGFeRD tests cover every mapped EN 16931 field, deterministic CII augmentation, rendered-value comparison, XSD, Schematron, payment-role exclusivity, and PAN masking.

  • Unsupported, ambiguous, or lossy required mappings fail closed; adapters neither invent nor silently discard material facts.

  • Full LLM benchmark reports evaluate outcomes, critical fields, business fields, XML validity, notes, and attempts after prompt or evidence changes.

Quality Requirements

This chapter details how well EER must perform its functions. Chapter 1 defines the top-level quality goals; this chapter expands them into a quality tree and measurable scenarios that drive architectural decisions.

Quality Tree

The quality tree shows each quality attribute, its sub-characteristics, and the scenarios that verify it. Priority reflects business impact: High scenarios shape architecture, Medium scenarios influence implementation choices.

Quality
├── Compliance [High]
│   ├── XSD Validation (QS-1)
│   ├── Schematron Validation — EN16931 rules (QS-1)
│   └── Mustang Business Validation — arithmetic checks (QS-2)
│
├── Usability [High]
│   ├── Email-based workflow — no login for core flow (QS-3)
│   ├── German error messages — MustangErrorHumanizer (QS-4)
│   └── Diagnosis-first recovery; editor only for recognition errors (QS-4)
│
├── Data Privacy [High]
│   ├── EU-only processing — Mistral AI (QS-5)
│   ├── No invoice content persistence (QS-5)
│   └── Temporary edit sessions with cleanup (QS-6)
│
├── Reliability [Medium]
│   ├── Sealed result types — no silent failures (QS-7, QS-8)
│   ├── Business event tracking for auditability (QS-10)
│   └── Healthcheck endpoints (QS-8)
│
├── Performance [Medium]
│   └── End-to-end email response time (QS-9)
│
├── Security [High]
│   ├── Webhook signature validation — HMAC-SHA256 (QS-11)
│   ├── Edge rate limiting — Caddy (QS-12)
│   ├── JWT authentication with HTTP-only cookies (QS-11)
│   ├── Encrypted, source-bound DATEV authorization (QS-13)
│   └── Non-root Docker containers (QS-11)
│
└── Operability [Medium]
    └── OpenTelemetry tracing to New Relic (QS-10)

Quality Scenarios

Overview

The following table summarizes all quality scenarios. Detailed descriptions follow in Scenario Details.

ID Quality Attribute Scenario Expected Response Priority

QS-1

Compliance

User submits a valid German B2B invoice PDF

System generates EN16931-compliant ZUGFeRD 2.5 XML that passes XSD and Schematron validation

High

QS-2

Compliance

Generated ZUGFeRD PDF is checked with Mustang validator

Validation passes with zero errors

High

QS-3

Usability

Non-technical small business owner sends first PDF

Receives e-invoice via email within minutes, no registration needed for email flow

High

QS-4

Usability

Validation fails because the invoice’s monetary values conflict

User receives a German diagnosis that requires source correction and resubmission; the editor is offered only for a recognition or mapping error

High

QS-5

Privacy

User asks where their data is processed

All LLM calls go to Mistral AI EU servers, no data leaves EU

High

QS-6

Privacy

Invoice edit session expires

Scheduler deletes edit sessions after configured TTL

Medium

QS-7

Reliability

Mistral AI API is temporarily unavailable

System returns extraction error email, no data loss, retryable

Medium

QS-8

Reliability

Database connection is lost during processing

Transaction rolls back, user gets error email, can resend

Medium

QS-9

Performance

User sends a standard 1-page invoice

E-invoice response email arrives within 60 seconds

Medium

QS-10

Operability

System error occurs in production

Error is captured by OpenTelemetry, visible in New Relic with full trace

Medium

QS-11

Security

Attacker sends forged webhook request

Mailgun HMAC signature validation rejects the request

High

QS-12

Security

Attacker floods abuse-prone endpoints (magic link, contact form, free website upload, invoice edit) to exhaust server resources or brute-force tokens

Caddy blocks requests exceeding per-IP thresholds with HTTP 429 before they reach the application

High

QS-13

Security and reliability

A DATEV token expires, is rotated, or belongs to a different firm during mandate import

Clarula serializes refresh, replaces the encrypted token pair atomically, and fails closed on source mismatch without changing the imported snapshot or confirmations

High

Scenario Details

QS-1: EN16931 Compliance on Valid Input
Source

Authenticated user

Stimulus

Sends a valid German B2B invoice PDF via email

Environment

Normal operation

Artifact

invoice.conversion analysis boundary and formats.zugferd output adapter

Response

System extracts invoice data via Mistral AI, generates ZUGFeRD 2.5 XML (EN16931 profile), validates against XSD and Schematron rules, embeds XML into PDF/A-3, and replies with the hybrid PDF

Measure

Generated XML passes XSD validation. Schematron validation produces zero EN16931 rule violations. Every generated invoice is validated before delivery — invalid invoices are never sent to users.

Architecture impact

Mustang Project runs EN16931 validation on every generated ZUGFeRD invoice. The pipeline never delivers a failing target; it routes findings through diagnosis-first recovery instead of treating generated XML as editable invoice state.

QS-2: Mustang Business Validation
Source

System (post-generation)

Stimulus

Generated ZUGFeRD PDF is verified against Mustang validator

Environment

Normal operation, after XML generation

Artifact

formats.zugferd — Mustang validation step

Response

Mustang validates arithmetic consistency (line item totals, tax calculations, grand total), structural completeness, and XML/PDF embedding correctness

Measure

Zero validation errors. Any arithmetic mismatch triggers a German-language diagnosis and no delivery; immutable source evidence cannot be overwritten through the editor.

Architecture impact

Validation is a mandatory step in the generation pipeline, not an optional check. The PdfToZugferdConversionService cannot produce output without passing Mustang validation.

QS-3: Zero Learning Curve
Source

Non-technical small business owner (first-time user)

Stimulus

Sends a PDF invoice to the EER email address

Environment

First use, no prior registration

Artifact

email.ingestion — webhook processing; identity — automatic account creation

Response

System receives the email via Mailgun webhook, creates an account from the sender’s email address, extracts invoice data, generates the ZUGFeRD e-invoice, and replies to the sender

Measure

User receives a compliant e-invoice within minutes. No registration form, no documentation, no software installation required. First email submission creates the account automatically.

Architecture impact

Email-as-interface drives the entire system design. The Mailgun webhook is the primary entry point. Account creation is implicit on first contact. The web UI exists only for corrections and account management.

QS-4: Actionable German Error Messages
Source

User who submitted a PDF with extraction or source-invoice findings

Stimulus

Common or target validation detects mismatched totals, missing fields, or a recognition error

Environment

Normal operation, validation failure

Artifact

Diagnosis workflow and invoice.editing; currently MustangErrorHumanizer supplies ZUGFeRD findings

Response

System translates technical findings into a German diagnosis (e.g., "Die Summe der Positionen (450,00 €) stimmt nicht mit dem Gesamtbetrag (500,00 €) überein."). It asks for source correction and resubmission when the PDF itself is defective. Only a genuine recognition or mapping error opens a pre-filled correction draft; Clarula may instead submit the unchanged original for firm review.

Measure

Every supported validation fixture names the affected field and routes to its expected recovery category. No source-defect fixture can alter SourceEvidence through correction. Users need no XML terminology.

Architecture impact

Diagnosis-first classification separates source defects from recognition errors before recovery. Correction revises a versioned InvoiceDraft without rewriting SourceEvidence; target artifacts are regenerated at the edge. See Solution Strategy: Fail-Fast with Diagnosis-First Retry, ADR-22, and ADR-34.

QS-5: EU-Only Data Processing
Source

User or data protection authority

Stimulus

Inquiry about where invoice data is processed

Environment

Any

Artifact

All external integrations (Mistral AI, Mailgun, Hetzner VPS)

Response

All invoice content processing occurs within the EU. Mistral AI (French company) hosts models on EU servers. The application runs on a Hetzner VPS in Germany. No invoice content reaches US jurisdiction.

Measure

No outbound API call transmits invoice content to a non-EU endpoint. Verifiable through network policy and OpenTelemetry traces.

Architecture impact

EU data residency drove the migration from OpenAI to Mistral AI. Spring AI’s provider abstraction made this a configuration change. See Solution Strategy: EU-First Data Processing and constraint LC-2 in Architecture Constraints.

QS-6: Edit Session Cleanup
Source

System scheduler

Stimulus

Edit session exceeds configured TTL

Environment

Background processing

Artifact

InvoiceEditCleanupScheduler in invoice.editing package

Response

Scheduled job deletes expired edit sessions and their associated data from the database

Measure

No edit session persists beyond its TTL. The scheduler runs periodically and removes all expired sessions in a single sweep.

Architecture impact

Privacy by design — the system stores no invoice content permanently. InvoiceEditCleanupScheduler enforces this constraint automatically. See constraint LC-6 in Architecture Constraints.

QS-7: LLM Provider Unavailability
Source

Mistral AI API

Stimulus

Returns HTTP 5xx or connection timeout during extraction

Environment

Degraded (third-party outage)

Artifact

invoice.conversion analysis boundary and ParallelInvoiceExtractor

Response

System catches the extraction failure, records a business event, and sends the user an error email explaining that extraction failed temporarily. The user can resend the PDF later.

Measure

No invoice is silently lost. User receives an error email within 60 seconds of the final failed attempt. The sealed DocumentAnalysisResult.ProcessingFailure retains retryability and every caller handles it explicitly.

Architecture impact

Each extractor performs one bounded infrastructure retry through RateLimitedClient. A second failure ends analysis without creating a correction session. Sealed outcomes guarantee exhaustive handling, and business events provide an audit trail; after the bounded retry, the user controls resubmission.

QS-8: Database Failure During Processing
Source

PostgreSQL database

Stimulus

Connection lost or transaction timeout during invoice processing

Environment

Degraded (infrastructure failure)

Artifact

Spring transaction management, email.ingestion processing pipeline

Response

The Spring transaction rolls back. If email delivery is still possible, the user receives an error email. If not, the user can resend the original PDF — the system is stateless with respect to invoice content.

Measure

No partial writes persist. Database returns to a consistent state after recovery. User can retry by resending the email.

Architecture impact

Spring’s declarative transaction management ensures atomicity. The "no master data" strategy (see Solution Strategy) means losing a transaction loses no irreplaceable data — the user still has the original PDF.

QS-9: Email Response Time
Source

User

Stimulus

Sends a standard 1-page invoice PDF

Environment

Normal operation, no system degradation

Artifact

Full processing pipeline — webhook → extraction → validation → generation → email reply

Response

User receives the ZUGFeRD e-invoice as an email reply

Measure

End-to-end response time under 60 seconds (target: 30 seconds). LLM extraction accounts for the majority of this time. Measured via OpenTelemetry trace duration.

Architecture impact

LLM extraction is the bottleneck. The rest of the pipeline (validation, PDF embedding, email sending) adds < 5 seconds. Performance optimization focuses on prompt efficiency and model selection, not application-level caching.

QS-10: Production Error Observability
Source

System (error condition in production)

Stimulus

Unhandled exception or business-level error

Environment

Production

Artifact

OpenTelemetry Java Agent, New Relic

Response

Error is captured as an OpenTelemetry span with full stack trace, request context, and correlation IDs. Business events record the failure in PostgreSQL for auditing.

Measure

Every error is visible in New Relic within 30 seconds. Traces include the full request path from webhook receipt to failure point. No error goes unrecorded.

Architecture impact

OpenTelemetry Java Agent instruments all Spring components, JPA queries, and HTTP calls without code changes. Business events provide a domain-level audit trail alongside technical traces. See constraint TC-12 in Architecture Constraints.

QS-11: Webhook Forgery Prevention
Source

External attacker

Stimulus

Sends a forged HTTP POST to the inbound email webhook endpoint

Environment

Normal operation

Artifact

MailgunWebhookSignatureValidator in email.ingestion package

Response

The validator computes HMAC-SHA256 over the request’s timestamp and token using the shared webhook signing key. If the signature does not match, the request is rejected with HTTP 403.

Measure

Every webhook request is validated before processing. Forged requests never reach the processing pipeline. Validation failures are logged with the source IP.

Architecture impact

WebhookSignatureValidator is an interface with two implementations: MailgunWebhookSignatureValidator for production (HMAC validation) and DevWebhookSignatureValidator for local development (always passes). This separation prevents accidental bypass in production.

QS-13: Source-Bound DATEV Authorization
Source

DATEV provider or Clarula administrator

Stimulus

An access token expires, a single-use refresh token rotates, or the authorized DATEV firm differs from the stored connection

Environment

Mock, sandbox, or production adapter mode

Artifact

datev connection and mandate-import capability

Response

Clarula serializes refresh-token use, encrypts and persists the replacement pair, verifies the stored DATEV firm reference before import, and rejects ambiguous or changed sources

Measure

No raw token appears in persistence, logs, UI, or analytics. One refresh token is submitted at most once concurrently. A source mismatch persists no mandate changes and preserves existing confirmations under their original source.

Architecture impact

A stable firm row serializes connection replacement, refresh, import persistence, and disconnect. Imported snapshots retain their source firm after disconnect; same-source re-import is idempotent, while source replacement is a separate future operation. Adapter-mode changes require revoking real provider grants, stopping the backend, and resetting the complete Clarula database rather than runtime transition logic. See ADR-29.

QS-12: Edge Rate Limiting on Abuse-Prone Endpoints
Source

External attacker

Stimulus

Floods abuse-prone endpoints (magic link authentication, contact form, free website upload, invoice edit submission) to brute-force tokens or exhaust server resources (CPU, LLM budget, outbound email quota)

Environment

Normal operation, potential DoS or brute-force attack

Artifact

Caddy reverse proxy with caddy-ratelimit plugin (deployment/environment/<env>/Caddyfile)

Response

Caddy buckets requests per client IP via {remote_host}. Requests exceeding the configured threshold receive a no-store RFC 9457 HTTP 429 problem directly from the proxy; the Spring Boot application is never invoked for the rejected request.

Measure

Canonical and compatibility aliases share one budget on every public host. Contact and free upload allow 5 requests/hour per IP; magic-link and registration submission allow 20 requests/5 minutes; correction preview/submission, diagnosis editor entry, and widget uploads allow 30 requests/5 minutes; widget auth, claim-with-session, and session-identity share one budget of 30 requests/5 minutes; frontend failure reporting allows 120 requests/5 minutes; widget result/status reads allow 300 requests/5 minutes.

Architecture impact

Abuse-prone inbound endpoints are rate-limited at the edge. The Spring Boot application additionally contains outbound provider rate-limiting logic for accepted work that later calls Mistral or Mailgun. Blocking at the reverse proxy prevents abusive traffic from consuming any application resources, while infrastructure.ratelimit protects shared external provider quotas after a request is already inside the app. Custom Caddy image is built via xcaddy (deployment/caddy/Dockerfile) and managed alongside the rest of the Docker Compose stack. See Deployment View: Edge Rate Limiting.

QS-13: Provider Rate-Limit Resilience for Outbound API Calls
Source

External provider (for example Mistral AI or Mailgun)

Stimulus

Returns HTTP 429 because concurrent conversions or mail bursts exceed the provider quota

Environment

Normal operation under bursty customer traffic

Artifact

infrastructure.ratelimit.RateLimitedClient

Response

Outbound calls are queued inside the application, paced per configured provider budget, and retried once when the provider sends Retry-After or a provider-specific reset header such as Mailgun’s X-RateLimit-Reset. Work is delayed, not dropped.

Measure

Productive Mistral chat calls stay at or below the configured pace (750 ms fixed delay by default). One HTTP 429 with a usable reset header does not fail invoice processing immediately. A second consecutive 429 fails fast with a clear technical error.

Architecture impact

Edge rate limiting and outbound provider rate limiting solve different problems. Caddy still protects abuse-prone inbound endpoints before they consume app resources. infrastructure.ratelimit protects shared outbound provider quotas after a request has already been accepted by the application. See LLM Integration and Deployment View: Edge Rate Limiting.

Traceability to Architecture

The following table maps quality scenarios to the architectural decisions and components that address them.

Scenario Architectural Decision Implementing Component

QS-1, QS-2

Mandatory validation before delivery

PdfToZugferdConversionService, Mustang Project validator

QS-3

Email as primary interface

EmailWebhookController, Mailgun webhook integration

QS-4

Diagnosis-first recovery with source-evidence preservation

Diagnosis workflow, versioned invoice drafts, invoice.editing package

QS-5

EU-first data processing

Mistral AI via Spring AI, Hetzner VPS

QS-6

Privacy by design — no persistent content

InvoiceEditCleanupScheduler

QS-7, QS-8

Sealed result types for exhaustive error handling

ExtractionResult, ProcessingResult sealed classes

QS-9

LLM extraction as primary bottleneck

Mistral AI mistral-small-2603 model selection

QS-10

Zero-code observability via Java agent

OpenTelemetry Java Agent, New Relic export

QS-11

Webhook signature validation

MailgunWebhookSignatureValidator

QS-12

Edge rate limiting at reverse proxy

Caddy rate_limit directive (caddy-ratelimit plugin)

QS-13

Outbound provider rate limiting with queueing and 429 retry

infrastructure.ratelimit.RateLimitedClient

Risks and Technical Debt

This chapter documents known technical risks and accumulated technical debt. Each entry includes an assessment, current mitigation measures, and suggested next steps.

Review this chapter quarterly — or whenever a new external dependency, architectural change, or incident reveals a new risk.

Risk Matrix

The matrix plots all identified risks by probability and impact. Items in the upper-right quadrant require active mitigation; items in the lower-left quadrant are acceptable with monitoring.

High Impact Medium Impact Low Impact

High Probability

R-2 LLM Extraction Quality

R-6 Regulatory Changes

Medium Probability

R-1 LLM Provider Dependency

R-5 Email Deliverability

Low Probability

R-3 Key Person Risk (Bus Factor)
R-4 Single VPS

Priority Summary

ID Risk Category Probability Impact Priority

R-1

LLM Provider Dependency

External

Medium (3)

High (4)

12 — High

R-2

LLM Extraction Quality

Technical

High (4)

High (4)

16 — Critical

R-3

Key Person Risk (Bus Factor)

Organizational

Low (2)

Critical (5)

10 — High

R-4

Single VPS Deployment

Infrastructure

Low (2)

High (4)

8 — Medium

R-5

Email Deliverability

External

Medium (3)

Medium (3)

9 — Medium

R-6

Regulatory Changes

Business

High (4)

Medium (3)

12 — High

Priority = Probability × Impact. Critical: 15—​25 (act now). High: 10—​14 (plan mitigation this quarter). Medium: 5—​9 (monitor and schedule).

Technical Risks

R-1: LLM Provider Dependency

Category: External
Probability: Medium (3) — any API provider can experience outages or degradation
Impact: High (4) — core conversion pipeline stops; no invoices processed
Priority: 12 (High)

Description: EER depends on Mistral AI’s Chat Completions API for every invoice conversion. An API outage, rate-limit change, or model deprecation halts all invoice processing. No local fallback or secondary provider exists.

Current Mitigation:

  • Spring AI abstracts the LLM provider behind a ChatClient interface. Switching to another provider (OpenAI, Anthropic, a self-hosted model) requires configuration changes, not code changes. See Solution Strategy, EU-First Data Processing.

  • OpenTelemetry traces capture API latency and error rates; New Relic alerts on anomalies.

Residual Risk: A provider switch still requires prompt tuning and extraction-quality verification. Spring AI abstracts the transport, not the prompt engineering.

Suggested Next Steps:

  1. Monitor Mistral AI status page and subscribe to incident notifications.

  2. Define a runbook for switching to a backup provider (estimated effort: 1—​2 days for prompt re-tuning).

  3. Evaluate whether a lightweight local model (e.g., Ollama with a small model) could serve as a degraded-mode fallback for simple invoices.


R-2: LLM Extraction Quality / Hallucination

Category: Technical
Probability: High (4) — LLMs hallucinate by design; extraction errors occur regularly
Impact: High (4) — incorrect e-invoices carry legal and financial consequences
Priority: 16 (Critical)

Description: The LLM may generate wrong amounts, fabricate line items, or assign incorrect buyer/seller names. Structural validation (XSD, Schematron, Mustang arithmetic checks) catches format violations and arithmetic errors, but cannot detect semantically wrong data — a plausible but incorrect buyer name passes all validators.

Current Mitigation:

  • Multi-stage validation: XSD schema check, EN 16931 Schematron rules, Mustang arithmetic verification.

  • Edit-and-retry workflow: users receive a pre-filled correction form when validation fails (see Solution Strategy, Fail-Fast with Edit-and-Retry).

  • Users see the extracted data before the e-invoice leaves the system (in the correction UI), giving them a chance to spot semantic errors.

Residual Risk: Users who do not review the extracted data may forward incorrect e-invoices. The system cannot distinguish "correct extraction" from "plausible hallucination" for semantic fields like names and addresses.

Suggested Next Steps:

  1. Add confidence scoring: flag fields where the LLM reports low confidence and force user review for those fields.

  2. Build a regression test suite of real-world invoices with known-good extraction results. Run it after every prompt change.

  3. Consider a secondary LLM call for cross-validation on high-value invoices.


R-3: Key Person Risk (Bus Factor)

Category: Organizational
Probability: Low (2) — small team, but healthy individuals
Impact: Critical (5) — critical knowledge concentrated in few people; loss of a key person disrupts maintenance, deployment, and troubleshooting
Priority: 10 (High)

Description: Bus factor is low. If a key team member is unavailable for an extended period, the system runs unattended. Bug fixes, security patches, and operational incidents may have no immediate responder.

Current Mitigation:

  • Clean, tested codebase: comprehensive unit and integration tests, enforced formatting, CI/CD pipeline.

  • This arc42 documentation explains the system architecture, deployment, and operational procedures.

  • Docker Compose deployment: another developer can re-deploy the system by running docker compose up.

  • Simple technology stack: Kotlin/Spring Boot/PostgreSQL requires no exotic expertise.

Residual Risk: Documentation and clean code reduce onboarding time but do not eliminate it. A new developer still needs days to become productive with the codebase.

Suggested Next Steps:

  1. Keep this architecture documentation current. Outdated docs are worse than no docs.

  2. Write a one-page operations runbook covering deployment, failed-deployment recovery, database backup/restore, and incident response.

  3. Consider a "trusted second" — a developer who has access to production credentials and has deployed at least once.


R-4: Single VPS Deployment

Category: Infrastructure
Probability: Low (2) — Hetzner has strong uptime history
Impact: High (4) — complete service outage; no redundancy
Priority: 8 (Medium)

Description: All components (application, database, reverse proxy) run on a single Hetzner VPS. A hardware failure, data center incident, or host OS crash takes down the entire service. See Architecture Constraints, TC-6.

Current Mitigation:

  • The dev environment is already reproducible from Terraform and cloud-init, which proves the replaceable-host pattern on a production-like stack.

  • PostgreSQL data resides on a persistent Docker volume.

  • Container restart policies (unless-stopped) handle transient process crashes.

Residual Risk: Production has not yet fully adopted the same replaceable-host model as dev. No automated failover. Database volume lives on a single disk — disk failure means data loss. Recovery time depends on how quickly the developer notices and responds.

Suggested Next Steps:

  1. Implement automated PostgreSQL backups (e.g., pg_dump cron job to offsite storage).

  2. Finish the prod migration to the Terraform-defined, replaceable-host model already exercised in dev.

  3. Accept this risk as appropriate for the current user base and revenue. Revisit when SLA commitments exist.


R-5: Email Deliverability

Category: External
Probability: Medium (3) — email deliverability is inherently unreliable
Impact: Medium (3) — user does not receive the converted e-invoice
Priority: 9 (Medium)

Description: Response emails carrying the ZUGFeRD e-invoice may land in the recipient’s spam folder or be blocked by corporate email gateways. The user has no indication that the conversion succeeded — they simply never see the result.

Current Mitigation:

Residual Risk: Corporate email filters apply custom rules that SPF/DKIM/DMARC cannot override. Attachments (especially PDFs) trigger spam filters at some providers.

Suggested Next Steps:

  1. Monitor Mailgun delivery metrics (bounce rate, spam complaints) via the dashboard.

  2. Add a "your e-invoice is ready" status page or notification endpoint so users can retrieve their result if the email fails.

  3. Consider providing a download link in addition to the email attachment as a fallback delivery channel.


R-6: Regulatory Changes

Category: Business
Probability: High (4) — e-invoicing regulations evolve actively in Germany and the EU
Impact: Medium (3) — requires development effort to adapt; non-compliance risks legal exposure
Priority: 12 (High)

Description: The German e-invoicing mandate is still evolving. New mandatory fields, format version updates (ZUGFeRD 2.5, XRechnung 3.x), or additional validation rules may require prompt adjustments, library upgrades, or new format support.

Current Mitigation:

  • Mustang Project actively tracks ZUGFeRD and EN 16931 standard updates; upgrading the library picks up new validation rules.

  • Package-by-feature architecture isolates format-specific code in formats.zugferd; new formats (XRechnung, Peppol) can be added as parallel packages without restructuring.

  • See Solution Strategy, Technology Decisions, Mustang Project.

Residual Risk: Breaking changes in the standard may require prompt re-engineering and a new extraction/validation cycle. Regulatory timelines may not align with the team’s development capacity.

Suggested Next Steps:

  1. Subscribe to ZUGFeRD Forum and FeRD mailing lists for early notice of standard changes.

  2. Track BMF (Bundesministerium der Finanzen) announcements on e-invoicing timeline adjustments.

  3. Maintain a buffer in the development roadmap for regulatory adaptation work.

Technical Debt Inventory

ID Severity Title Status Effort

TD-1

Low

Outdated SOLUTION_OVERVIEW.md

✅ Resolved

S (< 1 day)

TD-2

Low

Mixed package naming convention

✅ Resolved

M (1—​2 days)

TD-3

High

No resilience patterns for external services

Open

M (1—​2 days)

TD-4

High

No automated database backup

✅ Resolved

S (< 1 day)

TD-5

Medium

AGB (Terms of Service) missing

Open

M (1—​2 days, plus legal review)

TD-6

Low

No performance testing

Open

M (1—​2 days)

TD-7

Medium

Prod still uses the older deployment and secret model

Open

M (1—​2 days)


TD-1: Outdated SOLUTION_OVERVIEW.md

Severity: Low
Status: ✅ Resolved (2026-04-05)
Effort: Small (< 1 day)

Description: SOLUTION_OVERVIEW.md documented the package structure with outdated names (api/, config/, domain/, extraction/, processing/, generation/, validation/, template/, service/). The actual package structure uses business-capability names (email.ingestion, formats.zugferd, invoice.editing, etc.) as documented in Solution Strategy, Package-by-Feature.

Resolution: File deleted. All information is covered by the arc42 architecture documentation (chapters 1—​12).


TD-2: Mixed Package Naming Convention

Severity: Low
Status: ✅ Resolved (2026-04-05)
Effort: Medium (1—​2 days)

Description: Some packages used dot-separated directory names (email.ingestion/, invoice.editing/, email.delivery/) while others used standard subdirectory hierarchy (formats/zugferd/, business/tracking/). The invoice.editing capability was split across two filesystem roots: invoice.editing/ (session, notification) and invoice/editing/ (UI).

Impact: Inconsistency added minor cognitive load when navigating the codebase. No runtime or correctness impact.

Resolution: Standardized all packages to use subdirectory hierarchy, following the Kotlin coding conventions:

  • email.delivery/email/delivery/

  • email.ingestion/email/ingestion/

  • invoice.editing/ → merged into invoice/editing/

business/tracking/, formats/zugferd/, identity/, and infrastructure/ already used subdirectories and required no changes.


TD-3: No Resilience Patterns for External Services

Severity: High
Status: In Progress
Effort: Medium (1—​2 days)

Description: External service calls still lack full resilience patterns, but they no longer run completely unprotected. Application-level outbound rate limiting with queueing and one Retry-After-aware retry now protects Mistral and Mailgun from simple burst overruns.

  • A Mistral AI timeout still blocks the processing thread until the HTTP client times out.

  • Mailgun delivery failures beyond provider-rate-limit bursts are still not retried.

  • During a provider outage, incoming invoices are processed and fail without later recovery.

Impact: Transient API failures cause permanent processing failures. Users must re-send their invoice and hope the API is back up.

Proposed Solution:

  1. Extend retries beyond HTTP 429 to selected transient provider failures where safe.

  2. Add retry for Mailgun delivery failures beyond explicit rate-limit handling.

  3. Consider a dead-letter mechanism: persist failed processing attempts and retry them when the external service recovers.

  4. A full circuit breaker pattern may be over-engineered for current traffic — start with retries.

This debt directly increases the impact of R-1 (LLM Provider Dependency) and R-5 (Email Deliverability).


TD-4: No Automated Database Backup

Severity: High
Status: ✅ Resolved (2026-04-20)
Effort: Small (< 1 day)

Description: PostgreSQL runs in a Docker container with data on a persistent volume, but no automated backup exists. No disaster recovery procedure is documented.

Impact: A disk failure or accidental docker volume rm destroys all user accounts, business events, and processing history. This amplifies R-4 (Single VPS Deployment).

Resolution: Workflow .github/workflows/nightly-database-backup.yml (Nightly Database Backup) runs a nightly production full-state backup and still provides manual dev backup and dev restore entry points. Production backups are encrypted tar.gz.gpg archives containing custom-format dumps of invoicex and metabase, Caddy state, runtime config, manifest, and checksums. Dev backups remain encrypted SQL dumps for developer inspection and the current restore-dev action. Backups are stored in two independent places: /opt/apps/invoicex/backups/ on the VM and as GitHub Actions artifacts with 90-day retention. Prod restore stays a documented manual SSH procedure. Operational runbook and local extractor: deployment/BACKUPS.md, deployment/extract-latest-prod-backup.sh. Offsite Object Storage upload, dev scheduling, and unified full-state restore support are tracked in issue #580.


TD-5: AGB (Terms of Service) Not Yet Created

Severity: Medium
Status: Open
Effort: Medium (1—​2 days of drafting, plus legal review)

Description: The system operates without published Terms of Service (AGB). This was flagged as an open point in PDR: Tiering v2. Real payment processing cannot launch until AGB are finalized. See also Architecture Constraints, LC-7.

Impact: Blocks monetization. Users currently operate under an implicit free-trial agreement with no legal framework.

Proposed Solution:

  1. Draft AGB covering: service scope, liability limitations, data processing, cancellation terms.

  2. Get legal review (German Rechtsanwalt with IT law expertise).

  3. Publish on the website and require acceptance before paid tier activation.


TD-6: No Automated Performance Testing

Severity: Low
Status: Open
Effort: Medium (1—​2 days)

Description: LLM response times vary between 2 and 30 seconds depending on PDF complexity and API load, but no performance baseline or alerting threshold exists. The email processing pipeline has no load test.

Impact: Performance regressions go unnoticed until users complain. Capacity limits are unknown — the system may degrade under modest load without warning.

Proposed Solution:

  1. Define a performance baseline: measure P50/P95/P99 processing times for a representative set of invoices.

  2. Set up New Relic alerts when processing time exceeds the P95 threshold.

  3. A formal load test is low priority at current scale — focus on the alerting first.


TD-7: Prod Still Uses the Older Deployment and Secret Model

Severity: Medium
Status: Open
Effort: Medium (1—​2 days)

Description: Dev already uses the new operating model: Terraform-defined infrastructure, a replaceable host, and 1Password-fetched secrets at workflow runtime. Production still uses the older path: manual deploy workflow, .env generation from GitHub Environment secrets, and the legacy infrastructure module.

Impact: Operating two different deployment models increases cognitive load and makes disaster recovery, onboarding, and documentation harder. The team cannot yet apply the same rebuild procedure to the customer-facing environment that it already validated in dev.

Proposed Solution:

  1. Migrate prod workflow secrets to the same 1Password runtime pattern used by dev.

  2. Move prod infrastructure fully to the Terraform-defined replaceable-host model.

  3. Retire the legacy prod module once the new prod path is live and documented.

Prioritized Action Plan

The following actions address the highest-priority risks and debt items. Order reflects a combination of priority score and implementation effort.

Priority Action Addresses Effort

1

Set up automated PostgreSQL backups to offsite storage — ✅ Done (2026-04-20, manual workflow; scheduled + offsite deferred)

TD-4, R-4

Small (< 1 day)

2

Add retry with backoff for Mistral AI and Mailgun API calls — partially done (provider queueing + 429 retry); extend to broader transient failures

TD-3, R-1, R-5

Medium (1—​2 days)

3

Draft and publish AGB (Terms of Service)

TD-5

Medium (legal review needed)

4

Build LLM extraction regression test suite

R-2

Medium (ongoing)

5

Migrate prod to the dev-proven operating model (Terraform-defined host + 1Password runtime secrets)

TD-7, R-4

Medium (1—​2 days)

6

Write operations runbook (deployment, failed-deployment recovery, backup, incident response)

R-3, R-4

Small (< 1 day)

7

Define performance baselines and alerting thresholds

TD-6, R-1

Small (< 1 day)

8

Delete or redirect outdated SOLUTION_OVERVIEW.md — ✅ Done (2026-04-05)

TD-1

Small (< 1 hour)

9

Standardize package naming convention

TD-2

Medium (1—​2 days)

Glossary

Terms used throughout this documentation and the backend codebase. Domain terms reflect German e-invoicing regulations and the ZUGFeRD ecosystem. Technical terms are specific to the shared backend and its EER and Clarula product contexts.

E-Invoicing Standards and Formats

EN16931

European standard defining the semantic data model for electronic invoice core elements. ZUGFeRD 2.x and XRechnung both implement this standard. EER validates every generated invoice against EN16931 rules.

Factur-X

French equivalent of ZUGFeRD, cross-border compatible. Factur-X 1.09 and ZUGFeRD 2.5 share the same technical specification. Potential future format for EER.

PDF/A-3

ISO 19005-3 standard for long-term archival of PDF documents. Allows embedding arbitrary files — ZUGFeRD uses this to attach XML inside the PDF. ZUGFeRD technically requires PDF/A-3, but most readers also accept regular PDFs with embedded XML.

Peppol BIS

Pan-European Public Procurement OnLine Business Interoperability Specification. European standard for cross-border e-invoicing, primarily used in B2G contexts. Not currently supported by EER.

XRechnung

German implementation of EN16931 for public-sector (B2G) invoicing. Pure XML format without PDF wrapper. Not currently supported by EER but planned for future expansion.

ZUGFeRD

Zentraler User Guide des Forums elektronische Rechnung Deutschland. German e-invoicing standard that embeds structured CII XML within a PDF document. EER generates ZUGFeRD 2.5 with the EN16931 profile (COMFORT level).

Validation

Schematron Validation

Rule-based XML validation using ISO Schematron. Checks EN16931 business rules that XSD schema validation cannot express — for example, "tax amount equals base amount times tax rate."

XSD Validation

XML Schema Definition validation. Checks structural correctness of ZUGFeRD XML against the official CII schema (element names, types, cardinalities).

Libraries and Infrastructure

caddy-ratelimit

Caddy plugin that provides the rate_limit directive. The backend uses it for per-IP throttling on public endpoints at the reverse-proxy layer.

Caddy

Web server with automatic HTTPS via Let’s Encrypt. Runs as reverse proxy in production, routing traffic to backend and product-frontend containers and terminating TLS.

Mailgun

Email infrastructure provider. Handles inbound email via webhooks and outbound email delivery for EER. Also delivers DOI confirmation and welcome emails for the self-hosted registration flow. Configured for EU region to satisfy data residency constraints.

Metabase

Open-source business analytics platform. Reads from the business_events table in PostgreSQL to provide dashboards and alerts for conversion rates, error patterns, and trial activations. Accessible at analytics.einfache-erechnung.de (internal use only).

Mistral AI

EU-based AI company providing LLM APIs. EER uses the mistral-small-2603 model for invoice data extraction from PDFs. Selected over US-based providers for GDPR compliance (see Solution Strategy).

Mustang Project

Open-source Java library (org.mustangproject) for reading and writing ZUGFeRD/Factur-X invoices. Generates EN16931-compliant CII XML and validates it against Schematron rules.

OpenTelemetry (OTel)

Open-source observability framework for traces, metrics, and logs. The backend uses the Java agent for automatic instrumentation, exporting telemetry to New Relic via OTLP.

PDFBox

Apache PDFBox — Java library for PDF document manipulation. Attaches ZUGFeRD XML to the original PDF without re-rendering, preserving fonts and layout.

Spring AI

Spring framework module for integrating LLM providers. Provides the ChatClient abstraction that EER uses for Mistral AI. Enables provider switches through configuration changes.

Testcontainers

Java library for integration testing with real Docker containers. The backend uses it to run PostgreSQL in integration tests, ensuring test parity with production.

Backend System Components

backend

One deployable Spring Boot modular monolith containing EER and Clarula business logic. Persistence-free :app owns the BootJar and composes eager sibling product contexts; :eer owns the default / dispatcher and EER persistence, while :clarula owns /admin/clarula/* and Clarula persistence. Code location: applications/backend/

einfache-eRechnung frontend

Nuxt-generated static product frontend containing marketing, legal, content, registration, trial upload, authentication, portal, diagnosis, correction, and email-subscription routes. Public URL: https://einfache-erechnung.de Code location: applications/frontend/apps/einfache-erechnung/

Clarula frontend

Nuxt-generated static Clarula product frontend containing marketing, legal, contact, demo-entry, and explicitly declared application-shell routes. Public URL: https://clarula.de Code location: applications/frontend/apps/clarula/

partner demos

Static demo pages for partner/widget flows. Code location: demos/partner/

EER Domain Concepts

BaseEntity / ClarulaBaseEntity

Product-owned abstract JPA entity conventions providing UUID primary keys, @Version optimistic locking, and audit timestamps. EER entities extend BaseEntity; Clarula entities extend ClarulaBaseEntity so neither product introduces a persistence dependency through :core.

Business Event

Domain event published via Spring’s ApplicationEvent mechanism. Tracked in the database, as OpenTelemetry spans, and in metrics. Examples: InvoiceReceivedEvent, InvoiceProcessedEvent.

Edit Session

Temporary capability record (InvoiceEditSession) binding the original PDF to an integrity-checked InvoiceDraft snapshot whose schema version is relational metadata, plus validation and lifecycle state. Completed capabilities additionally retain their exact immutable result artifacts until expiry. Allows users to diagnose source problems and correct recognition or mapping errors via a web form. Expires after 30 days.

Free Website Upload

Anonymous PDF-to-ZUGFeRD conversion via the website at POST /api/free-upload/convert. No account required. Rate-limited per IP. Formerly called "Trial Conversion" in the codebase — renamed in code to FreeWebsiteUploadController and FreeWebsiteUploadEvent.

Magic Link

Passwordless authentication method. The user receives an email with a one-time login URL instead of entering a password. First email submission creates the account automatically.

MustangErrorHumanizer

EER component that converts technical Mustang library exceptions into German user-friendly error messages with actionable correction advice.

PendingRegistration

Database entity storing unconfirmed registration requests. Contains email, confirmation token, source path (which landing page), and timestamps. Deleted automatically after 48 hours if not confirmed.

Validation Status

State of an edit session: PENDING (not yet validated), VALID (all checks passed), or INVALID (validation errors found).

Business and Product Terms

Clarula Firm

The Clarula-owned tenant aggregate for one tax firm. It anchors DATEV authorization, imported mandates, confirmations, and future uploader/document state in the Clarula database. It has no implicit mapping to an EER Partner.

EER Partner

The einfache-eRechnung aggregate for widget origin registration, redirect allow-listing, attribution, affiliation, and partner tariffs. It never anchors Clarula tenancy or DATEV state.

E-Invoicing Mandate

German legal requirement under the Wachstumschancengesetz. B2B businesses must accept e-invoices from January 2025 and send e-invoices from January 2027 (businesses with revenue above 800k EUR).

EÜR

Einnahmenüberschussrechnung — German simplified accounting method for small businesses and freelancers. Relevant because the target audience expects predictable monthly costs, not usage-based pricing.

Fake Door

Product validation technique where a payment UI is shown but no real charges are made. Used to measure upgrade intent before investing in payment processing infrastructure.

Founding Member

Early adopter who registered during the pre-launch phase. Receives Pro-tier access for free as a reward for early feedback.

Grace Period

7-day period after trial expiration where the service continues to work. Prevents hard cutoff for a compliance-critical tool that users depend on for legal obligations.