feat(boundaries): WP-04 — ui ↛ infrastructure + showcase sanction

Move the two wizard lookups behind application-layer facades so ui/ no longer
injects infrastructure adapters directly:
- RegistratieLookupStore (BRP address + DUO diplomas): owns the resources,
  runs the trust-boundary parse, exposes adresStatus/prefillAdres/duoLookup.
- IntakePolicyStore (scholing threshold): owns the policy resource, exposes
  the derived threshold.

Add the lint rule ui/ + layout/ ↛ **/infrastructure/** (@typescript-eslint
variant so it composes with the base direction rules; stories/specs exempted
as test scaffolding). Add the documented showcase sanction (may read every
context). Fix the docs' inventory: 6 contexts / 5 layers, +brief, +contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-02 21:01:42 +02:00
co-authored by Claude Opus 4.8
parent 3a5c8f157a
commit 035e785c95
10 changed files with 513 additions and 227 deletions
@@ -0,0 +1,19 @@
import { Injectable, computed, inject } from '@angular/core';
import { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine';
import { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter';
/**
* Application-layer facade for the server-owned intake policy (the scholing
* threshold config value). It owns the httpResource (created here, in the required
* injection context) and exposes the threshold as a derived signal, falling back to
* the domain default until the backend answers. The UI reaches the network through
* application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the
* authority and re-validates on submit.
*/
@Injectable({ providedIn: 'root' })
export class IntakePolicyStore {
private policy = inject(IntakePolicyAdapter);
private policyRes = this.policy.policyResource();
readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
}
@@ -25,7 +25,7 @@ import {
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
@@ -120,13 +120,11 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private policy = inject(IntakePolicyAdapter);
// Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
// Server-owned policy: the scholing threshold is fetched from the backend, not
// hardcoded. The backend stays the authority and re-validates on submit.
private policyRes = this.policy.policyResource();
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
@@ -201,7 +199,7 @@ export class IntakeWizardComponent {
// only the policy value; untrack the dispatch (it reads the state signal
// internally, which would otherwise make this effect loop on its own write).
effect(() => {
const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
const scholingThreshold = this.policyStore.scholingThreshold();
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
});
}