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
+135
View File
@@ -0,0 +1,135 @@
# Strangler seam demo: Behandel portaal
A reference demo, not a product. It makes four integration seams and three
write paths between a legacy system and its replacement runnable, so a
migration strategy can be watched instead of slide-decked.
## Run it
```
docker compose up -d --build
```
Then open **http://localhost:8080**. That's the only host port published —
`legacy-backend` and `case-framework` are deliberately unreachable from the
host (see §4 below).
**Memory:** SQL Server (`legacy-db`) needs roughly 2GB of RAM; budget ~6GB
total for Docker/Podman. First start takes a minute or two while SQL Server
initialises (the healthcheck has a 60s `start_period`) — the app containers
wait on it before running their own migrations and seed data.
Verify everything end to end:
```
./scripts/smoke.sh
```
Run this against a **freshly started** stack — it depends on the untouched
seed data (legacy ids 10011012, owned ids `REG-2026-0001..0005`).
## What's built so far (Session 1 — backend)
Nine containers: two frontends (one placeholder, see below), three backends,
three databases, one proxy. `new-frontend` in this session is a deliberately
plain, unstyled HTML page (`new-frontend/index.html`) that exercises the same
API a real UI would — it exists to prove the backend before a real Angular
portal replaces it in Session 2, not to be a good UI.
## The seams and write paths
| Seam / write path | Direction | Implementation | Proven by |
|---|---|---|---|
| **A — Read ACL** | new backend → legacy API | `new/src/New.Infrastructure.Legacy/LegacyCaseSource.cs`, `LegacyWorklistReader.cs` | `GET /api/worklist` returns 17 merged items |
| **B — Write-through ACL** | new backend → legacy API | `new/src/New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs` | valid edit persists to `legacy-db`; a 3-field-invalid payload returns 3 mapped field errors |
| **C — Redirect** | new frontend → legacy frontend | `CaseDetailResponseFactory.BuildLegacyActions` (`new/src/New.Api/Contracts/CaseDetailResponseFactory.cs`) | a legacy case's `recordAssessment` action has `mode: "redirect"` |
| **D — Conformist** | new backend → case framework | `new/src/New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs` | `case-framework`'s 409-on-open-task rule surfaces as `closurePending` on assessment |
| **Redirect** write path | legacy enforces | `legacy/src/Legacy.Web/Pages/Beoordeling.cshtml` | outbound button, not a form |
| **Write-through** write path | legacy enforces | `PUT /api/worklist/legacy/{id}/details` | `Gevalideerd door het legacy systeem`-equivalent: every legacy error surfaces, none invented |
| **Owned** write path | new domain enforces | `New.Application.Assessments.RecordOwnedAssessmentHandler`, `UpdateOwnedApplicantDetailsHandler` | direct invalid payload to the assessment endpoint returns 422 |
| **Take ownership** | the strangler step | `New.Application.Ownership.TakeOwnershipHandler` | `POST /api/worklist/legacy/{id}/take-ownership` flips the resolver, seam inspector, and legacy's `MIGRATED` flag together |
| **Release ownership** | reversal | `New.Application.Ownership.ReleaseOwnershipHandler` | `204` with no edits, `409` once `domain_writes_since > 0` |
The single component that knows both sources exist is
`New.Api.Resolution.ApplicationSourceResolver` — enforced by
`Architecture.Tests` (rule 7), along with 10 other rules (project-reference
direction, no bare `Status` in the domain, no SQL Server package reference
anywhere under `new/`, ...). Run them with:
```
cd new && dotnet test tests/Architecture.Tests
```
## Why two database engines
`legacy-db` is SQL Server 2022; `new-db` and `case-db` are PostgreSQL 16.
This isn't decoration — a single shared engine would let an implementer
quietly join across schemas or share a `DbContext`, and the seam would
evaporate. Two engines force the read ACL to be a real HTTP call (§7.2),
force the take-ownership step ordering in `TakeOwnershipHandler` to be a real
constraint rather than a stylistic choice (no distributed transaction is
available across them), and make the legacy type vocabulary
(`CHAR`/`BIT`/`DATETIME2`, space-padded BSNs, local-time timestamps) into real
work for `LegacyAanvraagMapper` instead of a copy-paste.
## Deliberate substitutions and omissions
- **Legacy.Web (Razor Pages) stands in for WinUI.** WinUI can't be
containerised; a server-rendered, table-heavy, deliberately dated UI reads
as "legacy" just as effectively.
- **No auth.** Out of scope for the whole demo — see `docs/adr/` for what
*is* in scope.
- **No data sync between the two databases** — documented, not built. See
`docs/sync-not-implemented.md` for its two visible consequences (adopted
legacy rows show as stale-and-locked, and ownership release is blocked once
edits exist).
- **No bulk migration tooling.** Ownership is taken one legacy case at a
time, as an interim mechanism — see `docs/adr/ADR-003-ownership-is-taken-per-case.md`
for why, and for the intended path to a future bulk cutover for processes
that want one.
## Architecture Decision Records
- [`ADR-001`](docs/adr/ADR-001-decision-independent-of-closure.md) — a
register decision takes effect independently of case-framework closure.
- [`ADR-002`](docs/adr/ADR-002-write-through-has-no-business-rules.md) — the
write-through translator carries no business rules.
- [`ADR-003`](docs/adr/ADR-003-ownership-is-taken-per-case.md) — ownership is
taken per-case for now; bulk migration is a planned, separate capability.
- [`sync-not-implemented.md`](docs/sync-not-implemented.md).
## 10-minute click-through
1. **Werkvoorraad**`GET /api/worklist` (or the placeholder page at `/`):
17 cases from two databases in one list. Filter `?origin=Legacy` /
`?origin=Owned` to see which is which.
2. **`A-1001`** (`GET /api/worklist/legacy/1001`) — all three write paths
visible in one `actions` block; the `seams` block names where each
section's data comes from.
3. **`Gegevens wijzigen` with a bad payload** — `PUT
/api/worklist/legacy/1001/details` with a blank surname, missing house
number, and malformed postcode returns three field-level errors, one per
input.
4. **`Beoordeling`** on a legacy case — `actions.recordAssessment.mode ==
"redirect"`; following it lands on `/legacy/aanvraag/1001/beoordeling`,
outside the new portal.
5. **`A-1002` — take ownership.** `POST
/api/worklist/legacy/1002/take-ownership` → `201`. Re-fetch the same case
by its new id: the `seams` block now reads `owned` throughout, the
redirect and write-through actions are gone, replaced by owned-mode
actions. This is the argument the whole demo is making — same screen,
same two actions, only the authority changed.
6. **`/legacy`** — row 1002 now renders greyed out with a
`beheerd in nieuw portaal` link back into the new portal.
7. **`A-1005`** — `POST /api/worklist/legacy/1005/take-ownership` → `422`,
naming `Bsn.ElevenProof`. Three more distinct adoption failures exist at
`1003` (contact), `1006` (motivation), `1007` (partial address) — one
failure looks like a bug, four look like a policy.
8. **`REG-2026-0002`** — `POST
/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment`
succeeds and reports `closurePending: true`; the case-framework's own
closure-request is genuinely conflicted (an open task), and the decision
stands regardless.
Step 5 is the argument; everything before it is setup, everything after is
evidence that the boundaries hold.