Commit Graph
58 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 2eea860efe test(backend): type-state Aanvraag builder, illegal fixtures unrepresentable (WP-70)
RuleTests/OpenZaakZaakSourceTests hand-built Aanvraag fixtures by initializer,
keeping Submitted/Referentie/SubmittedAt/BesluitStatus consistent by hand. A
type-state builder (Given.Concept().Submitted().Decided()) makes an illegal
sequence a compile error instead, and delegates the toelichting-required rule
to the real BeoordelingRules so it can't drift from production. Adds
BesluitLifecycleTests covering the WP-68 besluit invariants end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:30:57 +02:00
ehoandClaude Opus 5 472a49f19f docs(backend): correct IntakePolicy's false re-validation claim (WP-68 F5)
The doc-comment claimed "the backend re-validates on submit as the authority" —
it doesn't. Neither SubmitApplicationRequest nor IntakeRequest carries a scholing
answer at all, so there's nothing to re-validate; both submit paths only apply
SubmissionRules.RejectZeroUren. A crafted POST can bypass the scholing requirement
entirely. States the gap and points to WP-69 (opened, not yet planned) for the
enforcement, which needs a wire change.

Also strengthens the F2 concurrency test to assert the persisted status matches
whichever request actually won the race, and updates WP-68's own Decisions/
acceptance-criteria text to reflect two implementation choices that improved on
the original write-up once real constraints surfaced: ProcessingWindow stays on
ApplicationStore (StatusAt is already in the same file), and AanvraagStatusTag is
not given a Concept member (would have broken
AanvraagStatusTag_covers_the_published_lifecycle) — AanvraagStatus.Tag is nullable
instead, null exactly for Concept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:42:21 +02:00
ehoandClaude Opus 5 31d4aa1848 test(backend): cover WP-68 F2/F6/T3 (besluit concurrency, toelichting rule, transitions)
- Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success: races two
  besluiten on the same open aanvraag, asserts exactly one 200 and one 409 — the
  behavior F2's in-lock guard exists to guarantee.
- Only_a_non_approval_requires_a_toelichting: unit test for
  BeoordelingRules.RequiresToelichting (F6).
- A_terminal_decision_refuses_any_further_besluit /
  MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal: the transition
  table at the aggregate level (T3) — an Aanvraag whose BesluitStatus already records
  a decision computes a terminal StatusAt, and CanDecide refuses a further besluit,
  independent of the endpoint-level equivalent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:38:30 +02:00
ehoandClaude Opus 5 fc6e73806a refactor(backend): move aanvraag status lifecycle into the domain (WP-68 F3)
The status was derived in Contracts/Mappers.ToStatusDto, not the domain; Concept was
a magic "Concept" string with no AanvraagStatusTag member; and the besluit endpoint
re-derived its own guard by reading the status back out of the DTO and Enum.Parse-ing
it. New Domain/Applications/AanvraagStatus.cs models the full status (Concept
included, via a null Tag rather than a sixth enum member) as a closed type,
constructible only through its factories. Aanvraag.StatusAt(now) carries the logic
verbatim; Mappers.ToStatusDto and ZgwZaakMapper's two status producers become
one-line projections onto the same wire DTO, so the wire shape is unchanged (gen:api
shows zero diff beyond F1's). The one remaining Enum.Parse (the beoordeling GET,
which crosses the IZaakSource wire boundary) is now non-throwing on an unrecognised
tag.

Also, WP-68 F2: the besluit transition-legality check now runs inside
ApplicationStore.RecordBesluit's write lock instead of in the endpoint beforehand —
two concurrent besluiten used to both pass the check before either wrote, letting
the second silently overwrite a terminal decision. RecordBesluit returns an
Ok/NotFound/Conflict outcome, mirroring DocumentStore.DeleteResult.

Also, WP-68 F6: the "toelichting required" rule moves from an inline endpoint check
into BeoordelingRules.RequiresToelichting, alongside CanDecide.

The three tests naming this refactor's regression net
(AanvraagStatusTag_covers_the_published_lifecycle,
AutoApprovable_flips_to_goedgekeurd_after_the_window, ZgwZaakMapperTests) pass
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:34:10 +02:00
ehoandClaude Opus 5 fd04221d2f chore(api): regenerate client for F1's new 400 responses
npm run gen:api after WP-68 F1 — draft-sync and submit now document their
document-ownership 400 ProblemDetails response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:33:50 +02:00
ehoandClaude Opus 5 a394950a1d fix(backend): reject foreign documentIds on submit and draft-sync (WP-68 F1)
submit and draft-sync took document ids straight from the request body with no
ownership check: a caller who knew a foreign document's id could attach another
citizen's upload to their own aanvraag (surfacing on the behandelaar's beoordeling
screen, POSTed to OpenZaak as their zaakinformatieobject) and permanently block the
victim's own delete by flipping Linked=true. ADR-0001 holds the FE has no authority;
this trusted it anyway.

Adds DocumentStore.ForeignIds(ids, owner) and calls it from both write paths before
any write, 400 ProblemDetails on a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:32 +02:00
ehoandClaude Opus 5 6cfd70eeeb fix(backend): resolve besluit endpoint's id via Referentie, not local PK
POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:36 +02:00
ehoandClaude Sonnet 5 d996ca2463 feat(behandelportal): WP-66 wire the decision into OpenZaak
Extends IZaakSource with RecordBesluit, mirroring WP-50's CreateZaak write
pattern: OpenZaakZaakSource POSTs a new Statussen entry (highest-volgnummer
statustype, since the harness catalogus has no per-outcome besluittype),
carrying the besluit + toelichting in statustoelichting; LocalZaakSource
no-ops. The beoordeling endpoint calls it after the local decision commits,
flagging a failure via RecordZgwDivergence the same way submit's
create-zaak/document writes do — closing WP-60's "second write pair" gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 09:41:28 +02:00
ehoandClaude Sonnet 5 af8a011819 feat(behandelportal): WP-65b beoordeling besluit (decision write)
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s
Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.

Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:46:20 +02:00
ehoandClaude Sonnet 5 4133b30e5d feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a
canBesluiten decision flag, gated by the same CanBeoordelen capability as the
werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam
method (WP-66 needs one anyway for the real write); owner BSN is masked.

Fixes a real gap found while wiring this up: the behandelportal's login was still
WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the
werkvoorraad screen (WP-64) always denied in a real browser. A dev-only
medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that.

WP-65's own Risks note authorized splitting read from write across sessions given
its size; this is the read half. The decision-recording mutation is next (65b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:01:09 +02:00
ehoandClaude Sonnet 5 fe69caee63 feat(behandelportal): WP-64 werkvoorraad (queue) screen
CI / changes (pull_request) Successful in 15s
CI / lint (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m14s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m10s
CI / e2e (pull_request) Successful in 3m3s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /werkvoorraad endpoint lists aanvragen still open (Ingediend/InBehandeling),
gated by the medewerker capability (CanBeoordelen) rather than the admin role — reuses
the existing ApplicationSummaryDto, no new DTO. GET /me now surfaces aanvraag:beoordelen
for a behandelaar so the FE can gate with the same AccessStore/capabilityGuard idiom
every other page uses.

FE: a behandeling domain type deliberately narrower than ssp's full AanvraagStatus
union (only the two open tags — illegal states unrepresentable), composed into a
werkvoorraad-list organism from existing shared/ui molecules. Replaces WP-61's
scaffold placeholder as the app's real landing page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 22:02:35 +02:00
ehoandClaude Sonnet 5 d3f3b13345 feat(behandelportal): WP-63 aanvraag status lifecycle enum
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 50s
CI / frontend (push) Successful in 1m32s
CI / backend (push) Successful in 1m50s
CI / e2e (push) Successful in 3m7s
CI / storybook-a11y (push) Successful in 6m53s
CI / semgrep (push) Successful in 1m12s
CI / api-client-drift (push) Successful in 1m43s
Model the full ADR-0002 lifecycle (Ingediend/InBehandeling/
MeerInfoGevraagd/Goedgekeurd/Afgewezen) as a backend enum backing the
existing AanvraagStatusDto.Tag string, and widen the FE union/parse
boundary/switches to match. Ingediend/MeerInfoGevraagd aren't reachable
yet (no behandelaar transition exists) — that's WP-65. Zero DTO shape
change, so gen:api has no drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 08:56:47 +02:00
ehoandClaude Sonnet 5 a09c4ed87b feat(behandelportal): WP-62 medewerker caller identity + authz seam
Splits backend CallerIdentity into the two ADR-0002 §3 actor kinds
(ZorgverlenerCaller/MedewerkerCaller), a stub X-Medewerker/X-Rollen header
path mirroring WP-53's citizen stub, and Authz.CanBeoordelen as the first
medewerker capability — backend-only, no consumer until WP-64. Also fixes
the backlog README's stale WP-61 status (done, but table said todo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 23:47:43 +02:00
ehoandClaude Sonnet 5 ba24784586 feat(openzaak): one-command UI-OpenZaak bridge, real BSN fix, flake mitigation
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 55s
CI / frontend (push) Successful in 1m33s
CI / backend (push) Successful in 1m46s
CI / e2e (push) Successful in 3m14s
CI / storybook-a11y (push) Successful in 6m50s
CI / semgrep (push) Successful in 1m13s
CI / api-client-drift (push) Successful in 1m47s
scripts/openzaak-ui-up.sh brings up the root app and the OpenZaak harness
together, wires them onto one docker network, seeds the catalogus, grants
the container-alias zaaktype scope, and verifies a real aanvraag submitted
through the UI lands in OpenZaak.

Along the way: DocumentStore.DemoOwner was reusing the seeded doctor's
11-digit BIG-nummer as a stand-in BSN, which isn't a valid 9-digit BSN shape
— OpenZaak rejects it, breaking both submit's rol-creation step and the
citizen's own applications list under Zgw:Enabled=true. Fixed to a real
elfproef-valid BSN.

Also adds mitigation for a still-unexplained per-container flake (every
outbound ZGW POST fails as if the body were empty, for that container's
whole lifetime) that correlates with host memory pressure: the script now
warns when host swap is heavily used, and an opt-in ZgwDiagnosticHandler
(ZGW_DEBUG_HTTP=1) logs Content-Length vs. actual bytes sent so the next
reproduction can confirm or rule out client-side body corruption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 08:23:01 +02:00
ehoandClaude Sonnet 5 3ff80c124f feat(openzaak): bounded retry + flagged write divergence (WP-60)
Local aanvraag/document writes and their paired ZGW writes aren't
transactional; a ZGW failure after the local write succeeds used to
diverge silently. ZgwHttpClient now retries transport-shaped failures
(not 500, which can follow a partial commit on the non-idempotent
statussen/rollen POSTs), and a ZGW failure that survives retry sets
Aanvraag.ZgwError plus a zgw:divergence audit row instead of failing
or diverging quietly. No outbox/reconcile job: three request-triggered
write paths don't justify a persisted queue that would also need to
carry citizen PII for the JWT audit claims.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:11:55 +02:00
eho 67abc58052 feat(openzaak): per-document-type confidentialiteit config (WP-59)
Drives the DRC upload's vertrouwelijkheidaanduiding from a new stamdata
table instead of the hardcoded "openbaar", following the existing
config-as-code pattern (ADR-0004). Adds the referential-integrity check
StamdataValidationTests was missing for the new table.
2026-07-30 17:30:25 +02:00
ehoandClaude Sonnet 5 3e983bd2cc feat(openzaak): real notification delivery to the BFF webhook (WP-58)
OpenZaak doesn't serve the Notificaties API itself (it's a separate app,
open-notificaties) — standing one up for a real abonnement would triple
this harness for a benefit it doesn't need (exactly one subscriber, this
repo's own BFF). Instead, an opt-in compose overlay adds a celery worker
and points OpenZaak's NotificationsConfig straight at the BFF's webhook
via a zgw_consumers Service; bootstrap-notificaties.sh configures it
idempotently and verify-notificatie.sh proves a real write delivers to
the BFF's audit trail end-to-end.

Verified live: preflight proves the webhook's shared-secret gate both
ways (204/401), a zaak PATCH triggers real celery delivery, and rerunning
both scripts against an already-configured harness stays idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:33:16 +02:00
ehoandClaude Sonnet 5 1e87997ea0 feat(openzaak): least-privilege client scopes (WP-57)
setup_configuration has no YAML field for granular autorisaties, so
bigregister-test now starts at heeft_alle_autorisaties: false (dev + prod
template) and bootstrap-catalogus.sh grants exactly the ztc/zrc scopes the
harness needs via the Django ORM, sidestepping the zero-scope
chicken-and-egg with the JWT-authenticated Autorisaties REST API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:05:54 +02:00
ehoandClaude Sonnet 5 89ad3490b0 feat(openzaak): idempotent catalogus/zaaktype/zaak provisioning (WP-56)
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 53s
CI / frontend (push) Successful in 1m42s
CI / backend (push) Successful in 2m11s
CI / e2e (push) Successful in 3m58s
CI / storybook-a11y (push) Successful in 8m8s
CI / semgrep (push) Successful in 1m17s
CI / api-client-drift (push) Successful in 1m50s
bootstrap-catalogus.sh now looks up every resource by its natural key before
creating it (catalogus by domein+rsin, zaaktype by catalogus+identificatie,
statustype by zaaktype+volgnummer, roltype by zaaktype+omschrijvingGeneriek,
zaaktype-publish by checking `concept` first, zaak by identificatie,
status/rol by existence-under-the-zaak), so rerunning against an
already-seeded instance reuses what's there instead of erroring.

The WP's original plan (move this into OpenZaak's `setup_configuration`
mechanism) turned out not to be achievable: reading the actual
django_setup_configuration steps installed inside the open-zaak image shows
no step exists for Catalogi/Zaken content anywhere in this OpenZaak version
— only sites/credentials/applicaties/selectielijst. Documented as a
deviation; the WP's own Risks section already anticipated this and sanctioned
falling back to an idempotent script.

Verified live: fresh instance -> full run (all created) -> integration test
green -> reran the script twice more against the same instance (all reused,
identical URLs, no duplicates) -> integration test still green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:57:37 +02:00
ehoandClaude Sonnet 5 3588057a75 feat(openzaak): real secrets + TLS for the production OpenZaak harness (WP-55)
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 12s
CI / frontend (push) Successful in 14s
CI / storybook-a11y (push) Successful in 17s
CI / backend (push) Successful in 1m51s
CI / semgrep (push) Successful in 1m13s
CI / e2e (push) Successful in 2m56s
CI / api-client-drift (push) Successful in 1m41s
docker-compose.openzaak.prod.yml layers real SECRET_KEY/DB password/site
domain/allowed-hosts (all required, fail-fast via ${VAR:?...}) on top of the
WP-54 dev harness, switches Postgres off trust auth, and sets IS_HTTPS for a
front-facing reverse-proxy TLS setup. The ZGW client secret lives inside a
file setup_configuration reads rather than a compose env var, so it's
templated (data.prod.yaml.template, no secret) and rendered host-side via
render-prod-secrets.sh into a gitignored data.prod.yaml, mounted over the
container's dev data.yaml. ZgwOptions.cs already binds from IConfiguration,
so the BFF side needed no code change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:27:21 +02:00
ehoandClaude Sonnet 5 ebf1f8f8b4 fix(backend): run the prod image as non-root (semgrep, live Gitea finding)
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 10s
CI / frontend (push) Successful in 13s
CI / storybook-a11y (push) Successful in 17s
CI / backend (push) Successful in 1m45s
CI / semgrep (push) Successful in 1m9s
CI / e2e (push) Successful in 2m53s
CI / api-client-drift (push) Successful in 1m46s
The pushed WP-30 item-5 Dockerfile predated the semgrep triage's local run —
CI's now-blocking semgrep gate caught what local verification couldn't:
dockerfile.security.missing-user-entrypoint (no USER, container runs as root).

mcr.microsoft.com/dotnet/aspnet:10.0 ships a pre-created non-root user for
exactly this ($APP_UID, uid/gid 1654) — switched to it, with --chown on both
COPY layers so the app can still create/write bigregister.db (WP-22, a
relative-path SQLite connection string resolved against the container's /app
cwd) as that user.

Verified for real: rebuilt, confirmed `whoami` is `app` inside the container,
ran it and curled a live GET /api/v1/brief/preview (200), confirmed
bigregister.db was created and is actually owned by app:app. Full semgrep
re-run (this file didn't exist during the original triage) is now 0 findings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 10:11:54 +02:00
ehoandClaude Sonnet 5 a0d8804a53 feat(backend): optional lean deployable image (WP-30 #5)
Multi-stage backend/Dockerfile (sdk build -> aspnet:10.0 runtime, ~312MB) +
docker-compose.prod.yml, additive only — not wired into CI or the existing
dev docker-compose.yml (which keeps the SDK image for dotnet run hot-reload).
New .dockerignore keeps the build context lean (node_modules alone is
~750MB) since the Dockerfile COPYs from the repo root to pick up
public/letter.css (WP-25's FE<->BE letter contract) as a sibling of backend/.

Verified for real: built the image, ran it, and curled a live
GET /api/v1/brief/preview against the running container — got back the
actual rendered letter HTML with letter.css inlined, confirming the
walk-up-from-BaseDirectory lookup resolves inside this image layout too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 09:35:21 +02:00
ehoandClaude Sonnet 5 5cb3e1a9f0 feat(zgw): docker OpenZaak integration-test harness (WP-54)
Opt-in docker-compose (postgres+redis+OpenZaak, no celery/nginx) +
bootstrap-catalogus.sh seed a real OpenZaak instance; OpenZaakIntegrationTests
(Category=Integration, excluded from default dotnet test/CI) proves the ZGW
seam against it for the first time. That live run caught a real bug:
ZgwHttpClient never sent Content-Crs/Accept-Crs headers, so every write would
412 against a spec-compliant OpenZaak — fixed alongside the harness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 09:08:35 +02:00
ehoandClaude Sonnet 5 73172510ea feat(zgw): real per-request identity seam + citizen-scoping (WP-53)
CI / frontend (push) Failing after 1m19s
CI / backend (push) Successful in 2m0s
CI / e2e (push) Successful in 3m57s
CI / storybook-a11y (push) Successful in 7m45s
CI / semgrep (push) Successful in 1m6s
CI / api-client-drift (push) Successful in 1m55s
Replaces the hardcoded DocumentStore.DemoOwner and the static ZgwOptions
UserId/UserRepresentation with one per-request CallerIdentity, resolved by a
pluggable IIdentityProvider (StubIdentityProvider reads X-Role/X-Subject
today; a real OIDC/DigiD provider swaps in without touching any consumer).

- Domain/Authorization/{CallerIdentity,IIdentityProvider,StubIdentityProvider}.cs
  + a resolution middleware in Program.cs, right after correlation-id.
- Authz.ResolvePrincipal(ctx) keeps its signature (now reads ctx.Caller().Role),
  so its ~15 call sites needed no changes.
- Every endpoint that passed DocumentStore.DemoOwner to a store now passes
  ctx.Caller().Bsn.
- ZgwTokenProvider gains Mint(CallerIdentity) alongside the original Mint()
  (kept for calls not tied to one citizen); ZgwHttpClient threads an optional
  caller through to pick the right overload.
- IZaakSource gains ListMyCases(caller, now) — the citizen-scoped read
  OpenZaakZaakSource backs with ZGW's rol__...__inpBsn filter. GET /applications
  now routes through it instead of ApplicationStore directly, closing the last
  "reads a static store" gap for a citizen-facing endpoint.

Backend 159/159 tests (+8, incl. an HTTP-level two-identity scoping proof),
npm run ci green, no api-client drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 08:27:53 +02:00
ehoandClaude Sonnet 5 bea04549dd feat(zgw): finish WP-52 OpenZaak Notificaties (NRC) webhook slice
CI / frontend (push) Successful in 2m33s
CI / backend (push) Successful in 1m45s
CI / storybook-a11y (push) Successful in 7m47s
CI / e2e (push) Successful in 4m3s
CI / semgrep (push) Successful in 1m7s
CI / api-client-drift (push) Successful in 2m3s
Endpoint/DTO/options landed already in c4dd846; this closes the loop with
NotificatieTests.cs (accept/reject/missing-header, asserting the AuthzAuditStore
row), missing appsettings.json keys (also backfills DrcBaseUrl/
InformatieobjecttypeUrls, stale since WP-51), and the webhook + abonnement
provisioning docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 08:07:50 +02:00
ehoandClaude Sonnet 5 c4dd846fbb fix(ci): unbreak backend format, storybook-a11y, and e2e jobs
CI / frontend (push) Successful in 2m12s
CI / backend (push) Successful in 1m37s
CI / e2e (push) Successful in 3m33s
CI / storybook-a11y (push) Successful in 8m23s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m52s
- backend: dotnet format the WP-51 migration (2-space indent, no BOM)
  to match .editorconfig — dotnet format --verify-no-changes was failing.
- storybook: stub FeatureFlagStore (WP-47) in shell/site-header stories
  alongside AccessStore, fixing NG0201 no-provider errors; bump the
  storybook-a11y container's memory cap 4g→6g (build-storybook +
  compodoc measured ~5.8GB peak RSS, leaving too little headroom).
- backend: fix a startup-breaking bug in the new (WP-52) POST
  /zgw/notificaties handler — it took ZgwOptions as a minimal-API
  parameter, which isn't registered in DI, so ASP.NET's endpoint-table
  build threw on every request once the route was registered (incl.
  /swagger, which is why Playwright's webServer health check timed
  out). Close over the existing `zgw` local instead.
- e2e: brief-v2.spec.ts's "Voorbeeld" button locator was ambiguous
  once a second "Voorbeeld met testwaarden" button existed (Playwright
  name matching is substring-based) — added `exact: true`. Also fixed
  the sent-letter preview flow to match app-letter-composer's actual
  behavior (single click → fetch, no in-page dialog, unlike
  app-behandel-scherm's), and fixed a watermark assertion that checked
  for the always-present `.preview-watermark` CSS class name instead
  of the conditionally-rendered "VOORBEELD" marker text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 07:18:07 +02:00
ehoandClaude Sonnet 5 5807937229 feat(zgw): OpenZaak Documenten (DRC) upload + zaak link (WP-51)
Extends the OpenZaak seam with IDocumentSource, sibling of IZaakSource
(WP-49/50): an upload always lands locally first (DocumentStore stays
the record of truth for preview/download/audit) and, when
Zgw:Enabled=true, is also registered as a DRC enkelvoudiginformatie-
object; once a zaak exists (IZaakSource.CreateZaak now also returns
its ZaakUrl), submit links each document to it via zaakinformatie-
object. FE upload/list DTOs are unchanged.

- ZgwOptions gains DrcBaseUrl + a category->informatieobjecttype URL
  map (the document analogue of ZaaktypeUrls).
- LocalDocumentSource is the same DocumentStore.Add/Link calls the
  endpoints used to make inline — zero behaviour change offline.
- OpenZaakDocumentSource POSTs the eio then the zaak link, persisting
  the DRC url (DocumentStore.SetDrcUrl) so linking doesn't re-upload.
- Factored the GET/POST-with-bearer-JWT plumbing shared with
  OpenZaakZaakSource into ZgwHttpClient; shared the stub handler
  between the two source test classes as ZgwStubHandler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:54:31 +02:00
ehoandClaude Sonnet 5 de3bff0d7f feat(zgw): OpenZaak create-zaak, first write slice (WP-50)
Extends the IZaakSource seam (WP-49, read-only) with CreateZaak: submitting
an aanvraag now also registers a Zaak + Status + Rol in OpenZaak when
Zgw:Enabled=true, routed through the existing /applications/{id}/submit
endpoint with the FE response DTO unchanged (ADR-0001/ADR-0005 — the
endpoint never branches on the config flag itself, DI already picked the
implementation).

- ZgwOptions gains a Type→zaaktype-URL map + the two RSINs a Zaak needs.
- LocalZaakSource.CreateZaak is a pure passthrough of what the endpoint
  already computes locally (zero behaviour change for the offline default).
- OpenZaakZaakSource.CreateZaak POSTs the zaak (identificatie = the same
  local reference, so both stay in sync), resolves + POSTs the initial
  status and the initiator rol (BSN) via Catalogi lookups, and maps the
  result back into the submit response.
- Marked ponytail shortcuts: first-statustype/roltype-Catalogi-returns
  (no per-type config) and no compensating transaction on partial failure
  — both fine for a first slice against a demo backend.

Verified: full `npm run ci` green, zero api-client drift, 144/144 backend
tests (142 existing + 2 new stub-handler tests asserting the POST bodies
+ type→zaaktype mapping per the acceptance criteria).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:03:13 +02:00
ehoandClaude Opus 4.8 1c3c195d32 feat(backend): expand stamdata + OpenZaak-ready cases seam (WP-49)
CI / frontend (push) Successful in 2m59s
CI / backend (push) Successful in 1m27s
CI / semgrep (push) Successful in 58s
CI / e2e (push) Successful in 2m30s
CI / api-client-drift (push) Canceled after 1m14s
CI / storybook-a11y (push) Canceled after 29m8s
Stamdata: add beroepen, opleidingen (temporal), and specialismen tables to the
schema-driven catalog (zero UI code). opleidingen.beroep and specialismen.beroep
both reference beroepen.code — the first stamdata->stamdata references, enforced by
two new StamdataRef entries in the CI gate.

OpenZaak/ZGW (WP-49, slice 1 — read-only zaken): introduce IZaakSource as the cases
read seam. Default LocalZaakSource reads the local SQLite store (offline); an
OpenZaakZaakSource (Zgw/ client: HS256 per-call JWT, ZGW->existing-DTO mapper,
paginating HTTP source) is selected behind Zgw:Enabled (default false). The FE never
changes — same ApplicationSummaryDto, no api-client drift. Unit-tested with fixtures
+ a stub HttpMessageHandler; no live OpenZaak needed.

Docs: ADR-0005, reference/openzaak-integration.md, WP-49..52 roadmap, stamdata.md
update, README index rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:01:06 +02:00
ehoandClaude Opus 4.8 fbc4bf51d0 feat(beheer): stamdata deletion protection — CI referential gate + editor expire/warn
CI / frontend (push) Successful in 2m27s
CI / backend (push) Successful in 2m3s
CI / storybook-a11y (push) Failing after 6m30s
CI / semgrep (push) Successful in 1m5s
CI / e2e (push) Successful in 3m22s
CI / api-client-drift (push) Successful in 2m10s
CI gate (authoritative): generalize the dangling-reference test in StamdataValidationTests
into a declared, extensible reference list ("every declared reference into a stamdata key
resolves against the currently-valid stamdata"), starting with Diploma.Opleiding →
professions.program. Removing/renaming a referenced program, or expiring it while current
data still references it, fails the PR build (ADR-0004). Editor (fast feedback): confirm
before delete (warns a referenced row fails CI) and, for temporal tables, a "Sluiten per
vandaag" action that closes validity (geldigTot) — steering to expire over hard delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:35:17 +02:00
ehoandClaude Opus 4.8 67802c68b4 feat(admin): runtime feature flags (catalog-in-code, admin toggle, FE+backend)
Catalog declared in code (Domain/Features/FeatureFlags.cs, build-validated), on/off state
persisted in SQLite (FeatureFlagStore + migration). GET /flags (drives FE gating) + admin
PUT /admin/flags/{key} (new flags:manage capability + FlagsAdmin gate). Enforced end-to-end:
the `inschrijving-open` flag hides the Inschrijven nav item + dashboard action (FE) AND makes
POST /applications for a registratie 403 when off (backend). FE FeatureFlagStore mirrors
AccessStore (enabled() deny-by-default); admin toggle page at /beheer/functies in ADMIN_LINKS.
+4 backend tests, /me cap-list updated, client regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:29:48 +02:00
ehoandClaude Opus 4.8 0f30143c5d feat(privacy): WP-41 — persisted, queryable authz/PII-reveal audit
Persist the security-relevant events (authz denials + BIG-nummer reveal/step-up) into a
data-minimised EF table (AuthzAuditEntry: At/Action/Resource/Decision/Role/CorrelationId —
never a name/BSN/value), extending the DocumentStore AuditEntry pattern (migration AuthzAudit).
AuditAuthz now persists via AuthzAuditStore.Record alongside its log line. GET /admin/audit
(admin-gated by the existing CasesAdmin) returns the trail newest-first. +3 backend tests incl.
a schema-carries-no-PII reflection test. Typed client regenerated (audit() + AuthzAuditDto);
no FE consumer yet (a future audit view must add the ROLE_AWARE prefix). Finishes WP-42's audit half.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:53:33 +02:00
ehoandClaude Opus 4.8 446ea9474b feat(registratie): WP-36 — admin cases page + admin delete
Admin-only overview of all cases across owners + an admin delete, gated by a new
`cases:manage` capability (Authz role→cap + CanManageCases + CasesAdmin gate;
FE capability + guard + nav + role.interceptor prefix — the org-template/stamdata
recipe). Backend adds ApplicationStore.ListAll()/DeleteAny() and GET /admin/cases +
DELETE /admin/cases/{id}; admin delete removes ANY case incl. submitted. Page lives
in registratie/ui (owns the Aanvraag aggregate; reuses aanvraag-view + parse),
routed /beheer/zaken; delete guarded by a native confirm, optimistic with rollback.
Typed client regenerated (documents the new endpoints + owner field).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:23:34 +02:00
ehoandClaude Opus 4.8 d1abd35b0d feat(registratie): WP-35 — one Concept per case type (server-enforced)
CI / semgrep (push) Has been cancelled
CI / frontend (push) Successful in 1m47s
CI / storybook-a11y (push) Successful in 5m17s
CI / backend (push) Successful in 1m29s
CI / e2e (push) Successful in 3m1s
CI / api-client-drift (push) Successful in 2m4s
Make "at most one unsubmitted Concept per type" a server invariant instead of a
client-only convenience. ApplicationStore.Create → CreateConcept guards atomically
under the write gate and POST /applications returns 409 when a duplicate would be
created. The FE draft-sync recovers from the 409 by adopting the existing Concept
(ensureId → findConcept) rather than erroring — one-per-type means the second
attempt lands on the existing draft. Typed client regenerated (documents the 409).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:07:11 +02:00
ehoandClaude Opus 4.8 0ea43af7b6 feat(registratie): WP-34 — phone field + BRP address read-only
Reshape the adreswijziging form into a contact-change form: the BRP address is
authoritative and shown read-only (you change it at the gemeente), and the phone
number becomes the editable/submittable field. New Telefoonnummer value object
(parse-don't-validate); backend RejectPhoneChange re-validates as authority.
POST /change-requests now carries { telefoon } (typed client regenerated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:11:26 +02:00
ehoandClaude Opus 4.8 0e77faf351 feat(stamdata): admin stamdata maintenance editor (beheer)
Realizes ADR-0004's "future low-code editor that commits a PR": an
admin-only stamdata maintenance editor built on the stamdata-as-code
foundation.

Backend: `professions` moves from a hardcoded C# dictionary to an embedded
`professions.json` data-file (typed as `ProfessionMapping`) with valid-time
(geldigVan/geldigTot, half-open). A generic, reflection-driven
StamdataCatalog/StamdataTable/StamdataFile describes every table so one
endpoint pair + one grid editor serve all of them; add a table in one line.
Two read-only, admin-gated endpoints (GET /stamdata, GET /stamdata/{table}
?peildatum=) — no runtime write path. Generic build gate
`Every_catalog_table_is_valid` (keys non-blank, no overlapping validity,
well-formed windows).

Frontend: new `beheer` context (route beheer/stamdata, capabilityGuard
'stamdata:edit'). A schema-driven grid editor edits rows locally; download()
emits {table}.json for the admin to commit as a reviewed PR (no mutation
command — the CI build + StamdataValidationTests stay the authority).

Full gate GREEN both sides; gen:api leaves no drift; new stamdata story
passes axe. See WP-29 + ADR-0004.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:43:51 +02:00
ehoandClaude Opus 4.8 c459fa0a60 feat(stamdata): extract policy-question text into Stamdata
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / frontend (push) Failing after 1m21s
CI / storybook-a11y (push) Failing after 5m27s
CI / backend (push) Successful in 1m35s
CI / codeql (csharp) (push) Failing after 1m50s
CI / api-client-drift (push) Successful in 2m1s
CI / e2e (push) Failing after 3h14m48s
Move the geldigheidsvragen wording out of DiplomaRules into
Stamdata.PolicyQuestions (business-editable text, config-as-code); DiplomaRules
keeps only the rule of which questions apply. Extend StamdataValidationTests
(no blank id/wording, distinct ids in the manual set) and update ADR-0004.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 07:46:38 +02:00
ehoandClaude Opus 4.8 fa7e9c5cfb feat(stamdata): profession↔diploma map as validated config-as-code
Extract the profession↔diploma table out of DiplomaRules into a dedicated
Stamdata.Professions module (business-editable data, separated from the rules
that consume it) and add StamdataValidationTests as the build-time gate: every
seeded diploma program must resolve to a real profession, no blank entries. A
bad edit now fails the build instead of silently rendering "Onbekend". Rules
and behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 07:41:04 +02:00
ehoandClaude Opus 4.8 d4e5a76873 docs: reorganize into project/ + reference/, expand Storybook Foundations
Move working docs (backlog, prd, roadmap) under docs/project/ and durable
docs (architecture ADRs, guides, audits) under docs/reference/; add a
docs/README.md index. Update every path reference in code comments, CLAUDE.md,
READMEs, and the new-ssp skill. Expand the Storybook Foundations curriculum
(Overview, BDD, i18n; rename Layers→Domain-Driven Design) and reorder the sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:33:32 +02:00
ehoandClaude Opus 4.8 55a0a2d166 test: split multi-assertion specs into single-behavior tests
One behavior per test across FE machine/store specs and backend endpoint
tests, so a failure names exactly what broke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:33:25 +02:00
ehoandClaude Opus 4.8 5cae44f163 feat(security): ABAC P2/P3-lite — BIG-nummer redaction, authz audit, guard; clear dev audit
- fix(deps): pin @babel/core ^7.29.7 via overrides → npm audit 0 (dev+prod),
  no --force / no Angular downgrade; README corrected
- feat(brief): field-level PII reveal (PRD-0002 §5c) — CaseContext BIG-nummer
  ships masked; step-up-stubbed (X-Step-Up), audited POST /brief/reveal-bignummer
  unmasks it; drafter-only capability, deny-by-default. Realized on the BIG-nummer
  (no BSN on the wire)
- feat(authz): no-PII AuditAuthz log for reveal attempts + org-admin denials (§8)
- feat(routes): wire capabilityGuard('orgtemplate:edit') onto brief/huisstijl (§6)
- test: backend +5 (Authz + reveal endpoint), FE +3 (adapter boundary, store swap)
- docs: PRD-0002 §5c/§9, WP-18 follow-up, README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:59:20 +02:00
ehoandClaude Opus 4.8 ba32e3dd9f feat(fp): brief v3 — besluit-driven guided drafting
Compose the herregistratie letter from the besluit instead of a library hunt:
the behandelaar picks positief/negatief (+ reden-checkboxes for a negatief) and
the kern's standaardteksten follow the selection live.

Front-end (this increment):
- Kern is recomposed reactively from the besluit selection (new BesluitSelected
  machine msg + composeKern); the "Genereer conceptbrief" button is gone. The
  drafter's free text is preserved across a selection change.
- The editor shows only the editable sections; the locked aanhef/slot render in
  the preview, not the authoring surface. Slot is a case-type template section
  (per templateId), documented as such.
- The panel re-seeds from the letter via inferSelection() — the besluit + redenen
  are read back off the kern's passage blocks, so the selection survives reload
  with no new wire fields (derive, don't store).
- letter-section drops the now-redundant per-section passage picker (besluit owns
  standaardteksten); keeps free-text + block edit/move/remove.

Fix: app-checkbox now falls back to a unique per-instance id when checkboxId is
omitted. The CIBG styled checkbox routes clicks through the label, so the shared
id="undefined" made every reason label toggle the first input — the second
checkbox could never be checked. Verified live (Playwright): each reason toggles
independently.

Backend/seam (brief v3 WIP): besluit/reason passage tags on the wire + seed,
carried through the adapter parse boundary.

Specs updated (besluit, brief.machine) and the affected stories re-pointed at the
new API. FE lint + build + 253 vitest specs green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:53:53 +02:00
ehoandClaude Opus 4.8 5e36d68f11 feat(fp): WP-27 — brief UX layer (undo/redo, standaardbrief, passage search, diff badges)
CI / storybook-a11y (push) Failing after 4m43s
CI / frontend (push) Failing after 1m15s
CI / backend (push) Successful in 1m24s
CI / codeql (csharp) (push) Failing after 2m51s
CI / e2e (push) Failing after 3h4m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 1m53s
Brief letter-composition UX improvements:
- undo/redo history in the brief store (snapshot stacks, Ctrl/Cmd+Z) + retry-save
- "Standaardbrief invoegen" starter for empty sections; isDefault library passages
  (backend DTO/seed + adapter parse)
- passage-picker client-side search (rich-text textOf helper)
- rejection diff badges on the letter canvas + show/hide changes toggle
  (pure brief-diff domain fns + spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:36:32 +02:00
ehoandClaude Sonnet 5 1bb9383344 feat(fp): WP-25 — server-rendered letter HTML preview
Adds LetterHtml.Render, a pure composer mirroring the FE letter canvas'
class vocabulary, behind two ExcludeFromDescription()'d endpoints
(GET /brief/preview, GET /admin/org-template/{subOrgId}/preview).
Auto-resolvable placeholders pull from seed/case data; unresolved
manual ones render as "[NOG IN TE VULLEN: label]". A sent brief
archives its composed HTML (BriefEntity.ArchivedHtml) so a later
org-template republish never changes it. FE gets a hand-written fetch
adapter (text/html, not JSON) and a "Voorbeeld" button that opens the
preview in a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 12:56:36 +02:00
ehoandClaude Fable 5 5a610c10f0 feat(fp): WP-23 — org-template backend + admin role
Second template axis (org identity: letterhead, footer, signature,
margins) server-side: OrgTemplateStore with JSON version history,
publish/rollback, sent-brief version pinning, admin role + capability,
5 admin endpoints, org-logo upload category. FE seam widened only
(Role/Capability unions, interceptor); WP-24/26 consume it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:17:05 +02:00
ehoandClaude Sonnet 5 556f2f47bf feat(fp): WP-22 — durable persistence (SQLite/EF Core)
Applications, documents (+ audit log) and the brief move off static in-memory
Dictionaries onto a real SQLite file via EF Core, so demo data survives a
process restart or `docker compose restart api` for the first time. The three
stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public
signatures and static-class shape — no DI, no async ripple into Program.cs's
minimal-API handlers — each method just opens a short-lived AppDbContext via
Db.Create() under the same lock it already had. Opaque nested shapes (a
wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
JSON text columns rather than redesigned into relational tables, matching the
existing "don't interpret it" posture.

Found two things the WP's own text got wrong, corrected in
docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never
seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which
stay in-memory) so there's no seed step; and no new docker-compose volume is
needed since the existing bind mount already covers the SQLite file — verified
against this environment's real podman-backed compose stack, not just by
reading the file.

Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own
transitive default bundles a pre-3.50.2 SQLite with a known high-severity
memory-corruption advisory); found and fixed a real xUnit test race where
concurrent test-class hosts stomped a shared static connection-string field,
fixed by disabling cross-class test parallelization rather than adding DI the
stores don't otherwise need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:19:23 +02:00
ehoandClaude Sonnet 5 40dbcb2606 feat(fp): WP-21 — resilience seams (correlation-id, idempotency, retry)
Correlation id becomes real ASP.NET Core middleware instead of a per-endpoint
read: every request gets one (client-supplied or generated), it's echoed as
an X-Correlation-Id response header, and pushed into the logging scope so
every log line for that request carries it — not just the Submit helper's,
verified against LogBrief which never threads it explicitly.

Idempotency-Key moves from per-HTTP-attempt (defeating its own purpose) to
per-logical-submit: runSubmit mints one key and threads it through a small
bridge (withIdempotencyKey/currentIdempotencyKey) since the NSwag-generated
client has no per-call header hook. Backend gains an IdempotencyStore that
short-circuits a replayed key to the first call's result instead of minting
a second reference — scoped to the Submit-helper endpoints per the WP's own
decision.

GET requests now retry transient failures (rxjs retry({count:2, delay:500}));
writes never auto-retry. Proven with a fake-HttpClient spec
(api-client.provider.spec.ts) rather than a manual network-tab check — the
WP's suggested `?scenario=error` check turned out not to exercise a real
network call at all (the interceptor throws before calling next()), so the
automated test is the actual proof.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 20:03:41 +02:00
ehoandClaude Sonnet 5 7ec13d8b59 feat(brief): WP-18 — ABAC capability spine (PRD-0002 phase P1)
Replace the FE-computed authorization anti-pattern in BriefStore.editable
(derived from the unverified X-Role header) with server-computed decision
flags, mirroring the existing HerregistratieDecisionsDto pattern:

- Backend: Authz.cs is the single authorization helper — the SAME check
  (Authz.CanActOn) both gates BriefStore.Review's mutations and computes
  the BriefDecisionsDto flags shipped on every brief response, so emit
  and enforce can never drift. New GET /me returns coarse, role-derived
  capabilities (PRD-0002 SS6).
- Every brief endpoint (including send, previously ungated on HttpContext)
  now returns a fresh BriefViewDto so decisions never go stale after a
  mutation.
- FE: brief.store.ts reads canEdit/canApprove/canReject/canSend off the
  loaded decisions instead of computing them from currentRole(); the
  brief.machine carries decisions through every status transition.
- New shared/domain/capability.ts + shared/application/access.store.ts +
  shared/infrastructure/me.adapter.ts: the general capability-spine
  infrastructure (AccessStore.can(), capabilityGuard) for future routes.

Deviates from the original WP-18 draft by NOT renaming auth/domain's
Session to a Principal union — ADR-0002 explicitly defers that refactor
until a second actor exists, and the brief workflow's drafter/approver
identity turned out to be a separate axis from the SSP login session
entirely. See docs/backlog/WP-18-abac-capability-spine.md for the full
as-built record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 20:31:53 +02:00
ehoandClaude Opus 4.8 1137f59f7b style: format backend with dotnet format
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
ehoandClaude Opus 4.8 84c2d1b6a0 feat(brief): locked sections, list formatting, auto/manual placeholder chips
- brief.machine: reducer refuses edits to locked (predefined) sections as
  defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
  list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
  both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
  aligned with the editor's chip styling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:50:22 +02:00