Skip to content
Deze documentatie wordt actief uitgebreid — kom regelmatig terug.

ADR-00012: OnboardingRequest Lifecycle Transition Seam

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 bodyStatusLast update
CTN Project TeamProposed2026-06-13
CTN Technical Advisory BoardPending
CTN Steering CommitteePending
BDI Conformance TeamPending
BDI Framework TeamPending

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.

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”.

Option 1: Status quo — anaemic field, transitions in handlers

Section titled “Option 1: Status quo — anaemic field, transitions in handlers”
  • Description: Keep public Status status and 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.
  • Description: Put startReview()/sendBack()/approve()/… on the OnboardingRequest entity; it guards its own status.
  • Pros: Behaviour with data; “tell, don’t ask”.
  • Cons: To make illegal moves unrepresentable the public status field 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 @Entity reaching into CDI beans is the wrong dependency direction.
  • 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 of status and owns the contextual (I/O-dependent) guards, actor recording, and persistence. The entity’s status is locked behind a single transitionTo(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 / @Transactional style.
  • Cons: One more module than a single rich entity; the status field becomes the one non-public field on an otherwise public-field entity (mixed visibility).

Chosen option: Option 4 — pure transition core + CDI shell + locked setter.

Submitted ──startReview──▶ UnderReview ──approve──▶ Approved (terminal)
│ ▲
sendBack │ │ resubmit
▼ │
ChangesRequested
UnderReview ──reject──▶ Rejected (terminal)
Submitted | ChangesRequested ──expire──▶ Rejected
  • ChangesRequested is a distinct first-class state, not a flag on UnderReview — it marks that control has passed back to the requestor.
  • Stale/abandoned requests are auto-rejected into Rejected; there is no separate Expired state (product treats abandonment as a rejection).
  • Open states (a request “in progress”) = Submitted, UnderReview, ChangesRequested. Terminal = Approved, Rejected.
verbedgeactor
startReviewSubmitted → UnderReviewadmin (explicit)
sendBackUnderReview → ChangesRequestedadmin (note req.)
resubmitChangesRequested → UnderReviewrequestor
approveUnderReview → Approvedadmin
rejectUnderReview → Rejectedadmin (note req.)
expireSubmitted | ChangesRequested → Rejectedsystem service acct
  • Creation into Submitted is Intake, not a verb (intake stays one-phase).
  • Enrichment (requestor adding/correcting documents and data) is a separate guarded data-update, not a transition; resubmit is the requestor’s explicit “I’m done, look again” move. startReview is explicit (an admin claim), never implicit on a list/read.
  • Pure core (in the …onboarding model/lifecycle package): the transition table, transition(from, verb) → to, and the derived isTerminal(status) = “no outgoing edge” / isOpen = its negation. No JPA, no CDI, no I/O.
  • Entity: status is non-public; the sole mutator transitionTo(next) delegates to the core for legality (throws IllegalTransitionException on an edge the table does not contain). JPA field-access hydration is unaffected.
  • Shell (CDI, @Transactional): loads the aggregate, runs the contextual guards, calls entity.transitionTo(next), stamps actor/time, persists. It is the only writer of status.
  • 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.applicantUserId for 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. expire runs under a service-account Actor, not as an absent actor (see the narrowed Actor glossary entry). Only Intake is actor-less.

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.

  • 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 (status non-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).
  1. Phase 1 — core + entity lock: pure transition core (table + isTerminal/ isOpen), entity transitionTo + non-public status, IllegalTransitionException. Unit-test the topology (a prototype is a reasonable first pass).
  2. 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 consume isOpen.
  3. Phase 3 — schema: Liquibase migration widening the check constraint and the partial unique index for ChangesRequested; the index-vs-core pin test.
  4. Phase 4 — enrichment SPI: OnboardingEnrichmentPolicy interface + the country implementations; wire it as the resubmit guard. Lands with the edit page.
  • KC account at intake (ADR-00003 sibling, deferred): this ADR assumes the requestor has an authenticated Keycloak identity (Actor = sub) by the time they resubmit/enrich, and that a service-account identity exists for expire. 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.
  • Security review completed (ownership guard, service-account scope)
  • Performance impact assessed
  • Integration impact evaluated
  • Documentation updated (CONTEXT.md lifecycle terms added)
  • Stakeholder approval obtained
DateStatusNotes
2026-06-13ProposedInitial proposal

In samenwerking met

Connected Trade NetworkConclusionData in LogisticsContargoInland Terminals GroupVan Berkel