diff --git a/README.md b/README.md
index f89e48e..b89622c 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@ Then open **http://localhost:8080** (Session 1's placeholder UI) or
**http://localhost:8080/portal** (Session 2's Angular portal) — both are
reachable side by side through the same proxy, for comparison. That's the
only host port published — `legacy-backend` and `case-framework` are
-deliberately unreachable from the host (see §4 below).
+deliberately unreachable from the host (only `proxy` publishes a port — see
+`docker-compose.yml`).
**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
@@ -32,6 +33,49 @@ seed data (legacy ids 1001–1012, owned ids `REG-2026-0001..0005`).
## What's built so far
+```mermaid
+flowchart TB
+ browser(["Browser"])
+ browser -->|":8080 · the only published port"| proxy["proxy · nginx"]
+
+ subgraph new_g["NEW — the replacement"]
+ direction TB
+ portal["portal-frontend
Angular"]
+ placeholder["new-frontend
plain HTML"]
+ newapi["new-backend
.NET"]
+ newdb[("new-db
Postgres 16")]
+ newapi --> newdb
+ end
+
+ subgraph legacy_g["LEGACY — being strangled"]
+ direction TB
+ legweb["legacy-frontend
Razor Pages"]
+ legapi["legacy-backend
.NET"]
+ legdb[("legacy-db
SQL Server 2022")]
+ legweb --> legapi --> legdb
+ end
+
+ subgraph cf_g["VENDOR — stays put"]
+ direction TB
+ cf["case-framework
.NET"]
+ cfdb[("case-db
Postgres 16")]
+ cf --> cfdb
+ end
+
+ proxy -->|"/portal/"| portal
+ proxy -->|"/"| placeholder
+ proxy -->|"/api/"| newapi
+ proxy -->|"/legacy"| legweb
+
+ newapi ==>|"seams A + B"| legapi
+ newapi ==>|"seam D"| cf
+```
+
+Ten containers; only the proxy publishes a port. The two thick edges are the
+seams — everything the new system knows about the old one crosses one of them.
+See [`docs/architecture.md`](docs/architecture.md) for how each seam works, and
+[`docs/playbook.md`](docs/playbook.md) for applying the pattern to a real system.
+
**Session 1 — backend.** Ten containers: three frontends, three backends,
three databases, one proxy. `new-frontend` (reachable at `/`) is a
deliberately plain, unstyled HTML page (`new-frontend/index.html`) that
@@ -76,7 +120,8 @@ cd new && dotnet test tests/Architecture.Tests
`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),
+evaporate. Two engines force the read ACL to be a real HTTP call
+([seam A](docs/architecture.md#the-four-seams)),
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
diff --git a/docs/adr/ADR-001-decision-independent-of-closure.md b/docs/adr/ADR-001-decision-independent-of-closure.md
index 648bbfe..48178df 100644
--- a/docs/adr/ADR-001-decision-independent-of-closure.md
+++ b/docs/adr/ADR-001-decision-independent-of-closure.md
@@ -7,7 +7,8 @@ Accepted.
`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).
+and is not ours to change — it is a conformist integration by design
+(seam D, `New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs`).
The new domain's own rule is different: once an assessment (approve/reject) is
recorded on a `RegistrationApplication`, that decision is legally in effect
diff --git a/docs/adr/ADR-002-write-through-has-no-business-rules.md b/docs/adr/ADR-002-write-through-has-no-business-rules.md
index 4f8f34d..c59f350 100644
--- a/docs/adr/ADR-002-write-through-has-no-business-rules.md
+++ b/docs/adr/ADR-002-write-through-has-no-business-rules.md
@@ -7,7 +7,7 @@ Accepted.
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).
+until ownership is explicitly taken ([ADR-003](ADR-003-ownership-is-taken-per-case.md)).
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
@@ -28,13 +28,14 @@ derived values, no defaulting. It only:
(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
+signal the capability should be taken into ownership instead, 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
+ notice on the write-through form (`portal-frontend/src/app/case-detail/edit-applicant-details/`)
+ 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
diff --git a/docs/adr/ADR-003-ownership-is-taken-per-case.md b/docs/adr/ADR-003-ownership-is-taken-per-case.md
index aeb5227..ec6bac1 100644
--- a/docs/adr/ADR-003-ownership-is-taken-per-case.md
+++ b/docs/adr/ADR-003-ownership-is-taken-per-case.md
@@ -22,7 +22,7 @@ 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
+`POST /api/worklist/legacy/{aanvraagId}/take-ownership`, triggered by
an explicit user action in the portal. This is the interim mechanism, not the
final one for every process.
@@ -43,11 +43,12 @@ This is deliberately the right building block either way:
- 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
+- Reversal (`ReleaseOwnershipHandler`) 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
+- This demo's non-goals (README, "Deliberate substitutions and omissions")
+ exclude building the bulk migration tool itself
— that's future work, not a rejected idea.
## Future work
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..2076166
--- /dev/null
+++ b/docs/architecture.md
@@ -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
Angular"]
+ api["new-backend
New.Api"]
+ legapi["legacy-backend
Legacy.Api"]
+ legweb["legacy-frontend
Beoordeling.cshtml"]
+ cf["case-framework
vendor"]
+
+ portal --> api
+ api -- "A · read ACL
GET /api/aanvragen
legacy owns the data" --> legapi
+ api -- "B · write-through
PUT .../gegevens
legacy owns the rules" --> legapi
+ api -- "D · conformist
POST /cases
vendor owns the rules" --> cf
+ portal -. "C · redirect — browser navigates
legacy owns the workflow" .-> 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
has a row?"}
+ r -->|no| ra["LegacyCaseSource
HTTP → legacy-backend"]
+ r -->|yes| rb["OwnedApplicationSource
in-process → new-db"]
+ end
+
+ subgraph list["GET /api/worklist — bypasses the resolver"]
+ l1["ILegacyWorklistReader
HTTP → legacy-backend"]
+ l2["IOwnedWorklistReader
in-process → new-db"]
+ m["owned ++ legacy.Where(!Migrated)
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 1–3 touch nothing, which is what makes a
+failed adoption free; the preflight endpoint is literally this prefix, stopped
+early. Steps 4–6 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
TakeOwnershipHandler
+ participant N as new-db
+ participant L as legacy-backend
+ participant C as case-framework
+
+ Note over P,C: Steps 1–3 · CheckAsync() · nothing is written
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,
and nothing has been written anywhere
+
+ Note over P,C: Steps 4–6 · writes begin
+ A->>C: 4 · POST /cases
+ C-->>A: caseId
+ Note over C: failure after this point leaves an orphaned
framework case — no compensating delete exists,
so find it by externalReference
+ A->>N: 5 · aggregate + legacy_ownership
in ONE transaction
+ A->>L: 6 · PUT .../migratie-vlag true
+ Note over L: failure here is swallowed and logged → split-brain:
owned locally, still writable in legacy.
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
ThroughTranslator
+ participant L as legacy-backend
GegevensValidator
+
+ P->>A: PUT .../legacy/1001/details
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
HUISNR_VERPLICHT · POSTCODE_ONGELDIG
+ T->>T: ToPortalErrors — veld → field path
+ T-->>A: 3 field errors, messages verbatim
+ A-->>P: 400 · surname
address.number · address.postalCode
+ Note over T: An unrecognized veld is logged and passed
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`.*
diff --git a/docs/playbook.md b/docs/playbook.md
new file mode 100644
index 0000000..5b9211d
--- /dev/null
+++ b/docs/playbook.md
@@ -0,0 +1,95 @@
+# Applying this to a production system
+
+The demo shows *that* the seams hold. This page is the transferable part: how
+to pick a path for each capability, and the order that keeps a cutover cheap
+to get wrong.
+
+## Which write path for which capability
+
+Ask this per capability, not per system. Most systems end up running all five
+answers at once — that is the point of the pattern, not a sign of a messy
+migration.
+
+```mermaid
+flowchart TD
+ S(["Pick one capability
in the legacy system"]) --> Q1
+
+ Q1{"Does the new UI
only need to read it?"}
+ Q1 -->|yes| A["Read ACL
translate at the boundary
legacy stays authoritative"]
+
+ Q1 -->|no| Q2{"Is it owned by a system
you don't control?"}
+ Q2 -->|yes| D["Conformist
surface its rules as-is
don't fight or hide them"]
+
+ Q2 -->|no| Q3{"Have you rebuilt the
domain rules yet?"}
+ Q3 -->|yes| E["Take ownership
new system becomes
the authority"]
+
+ Q3 -->|"no — and it's
a whole workflow"| C["Redirect
send the user back out
to the legacy screen"]
+ Q3 -->|"no — but it's
a simple edit"| B["Write-through
legacy still validates
translator gets zero rules"]
+```
+
+Read ACL and write-through are the cheap ones and where most capabilities
+should sit for most of the migration. Take-ownership is the only path that
+moves authority, so it is the only one that needs a rollback story.
+
+## Ordering a cutover so failures stay cheap
+
+The generalisable rule from `TakeOwnershipHandler`: **free checks first,
+external systems before your local transaction, least-recoverable action
+last** — and for whatever remains unrecoverable, write down how you would
+*detect* it instead of pretending it rolls back.
+
+```mermaid
+flowchart LR
+ subgraph free["Costs nothing to fail"]
+ direction TB
+ S1["1 · cheap guard
already migrated?"] --> S2["2 · read the source"] --> S3["3 · map it
invariants run here"]
+ end
+ subgraph writes["Each failure leaves a trace"]
+ direction TB
+ S4["4 · external system"] --> S5["5 · your local tx
atomic"] --> S6["6 · flip the old flag"]
+ end
+ free ==>|"a failure up to here
means nothing happened"| writes
+
+ S4 -.->|"fails after?"| F1["orphaned external record
find by external reference"]
+ S6 -.->|"fails?"| F2["split-brain
reconcile the two flags"]
+```
+
+Steps 1–3 doubling as a dry-run endpoint is what makes the rehearsal
+trustworthy: it *is* the real cutover's own check code, so it cannot drift
+away from what the real call will do.
+
+## Seven rules worth stealing
+
+| # | Rule | Why | Demonstrated by |
+|---|---|---|---|
+| 1 | Migrate the smallest unit that already exists in the domain | Here it's one case. Per-unit cutover means a failure is one bad row, not a bad weekend | [ADR-003](adr/ADR-003-ownership-is-taken-per-case.md) |
+| 2 | The translator carries **no** business rules | The urge to "just check the postcode here too" is the signal to migrate that capability instead | [ADR-002](adr/ADR-002-write-through-has-no-business-rules.md) |
+| 3 | Give yourself a dry run built from the real thing | A rehearsal that shares code with the performance cannot go stale | `GET .../take-ownership/preflight` |
+| 4 | Let the old system keep saying no | Every legacy error surfaces verbatim; none is invented or hidden | `LegacyDetailsWriteThroughTranslator` |
+| 5 | Make the boundary fail the build | Exactly one type may know both sources exist; a reviewer will eventually miss that, a test won't | `tests/Architecture.Tests` (11 rules) |
+| 6 | Write down what you deliberately did **not** build | An omission you named is a decision; an omission you didn't is a bug waiting | [sync-not-implemented.md](sync-not-implemented.md) |
+| 7 | Reversibility expires — say so out loud | Release works until the new side holds authoritative writes; after that the honest answer is a `409`, not a silent discard | `ReleaseOwnershipHandler` |
+
+## What this demo deliberately leaves for you
+
+None of these are hard to add; all of them are decisions a real migration has
+to make explicitly, so the demo declines to make them for you.
+
+```mermaid
+flowchart TB
+ subgraph shown["Proven here"]
+ direction LR
+ P1["4 seams"] ~~~ P2["3 write paths"] ~~~ P3["per-case cutover
+ dry run"] ~~~ P4["reversal, while
still reversible"]
+ end
+ subgraph yours["Yours to decide"]
+ direction LR
+ Y1["bulk / scheduled
cutover"] ~~~ Y2["new → old sync"] ~~~ Y3["authn / authz"] ~~~ Y4["metrics, alerting,
reconciliation jobs"]
+ end
+ shown --> yours
+```
+
+Two of these are already argued in writing rather than left blank:
+[ADR-003](adr/ADR-003-ownership-is-taken-per-case.md) on why bulk migration is
+a separate later capability, and
+[sync-not-implemented.md](sync-not-implemented.md) on the two visible
+consequences of having no sync.
diff --git a/docs/sync-not-implemented.md b/docs/sync-not-implemented.md
index 898d95d..444ba4d 100644
--- a/docs/sync-not-implemented.md
+++ b/docs/sync-not-implemented.md
@@ -21,6 +21,7 @@ consequences, both intentional:
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
+ > 0` (`ReleaseOwnershipHandler`) — 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.
diff --git a/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs b/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs
index 71b249f..5fa86a1 100644
--- a/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs
+++ b/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs
@@ -11,7 +11,8 @@ namespace New.Api.Seeding;
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
/// README click-through can reference them directly). REG-2026-0002 is
/// seeded with an open case-framework task on purpose, so a later closure
-/// request against it demonstrates the §6 conflict (409, decision stands).
+/// request against it demonstrates the seam-D conflict (409, decision stands
+/// - see docs/adr/ADR-001-decision-independent-of-closure.md).
///
internal static class OwnedApplicationSeeder
{
diff --git a/new/tests/Architecture.Tests/ArchitectureTests.cs b/new/tests/Architecture.Tests/ArchitectureTests.cs
index e765cd5..786f773 100644
--- a/new/tests/Architecture.Tests/ArchitectureTests.cs
+++ b/new/tests/Architecture.Tests/ArchitectureTests.cs
@@ -9,7 +9,7 @@ using Xunit;
namespace Architecture.Tests;
///
-/// Encodes §10's architecture rules as build-failing assertions. A demo that
+/// Encodes the design's architecture rules as build-failing assertions. A demo that
/// passes the smoke script but fails these has demonstrated nothing - the
/// seam boundaries are the point, not an implementation detail.
///
@@ -70,7 +70,7 @@ public class ArchitectureTests
[Fact]
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
{
- // Config-file concern, not code - see §10. Verified by inspection: the
+ // Config-file concern, not code. Verified by inspection: the
// only connection string anywhere under New.* is ConnectionStrings:New
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
// docker-compose.yml only ever injects ConnectionStrings__New into
diff --git a/portal-frontend/README.md b/portal-frontend/README.md
index 1057e55..c7799fb 100644
--- a/portal-frontend/README.md
+++ b/portal-frontend/README.md
@@ -1,59 +1,20 @@
-# PortalFrontend
+# portal-frontend
-This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.2.
+Session 2's Angular portal — the real UI over the new backend, reachable at
+**http://localhost:8080/portal** once the stack is up (`docker compose up -d`
+from the repo root). It is served by its own nginx container behind the shared
+proxy, *not* by `ng serve`.
-## Development server
+The whole app is driven off the API's `actions` and `seams` blocks: it renders
+whatever write path each case advertises (`writeThrough`, `redirect`, `owned`,
+`transition`, `query`) and never builds an endpoint URL from an id. That is
+what makes the same screens work unchanged for a legacy-owned case and an
+adopted one — see [`docs/architecture.md`](../docs/architecture.md).
-To start a local development server, run:
-
-```bash
-ng serve
+```
+npm test # unit tests (vitest, via ng test)
+npm run build # production build
```
-Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
-
-## Code scaffolding
-
-Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
-
-```bash
-ng generate component component-name
-```
-
-For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
-
-```bash
-ng generate --help
-```
-
-## Building
-
-To build the project run:
-
-```bash
-ng build
-```
-
-This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
-
-## Running unit tests
-
-To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
-
-```bash
-ng test
-```
-
-## Running end-to-end tests
-
-For end-to-end (e2e) testing, run:
-
-```bash
-ng e2e
-```
-
-Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
-
-## Additional Resources
-
-For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+This app is **zoneless**: state written from a `subscribe` callback must land
+in a signal, or the DOM will not update.