From cf69d474cd81a732b6b875282597c5b3f7f8d564 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 24 Jul 2026 13:39:32 +0200 Subject: [PATCH] docs: reference guides for stamdata, audit log, feature flags + document-feature skill Three how-it-works/how-to-extend reference docs (docs/reference/), each with a coupling section, indexed in docs/README.md. New document-feature skill so docs ship in the same diff as the code. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/document-feature/SKILL.md | 54 +++++++++++++++++ CLAUDE.md | 3 +- docs/README.md | 3 + docs/reference/audit-log.md | 68 +++++++++++++++++++++ docs/reference/feature-flags.md | 76 ++++++++++++++++++++++++ docs/reference/stamdata.md | 72 ++++++++++++++++++++++ 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/document-feature/SKILL.md create mode 100644 docs/reference/audit-log.md create mode 100644 docs/reference/feature-flags.md create mode 100644 docs/reference/stamdata.md diff --git a/.claude/skills/document-feature/SKILL.md b/.claude/skills/document-feature/SKILL.md new file mode 100644 index 0000000..a9bf6fe --- /dev/null +++ b/.claude/skills/document-feature/SKILL.md @@ -0,0 +1,54 @@ +--- +name: document-feature +description: Ship docs in the same diff as the code — add a doc for a new feature, update the existing doc when a feature changes. Use whenever you add a feature or adjust one that is already documented. +--- + +# Document feature (docs travel with the code) + +A feature change is not done until its docs match. **Same diff, not a follow-up.** +Undocumented behavior is a bug; stale docs are worse than none. + +## New feature → add the right kind of doc + +The repo splits docs by kind (`docs/README.md`). Pick one — don't write all three: + +- **Reference doc** — `docs/reference/.md`. A stable "how it works + how to extend + without coupling" page. Default for a feature others will build on or wire into. + Shape: follow `docs/reference/roles-and-access.md` (H1 title, intro that links the + governing ADR/WP, task-oriented `##` sections, `## See also` footer of backticked paths). +- **ADR** — `docs/reference/architecture/NNNN-.md`. Only when you're recording a + *decision/pattern* (a tradeoff, a "we chose X over Y"). One format, no drift: + `# ADR-NNNN — Title`, then `Status: Accepted · Date: YYYY-MM-DD`, then + Problem / Options / Decision / Consequences. +- **WP** — `docs/project/backlog/WP-NN-.md`. Tracked work; use the template at the + bottom of `docs/project/backlog/README.md`. + +Then **add the index row**: `docs/README.md` table for a reference doc/ADR, +`docs/project/backlog/README.md` for a WP. A doc with no index row is invisible. + +## Adjusted feature → update what already describes it + +Before you call the change done: `grep -ril docs/` and update every hit — +the reference doc's "how to extend" and coupling notes, the ADR's Consequences, the WP's +Outcome/Acceptance. If the change makes a doc claim false, fix the claim in this diff. + +## Coupling is the interesting part + +For any feature others consume, the doc's most valuable section is **how to extend it and +where the coupling lives** — which shared seams it touches (`ADMIN_LINKS`, the +`role.interceptor` ROLE_AWARE list, `Capability`), and any hand-wired pattern a second +consumer would repeat. Name the coupling; recommend the abstraction only when a real +second case lands (don't refactor speculatively). + +## Worked examples + +- `docs/reference/stamdata.md`, `docs/reference/audit-log.md`, + `docs/reference/feature-flags.md` — the three admin-feature reference docs. +- `docs/reference/architecture/0004-stamdata-as-code.md` — the decision behind stamdata. + +## Verify + +```bash +grep -ril docs/ # every place that mentions it is current +grep -q "" docs/README.md # (or backlog/README.md) — index row exists +``` diff --git a/CLAUDE.md b/CLAUDE.md index 0b29d07..7af1d8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,7 +203,8 @@ dispatch messages). Worked example: the intake wizard (`herregistratie/`). The recipes are also invocable skills in `.claude/skills/`: `new-feature`, `new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`, -`ui-component`, `new-ssp` (bootstrap a new portal from this template). +`ui-component`, `new-ssp` (bootstrap a new portal from this template), +`document-feature` (ship/update docs in the same diff as the code). ## Out of scope (POC, don't build unprompted) diff --git a/docs/README.md b/docs/README.md index cd377af..7995ee5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,9 @@ condensed, cross-linked curriculum. | [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. | | [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. | | [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). | +| [stamdata.md](reference/stamdata.md) | How stamdata (config-as-code reference data) is laid out, how to add a table with zero UI code, and why coupling stays low. | +| [audit-log.md](reference/audit-log.md) | How the data-minimised authz/PII-reveal audit trail is built, how to audit a new action, and the one-producer-hub coupling. | +| [feature-flags.md](reference/feature-flags.md) | How runtime feature flags work (catalog-as-code + runtime state), how to add one, and the hand-wired gating coupling to watch. | | [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. | | [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). | | [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). | diff --git a/docs/reference/audit-log.md b/docs/reference/audit-log.md new file mode 100644 index 0000000..575761e --- /dev/null +++ b/docs/reference/audit-log.md @@ -0,0 +1,68 @@ +# Audit log (authz / PII-reveal trail) — how it's built & extended + +A persisted, **data-minimised** trail of authorization decisions and sensitive reveals. +By construction it **never** records a name, BSN, or the revealed value — only _that_ a +decision happened, on what kind of resource, and its outcome. Built in WP-41 (persisted +backend) and surfaced by WP-42 (privacy/security showcase); it backs +[PRD-0002 — ABAC](../project/prd/0002-attribute-based-access-control.md) §8. + +## The one rule: one producer hub, not scattered logging + +Every audited event flows through a **single** helper — `AuditAuthz(…)` in `Program.cs`. +It writes the log line _and_ persists the row. Endpoints don't hand-roll audit records; +they call the hub. That's what keeps the "never log PII" guarantee enforceable in one place +instead of trusting every call site. + +## Layered pipeline + +Backend: + +- `Data/AuthzAuditStore.cs` — `AuthzAuditEntry (At, Action, Resource, Decision, Role, +CorrelationId)` + `Record(…)` / `List()` (newest-first). No PII column — a reflection + test asserts the schema stays that way. +- `Data/AppDbContext.cs` — `AuthzAudit` DbSet + migration (persists to SQLite, WP-22). +- `Program.cs`: `AuditAuthz(…)` (the hub, ~line 590) is called on every authz denial and on + the BIG-nummer reveal/step-up; `GET /admin/audit` (read) is gated by `CasesAdmin`. + +Frontend (`src/app/beheer/`): + +- `domain/audit-entry.ts` → `infrastructure/audit.adapter.ts` (`list()` + `parseAuditEntries` + boundary) → `application/audit.store.ts` (root singleton, `RemoteData`, **read-only**) → + `ui/audit.page.ts` (read-only table, gated on `cases:manage`, loads via a guarded `effect` + once the capability resolves). + +## How to audit a new action + +You do **not** touch the frontend. In the backend, at the decision point, call the hub: + +```csharp +AuditAuthz(ctx, action: "flags:manage", resource: key, decision: "deny"); +``` + +Use a short stable `action` slug and a **non-PII** `resource` identifier (an id or table +name, never a name/BSN). The admin table picks it up automatically. + +## Coupling + +Deep-linked on the **producer** side by design, self-contained on the consumer side: + +- **Consumer coupling: near zero.** The audit store is read by exactly one page; no other + component depends on it. +- **Producer coupling: centralized, not scattered.** Many endpoints call `AuditAuthz` + (stamdata/cases/flags denials, org-template edits, reveal-bignummer) — but all through + the one hub, so it's a spoke-and-hub, not logic sprinkled across the codebase. Extend by + calling the hub; never inline a new audit write. + +## Wire-up gotchas (both bit WP-41) + +- Add the read endpoint to the `role.interceptor` **ROLE_AWARE** list + (`/api/v1/admin/audit`) or it 403s silently with no `X-Role` header. +- The admin nav entry lives in `ADMIN_LINKS`, gated on `cases:manage`. + +## See also + +- [PRD-0002 — ABAC](../project/prd/0002-attribute-based-access-control.md) §8 — the audit requirement. +- [WP-41](../project/backlog/WP-41-persisted-authz-audit.md) (persistence), [WP-42](../project/backlog/WP-42-privacy-security-showcase.md) (showcase). +- `backend/src/BigRegister.Api/Data/AuthzAuditStore.cs` — the no-PII schema. +- `src/app/beheer/ui/audit.page.ts` — the read-only view. +- [Roles & access](roles-and-access.md) — `cases:manage` + admin gating. diff --git a/docs/reference/feature-flags.md b/docs/reference/feature-flags.md new file mode 100644 index 0000000..b8d521d --- /dev/null +++ b/docs/reference/feature-flags.md @@ -0,0 +1,76 @@ +# Feature flags — how they're built, extended & (de)coupled + +Runtime on/off switches for features. The split, deliberately mirroring stamdata: the +**catalog is config-as-code** (build-validated), the **on/off state is runtime** (SQLite, +like org-templates). Enforcement is **end-to-end** — the FE hides the surface _and_ the +backend refuses the action. Built in +[WP-47](../project/backlog/WP-47-feature-flags.md); see +[ADR-0004](architecture/0004-stamdata-as-code.md) for the catalog-vs-state reasoning. + +## The one rule: hiding is not enforcing + +A flag gates the UI _for feel_ and the endpoint _for real_. Flipping `inschrijving-open` +off both removes the "Inschrijven" nav/dashboard action **and** makes `POST /applications` +return 403. Never gate only the UI — a hidden button is not a closed door. + +## Layered pipeline + +Backend: + +- `Domain/Features/FeatureFlags.cs` — the code catalog (`FeatureFlagDef(Key, Description, +DefaultEnabled)`). One flag today: `inschrijving-open` (default on). +- `Data/FeatureFlagStore.cs` — stores **only overrides** in SQLite; `All()` overlays + catalog defaults with overrides; `IsEnabled` is **fail-closed** on unknown keys; + `Set(key, …)` rejects unknown keys (→ 404). +- `Program.cs`: `GET /flags` (readable by any principal — drives FE gating), + `PUT /admin/flags/{key}` gated by `FlagsAdmin`; the **server-side enforcement** lives at + the guarded endpoint (`POST /applications` → 403 when off). + +Frontend — note this feature lives in **`shared`**, not `beheer` (it's consumed app-wide): + +- `shared/domain/feature-flag.ts` (`FeatureFlag` + `FLAG_INSCHRIJVING_OPEN` key constant) → + `shared/infrastructure/feature-flags.adapter.ts` (`list()`/`set()` + `parseFlags`) → + `shared/application/feature-flags.store.ts` (root singleton mirroring `AccessStore`; + loads once; `enabled(key)` is **deny-by-default** and reactive). +- `beheer/ui/feature-flags.page.ts` — the admin toggle at `/beheer/functies`, gated on + `flags:manage`. + +## How to add a flag + +1. Add a `FeatureFlagDef` to `FeatureFlags.cs` and a key constant to + `shared/domain/feature-flag.ts`. +2. **Enforce it server-side** at the endpoint the flag protects (return 403 when off) — + this is the non-negotiable half. +3. Gate the UI surface(s) by reading `store.enabled(KEY)`. +4. Cover it in `backend/tests/BigRegister.Tests/FeatureFlagTests.cs` (default, admin-only + toggle, unknown-key 404, off→403 / on→201). + +## Coupling — the one to watch + +This is the **most-coupled** of the three admin features, and the honest teaching point. +Each UI consumer injects `FeatureFlagStore`, imports the flag-key constant, and hand-writes +its own gating predicate inline: + +- `shared/layout/site-header/site-header.component.ts` — filters the "Inschrijven" nav item. +- `registratie/ui/dashboard.page.ts` — hides the "Inschrijven" dashboard action. + +So a second flag with a second consumer **repeats the pattern by hand** — there's no shared +"gate this thing by flag" abstraction. That's fine at one flag / two consumers (a helper for +a single case is speculative). **Recommended: when a second flag lands, extract a small +`gateByFlag(items, key)` / a structural directive** rather than growing more inline +`.filter(… || enabled(KEY))` copies. Document the intent now; don't build the abstraction +until the second case forces it. + +## Wire-up gotcha + +`PUT /admin/flags` must be in the `role.interceptor` **ROLE_AWARE** list or the toggle +403s silently; the public `GET /flags` needs no `X-Role`. Admin nav lives in `ADMIN_LINKS` +(`flags:manage`). + +## See also + +- [WP-47](../project/backlog/WP-47-feature-flags.md) — the build. +- [ADR-0004](architecture/0004-stamdata-as-code.md) — catalog-as-code vs runtime state. +- `backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs` — the catalog. +- `src/app/shared/application/feature-flags.store.ts` — the deny-by-default store. +- [Roles & access](roles-and-access.md) — `flags:manage` + admin gating. diff --git a/docs/reference/stamdata.md b/docs/reference/stamdata.md new file mode 100644 index 0000000..b5641ed --- /dev/null +++ b/docs/reference/stamdata.md @@ -0,0 +1,72 @@ +# Stamdata (config-as-code reference data) — how it's built & extended + +Business-tunable reference data — the tables the business controls (profession↔diploma map, +policy-question text, thresholds) — is **typed, checked-in config changed via git → PR → +build**, not a runtime-editable database. For the _why_, see +[ADR-0004 — Stamdata as code](architecture/0004-stamdata-as-code.md); this page is _how the +code is laid out and how to add a table without coupling_. Built in WP-29, hardened in WP-48. + +## The one rule that shapes everything: no runtime write path + +The catalog is the source of truth and lives in code. The admin editor **downloads** an +edited `{table}.json` for a human to commit — it never PUTs. The authority that a change is +valid is the **CI gate**, not the server. This is what keeps a bad edit out of prod instead +of out of the database. + +## Layered pipeline + +Backend (`backend/src/BigRegister.Api/Stamdata/`): + +- `StamdataCatalog.cs` — the registry. Adding a table is **one line**: + `StamdataTable.Of("professions", …)`. +- `StamdataTable.cs` — generic table model: columns are _reflected_ from the typed record + (first property = key; temporal iff it has `geldigVan`+`geldigTot`). Holds `Validate()` + (referential integrity) and `RowsOn(date)`. +- `StamdataFile.cs` — reads the JSON as an **embedded resource** (identical read from API + and test assembly). No write method exists. +- Endpoints in `Program.cs`: `GET /stamdata` and `GET /stamdata/{table}?peildatum=` — + **reads only**, both behind the `StamdataAdmin` gate. + +Frontend (`src/app/beheer/`, the `beheer` context): + +- `domain/stamdata.ts` (pure model + `activeOn`/`rowErrors`/`toJson`), + `domain/stamdata-editor.machine.ts` (Elm-style union — **no save Msg by design**). +- `contracts/stamdata.dto.ts` → `infrastructure/stamdata.adapter.ts` (`list()`/`load()` + with a `parseStamdataTable` trust boundary; **no write method**). +- `application/stamdata.store.ts` — root singleton; derives `dirty`/`counts`/`errors`; + `download()` serializes the edited table to a blob to commit. +- `ui/stamdata.page.ts` (thin container) + `ui/stamdata-table-editor/` (the generic grid). + +## How to add a table (zero UI code) + +1. Add a typed record + its `professions.json`-style embedded JSON in `Stamdata/`. +2. Register it with one line in `StamdataCatalog.cs`. +3. If it references another table, add a `StamdataRef` to the CI gate (below). + +That's it — the editor grid renders from the reflected column schema, so **no per-table +component**. This is the payoff of the schema-driven design. + +## The CI gate is the authority + +`backend/tests/BigRegister.Tests/StamdataValidationTests.cs`. `Every_catalog_table_is_valid` +covers every registered table generically; the `StamdataRef` list catches dangling +references (today: `Diploma.Opleiding → professions.program`). A bad edit, an orphaning +delete, or a premature expire **fails the PR build** — never prod. + +## Coupling + +Low, and deliberately so. The page is a thin container binding store signals to the grid +organism and mapping the organism's outputs back to store commands +(`(cellEdited)="store.editCell(…)"`); the organism owns no state (pure input/output). +Because the editor is schema-driven, a new table adds **no** UI coupling. The only +editor-side niceties (WP-48) are fast-feedback nudges — `onExpire()` reuses the `CellEdited` +output to set `geldigTot`; the CI gate stays authoritative. + +## See also + +- [ADR-0004 — Stamdata as code](architecture/0004-stamdata-as-code.md) — the decision + the org-template exception. +- [WP-29](../project/backlog/WP-29-stamdata-beheer-editor.md) (editor), [WP-48](../project/backlog/WP-48-stamdata-deletion-protection.md) (deletion protection). +- `backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs` — register a table here. +- `backend/tests/BigRegister.Tests/StamdataValidationTests.cs` — the authoritative gate. +- `src/app/beheer/ui/stamdata-table-editor/` — the generic, schema-driven grid. +- [Roles & access](roles-and-access.md) — the `stamdata:edit` capability + admin gating.