feat: implement strangler-fig-demo Session 1 (backend + smoke script)

Builds the four-seam, three-write-path reference demo backend: case-framework
(seam D stand-in), legacy-backend/frontend (SQL Server, seams A/B/C targets),
and new-backend (Domain/Application/Infrastructure.*/Api implementing the
source resolver, take/release-ownership, write-through translator, and owned
assessment flow), wired together via docker-compose with a plain placeholder
frontend standing in for the Angular portal until Session 2.

All 11 Architecture.Tests pass and scripts/smoke.sh passes end-to-end against
a fresh `docker compose up`, covering acceptance criteria 1-3 and 7-22.

Fixes two real domain bugs found only once the stack ran for real: the BSN
eleven-proof checksum trivially passes all-zero digits, and the adoption
mapper silently treated a partial legacy address as absent instead of failing
loudly. Also fixes several environment-specific integration issues (rootless
Podman/SELinux bind-mount permissions, a buildah NuGet layer-caching bug,
SqlClient's invariant-globalization incompatibility, and an nginx path-prefix
mismatch for the legacy frontend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-31 07:57:26 +02:00
co-authored by Claude Sonnet 5
parent 09b27173a7
commit a6a1abbe9c
129 changed files with 6379 additions and 1 deletions
@@ -0,0 +1,49 @@
# ADR-001: A register decision takes effect independently of case closure
## Status
Accepted.
## Context
`case-framework` (seam D, a stand-in for a maintained vendor case-management
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
while any task on the case is still open. That rule belongs to the framework
and is not ours to change — it is a conformist integration by design (§6).
The new domain's own rule is different: once an assessment (approve/reject) is
recorded on a `RegistrationApplication`, that decision is legally in effect
immediately. It cannot wait for an administrative task (e.g. a filing or
notification step) to be ticked off in a separate system.
These two rules can genuinely conflict: an assessment can be recorded while a
case-framework task is still open, at which point the case cannot yet be
closed.
## Decision
Recording an assessment and requesting case closure are treated as two
separate, non-transactional steps:
1. `POST /api/worklist/owned/{id}/assessment` records the decision on the
aggregate and commits it. This always succeeds if the domain invariants are
satisfied, regardless of case-framework's task state.
2. The handler then calls `POST /cases/{id}/closure-request` on seam D as a
best-effort follow-up. A `409` here is an **expected, non-exceptional**
outcome, not a failure: the assessment is not rolled back, and the response
reports `closurePending: true` instead of an error.
The user-facing consequence: the outcome is decided immediately, with the UI
showing `Besluit vastgelegd. Administratieve afsluiting in afwachting.` when
closure is still pending. Administrative closure catches up whenever the
remaining task is completed — a scenario this demo does not automate, since it
is not a claim about the framework, only proof that it can lag safely.
## Consequences
- The domain layer's assessment-recording method must not be coupled to
case-framework's closure semantics — it has none of that knowledge, by
design (New.Domain/New.Application never reference the case-framework
client, see Architecture.Tests rules 1 and 8).
- A case can sit in "decided but not administratively closed" indefinitely.
That is accepted, not a bug: it is the visible cost of a conformist
integration whose task-completion timing this system does not control.
- No compensating transaction exists for a closure-request failure, because
there is nothing to compensate — the assessment was correct and complete on
its own terms.
@@ -0,0 +1,50 @@
# ADR-002: The write-through translator carries no business rules
## Status
Accepted.
## Context
Seam B lets a user edit a **legacy-owned** case's applicant details (name,
address, contact) from the new portal, without the new system taking
ownership of that case. The legacy system remains the authority on this data
until ownership is explicitly taken (§7.5).
It is tempting, once a translation layer exists between the portal's request
shape and legacy's `PUT /api/aanvragen/{id}/gegevens` shape, to also smuggle
in a validation shortcut or two — "just check the postcode format here too, it
saves a round trip." That temptation is exactly what this ADR forecloses.
## Decision
`New.Infrastructure.Legacy`'s write-through translator (the type backing
`ILegacyCaseGateway.UpdateDetailsAsync`) contains **no business rules**: no
conditionals on request values, no validation beyond null/shape checks, no
derived values, no defaulting. It only:
1. Maps the portal's 9-field request onto legacy's expected shape.
2. Calls `PUT legacy-backend/api/aanvragen/{id}/gegevens`.
3. Maps legacy's response — success, or **every** returned field error via the
`veld`/`code` → portal-field-path table — back into the portal's error
shape, including a generic fallback for any unrecognized legacy code
(logged as a warning, never dropped or guessed at).
If a rule needs to be enforced on this data from the new portal, that is a
signal the capability should be taken into ownership instead (§7.5), not
patched into the translator.
## Consequences
- The portal cannot offer a better validation experience than legacy already
has for this seam — by design. The `Gevalideerd door het legacy systeem`
notice on the write-through form (§8.3) exists specifically so the user
knows why: this is the honest version of a seamless UI, not a limitation to
hide.
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
request DTO and touches a `DbContext`) is only a **partial**, structural
proxy for this constraint — and is already close to vacuous given rule 3
(legacy DTOs are `internal` to `New.Infrastructure.Legacy` with no
`InternalsVisibleTo` grant, so `New.Api` cannot even name them). The
stronger claim this ADR makes — that the translator itself contains no
conditional business logic — is a **code-review rule**, not a
machine-enforced one. We say so here rather than implying test coverage
that does not exist.
- Any future temptation to "just add one small check" in the translator
should instead be read as a signal to take that capability into ownership.
@@ -0,0 +1,59 @@
# ADR-003: Ownership is taken per case for now — bulk migration is a later, separate capability
## Status
Accepted (interim). Superseded in part once bulk migration tooling (see
"Future work" below) ships.
## Context
The end state for at least some processes — registration cases among them —
is a **bulk cutover**: migrate the whole remaining population in one
operation and retire the legacy path for that process on a clean date. That
is a real, wanted outcome, not something this design argues against.
What this system cannot do is wait for that bulk-migration tooling to exist
before shipping anything of business value. Building a safe bulk migration
requires solving problems this demo deliberately defers: what happens to rows
that fail adoption (four such failure modes already exist in the seed data —
contact, BSN, motivation, and partial-address invariant violations), how a
partially-failed batch is reported and retried, and how the cutover is
scheduled and communicated. None of that should block getting the read ACL,
write-through ACL, and take-ownership mechanics themselves live and earning
their keep.
## Decision
Ship now with ownership taken **one legacy case at a time**, via
`POST /api/worklist/legacy/{aanvraagId}/take-ownership` (§7.5), triggered by
an explicit user action in the portal. This is the interim mechanism, not the
final one for every process.
This is deliberately the right building block either way:
- It is the same adoption logic (mapping, invariant validation, case-framework
correlation, atomic persistence, migratie-vlag flip) that a future bulk tool
would need to call in a loop — building it per-case first means the bulk
tool is an orchestration layer on top of already-proven logic, not a
parallel implementation to keep in sync.
- It gives a real, visible answer today for what a bulk migration would
otherwise discover the hard way: which legacy rows fail adoption and why
(surfaced here as a named `422` per case, not a batch-job log line).
- The read ACL (seam A) and write-through ACL (seam B) must work correctly
for a partially-adopted population regardless of how adoption happens —
that requirement doesn't change once bulk tooling exists.
## Consequences
- Until bulk tooling exists, full legacy retirement for a process happens
case-by-case, which is slower than a scheduled cutover — accepted as the
cost of shipping the seam mechanics now rather than waiting.
- Reversal (§7.6) stays per-case and gated on `domain_writes_since` for the
same reason a bulk reversal would be unsafe absent a sync
(`docs/sync-not-implemented.md`): undoing adoption after edits would
silently discard them.
- This demo's non-goals (§3) exclude building the bulk migration tool itself
— that's future work, not a rejected idea.
## Future work
A bulk migration tool for a given process (e.g. registration cases) can reuse
the same take-ownership handler per legacy id, adding: pre-flight reporting of
which rows would fail adoption and why (so the four invariant-failure classes
seen here are triaged before cutover, not discovered during it), a scheduled
cutover window, and a decision on whether failed rows block the cutover or are
carved out and finished by hand.
+26
View File
@@ -0,0 +1,26 @@
# Sync: documented, not implemented
In production, a one-way sync would propagate data the new system owns back
to the legacy store, so legacy-side readers (reports, other integrations that
still query `legacy-db` directly) keep seeing current data for adopted cases.
- **Direction:** new → old only. Never the reverse — once a case is owned,
the new domain is the sole authority on it (ADR-003), so nothing should flow
back to overwrite the new aggregate.
- **Shrinks over time:** as more capabilities are taken into ownership (and,
eventually, as legacy readers are themselves retired or redirected), the
set of fields this sync needs to cover shrinks. It does not grow.
This demo deliberately does **not** implement it. Its absence has two visible
consequences, both intentional:
1. **The legacy UI shows adopted cases as stale-and-locked, not updated.**
`/legacy` renders a `migrated=true` row greyed out with actions disabled
and a link back to the new portal — it does not show the new system's
edits, because nothing pushes them there. That greyed-out treatment is the
honest substitute for a sync that does not exist.
2. **Ownership release is blocked once edits exist.** `DELETE
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since
> 0` (§7.6) — releasing would silently discard those edits, since there is
no sync to have propagated them back to legacy first. The `409` is the cost
of the missing sync made visible, rather than a data-loss bug made invisible.