docs(architecture): add diagrams and implementation playbook

Added three new documents with nine Mermaid diagrams to make the strangler
fig strategy visible:

- README: container topology diagram at the start, with the proxy entry point
  and three seams labelled
- docs/architecture.md: five diagrams tracing the exact implementation:
  - The four seams and who holds authority at each boundary
  - How by-id read goes through the resolver, but list-read bypasses it
  - Case lifecycle state machine (the strategy in one picture)
  - Take-ownership sequence with failure windows annotated
  - Write-through error round-trip showing zero validation logic crossed
- docs/playbook.md: how to apply this to a production system:
  - Write-path decision tree (five read/write patterns)
  - Cutover ordering diagram (side-effects-free first, least recoverable last)
  - Seven transferable rules with pointers to the files that demonstrate them
  - Scope diagram of what's proven vs. left as your decisions

Resolved all 13 dangling § citations (to an absent spec doc) by linking to
the actual files or dropping them. Replaced portal-frontend/README.md
boilerplate with accurate content. All diagrams parse and link-check clean.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-01 09:15:39 +02:00
co-authored by Claude Haiku 4.5
parent 5f22156e6d
commit ddec15ccb2
10 changed files with 351 additions and 67 deletions
+178
View File
@@ -0,0 +1,178 @@
# How the seams work
Diagrams only. The prose arguments live in the [README](../README.md) and the
[ADRs](adr/); each diagram below names the file it was traced from, so a
reader can check it against the code rather than trust it.
## The four seams
Who holds authority at each boundary. Seam C is the odd one out: it is not an
HTTP redirect, it is an `ActionLink` with `mode: "redirect"` in the JSON
`actions` block — the **browser** navigates when the user clicks it.
```mermaid
flowchart LR
portal["portal-frontend<br/>Angular"]
api["new-backend<br/>New.Api"]
legapi["legacy-backend<br/>Legacy.Api"]
legweb["legacy-frontend<br/>Beoordeling.cshtml"]
cf["case-framework<br/>vendor"]
portal --> api
api -- "A · read ACL<br/>GET /api/aanvragen<br/><b>legacy owns the data</b>" --> legapi
api -- "B · write-through<br/>PUT .../gegevens<br/><b>legacy owns the rules</b>" --> legapi
api -- "D · conformist<br/>POST /cases<br/><b>vendor owns the rules</b>" --> cf
portal -. "C · redirect — browser navigates<br/><b>legacy owns the workflow</b>" .-> legweb
```
*Traced from `New.Infrastructure.Legacy/LegacyCaseSource.cs`,
`LegacyDetailsWriteThroughTranslator.cs`,
`New.Api/Contracts/CaseDetailResponseFactory.cs`,
`New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs`.*
## Reading a case: one id, two sources
`ApplicationSourceResolver` is the only type in the solution that references
both sources (Architecture.Tests rule 7). A legacy id keeps working after
adoption because this resolver — and only this resolver — checks the ownership
registry first.
The **list** endpoint deliberately does *not* go through it: it takes two
separate reader ports and merges in memory, dropping any legacy row whose
`Migrated` flag is set so adopted cases don't appear twice.
```mermaid
flowchart TB
subgraph byid["GET /api/worklist/legacy/{id} — via the resolver"]
r{"legacy_ownership<br/>has a row?"}
r -->|no| ra["LegacyCaseSource<br/>HTTP → legacy-backend"]
r -->|yes| rb["OwnedApplicationSource<br/>in-process → new-db"]
end
subgraph list["GET /api/worklist — bypasses the resolver"]
l1["ILegacyWorklistReader<br/>HTTP → legacy-backend"]
l2["IOwnedWorklistReader<br/>in-process → new-db"]
m["owned ++ legacy.Where(!Migrated)<br/>filter · sort · page in memory"]
l1 --> m
l2 --> m
end
```
*Traced from `New.Api/Resolution/ApplicationSourceResolver.cs:26` and
`New.Api/Endpoints/WorklistEndpoints.cs:18`.*
## The life of a case
This is the strategy in one picture. Every edge is a real endpoint with a real
guard.
```mermaid
stateDiagram-v2
[*] --> Legacy
Legacy --> Owned: POST take-ownership → 201
Owned --> Legacy: DELETE ownership → 204
Owned --> OwnedDirty: owned edit or assessment
Legacy --> Legacy: preflight — read-only
Legacy --> Legacy: write-through edit — legacy validates
Legacy --> Legacy: take-ownership 422 — nothing written
OwnedDirty --> OwnedDirty: further owned writes
note right of Legacy
no legacy_ownership row
legacy.Migrated = false
legacy is the authority
end note
note right of Owned
legacy_ownership row exists
legacy.Migrated = true
domain_writes_since = 0
still reversible
end note
note right of OwnedDirty
domain_writes_since greater than 0
Release refused with 409: no sync
exists to push these edits back
to legacy first.
end note
```
Not drawn as a state, because it is a failure condition rather than a
lifecycle stage: **split-brain** — a row in `legacy_ownership` while legacy's
`Migrated` is still `false`, left behind when step 6 below fails. Detected by
reconciling the two, not prevented.
*Traced from `New.Application/Ownership/TakeOwnershipHandler.cs`,
`ReleaseOwnershipHandler.cs`, and
`New.Infrastructure.Persistence/Entities/LegacyOwnershipRow.cs`.*
## Take ownership — the strangler step
The step order is load-bearing. Steps 13 touch nothing, which is what makes a
failed adoption free; the preflight endpoint is literally this prefix, stopped
early. Steps 46 are ordered so the least recoverable action happens last, and
each remaining failure window is *detectable* rather than pretended away.
```mermaid
sequenceDiagram
participant P as Portal
participant A as new-backend<br/>TakeOwnershipHandler
participant N as new-db
participant L as legacy-backend
participant C as case-framework
Note over P,C: Steps 13 · CheckAsync() · nothing is written<br/>PreflightAsync() runs exactly this much, then stops
P->>A: POST .../take-ownership
A->>N: 1 · LookupOwnedIdAsync
N-->>A: row exists → 409 AlreadyOwned
A->>L: 2 · GET /api/aanvragen/{id}
L-->>A: legacy row (absent → 404)
A->>A: 3 · map to RegistrationApplication
Note over A: domain invariant fails → 422 naming it,<br/>and nothing has been written anywhere
Note over P,C: Steps 46 · writes begin
A->>C: 4 · POST /cases
C-->>A: caseId
Note over C: failure after this point leaves an orphaned<br/>framework case — no compensating delete exists,<br/>so find it by externalReference
A->>N: 5 · aggregate + legacy_ownership<br/>in ONE transaction
A->>L: 6 · PUT .../migratie-vlag true
Note over L: failure here is swallowed and logged → split-brain:<br/>owned locally, still writable in legacy.<br/>Reconcile legacy_ownership vs legacy.migrated
A-->>P: 201 { registrationApplicationId }
```
*Traced from `New.Application/Ownership/TakeOwnershipHandler.cs` — the numbered
comments there are the source of truth for this diagram.*
## Write-through: whose rules run
The effort argument in one exchange. Three bad fields go in; three field
errors come back, produced entirely by legacy's own validator. No validation
logic crossed the seam.
```mermaid
sequenceDiagram
participant P as Portal
participant A as new-backend
participant T as LegacyDetailsWrite<br/>ThroughTranslator
participant L as legacy-backend<br/>GegevensValidator
P->>A: PUT .../legacy/1001/details<br/>blank surname · no house number · bad postcode
A->>T: ToLegacyRequest — reshape only
T->>L: PUT /api/aanvragen/1001/gegevens
L->>L: every rule runs HERE
L-->>T: 400 · NAAM_VERPLICHT<br/>HUISNR_VERPLICHT · POSTCODE_ONGELDIG
T->>T: ToPortalErrors — veld → field path
T-->>A: 3 field errors, messages verbatim
A-->>P: 400 · surname<br/>address.number · address.postalCode
Note over T: An unrecognized veld is logged and passed<br/>through, never dropped or guessed at
```
The translator carries no business rules at all — see
[ADR-002](adr/ADR-002-write-through-has-no-business-rules.md), which is also
honest that this is enforced by code review, not by a test.
*Traced from `New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs`
and `legacy/src/Legacy.Api/Endpoints/GegevensValidator.cs`.*