CTN ASR Coding Standards
This document defines the coding standards for the CTN Association Register project. All code should follow these conventions for consistency and maintainability.
Stack reminder. The preferred stack is decided in ADR-00002: Quarkus + PostgreSQL + Keycloak, Java-based, open-source, cloud-agnostic. The conventions below assume that stack. The frontend stack for the ASR portals is not yet decided (see Open Questions at the bottom); the front-end section gives only the language-agnostic rules until a framework is chosen.
1. Naming Conventions
Section titled “1. Naming Conventions”1.1 Database Schema (PostgreSQL)
Section titled “1.1 Database Schema (PostgreSQL)”Column Names — snake_case
✓ GOOD:legal_entity_iddt_createddt_modifiedkvk_verification_statusdocument_uploaded_at
✗ BAD:legalEntityIddtCreateddocumentUploadedAt -- missing prefix when part of kvk_* groupTable Names — snake_case, singular
✓ GOOD:legal_entitylegal_entity_contactendpoint_authorization
✗ BAD:LegalEntitylegalEntityContactlegal_entities -- pluralConsistency Rule: related columns share a prefix.
✓ GOOD:kvk_document_urlkvk_verification_statuskvk_verified_atkvk_verified_bykvk_extracted_company_name
✗ BAD:kvk_document_urlverification_status -- missing kvk_ prefixdocument_uploaded_at -- missing kvk_ prefix1.2 API Routes
Section titled “1.2 API Routes”Path Segments — kebab-case
✓ GOOD:/v1/legal-entities/{legalEntityId}/identifiers/v1/kvk-verification/flagged-entities/v1/participant-contacts
✗ BAD:/v1/legalentities/... -- no separation/v1/kvk_verification/... -- snake_case in URL/v1/participant/contacts -- inconsistent (missing hyphen)Path Parameters — camelCase
✓ GOOD:/v1/legal-entities/{legalEntityId}/v1/participants/{participantId}/members/{userId}
✗ BAD:/v1/legal-entities/{legal_entity_id} -- snake_case in URL params/v1/legal-entities/{LegalEntityId} -- PascalCaseQuery Parameters — snake_case
✓ GOOD:?task_type=kvk_verification?assigned_to=admin@ctn.nl?include_overdue=true
✗ BAD:?taskType=...?assignedTo=...1.3 Java / Quarkus Code
Section titled “1.3 Java / Quarkus Code”Packages — lowercase, dotted
✓ GOOD:package nl.ctn.asr.participant;package nl.ctn.asr.identifier;package nl.ctn.asr.token;
✗ BAD:package nl.ctn.asr.Participant;package nl.CTN.asr.participant;Classes / Records / Sealed interfaces — PascalCase
✓ GOOD:public record LegalEntity(UUID id, String name, ...) { }public sealed interface VerificationOutcome permits Approved, Pending, Rejected { }public class ParticipantResource { }
✗ BAD:public class participantResource { }public record legal_entity(...) { }Transport DTOs — Req / Resp suffixes allowed. Request/response records in dto packages may abbreviate the suffix words Request → Req and Response → Resp (CreateOnboardingReq, OnboardingReqResp). This is the only sanctioned abbreviation, and only for the transport-role suffix — glossary terms inside the name stay full words (ADR-00004): the entity stays OnboardingRequest. Rationale: avoids stutter like OnboardingRequestResponse where the glossary term Request collides with the transport suffix.
✓ GOOD:public record CreateOnboardingReq(...) { } (dto package, transport suffix)public class OnboardingRequest { } (domain entity, full glossary term)
✗ BAD:public class OnboardingReq { } (domain term abbreviated)public record CreateOnboardingRqst(...) { } (unsanctioned abbreviation)Methods / Variables — camelCase
✓ GOOD:public Uni<Participant> findByEuid(String euid) { }private final ParticipantRepository participantRepository;var legalEntityId = UUID.randomUUID();
✗ BAD:public Uni<Participant> Find_By_EUID(String EUID) { }private final ParticipantRepository ParticipantRepository;Constants — UPPER_SNAKE_CASE
✓ GOOD:public static final String VAD_TOKEN_AUDIENCE = "https://ctn.network";public static final Duration KVK_CACHE_TTL = Duration.ofHours(24);
✗ BAD:public static final String vadTokenAudience = "...";public static final Duration KvkCacheTtl = Duration.ofHours(24);File Names — match the public class / record / interface inside.
✓ GOOD:ParticipantResource.java (public class ParticipantResource)LegalEntity.java (public record LegalEntity)VerificationOutcome.java (public sealed interface VerificationOutcome)
✗ BAD:participantResource.javalegal-entity.java1.4 Records, Sealed Interfaces, Pattern Matching
Section titled “1.4 Records, Sealed Interfaces, Pattern Matching”Java 25+ features that the codebase actively uses:
recordfor DTOs, value objects, and immutable data carriers (legal entities, identifiers, claim payloads).sealed interface+permitsfor closed enumerations of states (e.g.VerificationOutcome,OnboardingState).- Pattern matching for
switchon those sealed hierarchies — exhaustiveness checked by the compiler.
✓ GOOD:public sealed interface VerificationOutcome permits Approved, Pending, Rejected { }
public record Approved(Instant at, String verifiedBy) implements VerificationOutcome { }public record Pending(String waitingFor) implements VerificationOutcome { }public record Rejected(String reason) implements VerificationOutcome { }
return switch (outcome) { case Approved a -> Response.ok(a).build(); case Pending p -> Response.status(202).entity(p).build(); case Rejected r -> Response.status(409).entity(r).build();};
✗ BAD:// Don't expose mutable POJOs in REST layerspublic class VerificationResultDTO { public String status; public Date when;}2. Date and Timezone Standards
Section titled “2. Date and Timezone Standards”2.1 Database Timestamps
Section titled “2.1 Database Timestamps”ALWAYS use TIMESTAMP WITH TIME ZONE
✓ GOOD:dt_created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMPkvk_verified_at TIMESTAMP WITH TIME ZONEexpires_at TIMESTAMP WITH TIME ZONE
✗ BAD:created_at TIMESTAMP WITHOUT TIME ZONE -- no timezone infoupdated_at TIMESTAMP -- ambiguousRationale: TIMESTAMP WITH TIME ZONE stores timestamps in UTC internally and converts to/from any timezone automatically. This prevents timezone bugs.
Unix Timestamp Conversion:
✓ GOOD:to_timestamp($1) AT TIME ZONE 'UTC'
✗ BAD:to_timestamp($1) -- assumes server timezone2.2 Java Date Handling
Section titled “2.2 Java Date Handling”Use java.time.* only. Never java.util.Date, java.util.Calendar or Joda-Time.
✓ GOOD:Instant createdAt = Instant.now();OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(24);LocalDate dueDate = LocalDate.of(2026, 6, 1);
✗ BAD:Date created = new Date(); // legacy, mutable, timezone-naiveCalendar.getInstance(); // legacyPersistence: store Instant (mapped to TIMESTAMP WITH TIME ZONE) for events, LocalDate for calendar dates without time.
2.3 API Date Responses
Section titled “2.3 API Date Responses”Always return dates in ISO 8601 with UTC offset (Z). Jackson is configured to serialise Instant and OffsetDateTime exactly this way.
✓ GOOD:{ "created_at": "2026-05-29T14:30:00.000Z", "expires_at": "2026-05-30T14:30:00.000Z"}
✗ BAD:{ "created_at": "2026-05-29T14:30:00", // no timezone "expires_at": "05/30/2026" // ambiguous format}2.4 Frontend Date Formatting
Section titled “2.4 Frontend Date Formatting”The frontend MUST format dates using the user’s locale, not a hard-coded one. Use the framework’s built-in localisation utilities (when the frontend stack is chosen; see Open Questions).
✓ GOOD:Format with the user's locale (resolved from i18n or browser).
✗ BAD:Hard-coded "nl-NL" or "en-GB" in display code.For relative times (e.g. “2 hours ago”), use the platform-standard relative-time formatter.
2.5 Timezone Display
Section titled “2.5 Timezone Display”Show timezone information for absolute timestamps:
✓ GOOD:"Created: 2026-05-29 14:30 UTC""Expires: 2026-05-30 14:30 (in 23 hours)"
✗ BAD:"Created: 2026-05-29 14:30" -- no timezone infoFor relative times, timezone is not needed:
✓ GOOD:"Created 2 hours ago""Expires in 23 hours"3. Error Handling Standards
Section titled “3. Error Handling Standards”3.1 HTTP Status Codes
Section titled “3.1 HTTP Status Codes”200 OK - Successful GET / PUT201 Created - Successful POST204 No Content - Successful DELETE400 Bad Request - Validation error401 Unauthorized - Missing / invalid auth token403 Forbidden - Valid auth but no permission404 Not Found - Resource doesn't exist409 Conflict - Duplicate resource422 Unprocessable - Business-logic error429 Too Many Req. - Rate limit exceeded500 Server Error - Unexpected server error3.2 Error Response Format (RFC 9457 — Problem Details for HTTP APIs)
Section titled “3.2 Error Response Format (RFC 9457 — Problem Details for HTTP APIs)”✓ GOOD:{ "type": "https://docs.ctn.network/errors/validation", "title": "Validation failed", "status": 400, "detail": "kvk_number must be 8 digits", "instance": "/v1/legal-entities/abc-123/identifiers", "errors": [ { "field": "kvk_number", "message": "must be 8 digits" } ]}
✗ BAD:{ "message": "Error" } -- not helpfulQuarkus exposes RestResponse<ProblemDetails> builders; use those rather than rolling a custom body shape.
4. Security Standards
Section titled “4. Security Standards”4.1 Database Access
Section titled “4.1 Database Access”Use Panache or parameter-bound PreparedStatement. Never concatenate user input into SQL.
✓ GOOD:participantRepository.find("email = ?1", email).firstResult();em.createQuery("SELECT p FROM Participant p WHERE p.email = :email", Participant.class) .setParameter("email", email) .getSingleResult();
✗ BAD:em.createQuery("SELECT p FROM Participant p WHERE p.email = '" + email + "'", ...);4.2 Secrets
Section titled “4.2 Secrets”Never commit secrets to git. Use the configured secret store (e.g. Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) and inject through Quarkus configuration:
✓ GOOD:@ConfigProperty(name = "kvk.api-key")String kvkApiKey;
✗ BAD:private static final String KVK_API_KEY = "abc123xyz";application.properties references the secret indirectly (e.g. ${KVK_API_KEY}); the secret-store driver resolves it at runtime.
4.3 Authentication
Section titled “4.3 Authentication”Use quarkus-oidc as the resource-server, configured against Keycloak (see ADR-00002, ADR-00008). Endpoints carry standard @Authenticated, @RolesAllowed, and @PermissionsAllowed annotations.
✓ GOOD:@Path("/v1/participants/{participantId}")@Authenticatedpublic class ParticipantResource { @GET @RolesAllowed("participant-member") public Participant get(@PathParam("participantId") UUID id) { ... }}4.4 Object-level authorization (ownership)
Section titled “4.4 Object-level authorization (ownership)”@RolesAllowed is coarse role gating only. It does not answer “may this caller touch this record?” When an endpoint exposes user-owned data, the role check must be backed by a data-dependent ownership check that compares the caller’s identity (Keycloak sub) to the record’s owner. Two callers with the same role must not be able to read or mutate each other’s data.
Enforce ownership below the resource layer (in the service/transition shell), so no endpoint — current or future — can forget the check.
Check ownership before state/existence, so a non-owner cannot probe a record’s lifecycle or existence through differing error codes (a non-owner always gets 403, never a 404/409 that leaks whether the record exists or what state it is in).
✓ GOOD:// OnboardingTransitions — ownership before statepublic OnboardingRequest findOwned(UUID requestId, Actor actor) { var request = load(requestId); if (!actor.sub().equals(request.applicantUserId)) { // owner == Keycloak sub throw new SecurityException("Actor may only read an onboarding request they own"); } return request; // 403 on mismatch, before any state read}
✗ BAD:@GET @Path("{requestId}")@RolesAllowed(Roles.REQUESTOR)public OnboardingRequest get(@PathParam("requestId") UUID id) { return repository.findById(id); // any requestor reads any requestor's record}Worked example — OnboardingRequest. After (anonymous) creation, only the creator (applicantUserId == sub) and the association admin (ASSOCIATION_ADMIN) may read or modify it. Admin endpoints carry @RolesAllowed(ASSOCIATION_ADMIN); requestor endpoints carry @RolesAllowed(REQUESTOR) and run the applicantUserId == actor.sub() ownership check in the transition shell (findOwned / enrich / resubmit).
5. Code Organisation
Section titled “5. Code Organisation”5.1 Project Layout (Quarkus, single module)
Section titled “5.1 Project Layout (Quarkus, single module)”asr/├── src/│ ├── main/│ │ ├── java/nl/ctn/asr/│ │ │ ├── participant/ ← Domain package (resource + service + repo)│ │ │ │ ├── ParticipantResource.java│ │ │ │ ├── ParticipantService.java│ │ │ │ ├── Participant.java (record)│ │ │ │ └── ParticipantRepository.java│ │ │ ├── identifier/│ │ │ ├── token/│ │ │ └── endpoint/│ │ └── resources/│ │ ├── application.properties│ │ └── db/changelog/ ← Liquibase changelogs (YAML)│ └── test/│ └── java/nl/ctn/asr/│ ├── participant/ParticipantResourceTest.java│ └── ...└── pom.xmlThe package layout is domain-first (participant, identifier, token, endpoint), not layer-first (no controllers/, services/, repositories/ top-level packages).
5.2 Module seam pattern (ADR-00022)
Section titled “5.2 Module seam pattern (ADR-00022)”Each top-level business package under org.bdinetwork.asr (audit, document, verification, onboarding) is a module that exposes a small, explicit seam and hides everything else. Because the code is a Quarkus modulith (no Spring Modulith) and Java access control cannot express “module-internal across sub-packages” — CDI proxying and JAX-RS resources force types to be public — the boundary is defined by package placement and enforced by an ArchUnit allow-list (ModuleBoundaryTest), not the compiler. See ADR-00022 for the full decision; this section is the working digest. Audit is the pilot; document/verification/onboarding migrate opportunistically as they are touched.
A module has three package zones:
audit/ ← SEAM: root package AuditRecorder (service interface — callers inject this, never the impl) Action (seam enum — root, NOT dto/) dto/ ← SEAM (lazy): the interface's own record types exceptions/ ← SEAM (lazy): seam exception types rest/ ← HTTP boundary — sibling of internal, NOT seam, NOT injectable AuditRecordResource dto/AuditRecordResp (+ from(entity) mapper — HTTP-only wire types) internal/ ← hidden implementation (subtree; add packages freely) AuditRecorderImpl (public, but ArchUnit-hidden) AuditRecordQueryService persistence/ ← internal.persistence: JPA entities + repositories AuditRecord AuditRecordRepositoryRules:
- Seam = module root +
dto+exceptions. Nothing else is reachable from other modules. Cross-module communication goes through the root service interface (+ any published events); callers inject the interface, never the impl. Createdto/exceptionslazily, only when a seam type needs them. dto/holds the interface’s own types, each arecord(perArchitectureTest.dtos_should_be_records). Enums and other non-record seam value types live at the root, notdto/.rest/is a top-level sibling ofinternal, not part of the seam (a deliberate deviation from the source spec’sinternal.rest): a module’s HTTP surface is externally significant, so it is visible at a glance — but the allow-list still forbids sibling modules from injecting it. HTTP-only wire types live underrest/(e.g.rest/dto), never the seamdto/. A resource may freely consume its own module’sinternal(same-module access is unrestricted).internal/is a subtree. Add hidden packages underinternal.*freely (fail-safe default). Persistence — JPA entities + repositories — lives ininternal.persistence. Never create hidden packages as seam siblings other thanrest/.model/repositoryparking packages dissolve into each module’sinternal.persistenceas modules migrate.- Tests are exempt (
ImportOption.DoNotIncludeTests): same-module tests may touchinternal; cross-module/e2e tests go through HTTP (RestAssured), not Java imports.
Watch the Javadoc {@link} trap. ArchUnit reads bytecode, so a {@link some.other.module.internal.persistence.Entity} — and the import it needs — passes the boundary test while still coupling your source to another module’s internals. Reference the seam type (or plain prose) in cross-module Javadoc, and keep no import of another module’s internal.. in production code.
5.3 Import Order
Section titled “5.3 Import Order”Java import grouping (IntelliJ / google-java-format default):
// 1. Standard libraryimport java.time.Instant;import java.util.UUID;
// 2. Third-party / Quarkus / Jakartaimport jakarta.ws.rs.GET;import jakarta.ws.rs.Path;import io.quarkus.security.Authenticated;import org.eclipse.microprofile.config.inject.ConfigProperty;
// 3. Projectimport nl.ctn.asr.participant.Participant;No wildcard imports. No unused imports. Static imports only for test assertions and obvious helpers (e.g. Assertions.assertThat).
6. Documentation Standards
Section titled “6. Documentation Standards”6.1 Public API documentation
Section titled “6.1 Public API documentation”Every public REST method has a Javadoc comment that explains what and why (not just the signature):
/** * Creates a new legal-entity identifier (KVK, LEI, EORI, …) for the * given participant. Idempotent on (participant, type, value). * * @param participantId participant the identifier belongs to * @param request identifier details (type, value, country) * @return 201 Created with the persisted identifier * @throws ValidationException if the value does not match the type's format * @throws ConflictException if the identifier already exists for this participant */@POST@Path("/{participantId}/identifiers")public RestResponse<Identifier> create( @PathParam("participantId") UUID participantId, @Valid CreateIdentifierRequest request) { ... }OpenAPI is generated from the resource classes (quarkus-smallrye-openapi) and is the contract for consumers. Because the generated spec is the consumer-facing documentation, every endpoint carries OpenAPI annotations:
@Operation(summary = …, description = …)on every endpoint method. The description explains the endpoint’s behaviour and intent in current, accurate terms — not a restated method name. Update it whenever behaviour changes; a stale description is a review finding, same as a wrong comment.@APIResponsefor each status code the endpoint can produce (success plus the error statuses from §3), with typed@Schemacontent.@Parameter/@RequestBodydescriptions on inputs whose meaning or format is not obvious from the name and type.- Examples —
@ExampleObjecton request/response content, orexampleon@Schemafields — unless an example is unreasonable in the given context (trivial primitives, binary payloads, pure pass-through wrappers). @Tagon each resource class so endpoints group sensibly in the spec.
@POST@Path("/{participantId}/identifiers")@Operation(summary = "Create a legal-entity identifier", description = "Registers a KVK, LEI or EORI identifier for the participant. " + "Idempotent on (participant, type, value).")@APIResponse(responseCode = "201", description = "Identifier created", content = @Content(schema = @Schema(implementation = Identifier.class), examples = @ExampleObject(name = "kvk", value = """ {"type": "KVK", "value": "12345678", "country": "NL"}""")))@APIResponse(responseCode = "409", description = "Identifier already exists for this participant")public RestResponse<Identifier> create(...) { ... }6.2 Complex Logic Comments
Section titled “6.2 Complex Logic Comments”// Calculate expiry date: 24 hours from issuance for VAD tokensInstant expiresAt = Instant.now().plus(Duration.ofHours(24));
// KvK numbers must be exactly 8 digits (Dutch Chamber of Commerce standard)private static final Pattern KVK_REGEX = Pattern.compile("^\\d{8}$");7. Testing Standards
Section titled “7. Testing Standards”7.1 Unit + Integration Tests (JUnit 5 + RestAssured)
Section titled “7.1 Unit + Integration Tests (JUnit 5 + RestAssured)”- Unit tests: pure Java with JUnit 5; mock external collaborators via Mockito where unavoidable.
- Integration tests:
@QuarkusTestwith the in-memory database (Dev Services for PostgreSQL via Testcontainers). - Aim for >70% line coverage on business logic packages; coverage gates enforced in CI.
@QuarkusTestclass ParticipantResourceTest {
@Test void create_returns_201() { given() .contentType(JSON) .body(new CreateParticipantRequest("Acme BV", "12345678")) .when() .post("/v1/participants") .then() .statusCode(201) .body("name", equalTo("Acme BV")); }}7.2 Authorization tests (mandatory for owner-scoped endpoints)
Section titled “7.2 Authorization tests (mandatory for owner-scoped endpoints)”Every endpoint that exposes user-owned data MUST have a test proving an unauthorized user cannot reach it. A happy-path test (owner gets 200) is not enough — it never exercises the deny path, so a missing or broken ownership check passes CI silently. The ownership rule of §4.4 is only real if a test defends it.
For each owner-scoped read/modify endpoint, assert at minimum:
- Owner allowed — the owning caller succeeds (
200/204). - Same-role non-owner denied — a different caller with the same role is rejected with
403(this is the test that catches missing object-level checks). - Deny before leak — where ordering matters (§4.4), the non-owner gets
403even when the record is in a state that would otherwise yield404/409, so existence/state is not leaked.
✓ GOOD:@Test@TestSecurity(user = "stranger-read", roles = Roles.REQUESTOR)void get_asNonOwningRequestor_returns403() { var requestId = seedOwned("33545678", UUID.randomUUID()); // owned by someone else given().when().get(PATH + "/" + requestId).then().statusCode(403);}
@Testvoid resubmit_byNonOwnerOfNonChangesRequested_throwsSecurityExceptionNotIllegalTransition() { // ownership check fires before the state check → 403, not 409 (no state leak)}
✗ BAD:@Testvoid get_returns200() { // only the owner path — deny path never tested given().when().get(PATH + "/" + ownRequestId).then().statusCode(200);}These tests live next to the resource/shell they protect (e.g. OnboardingRequestResourceTest, OnboardingTransitionsTest) and carry @Tag("requires-database") when they touch the DB.
7.3 Contract Tests
Section titled “7.3 Contract Tests”OpenAPI is the contract. Generate clients from the spec; the API project’s CI compares the generated OpenAPI against the committed openapi.yaml and fails on incompatible changes.
7.4 End-to-End Tests (UI)
Section titled “7.4 End-to-End Tests (UI)”The chosen browser E2E framework is Playwright for Java, driving Chromium, decided in ADR-00015. It is a test-scoped, pinned Maven dependency bound to Maven Failsafe: E2E *IT classes carry @Tag("requires-database") and run under ./mvnw verify on a Docker host alongside the DB integration tests; a Docker-less run (-DskipITs=true) activates the no-docker profile and reports them SKIPPED, never green (mirroring the DB/IT gating). The E2E suite runs after API contract tests pass.
This names the E2E test tooling only; it does not decide the ASR portal frontend stack (still open — see Open Questions). The current E2E coverage drives the throwaway asr-demo-frontend static demo, not a portal SPA.
8. Commit Message Standards
Section titled “8. Commit Message Standards”Follow Conventional Commits:
feat: add KvK verification review workflowfix: correct timezone handling in date displaydocs: update API documentation for identifiersrefactor: standardise naming across participant packagetest: add E2E tests for participant creationchore: bump Quarkus to 3.35.0Format:
<type>(<scope>): <short description>
<optional longer description>
<optional footer>Scope is optional but useful (feat(participant): …, fix(token): …).
9. Summary Checklist
Section titled “9. Summary Checklist”When writing code, verify:
- Database columns use
snake_casewith consistent prefixes. - Database timestamps use
TIMESTAMP WITH TIME ZONE. - API routes use
kebab-casefor paths;camelCasefor path params;snake_casefor query params. - Java classes/records/interfaces use
PascalCase; methods/variablescamelCase; constantsUPPER_SNAKE_CASE. - DTOs are immutable (
record); state machines aresealed interfaces. - Dates use
java.time.*; API serialises as ISO 8601 withZ. - SQL is parameter-bound (Panache or
@Parameter); no string concatenation of user input. - No secrets committed; secrets resolved via Quarkus configuration from the secret store.
- Endpoints carry
@Authenticated/@RolesAllowedper ADR-00008. - Owner-scoped endpoints back the role check with a data-dependent ownership check (§4.4), enforced below the resource layer and evaluated before state/existence.
- Every owner-scoped endpoint has a test proving a same-role non-owner is rejected with
403(§7.2), not just an owner happy-path test. - Cross-module access touches only the seam (module root +
dto+exceptions); no productionimportof another module’sinternal.., not even in a{@link}(§5.2, ADR-00022). - Error responses follow RFC 9457 (Problem Details).
- Public REST methods have Javadoc explaining behaviour and contracts.
- OpenAPI passes the CI compatibility check.
- Test coverage on the touched package is at or above the 70% threshold.
Open Questions
Section titled “Open Questions”- Frontend stack: the ASR portals (admin, participant, data-consumer) need a chosen frontend stack. Candidates discussed include the existing React + TypeScript prototype, a server-rendered Quarkus + Qute approach, or a fully framework-agnostic web-component approach. A separate ADR is expected.
- OpenAPI versioning: how do we evolve
/v1→/v2? Deprecation policy and concurrent-version support remain TBD.
Questions or clarifications? See AGENTS.md or ask in project discussions.
In samenwerking met




