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:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user