ADR-00012: OnboardingRequest Lifecycle Transition Seam
Approval Status
Section titled “Approval Status”Status flow: Proposed → Accepted → Reviewed → Approved (or Rejected), or Not Required. A superseded ADR keeps its file; only its status changes to Superseded by NNNNN.
| Governance body | Status | Last update |
|---|---|---|
| CTN Project Team | Proposed | 2026-06-13 |
| CTN Technical Advisory Board | Pending | — |
| CTN Steering Committee | Pending | — |
| BDI Conformance Team | Pending | — |
| BDI Framework Team | Pending | — |
RdN Question: I don’t really understand in which cases this can occur. It is pending with the “aspirant-participant” and in that case the request can only be managed with him/her. Or he/she has submitted the request and than it is in the hands of the ASR-admin. In other words in my opinion we can’t have colliding states in which ASR admin and the adspirant-participant are stepping on each other feet.
Context and Problem Statement
Section titled “Context and Problem Statement”OnboardingRequest is becoming a long-lived, stateful aggregate. Today its status is a bare JPA-mapped enum (Submitted, UnderReview, Approved, Rejected) on an entity with all-public mutable fields, and the only transition that exists is intake setting status = Submitted inside OnboardingRequestService.save.
The roadmap (see CONTEXT.md Onboarding lifecycle, and the project memory onboarding-future-lifecycle) turns it into an aggregate mutated by two human actors taking turns — a requestor (authenticated “edit onboarding request” page) and an admin (validation, with multi-round “needs more data” back-and-forth) — plus a system sweep that auto-rejects abandoned requests. Three handlers (edit page, admin validation, expiry job) are about to land, each tempted to mutate status directly.
Without a seam, the transition rules (which moves are legal, who may make them, what gates each) scatter across those handlers and the invariant “an illegal transition is unrepresentable” is enforced only by reviewer vigilance. The country-flow divergence (NL eHerkenning step-up, DE document upload) also needs a home; the settled intake design keeps intake DTOs uniform, so divergence must live post-intake, in this phase.
We need a place that owns the lifecycle so that:
- the legal set of state transitions is defined once and illegal moves cannot be expressed,
- each transition records who performed it,
- per-country enrichment requirements plug in as guards without leaking into the intake DTOs or the core,
- the one-open-request-per-identifier intake invariant (ADR-scope of the duplicate guard) and the lifecycle stay consistent about which states are “open”.
Considered Options
Section titled “Considered Options”Option 1: Status quo — anaemic field, transitions in handlers
Section titled “Option 1: Status quo — anaemic field, transitions in handlers”- Description: Keep
public Status statusand let each handler assign it. - Pros: No new code; familiar.
- Cons: Transition rules duplicated across three handlers; illegal moves (
Submitted → Approved, mutating someone else’s request) representable and only caught by review; no single place for guards, actor recording, or the future audit hook. This is the smell the ADR exists to remove.
Option 2: Rich-entity methods
Section titled “Option 2: Rich-entity methods”- Description: Put
startReview()/sendBack()/approve()/…on theOnboardingRequestentity; it guards its own status. - Pros: Behaviour with data; “tell, don’t ask”.
- Cons: To make illegal moves unrepresentable the public
statusfield must be locked, fighting the house anaemic-entity style; worse, contextual guards need injected collaborators (repositories, the per-country enrichment SPI, the acting Actor) — an@Entityreaching into CDI beans is the wrong dependency direction.
Option 3: Typed state-machine library
Section titled “Option 3: Typed state-machine library”- Description: Model the lifecycle with a state-machine framework / explicit transition table as the runtime engine.
- Pros: Strong “illegal edge absent from the table” guarantee.
- Cons: Over-engineered for 5 states / 6 verbs; injectable guards still need a home outside the table; adds a dependency and a vocabulary the rest of the service doesn’t use.
Option 4: Hybrid — pure transition core wrapped by a CDI shell (chosen)
Section titled “Option 4: Hybrid — pure transition core wrapped by a CDI shell (chosen)”- Description: A dependency-free transition core owns the legal-edge topology
(from, verb) → to; a CDI shell is the only writer ofstatusand owns the contextual (I/O-dependent) guards, actor recording, and persistence. The entity’sstatusis locked behind a singletransitionTo(next)mutator that delegates to the core. - Pros: Illegal transitions unrepresentable even from inside the model package (compiler + core, not review); pure core is a trivial unit-test/prototype target; guards that need injected deps live where injection works; fits the existing
@ApplicationScoped/@Transactionalstyle. - Cons: One more module than a single rich entity; the
statusfield becomes the one non-public field on an otherwise public-field entity (mixed visibility).
Decision Outcome
Section titled “Decision Outcome”Chosen option: Option 4 — pure transition core + CDI shell + locked setter.
State set
Section titled “State set”Submitted ──startReview──▶ UnderReview ──approve──▶ Approved (terminal) │ ▲ sendBack │ │ resubmit ▼ │ ChangesRequestedUnderReview ──reject──▶ Rejected (terminal)Submitted | ChangesRequested ──expire──▶ RejectedChangesRequestedis a distinct first-class state, not a flag onUnderReview— it marks that control has passed back to the requestor.- Stale/abandoned requests are auto-rejected into
Rejected; there is no separateExpiredstate (product treats abandonment as a rejection). - Open states (a request “in progress”) =
Submitted, UnderReview, ChangesRequested. Terminal =Approved, Rejected.
Lifecycle verbs
Section titled “Lifecycle verbs”| verb | edge | actor |
|---|---|---|
startReview | Submitted → UnderReview | admin (explicit) |
sendBack | UnderReview → ChangesRequested | admin (note req.) |
resubmit | ChangesRequested → UnderReview | requestor |
approve | UnderReview → Approved | admin |
reject | UnderReview → Rejected | admin (note req.) |
expire | Submitted | ChangesRequested → Rejected | system service acct |
- Creation into
Submittedis Intake, not a verb (intake stays one-phase). - Enrichment (requestor adding/correcting documents and data) is a separate guarded data-update, not a transition;
resubmitis the requestor’s explicit “I’m done, look again” move.startReviewis explicit (an admin claim), never implicit on a list/read.
Structure
Section titled “Structure”- Pure core (in the
…onboardingmodel/lifecycle package): the transition table,transition(from, verb) → to, and the derivedisTerminal(status)= “no outgoing edge” /isOpen= its negation. No JPA, no CDI, no I/O. - Entity:
statusis non-public; the sole mutatortransitionTo(next)delegates to the core for legality (throwsIllegalTransitionExceptionon an edge the table does not contain). JPA field-access hydration is unaffected. - Shell (CDI,
@Transactional): loads the aggregate, runs the contextual guards, callsentity.transitionTo(next), stamps actor/time, persists. It is the only writer ofstatus.
Guards and authorization
Section titled “Guards and authorization”- Topology (is this edge legal at all) lives in the core.
- Contextual guards (is this legal edge allowed now) live in the shell: ownership, per-country enrichment completeness, the expiry clock.
- Authorization is two-layer: coarse role checks stay at the HTTP/resource layer (existing Quarkus security policy — admin vs authenticated requestor); the data-dependent ownership guard (
actor.sub == request.applicantUserIdfor requestor verbs) lives in the shell, where the aggregate is loaded, so it cannot be bypassed by a new endpoint. - Every verb carries a non-null Actor (KC
sub), used for both the ownership guard and the future audit record.expireruns under a service-account Actor, not as an absent actor (see the narrowed Actor glossary entry). Only Intake is actor-less.
Per-country divergence
Section titled “Per-country divergence”Per-country enrichment requirements are a pluggable SPI, OnboardingEnrichmentPolicy, mirroring the existing OnboardingProvider SPI (ADR-00010 phase 1): CDI-discovered, supports(country) + a completeness check, resolved by intakeCountry (the server-stamped flow country, not the applicant’s address country). The shell invokes it as the guard on resubmit — a requestor cannot hand the ball back to review until systematic country requirements are met. Ad-hoc admin requests (“scan is unreadable”) are handled by the sendBack note and re-judged by the admin, not machine-guarded.
A full transition history (per-round who/when/from→to/note) is deferred to a future auditing service; until then transition history is not persisted. The shell, as the single transition writer, is the one place the future audit call goes and is marked as an explicit (currently no-op) extension point. Retained as current state now: a single overwritten lastDecisionNote (the latest sendBack/ reject reason — functional, the requestor reads it) and the existing reviewedAt/reviewedBy stamps (denormalised “latest decision”).
Open-states consistency (interaction with the duplicate-request invariant)
Section titled “Open-states consistency (interaction with the duplicate-request invariant)”The intake duplicate guard (one open request per business register identifier) keeps ownership of that invariant but sources the open-state definition from the lifecycle core (isOpen) instead of a hand-maintained list. The DB partial unique index uq_onboarding_open_identifier and the status check constraint must be widened to include ChangesRequested; because SQL cannot derive the set, a test pins the index’s status list to the core’s open set so the two cannot drift.
Consequences
Section titled “Consequences”- Positive:
- Illegal transitions are unrepresentable; transition rules live once.
- Three upcoming handlers call verbs, never
status =. - Country divergence has a home that keeps intake DTOs uniform.
- “Open vs terminal” derives from topology — adding a state can’t silently break the duplicate guard once the pin-test is in place.
- Negative:
- Transition history is lost until the auditing service exists (accepted; the seam is marked).
- A DB migration is required (widen check constraint + partial index for
ChangesRequested). - Mixed field visibility on the entity (
statusnon-public, the rest public).
- Neutral:
- Assumes an authenticated requestor identity and a system service account exist — provisioning them is out of scope (see dependency below).
Implementation Plan
Section titled “Implementation Plan”- Phase 1 — core + entity lock: pure transition core (table +
isTerminal/isOpen), entitytransitionTo+ non-publicstatus,IllegalTransitionException. Unit-test the topology (a prototype is a reasonable first pass). - Phase 2 — shell + verbs: CDI transition shell with the six verbs, non-null Actor parameter, ownership guard,
lastDecisionNote, the marked (no-op) audit hook. Migrate intake’s duplicate guard to consumeisOpen. - Phase 3 — schema: Liquibase migration widening the check constraint and the partial unique index for
ChangesRequested; the index-vs-core pin test. - Phase 4 — enrichment SPI:
OnboardingEnrichmentPolicyinterface + the country implementations; wire it as theresubmitguard. Lands with the edit page.
Dependencies and Out of Scope
Section titled “Dependencies and Out of Scope”- KC account at intake (ADR-00003 sibling, deferred): this ADR assumes the requestor has an authenticated Keycloak identity (Actor =
sub) by the time theyresubmit/enrich, and that a service-account identity exists forexpire. Provisioning the requestor’s KC account at intake — a shift from today’s anonymous intake — and the expiry service account are a separate identity decision to be made when the edit page is built. Not decided here. Decided in ADR-00013: the account is provisioned (reuse-or-create by email) on intake; the expiry service account remains out of scope. - The “edit onboarding request” page and the expiry scheduler themselves are consumers of this seam, designed separately.
Compliance and Validation
Section titled “Compliance and Validation”- Security review completed (ownership guard, service-account scope)
- Performance impact assessed
- Integration impact evaluated
- Documentation updated (CONTEXT.md lifecycle terms added)
- Stakeholder approval obtained
Related Documents
Section titled “Related Documents”- 00003-separate-users-and-participants.md — Keycloak owns identity; basis for the deferred KC-account-at-intake sibling
- 00010-evolving-to-quarkus-extensions.md — the
OnboardingProviderSPI pattern the enrichment SPI mirrors - 00011-uri-based-legal-entity-identifier-scheme.md / TDR-0002 — the business register identifier the duplicate guard keys on
docs/ai/CONTEXT.md— Onboarding lifecycle and Actors and auditing termsonboarding-future-lifecycle(project memory) — roadmap/source of intent
Status History
Section titled “Status History”| Date | Status | Notes |
|---|---|---|
| 2026-06-13 | Proposed | Initial proposal |
In samenwerking met




