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
+47 -2
View File
@@ -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 **http://localhost:8080/portal** (Session 2's Angular portal) — both are
reachable side by side through the same proxy, for comparison. That's the reachable side by side through the same proxy, for comparison. That's the
only host port published — `legacy-backend` and `case-framework` are 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 **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 total for Docker/Podman. First start takes a minute or two while SQL Server
@@ -32,6 +33,49 @@ seed data (legacy ids 10011012, owned ids `REG-2026-0001..0005`).
## What's built so far ## 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<br/>Angular"]
placeholder["new-frontend<br/>plain HTML"]
newapi["new-backend<br/>.NET"]
newdb[("new-db<br/>Postgres 16")]
newapi --> newdb
end
subgraph legacy_g["LEGACY — being strangled"]
direction TB
legweb["legacy-frontend<br/>Razor Pages"]
legapi["legacy-backend<br/>.NET"]
legdb[("legacy-db<br/>SQL Server 2022")]
legweb --> legapi --> legdb
end
subgraph cf_g["VENDOR — stays put"]
direction TB
cf["case-framework<br/>.NET"]
cfdb[("case-db<br/>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, **Session 1 — backend.** Ten containers: three frontends, three backends,
three databases, one proxy. `new-frontend` (reachable at `/`) is a three databases, one proxy. `new-frontend` (reachable at `/`) is a
deliberately plain, unstyled HTML page (`new-frontend/index.html`) that 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. `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 This isn't decoration — a single shared engine would let an implementer
quietly join across schemas or share a `DbContext`, and the seam would 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 force the take-ownership step ordering in `TakeOwnershipHandler` to be a real
constraint rather than a stylistic choice (no distributed transaction is constraint rather than a stylistic choice (no distributed transaction is
available across them), and make the legacy type vocabulary available across them), and make the legacy type vocabulary
@@ -7,7 +7,8 @@ Accepted.
`case-framework` (seam D, a stand-in for a maintained vendor case-management `case-framework` (seam D, a stand-in for a maintained vendor case-management
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict** 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 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 The new domain's own rule is different: once an assessment (approve/reject) is
recorded on a `RegistrationApplication`, that decision is legally in effect recorded on a `RegistrationApplication`, that decision is legally in effect
@@ -7,7 +7,7 @@ Accepted.
Seam B lets a user edit a **legacy-owned** case's applicant details (name, 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 address, contact) from the new portal, without the new system taking
ownership of that case. The legacy system remains the authority on this data 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 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 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). (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 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. patched into the translator.
## Consequences ## Consequences
- The portal cannot offer a better validation experience than legacy already - The portal cannot offer a better validation experience than legacy already
has for this seam — by design. The `Gevalideerd door het legacy systeem` 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 knows why: this is the honest version of a seamless UI, not a limitation to
hide. hide.
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy - Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
@@ -22,7 +22,7 @@ their keep.
## Decision ## Decision
Ship now with ownership taken **one legacy case at a time**, via 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 an explicit user action in the portal. This is the interim mechanism, not the
final one for every process. 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 - Until bulk tooling exists, full legacy retirement for a process happens
case-by-case, which is slower than a scheduled cutover — accepted as the case-by-case, which is slower than a scheduled cutover — accepted as the
cost of shipping the seam mechanics now rather than waiting. 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 same reason a bulk reversal would be unsafe absent a sync
(`docs/sync-not-implemented.md`): undoing adoption after edits would (`docs/sync-not-implemented.md`): undoing adoption after edits would
silently discard them. 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. — that's future work, not a rejected idea.
## Future work ## Future work
+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`.*
+95
View File
@@ -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<br/>in the legacy system"]) --> Q1
Q1{"Does the new UI<br/>only need to read it?"}
Q1 -->|yes| A["<b>Read ACL</b><br/>translate at the boundary<br/>legacy stays authoritative"]
Q1 -->|no| Q2{"Is it owned by a system<br/>you don't control?"}
Q2 -->|yes| D["<b>Conformist</b><br/>surface its rules as-is<br/>don't fight or hide them"]
Q2 -->|no| Q3{"Have you rebuilt the<br/>domain rules yet?"}
Q3 -->|yes| E["<b>Take ownership</b><br/>new system becomes<br/>the authority"]
Q3 -->|"no — and it's<br/>a whole workflow"| C["<b>Redirect</b><br/>send the user back out<br/>to the legacy screen"]
Q3 -->|"no — but it's<br/>a simple edit"| B["<b>Write-through</b><br/>legacy still validates<br/>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<br/>already migrated?"] --> S2["2 · read the source"] --> S3["3 · map it<br/>invariants run here"]
end
subgraph writes["Each failure leaves a trace"]
direction TB
S4["4 · external system"] --> S5["5 · your local tx<br/>atomic"] --> S6["6 · flip the old flag"]
end
free ==>|"a failure up to here<br/>means nothing happened"| writes
S4 -.->|"fails after?"| F1["orphaned external record<br/><i>find by external reference</i>"]
S6 -.->|"fails?"| F2["split-brain<br/><i>reconcile the two flags</i>"]
```
Steps 13 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<br/>+ dry run"] ~~~ P4["reversal, while<br/>still reversible"]
end
subgraph yours["Yours to decide"]
direction LR
Y1["bulk / scheduled<br/>cutover"] ~~~ Y2["new → old sync"] ~~~ Y3["authn / authz"] ~~~ Y4["metrics, alerting,<br/>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.
+2 -1
View File
@@ -21,6 +21,7 @@ consequences, both intentional:
honest substitute for a sync that does not exist. honest substitute for a sync that does not exist.
2. **Ownership release is blocked once edits exist.** `DELETE 2. **Ownership release is blocked once edits exist.** `DELETE
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since /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 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. of the missing sync made visible, rather than a data-loss bug made invisible.
@@ -11,7 +11,8 @@ namespace New.Api.Seeding;
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and /// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
/// README click-through can reference them directly). REG-2026-0002 is /// README click-through can reference them directly). REG-2026-0002 is
/// seeded with an open case-framework task on purpose, so a later closure /// 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).
/// </summary> /// </summary>
internal static class OwnedApplicationSeeder internal static class OwnedApplicationSeeder
{ {
@@ -9,7 +9,7 @@ using Xunit;
namespace Architecture.Tests; namespace Architecture.Tests;
/// <summary> /// <summary>
/// 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 /// passes the smoke script but fails these has demonstrated nothing - the
/// seam boundaries are the point, not an implementation detail. /// seam boundaries are the point, not an implementation detail.
/// </summary> /// </summary>
@@ -70,7 +70,7 @@ public class ArchitectureTests
[Fact] [Fact]
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config() 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 // only connection string anywhere under New.* is ConnectionStrings:New
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and // (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
// docker-compose.yml only ever injects ConnectionStrings__New into // docker-compose.yml only ever injects ConnectionStrings__New into
+15 -54
View File
@@ -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: ```
npm test # unit tests (vitest, via ng test)
```bash npm run build # production build
ng serve
``` ```
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. This app is **zoneless**: state written from a `subscribe` callback must land
in a signal, or the DOM will not update.
## 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.