Changelog & Roadmap¶
Changelog¶
v2026.09.12 β The API Describes Itself, and One Rule Decides Every Tenant (September 2026)¶
The published description: API Specification. The tenant rules: Authentication & IAM β Tenancy. The pipeline: CI/CD. Measured suites: Testing.
The backend publishes an OpenAPI description, and a test keeps it honest. GET /v1/openapi.json is served at the location the NL API Design Rules prescribe, and the root banner advertises it again. routes/registry.ts is one array that drives three things: index.ts mounts from it, the banner advertises from it, and src/openapi/coverage.test.ts compares the document against it β failing when a served operation is neither described nor listed as pending, when a described one is not served, or when the pending list grows. Mount order, which used to be a comment, is data with a test: the ValidSign callback router must precede the authenticated one. The document is read once at module load, because a zip deploy overwrites files before it restarts the process, and a lazy read would let an old process serve a document from an artifact it is not running. npm run lint:openapi runs Spectral in both backend workflows; the NL API Design Rules found two real gaps β the servers needed /v1 in the URI, and API-Version was not sent at all, so version.middleware.ts now sets it. Three rules are recorded as exceptions: nlgov:semver, because releases are CalVer, and the problem-details rules, because the API answers its own { success, error } envelope. The unused ENABLE_SWAGGER is gone.
113 of 131 operations are described, each checked against a running service. Seven phases covered the unauthenticated surface (/v1/public, /v1/media-aggregator), the execution core (/v1/process, /v1/task, /v1/decision), documents, delivery and signing (/v1/edocs, /v1/validsign, /v1/doccle), policy analysis (33 operations under /v1/pa), and the rest (/v1/rip, /v1/mcp, /v1/hr-capacity, /v1/hr, /v1/brp, /v1/admin). Live testing corrected what reading the handlers had produced, again and again: list endpoints that answer { items, pagination } rather than a bare array, request bodies nested differently than the code suggested, twenty omitted required fields found by comparing schemas with live samples. The document records the API as it is rather than as it might be tidied β four response envelopes, snake_case beside camelCase, one streaming operation (POST /v1/mcp/chat, server-sent events), and a second security scheme, mediaAggregatorKey, for /v1/media-aggregator/search, which requires a bearer token only when MEDIA_AGGREGATOR_ACCEPT_KEY is set. The remaining 18 operations are /v1/m2m, pending #214; they stay listed in openapi/pending.json and on API Endpoints.
Tenant access is decided in one place, and fails closed. The process routes decided tenancy from the municipality process variable; the task routes decided it from Operaton's deployment tenantId. The two diverged whenever someone started a process deployed under another organisation, leaving an instance half-readable by each tenant (#218) and task lists offering tasks the detail endpoint then refused (#219). auth/tenant-access.ts now decides every process and task question from municipality alone: a missing label refuses, and every refusal answers 403 TENANT_MISMATCH. A start resolves the organisation the process is deployed under before anything reaches Operaton β the caller's own when it deploys the key, otherwise the single one that does, and 409 AMBIGUOUS_DEPLOYMENT when several do. Staff are refused across organisations; a citizen's case goes to the deploying organisation, with originTenantId recording their own, and the applicant keeps read access to it. municipality, originTenantId and applicantId are reserved: a task completion carrying any of them gets 400 RESERVED_VARIABLE. A minted business key now carries the owning organisation rather than the caller's (#234). The ValidSign task endpoints, which authenticated the caller but never compared organisations, now refuse another tenant's task before any signing request can be sent (#227).
An unused Keycloak adapter is gone, and 48 packages with it. keycloak-connect had been declared since the initial commit and imported by nothing β authentication is jwt.middleware.ts with jsonwebtoken and jwks-rsa. It sat under dependencies, so production carried 43 packages that existed only because of it, among them chromedriver at a floating tag; the production tree loses 48. Three Dependabot alerts can no longer arrive by that route.
The frontend stops logging a BSN. brp.api.ts logged the burgerservicenummer to the browser console on a failed person fetch β harmless with test users, not once DigiD fills the claim. The other Semgrep findings were triaged in the source, each false positive annotated with its reason, and a first verification scan that proved nothing (its ruleset did not contain the rules) was re-run with the exact rule ids: 14 before, 0 after.
Every release carries an SBOM, and dependencies are audited daily. scripts/write-sbom.mjs writes a CycloneDX document of the production dependencies to docs/sbom/ as a bump-release step; sbom.yml uploads it on a promotion and asserts the released version has one. dependency-audit.yml reads the lockfiles of acc and main every morning and fails on a high or critical advisory in production dependencies. A step named "Lockfile matches package.json" now fails a pull request whose lockfile disagrees with package.json β added after three dependency pull requests merged back to back, each green against its own base, left acc with a lockfile matching no package.json. Renovate never offers a major's X.0.0, and Ubuntu 26.04 and Node 24 are deferred on record: both App Services run NODE|22-lts, which can be pinned only to a major, so the App Services move first and .nvmrc follows.
The local stack's images are pinned. docker-compose.yml pins all five images by tag and digest, maintained by Renovate; alpine and operaton/operaton were :latest, which nothing watched, and are now 3.24.2 and 2.1.5. See Local Development.
v2026.09.11 β Input Concepts Told Apart From Output Ones (September 2026)¶
A service's concepts now say which side of the rules they sit on. A regel detail page on the public site listed every concept of a service in one alphabetical row, so nothing distinguished the values the rules consume from the ones they produce. The distinction was already in the knowledge graph and was being dropped on the way out. The concept query now carries it two ways, one per generation of export: an older export states it as the variable's edge to the DMN (cpsv:isRequiredBy / cpsv:produces), a CPRMV 0.4.1 export only as the /input/N or /output/N tail of the variable URI. conceptDirection() reads the edge first and falls back to the URI. Across the live graph the two agree wherever both appear β 193 of 193 rows β and all 241 concept rows of all 14 services resolve to a side.
The open API keeps its existing shape. PublicIndexItem gains begrippenIO, the same concepts each with its direction; the flat begrippen array is left exactly as it was, because it is part of the open, anonymous API and outside consumers read it. Both lists are deduplicated β one concept can reach a service through more than one variable, as Aanspraken does in Digital Twin Inkomensregelingen, and neither list may name it twice. The detail page keeps its heading and the count of every concept, and divides the chips into Invoer β gegevens die de regels nodig hebben and Uitvoer β wat de regels bepalen. Two fallbacks keep a concept from going missing: a response with no directions at all, from a backend older than the field, renders the one undivided row it always did, and a concept the graph leaves undirected gets a group of its own rather than being filtered away. The grouping is per service, not per rule β all 21 concepts of the thuisbatterij service hang off a single DMN, so Recht Op Subsidie reads as an output of the service even though the third rule plausibly consumes it. Per-rule attribution is not in the graph.
The config validator's Node moves to 24.21.0. One line in zizmor.yml, and deliberately not shared with .nvmrc. The renovate-config-validator step runs on its own exact Node 24 because renovate@44.50.3 declares engines.node ^24.11.0, and npm accepts a mismatch with an EBADENGINE warning rather than refusing β so before that pin the validator had been running unsupported and green. Everything else in CI builds, tests and ships on the repository's single .nvmrc, still 22.23.2, which the App Service plans match. Renovate maintains this pin behind the same 14-day cooldown as every other dependency: v24.21.0 was released on 7 September, sixteen days before this release, checked against the Node release index rather than taken from the stability-days status β the day before, that status read not met on three lock-file maintenance branches that were in fact compliant.
/bump-release runs the tests before it commits. The release command normalised formatting and ran lint, but not the tests β and the step immediately above it edits source files: five package.json manifests and the lockfile. Lint and Prettier read a package.json as data; a test can read it as input, and then a version bump is a behaviour change. v2026.09.10 is what proved it. Step 6 now runs npm test at the root, which covers the workspaces with no deploy workflow of their own, and step 7's report must state that format, lint and test are clean with the suite's counts β because a step nothing reports on is a step that gets skipped.
v2026.09.10 β A Promotion Is One Ordered Run, and the Backend Deploys Itself (September 2026)¶
A push to main fired four deploy workflows at once and nothing sequenced them. The backend job is the slowest of the four β it runs the full backend suite before it packages anything, while a Static Web App deploy is a build and an upload β so the frontends reliably finished first. A frontend calling a route the deployed backend does not serve yet gets a 404, and on the public site that is worse than transient: its build prerenders against the live API, so a prerender inside the window bakes the failure into the deployed output. promote-to-production.yml is now the only thing that triggers on a push to main. It works out what changed, then calls the four deploy workflows as reusable workflows β backend first, then the three sites in parallel once the backend has succeeded or been skipped. The four keep workflow_dispatch and lose their push trigger. The four paths: filters became scripts/promotion-targets.sh, one script that answers for all four, exercised against fourteen cases and five real commit ranges rather than only in anger. Secrets are named rather than inherited: secrets: inherit would have handed each site workflow the Keycloak VM's SSH key and the Semgrep token to deploy one static site, and the backend takes none at all because it authenticates with OIDC. A dry_run dispatch input, defaulting to true, proves the wiring without promoting. acc is left alone, because its ruleset names four build jobs and a reusable workflow's check renames every required context. The consequence worth knowing is recorded in the workflow's own header: if the promotion workflow breaks, nothing deploys β silently, because main's ruleset requires only audit and these are not required checks. The escape hatch is that all four keep workflow_dispatch.
The backend deploys from the workflow, over OIDC. The cheap fix would have been a publish profile, as a neighbouring repository uses β but SCM basic auth is disabled on both of these App Services, measured rather than assumed, so a publish-profile deploy would be rejected. OIDC is not a new mechanism here: the hand-run scripts already deploy with az webapp deploy over an ARM token from az login. This is that same call with a machine identity instead of a human session, which removes the failure that stopped an earlier release reaching acceptance β an expired login, discovered after the merge. The deploy bundle now installs from the lockfile: it used to be npm install --production in a directory that had a package.json and no lockfile, so it re-resolved every caret range at deploy time, and on 29 August an acceptance deploy ran with @anthropic-ai/sdk freshly jumped 42 minor versions and uuid five majors, none of it matching what any build had verified. A build-info.json in the artifact records which commit and run produced it, /v1/health reports it, and the workflow polls that value until it matches the commit it just deployed β because the previous build keeps answering while Azure starts the new one, so a liveness check passes against either. On acceptance the deploy steps are gated on the event, so a pull request builds and tests without shipping and the build check stays required.
The backend workflows are named for what they now do. Build Backend for ACC was accurate while the workflow ended at upload-artifact and a person ran a script afterwards, and a workflow whose name understates it is how "the build fired, so I should deploy" became a habit. They are now Deploy Backend to Azure ACC and β¦ to Azure Production, matching the four sibling workflows. An earlier note claimed renaming would silently un-require a check; that was wrong β the acc ruleset requires job names, not workflow names, and the job ids are untouched. The deploy bundle also stopped failing on un-hoisted dependencies: the staging install's guard asserted that no production dependency is installed under packages/backend/node_modules, and altcha-lib has been recorded there by the lockfile since it was added, so the guard failed every run. The guard becomes packaging β deploy/ is the backend's root, so an un-hoisted entry is merged into deploy/node_modules after the hoisted tree is copied, and the genuinely ambiguous case, where the same package is also hoisted, still fails rather than guessing.
A pull request's preview can reach the acceptance backend. A Static Web Apps preview gets an ephemeral origin that is not in CORS_ORIGIN, and there is a new one per pull request, so it cannot be listed β which meant a preview could only ever demonstrate that static pages render, on an environment the pull request had already paid to build and deploy. origin is now a function, matched on the app's stable slug rather than on the domain: a *.azurestaticapps.net pattern would have let any Azure Static Web App in the world make credentialed cross-origin requests to the tier, so CORS_PREVIEW_SLUGS carries slugs and the pattern anchors on them. It is never in production, enforced in code rather than by trusting the setting to be empty, and the guard reads deploymentEnv rather than nodeEnv β acceptance deliberately runs NODE_ENV=production, so keying on nodeEnv would have treated it as production and refused every preview. Verified live on both tiers afterwards: the three registered slugs allowed on acceptance, refused on production, and five near-miss origins refused on both.
A preview environment is created only when someone asks for one. Every pull request that touched an app's paths deployed a public copy of it to a Static Web Apps slot, and most showed nothing a build had not already proved. The clearest case: three Renovate security pull requests claimed eight preview environments between them, because each path filter matches its own package.json and a dependency bump therefore looks exactly like a source change. All eight then leaked. A preview is now created only when the pull request changed something other than a manifest and carries the preview label; adding the label starts a run. Both conditions gate the deploy step, not the job β and that distinction is the whole design, since build_and_deploy_job is the only place the frontend, PA demo and public site are linted, type-checked, unit-tested and built on a pull request, and a skipped job reports success. The two decisions fail safe in opposite directions, deliberately: the build filter errs towards building when the GitHub API call fails, because an error must never be a free pass, while the preview decision withholds, because the risk it guards is publishing a copy of an unreviewed branch rather than failing to test one.
check-previews reports preview environments that outlived their pull request. A preview is deleted by a close job when the pull request closes, and that job cannot catch everything: GitHub does not run pull_request workflows while a pull request has a merge conflict, closing included. On 12 September three Renovate security pull requests left eight previews behind across three apps, found by hand on 15 September and still there on 20 September β eight public URLs serving old code, each holding a slot on a plan with a ceiling this repository has already hit. The script finds the apps by their repositoryUrl rather than by workflow filename, so an app added later is checked without editing it, and it reads every subscription az account list returns because these six apps span two of them. Anything unchecked is reported rather than passed over: the session is proven with a real ARM call rather than az account show, which reads cached state and succeeds against a token that expired days ago; a subscription that cannot be read fails the command; and finding no apps at all fails too, because it means nothing was compared. Like check-mirror, it never deletes β it prints the exact command and stops.
set-secret.sh stores a GitHub secret without the whitespace that breaks deploys. Piping a token straight out of the Azure CLI into gh secret set stores a trailing newline β 120 bytes where the key is 119. Both halves are the documented way to do their job; the composition is what goes wrong. It cost the public site its first production deploy, and the failure named nothing: everything passed, then "An unknown exception has occurred" with a DeploymentId printed first, which reads as an upload that began and failed rather than an authentication that never happened. The script reads the value from stdin, strips leading and trailing whitespace, refuses an empty result, and reports the byte count it stored β the byte count being the point, since a secret cannot be read back and nothing about a stored secret can afterwards confirm or deny a stray newline. The value is never echoed, never passed as an argument, and never written to a file.
The API stops advertising documentation that was never served. The root response promised documentation at /v1/docs and the startup log repeated the claim. Nothing ever mounted it, and the advertisement traces to the initial commit β so the service had promised documentation since day one and never served it. There is no Swagger, OpenAPI or Scalar dependency in the backend either: this was planned and not built, not a mount that regressed. Serving real documentation is the better answer for a service with 17 route groups and external consumers, and it stays open; until something serves it, saying nothing beats pointing a consumer at a 404. The banner moves into its own route module, and four tests now cover it, including that nothing in the payload mentions /v1/docs. What this deliberately does not fix: nothing enforces that every advertised path is mounted, because index.ts calls startServer() at import and a test cannot load it β keeping the two in step remains a human job, and the docstring says so rather than implying a guarantee that is not there.
The vestigial KEYCLOAK_CLIENT_SECRET is gone, and placeholders fail the boot. ronl-business-api is a public client: the realm export gives it publicClient: true, no secret and no service account, so there is no client secret to configure β and nothing in the backend ever read one. The setting was declared on Config, populated from the environment, required in production, and consumed by no code path, which is how the production App Service came to hold the literal not-used. It is removed rather than corrected, with a comment at the site recording why there is nothing to put back. The same investigation found the shape of a real problem, which this keeps: a boot-time check now rejects an unfilled value in production, not only an empty one, and ANTHROPIC_API_KEY uses it β that key is genuinely consumed, so an unfilled value currently boots healthy and fails at the first call. Matching is anchored, never by substring: exchange-mechanism-2026 contains change-me, and failing a boot over a legitimate secret would be worse than the fault being prevented. Production only, because failing a developer's boot over an unfilled .env would be hostile.
Acceptance's raised rate limit is recorded, and why it is per client. Acceptance's RATE_LIMIT_MAX_REQUESTS was raised from 100 to 1000 so a full end-to-end run from one machine stops throttling in the PA cockpit specs, which spend about twenty requests per authoring journey. The settings table said acceptance ran 100 and justified production's value as parity with it; both halves were wrong, and that table is what the promotion is read from. It now carries the reasoning it left implicit: TRUST_PROXY is true on both tiers, so the limiter buckets per client rather than once per deployment, which is what makes a raise a convenience decision rather than a safety one β and production should not follow automatically, since a tenfold ceiling per client is a much weaker defence on a public tier.
A Thuisbatterij journey, and an end-to-end suite that can run against a tier. A third deep journey alongside the kapvergunning and zorgtoeslag ones: citizen applies, the six-decision RechtEnHoogteSubsidieThuisbatterij decision requirement diagram is evaluated, the caseworker reviews and closes the notify task. It exists to catch the failure that took Kapvergunning down on acceptance β a tenant-scoped process that cannot resolve its untenanted decisions refuses to instantiate. The harness is now target-aware: a target helper resolves the frontend, backend, Keycloak, Linked Data Explorer and Operaton URLs, each defaulting to the value the harness used to hard-code, so a plain local run is unchanged, and the journeys refuse to run against production without CONFIRM_PROD=1. The caseworker steps no longer take the first task with a matching name β the queue is shared, and on acceptance that selector found five open Phase 6 tasks, four of them foreign β so TakenInbox renders the task's process-instance id as a data attribute and the helper resolves the run's own instances from its businessKey. Alongside it: a precondition gate that asks the engine for the seven decision keys the suite's processes call and asserts an untenanted version of each exists, because the confusing failure is not the absent DMN but the present one, deployed under an Organization and invisible to a ${null} lookup; a globalSetup that launches Chromium once and, if it cannot, stops with one message naming the missing executable instead of 28 identical failures; pending-cleanup entries that record their engine, so a local run no longer looks an acceptance run's keys up on localhost and treats the file as handled; and the R2.1 journey resolved to the target's engine rather than a hard-coded localhost:8081, which had let the skip guard count instances on the wrong engine and let a run proceed where it should have refused.
R2.1 starts with a project identity, and a tier that signs for real is a skip. Projectnummer and Projectnaam became required when R2.1 is started from its own detail page in v2026.09.8, and the button is gated on both; the spec still clicked it with no input and waited ninety seconds for a control that can never become enabled. The behaviour was correct and the test was lagging the feature. Both fields are now filled from fixtures, toBeEnabled() before the click makes the gate an assertion rather than an implicit wait, and a new assertion checks the instance carries the number and name it was started with. Separately, the R2.1 journey refused to sign against acceptance and reported that refusal as a failure β acceptance runs ValidSign live on purpose, with a real sender address, so the refusal is correct and calling it red was the error. It now skips with a reason naming VALIDSIGN_STUB_MODE and the target, with the guard before the package call so nothing is created and afterEach still cleaning up.
The smoke script has a prod target, behind CONFIRM_PROD. TARGET accepted local and acc only, so verifying anything on production meant overriding two URLs by hand β which is how a client-secret rotation was verified on production. A production run now refuses to start without CONFIRM_PROD=1, and the guard is checked against the resolved URLs rather than against TARGET, so it holds however production was reached. The guard is not about damage, since the run never mutates anything; it is about the tier being real β the run authenticates as a confidential client, so a mistyped target leaves a token and a trail of requests in production's audit log for no reason.
packages/pa-cockpit carries a release version rather than a pin. v2026.09.9 gave /bump-release a rule to version the package when a release includes a change to it, but that changed the command file and nothing else, so the package's own scaffold test kept asserting the opposite β pinned at 1.0.0. This release is the first to include a pa-cockpit change, and the test stopped it. The bump wins: the package is private and consumed as a wildcard, so its version constrains nothing at install time; what it does is record which pa-cockpit code a frontend or pa-demo release contains, for the lockfile and for the SBOM, audit and provenance tooling that reads it. Pinned at 1.0.0 it sat through 49 commits saying nothing, while packages/shared moved for a single devDependency range. The assertion now pins the rule rather than a literal.
tk.client's unreachable multi-term filter arm is gone. buildFilter carried a branch joining clauses with or that had no caller. Removing it is not tidying: the arm built precisely the request the fan-out exists to avoid β measured against the live API, a five-term OR query took 23β48 seconds and blew the 15-second abort every time, so every multi-term criterion silently retrieved nothing and looked like a source with no matches. A working implementation of that, one call site away from being live again and with nothing in the code saying so, is worse than no implementation.
Lock-file maintenance, and the first measured confirmation of the cooldown. Renovate's weekly refresh moved 62 packages, among them @playwright/test 1.63.0, the typescript-eslint 8.70.0 family, jose 6.2.12, undici 7.29.1 and altcha-lib 2.4.0. Every version it introduced was at least fourteen days old when the branch was written, measured against the npm registry's own publish dates. Worth knowing: the renovate/stability-days status read "Updates have not met minimum release age requirement" on the branch anyway, because lockFileMaintenance is flagged rather than evaluated. The measurement is the thing to read, not the status. @testing-library/user-event moved to ^14.6.7 in the same release, and the understand-anything plugin's leftovers were removed now that nothing generates or reads them.
v2026.09.9 β Every Deployed Process Shows, and the Build Checks Gate acc (September 2026)¶
The public process library required a status the source database cannot hold. A bundle had to carry status === 'active', but the Linked Data Explorer's own constraint permits example, wip and e2e only β so nothing had ever passed that check. Production showed no processes at all, and acceptance showed only what the PUBLIC_SHOW_WIP_PROCESSES escape hatch let through. The filter now decides on board ownership alone, so both tiers behave the same way and the escape hatch is gone, including its rows in the promotion and go-live runbooks. PUBLIC_PROCESS_BOARDS replaces the hardcoded allowlist, defaulting to caseworker, so the public site's scope is configuration rather than code. A process also shows its status label on the listing and in search results now, not only on its detail page. The tests that let this through asserted against a status the source database cannot produce; the fixtures are rebuilt from the real vocabulary, and one case pins visibility as independent of the label.
The acc ruleset gained five required checks, which first required moving each filter off its trigger. A required check must report on every pull request, and a workflow whose pull_request trigger filters it out never starts and reports nothing β so a required build check would have left every unrelated pull request waiting forever. A job skipped by its own if: reports success instead. Each of the four ACC deploy workflows therefore drops the path filter from its trigger and gains a changes job, which asks the GitHub API for the pull request's files and matches them against one pattern mirroring the push filter; the build and close jobs run when that job says relevant and when it did not succeed at all, so a failed lookup means a full build rather than a free pass. Push triggers keep their filters. acc now requires scan, build, Build and Deploy ACC Frontend, Build and Deploy ACC PA Demo and Build and Deploy ACC Public Site alongside audit; main stays on audit alone, deliberately, because no production workflow carries a pull_request trigger at all. Being documentation-only, the pull request that recorded this was also its own test: every changes job reported relevant false, every build check was skipped, and it was mergeable.
A change to .nvmrc now builds and deploys. .nvmrc sets the Node version every deploy workflow builds, tests and ships on, and no path filter included it β so a Node bump built nothing, tested nothing and deployed nothing, and the new version reached the next unrelated deploy untested. Once the build checks became required, such a pull request also showed every required build check skipped and was mergeable. .nvmrc is now in the push filter of all eight app deploy workflows and in the changes pattern of the four ACC ones, each checked to match the repository root only and to leave its other matches as they were.
A 14-day package-manager cooldown, in .npmrc rather than only in Renovate. Renovate's minimumReleaseAge covers only the updates Renovate proposes; lock-file maintenance hands the refresh to npm, where the transitive tree actually moves, and Renovate documents that its own cooldown cannot apply there. A root .npmrc now sets min-release-age=14, so npm itself will not resolve a version younger than fourteen days. Measured before it was written: npm 11.10 or newer honours it on install and update, npm ci ignores it on purpose so CI cannot fail on it, and npm 10.9.8 β the version Node 22.23.2 bundles β ignores it without a warning, which is why scripts/check-deps.sh now warns when npm is older than 11.10. It does not reach the backend deploy, which installs in its own deploy/ folder without a lockfile.
Every job names ubuntu-24.04. ubuntu-latest is a label GitHub moves to a new Ubuntu release on its own schedule, so an OS change used to arrive silently; it now arrives as a diff here. The label pins the release, not the image, which GitHub rebuilds about weekly and a hosted runner cannot pin by digest.
Three statements about the pipeline stopped being true, and one never was. The supply-chain register and two workflow comments said zizmor's version: input is bumped by hand, because Renovate's github-actions manager does not parse action inputs; for this action it does β Renovate maps zizmor-action to the image ghcr.io/zizmorcore/zizmor and maintains it behind the fourteen-day cooldown. renovate.json and a workflow comment also called the acceptance frontend Free: read from Azure on 15 September, all three acceptance apps are Standard and it is the production frontend that is Free. And a dependency pull request editing packages/pa-cockpit does hold previews, on both frontend-acc and pa-demo-acc β only lockfile-only and root-only ones do not.
/bump-release versions packages/pa-cockpit when a release includes it. Step 2 counted the package toward a release's scope, but step 4 had no rule to bump it, so it sat at 1.0.0 across 49 commits. It is now bumped, lockfile entry included, only when a pa-cockpit change is part of the release, and the report always names both shared and pa-cockpit.
v2026.09.8 β The Callback Speaks Basic, and a New Project Is Named at Start (September 2026)¶
ValidSign's callback would have 401'd every time, invisibly. The handler is registered with security type Bearer token, but POST /v1/validsign/callback read only an x-validsign-secret header β so every real callback was rejected, and nothing showed it, because the poller completes the signature anyway and the process looks healthy. The route now accepts Authorization: Bearer <key> as well, scheme matched case-insensitively, both through the same constant-time comparison. A rejection logs which credential forms arrived, never their values β header names and the Authorization scheme, with a scheme-less value logged as (no scheme) because it could be the key.
That log line is what showed ValidSign sends Basic. The first live signing on acceptance rejected all six callbacks for package a2beacfa with authorization:Basic, and the poller completed the signature seven seconds later. The value after Basic is never logged, by design, so the route now accepts every Basic form that still requires the key β the raw key, the base64-encoded key, and a base64 name:key pair with the key on either side. A value that merely contains the key, a wrong key in any form, or another scheme such as Digest is still a 401, and Bearer and the header keep working. "ValidSign callback received" now logs which credential form matched, so the check can be narrowed to the one form ValidSign actually uses. Tests cover the four accepted Basic forms without the key appearing in the log, and four rejected variants.
A new R2.1 project is named at start, and an unnamed one is shown by its state. Projects started with R2.1 starten appeared in the portfolio as β for both number and name: the start sent no variables at all, and projectNumber and projectName are only set when the intake form is submitted. The start now asks for Projectnummer and Projectnaam, both required and trimmed, and passes them as start variables; the intake form asks for the same two fields and opens pre-filled with them. Instances started any other way still wait for the intake, so the backend's R2.1 phase lists return empty strings rather than an em dash, and liveProjectName/liveProjectNumber name a live instance in one place β "Nieuw R2.1-project Β· intake open" for a nameless R2.1 instance, "RIP R2.x project" for a later phase β reading an em dash as missing too, because frontend and backend deploy separately. The portfolio, the command palette and PhaseDetail's WIP and Gereed tables all use them.
The board's live instances refresh without a page reload. A project started outside the board did not appear until a hard refresh: v2026.09.5 moved the live instances into one aggregate request for the whole board, and that request ran once on mount with nothing calling its reload. The single aggregate request stays and is now repeated when the tab becomes visible again and when switching between Mijn dag, Portfolio and Beheer, skipped on first render β exactly one GET /v1/rip/phases/active per refresh, never one per consumer or per phase, and the tests pin that.
deps:check stopped firing on release bumps, and now names npm ci. npm run dev refused to start after every release even when no dependency had changed, because the check compared package-lock.json byte for byte with a post-install snapshot and every release bump rewrites our own package versions in that file. On a workstation last installed at v2026.09.5 and now on v2026.09.7, six entries differed β all six our own packages. The check now parses both files and ignores only this repository's own version numbers, so upgrading, removing or adding a third-party package is still reported and line endings no longer matter. The remedy it prints is npm ci, not npm install: the committed lockfile is the source of truth, while npm install re-resolves caret ranges with no package-manager cooldown.
The install is checked against the lockfile before every push, not only when a dev server starts. A push does not start a server, so a clone not reinstalled since the lockfile moved went straight into lint and check-format on the wrong tool versions. Not hypothetical: on 14 September a clone still had Prettier 3.8.1 installed after the lockfile moved to 3.9.6, and its push failed check-format on seven correctly formatted files with nothing saying the install was the problem. deps:check now runs first in the pre-push hook, and husky runs hooks with sh -e, so a stale install stops the push there rather than in a confusing place further along.
The first lock-file maintenance, and the Prettier pin it needed first. The refresh is the first this repository has had β +5277 β5362 lines of package-lock.json and no manifest change β and it moved most of the exposure the first authenticated Semgrep scan had found on 12 September: Semgrep findings 435 β 25, reachable Supply Chain findings 249 β 0, open Dependabot alerts 154 β 7, and the seven open security pull requests closed by Renovate as superseded. None of the seven remaining alerts is reachable by a routine update. The refresh also moved Prettier from 3.8.1 to 3.9.6, which both ^3.1.1 declarations admit, and its pull request failed the required audit check at Check formatting on eight files it had not touched β the check working exactly as intended, since without it the refresh merges green and the next person's push fails on files they never opened. Prettier is now pinned exactly in both declarations, so a formatter change arrives as its own pull request.
Renovate stops raising engines floors, and holds pre-1.0 minors. rangeStrategy bump applies to engines too, and had raised engines.node to >=22.23.2 and engines.npm to >=10.9.9 β a floor no Node 22 release satisfies, since 22.23.2 bundles npm 10.9.8. Nothing enforces engines here, so a raised floor only produces EBADENGINE warnings on a slightly older toolchain and drifts the root away from packages/backend and App Service's NODE|22-lts. engines now uses rangeStrategy widen, which leaves a range untouched when the new version already satisfies it, so >=22 stays >=22 and the exact runtime stays in .nvmrc. Separately, Renovate classifies 0.4 β 0.5 as a minor, which let eslint-plugin-react-refresh ^0.5.5 through β its 0.5.0 requires ESLint 9 and flat config, and every workspace is on ESLint 8, so audit, both ACC previews and the lockfile update all failed on ERESOLVE. Minor updates whose current version is below 1.0.0 now wait on the Dependency Dashboard like majors; patch updates stay routine and security fixes still bypass approval. Lock-file maintenance is exempt from the repository-wide pull-request limits, which had rate-limited its first scheduled run to nothing at all.
The Node runtime moves to 22.23.2, and the config validator to an exact 24.20.0. .nvmrc β the exact runtime every deploy workflow reads β moves past three Node security releases, and Renovate's own lockfile runs had been warning that npm 12 does not support 22.22.0. zizmor.yml's setup-node for renovate-config-validator moves from a floating '24' to 24.20.0, the last floating Node version in CI; it stays on Node 24 deliberately, because renovate declares engines.node ^24.11.0. Forty-one declared ranges in packages/backend also move up to versions already resolved, of which only pg 8.16.3 β 8.23.0 changes the installed runtime tree; the React, TypeScript, react-router, Vitest, Testing Library, ESLint, husky and lint-staged updates in this release are declared ranges the lockfile already held.
v2026.09.7 β The CI Alignment Closes, and One Node Runtime (September 2026)¶
acc and main now carry the same rules, which closes the CI alignment. acc gained the deletion and non_fast_forward rules main had held since the week it was created β for a month acc could be force-pushed by anyone who could push to it, two rulesets in one repository differing in a way nobody had decided. The rules array was replaced whole with a PUT and then read back, because the response to a write is not evidence: omitting require_extra_approval_for_unattributed_changes has GitHub store it as true invisibly, which had nearly deadlocked the promotion pull request the main ruleset exists to protect. The two rulesets still differ in exactly one parameter, deliberately β acc keeps that flag true, main has it false, because its promotion carried commits under three author identities against a ruleset requiring zero approvals, so the flag would have demanded an approval nobody could give. Classic branch protection still reports allow_force_pushes: true on both branches; that is a vestigial second layer, and the ruleset's non_fast_forward is what refuses the push.
An unmodelled RIP phase is now unrepresentable. processDefinitionKey becomes required on RipPhaseKey in @ronl/shared, and the backend's 409 PHASE_NOT_MODELLED branch goes with it: R5.3 completed the ladder, all twelve entries carry a key, and no input could reach that branch any more β its three tests had been reporting as skipped on every run since. Two frontend readers had been compensating for the optionality in silence. PhaseDetail.tsx used a double non-null assertion twice, and rip-phases.catalog.ts re-declared the field in a frontend-local interface populated by an optional-chained lookup, which yielded undefined on a miss and rendered ontwerp forever; it now throws, naming the phase and both lists. Verified in the running app against a local Operaton with all twelve processes deployed: the Faseladder reads 12 of 12 deelprocessen inzetbaar, with none waiting on deployment.
@ronl/shared is kept free of logic, and now checked. The package has no test runner, so a function placed there is not under-tested β it is outside the measurement entirely, and nothing signals that: no run fails and no number moves. v2026.09.4 had moved a branching label helper back out of it for exactly this reason, found by hand rather than by any check. The check uses the TypeScript compiler API rather than a pattern over text, because a regex cannot tell (x: string) => void in an interface β a FunctionType, which is what this package is for β from the same syntax assigned to a const, which is an ArrowFunction and is not. It runs in the audit job, which has no paths filter and is the required check, so every pull request reaches it β including the one that would add the first function to a package a filter does not yet watch.
The GitLab mirror is checked at every release. scripts/check-mirror.sh runs from step 8 of /bump-release. It never pushes: it prints the exact command and stops. It prints the remote-tracking form of the ref rather than the short one, and separates behind from diverged using merge-base, because those two states need different answers.
The Node runtime has one source of truth. Both App Service plans run NODE|22-lts, so eight workflows were building the deployed artifact on Node 20 and shipping it to a Node 22 host. The eight deploy workflows now read node-version-file: .nvmrc, .nvmrc carries an exact 22.22.0, and engines.node moves to >=22. zizmor.yml keeps its literal 24 deliberately β its renovate-config-validator step needs Node 24, because renovate declares engines.node ^24.11.0 and npm accepts the mismatch with a warning rather than refusing.
Axios response headers are coerced to string at both form call sites. getDeployedTaskForm and getDeployedStartForm each read a response header whose value axios 1.18 widens to a union. Both now wrap the read in String() rather than asserting the type away with a cast.
Renovate is aligned with the cross-repository posture. lockFileMaintenance was absent entirely, so nothing had ever refreshed the transitive tree. The global dependencyDashboardApproval is replaced by one scoped to majors, prConcurrentLimit is 5, four workspace groups cover the six packages and four deployables, and baseBranchPatterns is added so main never receives a dependency pull request directly.
Semgrep scans for code and supply-chain findings, in one job covering the whole monorepo β all six workspaces resolve through the single root package-lock.json, so there is one lockfile to read and no per-workspace fan-out to keep in step. It covers what check-supply-chain structurally cannot: that check verifies GitHub Actions digest pins resolve to the versions their comments claim, and says nothing at all about the packages in the lockfile. scan is deliberately not a required check while the baseline is triaged β requiring a check before knowing what it reports is how a gate ends up bypassed in its first week. Promotion is a ruleset change, so nothing in the workflow moves when it happens.
The supply-chain check blocks a merge. It had run with continue-on-error: true since its adoption. That does not merely keep the job green: it rewrites the step's reported conclusion too, and the honest outcome is not exposed by the REST API at all β observed on the zizmor-action bump to v0.6.4, where the step, the job and the pull request's checks list all read success while the step's own log carried a register mismatch. What the promotion had been waiting on is that Renovate rewrites workflow pins and never touches the register, so every action bump would fail a required check; resolved by updating the register on the bump's own branch, before merging.
Formatting is checked in the audit job, not only at pre-push. A Prettier 3.7 β 3.9 upgrade changed how short union types are formatted, and five files nobody had touched began failing prettier --check the moment the upgrade merged β with every CI check green. The symptom would have been the next person's git push failing on files they had never opened. It runs the root script, matching what the pre-push hook runs, and it belongs in audit because that job has no paths filter and is the required check, so a documentation-only pull request reaches it too.
The backend suite runs on pull requests, not only after the merge. The backend's 2008 tests used to run on push alone, so a backend pull request reached acc with audit as its only check β and audit validates workflows, renovate.json and the supply-chain register, saying nothing about whether the code works. No event guards were needed, unlike the Linked Data Explorer's equivalent change: that workflow deploys to Azure and had to gate six deploy-side steps on the event, while this one ends at an uploaded artifact and contains no deploy step at all. The path filters on both backend workflows also gain package-lock.json and package.json, safe to widen here and nowhere else because this job claims no Static Web Apps staging environment and so cannot exhaust the three-environment ceiling. A concurrency group keyed on the pull-request number stops a twice-pushed branch running the suite twice. The production workflow deliberately gains no pull_request trigger: every commit reaching main is promoted from acc and has already run this suite on its own pull request.
v2026.09.6 β The Card Reads Production, and the Footer Names the Build (September 2026)¶
The social card reads production. The Open Graph card had acceptance baked into its pixels β an ACCEPTATIEOMGEVING badge, and acc.plato.open-regels.nl in its footer β so deployed to production unchanged it would have read ACC while og:url read PROD. social-card-origin.ts rewrites text, never pixels, which is why the image itself had to be re-captured: from the handoff's reference HTML at exactly 1Γ (1200Γ630), badge dropped, footer set to plato.open-regels.nl. The capture refuses to run unless Fira Sans and JetBrains Mono actually loaded, a silently substituted font being the failure mode that produces a card looking nearly right. One asset serves every tier, so the remaining trade is recorded rather than left to be discovered: an ACC link preview now shows the production hostname inside the image while og:url still points at ACC. It is written down in index.html, in social-card-origin.ts and in the go-live checklist.
The public site's footer identifies the build, not just the release. The version beside it comes from package.json and is bumped by hand at release time, so it names a release rather than a build of it β and ACC and PROD can serve different builds of the same version string. The footer's mono line now ends publiek.open-regels.nl Β· v2026.09.2 Β· build 570fd98 Β· #412, with the full 40-character SHA on the title attribute so it can be copied for a lookup without cluttering the line. Half-configured counts as untracked: a run number with no SHA behind it implies a provenance the bundle does not have, so an uninjected bundle reads local build rather than resembling a deployed one. buildInfo.ts and its eight tests are ported from packages/frontend. The env block goes on the build step in both workflows, because this package builds on the runner and the Static Web Apps action only uploads dist/.
The prerendered seed is revalidated instead of trusted. The prerender step embeds each route's data into the HTML as __PUB_DATA__ so the first client render already has content; both consuming pages then returned early whenever the blob was present, so the public site rendered a snapshot frozen at the last build. That is why /regels showed two services SZW had already retired and a Diensten count of 14 while the caseworker dashboard, reading the identical API, showed one and 13 β and why the Begrippen counter had drifted 196 against 204. Both pages now paint the seed and revalidate underneath it, with the swap gated on isSamePayload so a matching response causes no state update and no re-render, preserving the absence of layout shift the seed exists for. A failed revalidation keeps what is on screen. The change covers every prerendered content route, not only the one where the drift was noticed.
The per-file 80% branch floor is enforced in all five runner configs. perFile is the mechanism and the whole point: measured against a package average the threshold is inert, because one file dropping to 40% barely moves an average near 90 and the regression the floor exists to catch passes. Branches only, deliberately β a functions floor at 80 would fail 31 files across the repository today. In the same pass, @ronl/pa-cockpit's 476 tests are run by both frontend workflows: it is a library with no deploy workflow of its own, so until now its tests ran nowhere in CI, even though a change to it already triggered the frontend build through that workflow's path filter.
check-supply-chain joins the audit job, non-blocking at first. zizmor validates pin format β that a uses: names a 40-character SHA β and cannot say the SHA is the right one, so a wrong digest carrying a plausible # v7.0.1 comment passes zizmor, Prettier and review alike. This step resolves every digest against the GitHub API and compares the register in SECURITY-PIPELINE.md with the workflows: digests, versions, the (ΓN) multiplicities and the totals headline. The multiplicities are not decoration β setup-node went from Γ8 to Γ9 when a step was added, and the register still said Γ8 with every gate green.
The changelog panel identifies the build. One small monospace line under the changelog heading reads build 570fd98 Β· #412, with the full SHA on its title; a run number with no SHA renders local build. The values are injected by the deploy workflows and never derived from git at build time, because a build id that silently fails to resolve is worse than none. It lands in the lazily loaded ChangelogPanelContent chunk rather than in index.js.
v2026.09.5 β The Board Asks Once (September 2026)¶
Rendering one Infra-board screen fired up to 48 HTTP requests. The active-across-phases hook issued one request per modelled phase β twelve β and four components called it independently, because useAsync has no cache and no dedup: the page itself, Portfolio, the command palette and ProjectDetail. Browsers allow roughly six connections per host, so those queued about eight deep and the swimlane model request waited behind them. That is the likely explanation for a diagram occasionally needing a page refresh before it appeared: the request was never lost, it was starved.
One aggregate endpoint, and one provider sharing the fetch. A new route returns every modelled phase's active instances in a single response, each row tagged with the phase it belongs to so the caller reshapes nothing. A provider at the board root shares that one fetch across all four consumers, following the PaDataProvider precedent. useRipActiveAcrossPhases keeps its name and signature and reads context instead, so three of the four call sites did not change at all. Calling the hook outside its provider now throws rather than quietly issuing a duplicate fetch β a deliberate trade toward loud failure, and one nothing at compile time enforces.
One phase failing must not blank the rest. That property used to come from the frontend's per-request catch; the backend now owns it. Promise.allSettled runs over every modelled phase, a rejected phase is logged and omitted, the response stays 200, and only a total failure answers 500.
The parsed diagram is cached, not just its XML. The swimlane model was rebuilt on every request: the BPMN was cached but the parse was not, so each diagram view re-read up to 74 shapes and 68 edges, re-ran the depth-first back-edge walk and re-layered the graph before throwing the result away. It is cached now, beside the XML cache and keyed the same tenant-inclusive way β a key without the tenant would reintroduce exactly the cross-tenant leak that convention exists to prevent. A definition's BPMN is immutable, so it never needs invalidating.
v2026.09.4 β Swimlanes Derived From Deployed BPMN (September 2026)¶
The phase diagram is now parsed from the BPMN Operaton actually has deployed, for all twelve phases. FASE1_LANES, FASE1_NODES and FASE1_EDGES are deleted: they were a hand-kept copy of something the engine already knew, and the bug that started this work was exactly that copy going stale. A new backend endpoint returns lanes, nodes and edges parsed from the deployed definition, fetched by process-definition key rather than definition id β deliberately, so a phase with no running instance still resolves and mock portfolio rows get a diagram too.
The parser is a pure function, so it is tested against the twelve real phase files rather than a mock of them. BPMN XML in, swimlane model out: no I/O, no Operaton, no config. Lanes come from the drawn vertical position of their diagram shape; node-to-lane membership comes from the explicit lane references present in all twelve files, so no geometric inference is needed and a shape straddling a boundary cannot be misassigned. The BPMN's own coordinates are read for lane ordering and otherwise discarded β node positions are recomputed so all twelve share one visual language.
Layering and rework detection, in an order that is not the obvious one. Back edges are found structurally first, by a depth-first walk where an edge into a node already on the stack closes a cycle, and only then are columns computed over the forward edges alone. Deriving them from the columns cannot work: a cyclic relaxation pushes both endpoints of a loop rightwards until the pass cap, so nothing is left pointing backwards and columns inflate to roughly the node count. Layering is longest-path rather than shortest, so a node never sits left of its own predecessor.
Four defects the R2.1-only renderer had never met. Thirty-eight nodes were invisible across the twelve phases β nodes were positioned from their column and row alone, so two occupying the same cell drew at identical coordinates and all but the topmost vanished; R5.2 alone lost twelve. R2.1 is the only phase with no collisions, which is why it survived every review. Rework edges all routed through one fixed band, so loops with overlapping column ranges drew perfectly coincident lines; they now get their own reserve below the lane rows, and a phase with no rework loops renders at exactly the previous height. A finished rung drew every node white, because the activity history came from the current phase's instance while the diagram came from the selected one. And back-edge classification followed flat document order rather than each node's own outgoing list, so one R2.1 loop closed a cycle an edge downstream.
Staleness cannot be detected using the variable whose change caused it. Two successive attempts inferred whether data was current from the phase code alone β the very value that has already changed on the render where stale data leaks. The model now carries the phase it describes, so the question is answerable directly, and an empty model's blank code can never equal a real one. That makes the leak structurally impossible rather than guarded against.
One renderer draws every phase, from a model prop β a generalisation, not a redesign: same CSS classes, same SVG structure, same visual language. Four things R2.1 never exercised had to be handled: parallel gateways (R2.1 has none; the others hold 32, and drawing them as exclusive diamonds asserts the wrong semantics), more than one end event (twenty across the twelve, four in R5.3 alone), an empty model whose maximum over no nodes yields negative infinity and poisons every derived dimension, and wide models, which now scroll rather than clip. Tests assert that no SVG attribute contains NaN β a NaN in a width silently produces a broken diagram that every existence check would pass.
Several tests turned out to be asserting nothing. One claimed to pin accepted-versus-fabricated behaviour while asserting only that a swimlane existed and a label rendered β it would have passed whether the node read todo, active or done. Another was titled "spanning done/active/todo" over a scenario that can only produce done or active. The per-fixture back-edge count table could not verify the classification fix at all, because eleven of its twelve counts were recorded from the implementation's own output; an order-independent invariant replaced it β removing the back edges must leave an acyclic graph, checked by an independent topological sort.
A coupling guard that was a copy checked against a copy. FASE1_DOCS and FASE1_NODE_ROLE existed only as hand-transcribed duplicates in the backend suite, so editing a table left them green. A frontend test now imports the real tables, and the two sides triangulate from opposite ends of a boundary neither may cross.
The element map is an allowlist, and now says so. A comment claimed anything unlisted was treated as a task; it was not β the loop iterates the map's own keys, so an unlisted element type was omitted from the model entirely, leaving edges pointing at ids that are not there. Deliberately not fixed by iterating every child and defaulting, which would manufacture nodes out of sequence flows and lane sets. The protection is an invariant test: every id a lane declares must appear as a node, across all twelve fixtures, with those ids re-derived from the raw XML by regex so the test cannot share the parser's blind spot.
The parsed tree is typed rather than cast to any. Seven any annotations had put seven ESLint warnings into a repository that had none, and warnings do not fail the build, so the warning-clean state would have ended silently. unknown is the point: this file believes a document it did not write, and any would let a malformed one produce a confidently wrong model where unknown produces a compile error.
v2026.09.3 β Twelve of Twelve Deelprocessen Inzetbaar (September 2026)¶
R5.3 was the last rung with no process model, and it is deployed. Its design sheet arrived on 3 September and it runs as RipR53Process on both the local engine and ACC β verified against the engine's REST API rather than taken from the deploy pull request: deployment form bindings throughout, boardOwner and organization set, tenant flevoland, and no form on the start event. The catalogue entry had been a placeholder with a source line reading PLACEHOLDER; all of it now derives from the deployed BPMN, and the six roles match the six candidate groups on the engine exactly β an independent check that the entry and the engine agree.
The beyond phase concept is gone, and so is geparkeerd. R5.3 was the catalogue's only beyond phase, so adopting it left the flag with no users. Removed with it: the onbekend deploy status, skippedPhasesBefore (which returned an empty list for every phase), parkedCount, the "Niet gemodelleerd" placeholder view and the parked badge on the Beheer rail. geparkeerd turned out to be entirely a frontend fiction β the live counts endpoint returns wip and gereed only, no backend code mentions the concept, and the mock counter credited it exclusively inside the beyond branch. It existed to give the one unmodelled phase something to show instead of WIP. Net 197 lines removed.
R5.4 reports no Klaar figure, because it cannot be derived. Klaar is computed as gereed[predecessor] β wip β gereed, which assumes finishing a phase means advancing to the next one. That holds everywhere except the transition out of R5.3, which has four end events and only one leading to R5.4; the other three return to R5.2, and one of them β a vervroegde ingebruikname, where part of the areaal goes into use while work carries on β means a project can legitimately complete the phase more than once. A number there would overstate R5.4's candidates in a way nothing on the screen could reveal, so R5.4 shows β, the same treatment R2.1 gets for having no predecessor. The behaviour is driven by a multipleExits flag on the phase rather than a literal phase code in the arithmetic.
A live project shows its own RIP phase, not always R2.1. ProjectDetail hard-coded the current phase for every live instance. That was true when it was written β R2.1 was the only process modelled β and deploying R2.2 through R6.1 falsified the premise while the constant stayed, producing three wrong things at once for a project sitting in R2.2.
PA caching had never worked in either environment. The misconfiguration was fixed operationally on ACC; three things in code let a total cache outage run for at least nine days unnoticed. The root cause of the silence is the instructive one: connect() was unbounded. The timeout wrapper guarded get() and set() but not the connect itself, and node-redis retries a failing socket internally rather than rejecting β so the await never settled. /v1/health now reports cache state, with a down cache visible without failing the health check.
Three guards keep the changelog drawer a shim. The drawer's lazy split is undone by three small edits that leave every behavioural test green, because none of them change what a user sees. The guard originally inspected only import declarations, so a bare dynamic import at module scope passed all three assertions while firing the fetch at module evaluation β and evaded noUnusedLocals by having no binding. A re-export with a module specifier was an equally static edge: adding one plus a consumer collapsed the output to a single 709 KB gzipped chunk, confirmed with a real build, where the split produces a 581 KB entry and a separate 129 KB changelog.
v2026.09.2 β An 80% Branch Floor in Every Workspace (September 2026)¶
Measured figures: Coverage.
The backend-only coverage campaign extends to all five workspaces that have a test runner. Fifty-three files were below 80% branch coverage; none are now. @ronl/shared has no test script and needs none β it is types plus two seed modules, with no functions and no branches.
The new tests are behavioural rather than coverage-shaped, and three themes recur. Per-type render arms: DecisionViewer, CapacityClaimDocumentsViewer and RipFase1WipViewer each carry their own copy of the same TipTap renderer, and each now gets a document exercising every node type the composer can emit. Upstream absence: a source answering with no value array, or a signal from a source this build has no label for, each has a fallback that now has a test saying what it is for. And the difference between "we looked and found nothing" and "we could not look" β Results, GereedschapSection and Home all render those distinctly, because a zero in place of a failed fetch is a wrong statement of fact rather than a neutral default.
The EU signaalbron moves to the EP Open Data API. The source returned nothing on ACC for a reason outside this repository: www.europarl.europa.eu is CDN-fronted with undocumented bot mitigation that answered ACC's outbound range with an empty 202 text/html. That passes res.ok, so the empty body parsed to zero items and the source reported success while contributing nothing β starving both the Ongefilterd browse and the six-hourly curation cron. Measured from inside the App Service in one run: both RSS URLs returned 202 with zero bytes, while data.europarl.europa.eu returned 200 with 211 KB of Atom.
Three things the live feed decided that the specification would not have. The parliamentary term is not always 10, because the window covers documents updated rather than only published β so the RSS-era pattern hardcoding -10- had silently dropped every older-term document. Amendment refs carry extra segments and are excluded deliberately, an amendment being a fragment of a document with no doceo page of its own. And the type now comes from the work-type vocabulary rather than the ref prefix, which had never learned QOB. Two trade-offs are recorded rather than discovered later: entries are English only, since they arrive xml:lang="en" and Accept-Language is ignored, while the doceo link stays Dutch because the document itself is translated even when its metadata is not; and press releases are gone, the API having no equivalent endpoint for what is a communications product rather than legislative data.
The release runbook stopped contradicting itself about which sites redeploy. It stated the same fact twice and disagreed: a callout listed all four ACC workflows as path-filtered β the stated justification for the entire per-scope versioning scheme β while a step fifty-five lines below said frontend, pa-demo and public-site trigger on push to acc with no paths: filter. The callout was right, and a third of that sentence had been wrong on every backend release since the filters went in. The duplicate was deleted rather than corrected, because fixing the wrong copy in place would have left the duplication that produced it.
The supply-chain audit runs on every pull request β see Supply-Chain Pinning. The pull_request trigger was filtered to acc and main, so a stacked pull request matched no trigger, accumulated no audit, and reported CLEAN with zero checks: ready-looking and not ready. When the parent merged and GitHub retargeted it, the required audit was missing and the pull request blocked permanently, because retargeting emits no pull_request event. push keeps its filter β there is no reason to audit a push to a feature branch.
v2026.09.1 β The Ladder Reaches R6.1, and the 28 Roles It Needed (September 2026)¶
Five more phases adopted, then four more: R2.3 through R4.1, and R5.1, R5.2, R5.4 and R6.1. Eleven phases modelled at the close of this release, with the Faseladder reading 7 / 12 deelprocessen inzetbaar after the first batch. No source change was needed beyond the catalogue itself β the endpoints, readiness rule and progression generalised in v2026.09.0 already cover any modelled phase. Each was verified off the engine before adoption rather than taken from the deploy pull requests.
113 of the ladder's 201 user tasks were unreachable. The process models address tasks to 34 candidate groups; the Keycloak realm defined six. GET /v1/task passes the caller's realm roles to Operaton as candidateGroups, so a task whose groups all fall outside them is filtered out before it reaches the client β not "cannot claim", not listed at all. The gap grew down the ladder: R4.1 onward was majority-invisible and R5.2 showed 9 of its 36 tasks. Nothing caught it because the surfaces used most do not take that path β the Faseladder and its WIP/Gereed lists filter on the municipality process variable and never look at candidate groups. Only the local seed realm is changed here; ACC and production run their own realms, so the roles have to be created there too.
Rollen & rechten describes roles that exist. Of seven rip-* entries in the description map, exactly one described something real, while five of the six realm rip roles had no description at all. The map is maintained additively: an entry for something that appears in no list simply never renders, whereas a missing one degrades the page to a bare identifier.
Browse each signaalbron's raw feed, unfiltered. An Ongefilterd segment sits beside Gecureerd and Inbox, showing the tab's own sources with no query applied. The data path already existed end to end β GET /pa/feed reads q as string | null, every source client defaults it to null, and that blank path is what the curation cycle itself runs on. What was missing was any way to reach it: runSearch returned early on an empty string, so a blank search showed nothing rather than everything. Where the segment appears is derived rather than listed, so a future source tab gets it for free and agenda β a monitoring tab with no feed β never does.
The Ongefilterd count reads as a cap, not a total. The number on the segment was a page size wearing the clothes of an answer: Politiek showed 60 and the other signaalbronnen 30, not because the feeds hold that many but because the view asks for 30 per source and politiek has two. It now follows the convention the Inbox segment already had β 60+ / 30+ when capped, with a banner carrying the real total. Two judgements are pinned by tests: a full page counts as capped even when the total is unknown, because TK returns total: null for multi-term queries and a null must never be read as "nothing more"; and the total is shown only when every source reported one, since a partial sum would look authoritative without being so.
Vite 5 β 6 across frontend, pa-cockpit, pa-demo and public-site, closing a security advisory open since 29 August. Build-time only β nothing Vite-related ships in the deployed bundle.
v2026.09.0 β Finishing a Phase Makes the Next One Ready (September 2026)¶
Completing R2.1 did nothing for R2.2. getReadyProjects read only the mock portfolio, so a completed live instance never entered it β and in the mock data a project at ladder position 1 can never be wachtend, which is what R2.2's ready list requires. Both routes to a ready project were closed.
The rule comes from the phase specs: a completed instance of phase N makes its project ready to start the next modelled phase after N. Every phase's entry criterion names the previous phase's exit artefact, several verbatim, so the ladder is strictly linear. R5.3 was the exception and the reason previousModelledPhase exists rather than RIP_PHASES[i-1]: R5.4 enters on "Oplevering areaal na R5.3", so R5.3 happens β but at the time it had no overzichtsplaat, no process model and no observable exit, so skipping it was an assertion the code had to surface rather than assume.
businessKey identifies the project's journey rather than one instance. The originating R2.1 run mints it and every later phase inherits it. It flows through both RIP list builders, and useRipPhaseReadiness excludes candidates whose key already has an active or completed instance of this phase. A candidate whose predecessor carries no key is kept deliberately β offering a possibly-duplicate start is recoverable, silently dropping a project from the board is not.
A caller-supplied business key survives instead of being overwritten. addTenantToProcessVariables minted a fresh businessKey on every process start, unconditionally, discarding whatever the caller sent. It was invisible because the key it produced looked exactly like the one it threw away β and harmless while every instance stood alone. It stops being harmless the moment two instances need to be recognisably related: with the overwrite in place each RIP phase got its own key, nothing was related to anything, and the R2.2 Starten tab kept offering projects that were already running. Honouring a supplied key is safe, because businessKey grants no access β tenant isolation runs on the municipality variable taken from the token, which an added test asserts still holds.
RIP phase endpoints take a phase code instead of assuming R2.1. Deploying RipR22Process exposed how much of the live data path was pinned to R2.1 by a literal: the Faseladder badge and per-phase counts generalised for free from the catalogue, but the WIP and Gereed lists, the portfolio's live rows and the command palette read RipR21Process or R2.1 directly, so R2.2 rendered mock data beside a Gedeployed badge. resolvePhaseKey separates two failure modes an empty list would conflate β 404 UNKNOWN_PHASE for a code the catalogue does not carry, 409 PHASE_NOT_MODELLED for a known phase with no process yet. A caller must be able to tell "no process deployed" from "deployed and idle".
R2.2 maps to RipR22Process in the phase catalogue. The process was deployed on the engine but RIP_PHASE_KEYS carried R2.2 with no processDefinitionKey, and that single omission broke the chain at both ends: the deployment-status endpoint builds its query from this list, so the engine was never asked about RipR22Process, and getPhaseDeployStatus requires the field before it can match a returned key. R2.2 rendered as In ontwerp and the Faseladder read 1 / 12.
The untenanted start-form fallback sees a text-typed error body. getByKeyWithTenantFallback decides whether to retry against the untenanted path by matching Operaton's "No matching process definition with key" wording in error.response.data.message. getDeployedStartForm is the only caller passing responseType: 'text', and axios disables JSON parsing of the error body when responseType is set β so the body arrived as an unparsed string, .message was undefined, the guard rethrew, and the fallback could never fire. Every process deployed without a tenant answered 404 FORM_NOT_FOUND to any caller carrying one: on ACC, 21 of 23 latest-version definitions, including AwbShellProcess behind the citizen Kapvergunning form. The existing fallback test mocked the error body as an already-parsed object, which is why the suite stayed green while the path was dead.
A disabled button on the infra board now looks disabled. .cwd-v2 .v2-btn set cursor: pointer unconditionally and no :disabled rule existed anywhere in the stylesheet, so a disabled button rendered in full accent pink with a hand cursor β indistinguishable from a live one, on every screen using the shared v2 chrome.
v2026.08.36 β ValidSign Phase-Approval Signing (August 2026)¶
Developer detail: ValidSign phase-approval signing.
A project leader signs the phase-exit approval without leaving the Infra-board. The task panel's actions section becomes three-way: an unclaimed task shows the claim button, a claimed signature-bearing task shows the signing panel, and everything else keeps the form it had. The whole feature is opt-in from the process model β it activates on a user task carrying ronl:signatureRef, and returns nothing for a task without it, which is every ordinary task. The Linked Data Explorer sets that one attribute on the task that closes R2.1; everything else is implemented here.
Live signing is behind three locks, because the licence has no sandbox. The ValidSign API key is account-wide and production-only, so a misconfigured environment must not be able to fire a real request. Stub mode must be off, an API key present, and DEPLOYMENT_ENV named in VALIDSIGN_LIVE_TIERS β which is empty by default, so no tier signs for real until one is deliberately named. It is an allowlist rather than a hardcoded exclusion of acceptance: adding acceptance makes ACC sign exactly as production does.
Two of the five routes sit outside JWT, and neither is an oversight. ValidSign's cloud posts the callback with no bearer token, so it is verified by a shared secret; the stub ceremony loads in an iframe, which cannot carry one either. Stub package ids were sequential, which on any internet-reachable deployment would let someone enumerate values and approve a phase-exit another person was mid-way through signing. They are random UUIDs now, making the ceremony URL a capability, and both pre-auth routes share a rate limiter keyed on client IP rather than the attacker-controlled secret header.
Completion is one idempotent path with two racing callers. The webhook and a poller both drive it: archive the signed PDF and the evidence summary into the project's eDOCS workspace, then complete the Operaton task. The poller is not belt-and-braces β ValidSign's cloud cannot reach a developer's localhost, so during local work the callback never arrives at all. Which header carries the callback key is still unconfirmed with ValidSign; the route expects x-validsign-secret, and if the platform sends the OneSpan-conventional one instead, every callback 401s silently because the poller completes the task anyway.
Documents come from one intermediate representation. A deployed template plus the instance's process variables render to an IR, from which Markdown and PDF are emitted separately, so the archived copy and the signed artifact cannot drift apart. rip-pdp moved off a hardcoded switch that restated, as TypeScript string literals, content the templates already define.
Six design claims proved wrong under live testing β against production ValidSign, live eDOCS and a real browser. The ceremony iframe loaded the app's own landing page, because a relative stub path resolved against the board's origin; helmet's global X-Frame-Options then refused to frame it at all; the panel could never observe success, so a completed signature looked stalled; the first real signature archived nothing, from two separate defects; stub documents were 27-byte strings that uploaded perfectly and would not open; and package creation had no guard, so a second call could put a second signature request into a real person's inbox, which cannot be recalled. Each is marked in the design as a correction rather than quietly rewritten.
The signer's identity was not arriving. Package creation takes the name and email from the caller's Keycloak token, and a real token carried no email claim and no name claim at all β so creation would have refused for every user, working exactly as designed and useless. Three protocol mappers are now added by a tracked, idempotent script that touches only the Admin REST protocol-mappers endpoint. A partial realm import cannot do this safely: SKIP skips an existing client entirely and OVERWRITE replaces the whole definition.
The end-to-end guard now refuses before a package is requested, not after. It checked the ceremony URL, which only exists once the package exists β so running the journey against a live backend still created a real package that then sat unsigned against the licence. The guard stopped the signature but not the request that costs something.
v2026.08.35 β Two Advisories Closed (August 2026)¶
uuid and @anthropic-ai/sdk advisories closed through the security fast-lane, which clears the 14-day cooldown for known advisories.
v2026.08.34 β CI Follow-Ups Closed, and OIDC Ruled Out (August 2026)¶
Pipeline detail: CI/CD Β· Supply-chain gate.
The backend deploy scripts run anywhere, and fail fast on a dead session. Both were Ubuntu-specific; the portable path falls back to the bsdtar bundled at System32\tar.exe, because Info-ZIP's zip cannot be installed on a managed Windows laptop.
The SCM basic-auth hypothesis is disproved β OIDC is not needed. The standing theory for why a workflow-based App Service deploy could not be made to work was that azure/webapps-deploy authenticates over SCM basic auth, which Azure now disables by default, and that the route forward was an app registration with a federated credential. Tested against a real failed run, that turned out not to be the cause. The follow-up list records the disproof rather than carrying a plausible diagnosis forward as if it were established.
The supply-chain register was reconciled with the workflows. After the v7 action upgrades landed in v2026.08.33, SECURITY-PIPELINE.md still listed the superseded v4 digests for actions/checkout, actions/setup-node and actions/upload-artifact, and every gate stayed green throughout β which is exactly the drift its own "What the audit cannot see" section predicts. A quieter second drift came with it: setup-node had gone from Γ8 to Γ9 when the config-validator step was added, and the renovate@44.50.3 pin that step introduced was missing from the table entirely. A count is as easy to falsify as a digest, and neither the audit nor review catches it.
Two further register corrections. The deploy scripts were described as deliberately gitignored; .gitignore carries the pattern, but two of the three were committed before it and remain tracked, since an ignore rule does not untrack an existing file. And node-version no longer floats uniformly: eight workflows request 20 while the config validator requests 24, because renovate@44.50.3 declares engines.node ^24.11.0 and npm accepts the mismatch with a warning rather than refusing.
renovate.json is validated by the audit gate, as a second step in the same job so it needs no ruleset change, under if: always() so a zizmor failure cannot hide a broken configuration behind it. Each remaining open follow-up now points at its tracking issue.
v2026.08.33 β The Action Majors, Verified by the Gate They Upgrade (August 2026)¶
Pipeline detail: Supply-chain gate.
Three Renovate pull requests, each held for the full 14-day cooldown, moved the pinned first-party actions to their current majors: actions/checkout to 3d3c42e5 (v7.0.1) across nine workflows, actions/setup-node to 820762786 (v7.0.0) across eight, and actions/upload-artifact to 043fb46d (v7.0.1) in the two backend workflows. Each digest moved together with its # vX.Y.Z comment, which is what makes a pin readable rather than merely immutable.
This closes the Node-runtime deprecation. The pinned v4 actions targeted a Node version the runner had begun force-upgrading; v7 declares node24 natively. Pinning had deliberately frozen those versions in place, and upgrading them was kept as separate work so that a broken deploy would be attributable to one change or the other rather than to both at once.
The checkout upgrade also covers zizmor.yml, which made it the one upgrade whose failure would have been self-obscuring β a broken checkout inside the audit gate breaks the mechanism that would otherwise report it. The gate passed on all three merges, so the upgrade is verified by the thing it upgrades.
v2026.08.32 β CI Work Gets Its Own Changelog Type (August 2026)¶
Pipeline and supply-chain work had no home in the changelog, so it landed as a chore, filed next to housekeeping β when it is the one category whose commits change how everything else is built and shipped. ci is now a changelog type in its own right.
It is also a release scope, and deliberately not a deployable one: a CI-scoped release bumps the root version only. Bumping a package version for pipeline work would trip that package's path-filtered workflow and deploy code that had not changed.
v2026.08.31 β The Gate Gets Teeth: 49 Findings to Zero (August 2026)¶
Pipeline detail: Supply-chain gate Β· CI/CD.
Every action reference is now an immutable commit digest. All 27 uses: references across eight workflows were pinned, each at its then-current major so the change stayed behaviour-preserving. A tag can be moved; a digest cannot β and the Static Web Apps deploy action referenced here publishes one ref as both a 2021 tag and a 2024 branch head, 3.5 years apart, across nine references in four deploy pipelines.
Credentials are no longer left in the workspace. Before this, actions/checkout wrote a live GITHUB_TOKEN into .git/config, which was then mounted into a closed-source third-party container. persist-credentials: false is now set everywhere. Token scope drops to read-only by default, with write granted only to the six jobs that comment on pull requests, and none at all to the three that only tear down a preview environment.
A blocking audit gate enforces it. The audit job runs zizmor on every pull request and on pushes to acc and main, pinned to an exact tool version rather than the action's default of latest β a supply-chain gate that pulled an unpinned tool would defeat itself. Together with an acc ruleset requiring a pull request and a passing check, the workflow became enforcement rather than advice: the check alone would still have let a direct push bypass it.
Renovate keeps the pins alive. Its configuration had to land before the app was installed, because Renovate reads configuration only from the default branch. Updates are held for fourteen days so vendors and researchers have time to find problems, with one deliberate exception β security advisories bypass the wait entirely, which is the clause most cooldown policies omit and the reason such policies get switched off mid-incident.
The register was corrected on one important point. It had implied CI deploys the backend. It does not: the backend workflows build, test and upload an artifact with no deploy step at all.
Squash and rebase merging were disabled repo-wide, leaving merge commits only β changelog entries name commits by SHA, and both alternatives rewrite those hashes, rebase deceptively so since it preserves the commit count while replacing every hash. Releases consequently land through a pull request rather than a local fast-forward. The lockfile's engines were brought into line with package.json, committed as its own change rather than riding along in an unrelated one.
v2026.08.30 β Guards for the Duplication the Extraction Did Not Reach (August 2026)¶
The investigation changed its own framing twice. Three of the four listed duplications turned out to be required copies rather than decay: the demo never imports the caseworker stylesheet, so the package must carry its own rule for every class its components render, or the demo renders unstyled. The answer was therefore to make divergence detectable, not to remove it.
The five brand colours are pinned across all four files β two stylesheet root blocks and two build-config fallbacks. One fact, four hand-maintained copies, previously with nothing keeping them in step. Every rendered class must be styled in both sheets, so a package-owned component that grows a new class fails the guard rather than letting the demo silently render unstyled. Both guards strip CSS comments before matching, because a class named only inside a comment would otherwise count as defined β a hole that a later task in the same plan would have opened by adding prose comments naming real selectors.
The demo's auth adapter is pinned, including where it correctly differs from the frontend's. The two differ in exactly two places and both differences are right, so the test pins the differences too rather than asserting a false equivalence.
Stylesheet citations now cite by anchor rather than line number, after an eleven-line comment inserted near the top of a shared stylesheet shifted every citation below it β one of them landing mid-declaration inside an unrelated rule.
v2026.08.29 β The Session Seam Goes Back to the Host (August 2026)¶
Test detail: PA cockpit β tests.
The package had hardcoded five facts belonging to a host application: two session-storage key names, an identity-provider literal and two router paths. That protocol is a house convention shared by five files in the caseworker frontend β the cockpit was its sixth participant, and extraction had stranded it outside the app that owns the convention. Session controls are now handed back: optional login and logout callbacks are added to the host contract and both hosts wired to supply them, the cockpit's three control sites key off those callbacks, and logout leaves the auth contract once nothing calls it. Each step leaves all five workspaces green.
The parallel-run flakiness was timeouts, not shared state. The frontend suite is green in five consecutive parallel runs on an idle machine and produces 8β16 failures under concurrent load β every one of them a timeout at Vitest's 5000ms default, which none of the four Vitest workspaces had overridden. The affected file set tracks machine load rather than any property of the tests, which is what ruled out contention. testTimeout is now 20s in all four.
Every workspace gained a test:serial script. The repo runs two test runners behind one command shape β the backend on Jest, the other four on Vitest β so any serial flag a caller reaches for is right in four places and wrong in the fifth, and Jest rejects the Vitest ones outright. A named script per workspace means the runner's identity stops being something the caller has to know.
Two guard residuals closed: a decoy function declared in an inner scope could disarm the modes rule for a whole file while compiling with zero type and lint errors, and the auth-path guard's needle was quote-wrapped so a double-quoted path or a template literal walked straight past it.
v2026.08.28 β The Fork Is Deleted (August 2026)¶
The demo renders from the package, and the vendored copy is gone β 44 files and 1.1 MB of byte-identical cockpit, together with the manifest, sync script, byte-level drift checker and the workflow that ran it. That deletion is what the whole extraction was for. The demo now supplies its own host adapter rather than overlaying files, which is the seam the vendored fork existed to discover.
The bundle gate had silently no-opped on Windows. Both bundle-check scripts guarded their entry point by comparing a module URL against the process argument; on Windows that argument is a drive-letter path with backslashes, so the comparison never held. The gate exited zero having checked nothing, and the build passed. Both scripts run as the last step of every build for two public, unauthenticated sites, checking for auth libraries, telemetry and backend origins β on Windows, none of that had ever been checked.
pa-cockpit was wired into the CI path filters and the release scoping, without which a change to the cockpit would trigger nothing and version nothing. The demo now starts alongside the other dev servers, which matters once a cockpit change affects both apps. Deny-by-default section filtering and the user forward were pinned by regression tests, and the demo gained its own changelog panel rather than a vendored stand-in.
v2026.08.27 β The Cockpit Becomes @ronl/pa-cockpit (August 2026)¶
Developer detail: PA-Cockpit package.
Thirteen tasks against an approved design, in an order that is load-bearing: pure data and mode config first so later tasks have something to import, the API services next because they carry the auth seam and nothing else, then the views, then the modes context, and the shell last since it carries all five seams.
The host declares what it provides. The package publishes the interface a host must satisfy to mount the cockpit β auth and tenant services, plus the components the shell renders but does not own. The shell takes those seams as a required prop rather than an optional one, so a host cannot silently omit a seam and discover it at runtime. Auth and tenant resolve through configured getters inside functions, so a refreshed token is never captured by value and left stale.
One PaSectionsRouter replaces a fourteen-export surface. The frontend's section router and the demo's carried byte-identical id groupings, an identical panel dispatch and an identical remount branch β section-id grammar about the package's own ids, not host knowledge. Exporting fourteen components and asking every host to hand-maintain that grammar would have kept the vendored fork's most-duplicated behaviour, merely formalised.
The rail and command palette now derive from the mode set the host injects, which is what lets the demo present a curated subset without the package knowing anything about curation. The notifications panel dropped Tailwind for scoped pac-* rules, using the literal Tailwind-computed values rather than the nearest design token so the rendered result stays pixel-identical.
The frontend deliberately does not type-check between two of these tasks. That was stated up front in the plan so an implementer would not try to repair it early and fight the migration.
v2026.08.26 β A Social Card, and the Tenant Config That Should Never Have Shipped (August 2026)¶
18 KB of internal multi-tenant configuration was being deployed to the public demo. Because the asset vendor root was the public directory, vendoring the tenant config shipped real contact details, non-public sections, entries explicitly flagged as not public, and the very IOU sections the demo deliberately drops. Nothing in the demo ever fetched it; its only consumer was a drift test, which now reads the frontend copy directly.
The demo gained an Open Graph social card β 1200Γ630, with a full OG and Twitter meta block and a description tag it never had. The part needing care is the origin: a static index.html cannot know its own deploy target, and the OG specification requires absolute URLs because scrapers do not resolve relative paths. The page is authored against production and a build plugin rewrites the URL and image to whichever origin is being built; without it, an acceptance deploy would advertise production assets.
The E2E suite runs in the acceptance deploy workflow β the first Playwright suite in this repository to run in CI at all. It is the only proof of two of the four no-Live layers: that the live toggle is actually hidden in a real browser's cascade, and that the page issues no network request. The demo needs no backend, database or Keycloak; Playwright starts its own dev server and that is the whole environment.
The mock-only environment invariant is now actually guarded. The existing lock test only ever proved the test environment file, because Vitest's mode is always test β nothing read the production or acceptance files, so deleting a mock flag from either would have left every check green. A new test asserts across all four environment files that the three mock flags are true and the API URL absent, verified red then green.
The floating assistant toggle turned out to be a one-way trap rather than a dead control: clicking it set the open flag, which both hid the button and rendered a null shim, leaving nothing able to unset it. The backend-request guard now compares against the run's own origin instead of hardcoding localhost, which had produced two false failures against the acceptance site. A curated executive-summary changelog replaced the placeholder, covering the CalVer releases in eight themed entries rather than re-listing every commit.
v2026.08.25 β The Demo Bar Retired, the Theme Applied at Runtime (August 2026)¶
The role selector had existed in three places β the demo bar, Beheer β Rollen & rechten, and Dossierbeheer's inert vendored role bar β plus two separate resets. Role switching now lives only on Rollen & rechten, and reset only in Dossierbeheer's own mock banner.
The cockpit was silently rebranding itself orange. Its stylesheets read theme custom properties with Flevoland blue and magenta as fallbacks, which apply only while those properties are undefined β and the vendored global stylesheet set them to generic RONL blue and orange at the root, satisfying the fallback. The theme is now applied at runtime on the document element, which is how the real tenant service solves it for every tenant, and wins the cascade over an inherited value.
Every monitor icon 404ed on two sections. A vendored component reads 14 icons from a hard-coded path rather than an import, so a manifest that discovers files by following imports could never find them. A second asset root now sits alongside the source manifest. Tailwind, PostCSS and Autoprefixer were vendored at the exact versions the frontend pins, fixing three visual bugs caused by utility classes never being processed at all, and a double scrollbar was removed by capping the root to the viewport in a column flexbox.
Deploy workflows for acc.plato.open-regels.nl and plato.open-regels.nl landed alongside a vendor-drift workflow, triggered on frontend source pushes rather than demo ones since the deploy workflows are path-filtered to the demo package and would never see an edit to the origin the copy tracks.
v2026.08.24 β A Public, Mock-Only PA Cockpit (August 2026)¶
User guide: PA-Cockpit demo Β· Test detail: PA-demo suite.
A showcase instance of the Public Affairs cockpit on an unauthenticated public site, with no option to switch to Live. Mock mode was already a working demo driven by the same mutation actions live uses, so the work was packaging and severing auth rather than building demo behaviour.
It shipped as a vendored copy deliberately ahead of extracting the package, not after it. An extraction has to commit to an interface, and nothing outside packages/frontend had ever consumed the cockpit β so building a real second consumer first made that boundary empirical instead of imagined, and left the shipping cockpit alone during a 342-commit promotion.
Sections are curated by a deny-by-default allow-list: 21 static ids plus the data-driven dossiers sentinel, with five dropped ids, reconciled one-to-one against the real modes config's 26 rail items. The filter is applied at the data source rather than at the router, because the command palette calls the section list directly with no props β filtering anywhere else would have missed it.
No-Live is enforced twice over. staticwebapp.config.json ships a CSP with connect-src 'self', so the browser refuses any outbound request the demo's code might someday make. The stronger layer is a build-time bundle gate that scans every built .js file for auth libraries, telemetry and the real backend origins and fails the build if any appear β proving the URL is not in the bundle to be requested at all. Dossierbeheer's own switch-to-live button is suppressed separately: a visitor clicking it mid-demo would have flipped the cockpit to live against a backend that does not exist, in front of the audience the demo exists to persuade.
Selecting a role rewrites the shim's synthetic Keycloak roles array and derives capabilities through the product's own deriveDossierRole, so capability chips, editor lock hints and every disabled action follow, with no vendored file touched. Four positions are offered with the broadest as the default β a visitor should see the whole product before being shown what a narrower role loses. Profiel and Rollen & rechten are demo-owned pages rather than reused caseworker ones, and five Playwright tests drive the journey end to end with no backend, database or Keycloak.
v2026.08.23 β Mock Mode Made Real, and the Throttle That Looked Like an Outage (August 2026)¶
Test detail: PA cockpit β tests.
Mock mode becomes a working demo, driven end to end¶
Mock mode stopped being a read-only snapshot: curating a signal moves the rail badges and the move survives a reload, an ignored signal stays ignored, and Reset demodata restores every source to its fixture baseline. Every signaalbron now carries a watchlist orphan that can be linked to a dossier, and the demo counts are no longer identical across sources. PA_USE_MOCK was dropped β nothing read it.
Two Playwright specs now drive the cockpit: pa-mock-journey.spec.ts against the real store with no mocking, and pa-live-authoring.spec.ts against the live backend and a real database. Every mock-mode defect fixed in this window was invisible to the unit suites by construction β a saved-search write that was a bare return;, a confirm that built an object and discarded it, notifications hardcoded to empty β because a component test mocks the very seam that was broken.
The shipped rate limit was below what the cockpit costs to use¶
One short authoring journey measures 21 requests to /v1/pa/* against a 100/minute per-IP budget, so two specs back to back exhausted it, and the UI rendered the resulting 429 as "Kon dossiers niet laden" β indistinguishable from a backend that is down. The default is now 1000/minute, and e2e/helpers/rate-limit.ts fails a test with a message naming the throttle rather than retrying past it.
Mocks that could not pass vacuously¶
expectMockNamesRealExports originally compared a mocked path against the mock β itself β and would have passed no matter what; it was caught by injecting a bogus export and noticing the assertion failed to fail. Both mockModule helpers now require importActual/requireActual, the PA mocks are built on the real modules rather than replacing them, and a parity-checked stub for the usePaData context fails loudly when a member is added to one side and not the other.
Data-integrity and feed fixes¶
Deleting a dossier now takes its signals and zoekcriteria with it. A created dossier is on the overview by the time you arrive there. TK is fetched one term at a time so multi-term criteria stop aborting, an empty cached feed is no longer served for the rest of its TTL, and the rail's confirmed counter no longer freezes at mount.
v2026.08.22 β EU Feed Recovery and One Switch for the Cockpit (August 2026)¶
The mock/live toggle is now one switch for the whole cockpit, and demo data stopped leaking into the live database β neither the demo taxonomy nor demo dossiers are seeded there any more.
Half the plenary feed was missing. EU refs are now recovered from the guid, restoring it. Europarl receives a User-Agent and an empty 202 is no longer read as a feed; EP motions that arrive once per political group are collapsed into a single signal; EP press releases are surfaced and commissie is populated for EU items. The raw EU feed can now be searched from the cockpit directly.
Inbox badges stay current instead of freezing at page load, and every badge is populated on mount from a single counts request.
M2M decision checks became environment-aware, and the local client secret is read from the realm export rather than from .env.
v2026.08.21 β Backend Coverage, and Nine Test Files That Never Compiled (August 2026)¶
Test detail: Backend suite Β· Writing tests.
Backend test files made modules so their globals stop colliding. Nine test files had no top-level import or export, which makes each a global script to TypeScript, so every top-level declaration landed in the global scope: mockAxios collided across five files, Mod and freshModule across six each. The suite passed locally for weeks because ts-jest caches type diagnostics per file; the first CI run on a cold runner failed immediately. Adding export {}; to each scopes them.
Backend branch and function coverage lifted above 80% for every file. This is the release that took utils/ from 43.47% statements and 6.54% branches β long documented as an accepted artifact, with config.ts at 0% and logger.ts mocked everywhere β to 100% across all four metrics, tls-bootstrap.ts included. Backend coverage overall moved from 94.28/73.54/94.13/95.69 to 98.35/91.49/96.65/98.75.
Required-env checks now actually fire, and /v1/health reports the deployment tier. A seed dossier without an owner is now a compile error, inferType was extracted from four routers into one tested helper, and local M2M targets the Docker Operaton so its route script runs locally.
v2026.08.20 β Every Deploy Gated on Its Tests (August 2026)¶
Backend and frontend deploys now run the suites¶
Public-site was the only package whose pipeline could block a deploy. Backend CI linted and built but never ran its 1145 Jest tests; frontend CI ran neither lint nor test, going straight from npm ci to vite build to deploy. For those two, npm test was a manual discipline enforced by review rather than an automated gate. The backend workflows gain a Jest step beside their existing linter, and the frontend workflows gain a linter, the Vitest suite, and the performance budget as a step of its own.
The simEngine budget moved rather than moved up¶
simEngine.ts carries a real budget: run(cfg) must process the default 3,150-application population in under 250ms, and its source comment is emphatic that the threshold must not be loosened β the intended remedy is a web worker, not a bigger number. The problem was never the threshold but what the assertion measured. On a contended host it was observed at 302ms, then 837ms, then 1297ms; inside a full npm test, where Vitest saturates every core with 130 parallel files, even the fastest of three CPU-time samples came out at 468ms, against ~100ms in isolation. Wiring the CI gate would have made that a permanently red pipeline. The assertion now lives in simEngine.perf.test.ts, still asserting < 250ms, run by npm run test:perf with file parallelism disabled and gated as its own CI step. ChangelogPanel.test.tsx was the other casualty of the same contention β 15s timeout, observed at 22s β raised to 60s, since a timeout catches a hang rather than asserting a speed.
Two Vitest config defects, and a lint-staged blind spot¶
setupFiles resolved against the process cwd rather than the config file, so the single-file command documented on the Testing page failed from the repo root and worked only from inside the package. coverage.reportOnFailure is now set in both frontend and public-site, so a red run keeps its coverage figures instead of discarding them exactly when they are wanted. And lint-staged had globs for frontend, backend and shared but none for packages/public-site, so a staged public-site source file got neither ESLint nor Prettier at commit time β pre-push always caught it, but a local commit could carry it.
v2026.08.19 β DMN Downloads + Tenant-Mandatory Deployment Closed Out (August 2026)¶
DMN source files downloadable from the public rule catalogue¶
The public site now joins the Linked Data Explorer's published-DMN list onto Regelcatalogus services and offers the DMN 1.3 XML as a download row in each rule's Technical details. A new getPublicDmnsByService() in lde.service fetches /v1/dmns for the RONL graph, groups the result by service URI, caches for five minutes, and returns an empty map on failure so the catalogue still renders when LDE is unreachable; the relative xmlUrl is resolved against the configured API URL rather than concatenated, which is what keeps /v1 from doubling. SPARQL_ENDPOINT is now exported from regelcatalogus.service so LDE is queried against the same graph instead of a second copy of the URL. Regel items carry dmns only when LDE knows the service, so unmatched services simply render no download row; TechDetails takes an optional downloads prop rendering a "download DMN (xml)" row inside the existing key/value table, with several DMNs for one service stacking under one key. Because the link points straight at LDE, the saved filename comes from its Content-Disposition, which uses the Operaton decision key β where that key is a generated GUID (the HvA export) the download name differs from the displayed title, an accepted gap since the real fix belongs at the source deployment rather than here.
Tenant-mandatory deployment: the design plan executed against a real two-tenant Operaton¶
The release that motivated all of this: starting RipR21Process (at the time still named RipPhase1Process) from Beheer β R2.1 β Starten failed with "is niet gevonden", even though the Faseladder's own deploy-status check correctly showed it as deployed. The root cause was startProcess calling only Operaton's untenanted /process-definition/key/{key}/start shorthand, which Operaton only resolves against definitions deployed with no tenant-id β but this process is now deployed with a native tenant-id. The fix tries the tenant-scoped start endpoint first and falls back to the untenanted one only on Operaton's specific "no matching process definition" response, so every process deployed before this point keeps working unchanged.
That fix was followed by a full design and implementation plan (all 53 OperatonService Operaton calls read and categorized) for closing the two remaining tenant-scoping gaps, redeploying the five active process bundles under their correct tenants, and standing up a single-source-of-truth E2E test bundle at linked-data-explorer/e2e-fixtures/<tenant>/, replacing two pre-existing, already-diverged "examples" locations. Executed against a real, live, two-tenant Operaton instance rather than unit mocks alone, it surfaced two further genuine regressions only once real cross-tenant traffic was exercised for the first time: a cross-tenant process-start lookup bug, and Operaton's DMN business-rule-task tenant resolution requiring an explicit camunda:decisionRefTenantId override for shared decision tables once the calling process itself becomes tenant-scoped β both confirmed via a live, throwaway empirical spike before being applied for real.
Concretely, this release also: renamed the process key from RipPhase1Process to the self-describing RipR21Process, matching the Faseladder's own R2.1 stage code, end to end across the shared RIP_PHASE_KEYS catalog, both backend query filters, the frontend's start action, and every asserting test; fixed startProcess and getDeployedStartForm to discover a process's actual deployed tenant via Operaton's unscoped list endpoint rather than assuming it matches the caller's own tenant, which broke AwbZorgtoeslagProcess β always handled under the toeslagen tenant by design, callable by citizens of any tenant; closed a cross-tenant data leak in GET /v1/rip/phases/deployment-status and /v1/rip/phases/counts, which queried Operaton with no tenant filter at all β getPhaseInstanceCounts in particular counted every running instance of a process-definition key globally, almost certainly the mechanism behind an earlier-observed Portfolio/Faseladder discrepancy, fixed by threading the caseworker's tenant through Operaton's native tenantIdIn filter; and fixed two more untenanted-key lookups, getBoardOwner (degraded silently to "untagged" via a try/catch, quietly breaking the Beheer archive's board split) and getDeployedStartForm (threw outright, breaking the citizen-facing "start a new case" flow), both now sharing the tenant-scoped-then-fallback helper startProcess established.
E2E hardening, reporting UX, and an accessibility correction¶
global-setup.ts now queries Operaton directly for the five processes the E2E suite requires and fails fast with a specific message β which key, which tenant, what to do β before any test runs, rather than a mismatch surfacing as a confusing failure deep inside an unrelated spec; it verifies only, never deploys. Separately, the Operaton-history cleanup prompt is consolidated from one confirmation per business key (a dozen instances meant a dozen [y/N] prompts) to a single prompt covering all pending keys at once, and Playwright's HTML report now opens automatically after every run instead of relying on a show-report hint that could resolve to the wrong path (it's computed relative to the npm workspace's internal working directory, not the shell the command was typed in). linked-data-explorer's E2E fixture sub-processes were also renamed with an E2E suffix to give them their own process-definition keys, distinct from the general-purpose seeded examples sharing the same BPMN process id.
Finally, the accessibility statement's claim that every interactive element gets a 2px black-and-yellow focus indicator was corrected β an earlier release (2026.08.1) had already changed form-field focus to black-and-blue on request, but the statement was never updated to match; confirmed directly against the shipped CSS and reworded rather than reverted, since the blue was a deliberate change.
v2026.08.18 β Regelsimulatie: a Deterministic Budget-Exhaustion Simulator (August 2026)¶
The simulation engine¶
A new, deterministic, pure-TypeScript engine ports the Flevoland home-battery subsidy's budget-exhaustion simulation: entitlement rules, amount calculation, split/merged budget pools with a 1 October boundary and year-to-year carry-over, first-come-first-served allocation with no back-fill once a pool seals, and two counterfactual re-runs isolating how much of the unpaid total is attributable to an information-request delay versus a successful appeal. Twenty-two tests cover determinism, amount boundaries, entitlement precedence, resolver sealing and priority ordering, full-run bookkeeping invariants, the budget-year-is-submission-year rule, both counterfactuals, and a performance budget (under 250ms for the default 3,150-application population). A review found the port's appeal-displacement attribution logic β which appeal "displaced" a given application β actually diverges from the reference implementation: the reference compares against a field that's never set on the object type involved, making that branch permanently dead code there, while the port's version can correctly pick the nearest-preceding appeal winner instead of always the pool's overall minimum. Confirmed with the project owner as intended behaviour rather than a bug to revert; it only affects which application id is shown in one caption, not totals or payment outcomes, and is now disclosed in a code comment with a test proving the two strategies diverge.
UI: RegelSimulatie section, chart, and presentational primitives¶
The section shell ties the engine to a header, a collapsible scenario-parameter panel of fifteen sliders, a playback control bar, and a two-column card layout, recomputing the simulation only when a parameter actually changes, never while scrubbing the timeline. SimChart renders the saw-tooth budget-over-time chart as hand-rolled SVG with no charting library β free vs. reserved budget, split and merged pot phases, exhaustion markers, and the current-day indicator β alongside SimMissedPanel, the "Geldige aanvragen die misliepen" panel with three filters (RFI priority-shift, successful appeal, all unpaid) over a per-application timeline. The smaller presentational primitives (SimPot, SimOutcomeRow, SimTweak) and a dedicated simFormat.ts module (extracted from SimPot.tsx after it broke Vite's react-refresh assumption that a component file exports only components) round out the surface, ported unchanged and scoped under the existing .cwd-v2 design tokens with no new colours. The feature is wired into the V2 shell as a new Simulatie mode between Zoeken and Beheer, with a Regelsimulatie rail item gated behind both a per-item auth requirement and a separate tenant-visibility gate.
Review fixes: a slider freeze, persistence scoping, and a rename¶
The final whole-branch review found a genuine multi-second main-thread freeze while dragging a parameter slider at settings the UI itself exposes (population or RFI chance near their max) β every drag step fired a full engine run synchronously β fixed by decoupling a slider's visual position from when its value actually commits, on release rather than on every step. The review also added a missing type="button" attribute and accessible slider labels, corrected a silent trailing-zero formatting divergence and an unclamped restored-from-storage scenario value, and separately fixed the persisted playback day: it had shared one localStorage key with the scenario parameters, so a caseworker could land on a fully-played-through simulation (day 719/719) left behind by an earlier visit. Scenario parameters stay under the shared key β there's no per-user meaning to a set of sliders β but the playback day now lives under a key scoped by the caseworker's stable Keycloak sub, so a different caseworker, or the same one on a first visit, always starts at day 0. The rail label was also renamed from the generic "Regelsimulatie" to the specific "Subsidie thuisbatterij," since the Simulatie mode is deliberately built to hold more than one simulation later and needs a name that won't collide with an equally generic sibling.
v2026.08.17 β Faseladder Content Correction: Rule Sets Per Competent Authority (August 2026)¶
The public site's "VerifiΓ«ren & live" pipeline stage note claimed a citizen always sees one environment behind which sit exactly two rule sets from two competent authorities. Corrected to "one or more rule sets from corresponding competent authorities" β not every deployment involves exactly two authorities.
v2026.08.16 β Herkomst Polish: Scroll, Citations, and Prerendering (August 2026)¶
Following the Herkomst provenance page introduced in 2026.08.15, four fixes tightened it up. Selecting a concept, drilling into a chip, clicking a trail segment, or "Begin opnieuw" could each change which concept's trace is shown without the page scrolling, leaving the new concept's trail bar and header off-screen if the reader was scrolled deep into a long trace; the jump buttons' existing scroll-to-id helper was extracted into a shared herkomstScroll.ts, which HerkomstExplorer now calls on every concept change. /herkomst was in the sitemap urls array but, unlike every other content route (berichten, nieuws, producten, regels, processen), had no writeRoute() call, so a crawler that doesn't execute JS got the homepage's title and description via Azure's SPA navigationFallback instead of Herkomst's own β its title, description, and crawlable summary are now sourced directly from herkomstData.ts/herkomstConcepts.ts, verified against a real build:acc output. Three legal citations were also corrected against the content owner's sources: the Leeftijd concept's Wet op de zorgtoeslag citation (art. 1 lid 1 onder b β onder c), and the BSN and Datum berekening concepts' wet.tekst fields, which had been paraphrases and are now exact statutory quotes with corrected bron references. Finally, HerkomstExplorer.tsx's plain-function export nextTrail β which broke Vite's react-refresh assumption that a component file exports only components β was moved into its own herkomstTrail.ts module with its own test.
v2026.08.15 β Social Card + the Herkomst Provenance Page (August 2026)¶
Sitewide Open Graph / Twitter social card¶
Sitewide og:*/twitter: meta tags were added to index.html, plus a 1200Γ630 og-open-regels.png asset, per the design handoff's social card addition β confirmed sitewide rather than per-page is correct, since the prerender script only ever swaps <title>/<meta name="description"> and never touches og:*/twitter: tags, so every prerendered route already carries the same card.
Herkomst: tracing a concept from statute to screen¶
A new provenance feature, Herkomst, walks a citizen-facing question back to its legal source. HerkomstTrace is the core component β an eight-step, two-track grid tracing a concept from quoted legal text (Wet- & Regelgeving) to the question a citizen sees on screen (Gebruikers), row-aligned step for step, handling both chain-end cases (no-DMN concepts render an explanatory fallback line; concepts with nothing left to derive from render "einde van de keten" instead of chips). HerkomstChip is the clickable concept chip used throughout β either a button drilling into another concept, or a plain leaf chip carrying its own inline definition β and HerkomstBackground renders the grey background band below the trace: the four-stage pipeline, the (a)/(b)/© concept chain with its catalogue band, and the open/gesloten standards list. HerkomstExplorer ties it together, owning the drill-down trail state: selecting a concept in the list resets it, drilling into a chip pushes onto it (a no-op if already on that concept), trail segments truncate to that depth, and "Begin opnieuw" resets to the first concept. The page itself wires all six components together at /herkomst, with a breadcrumb, page head, jump links, a nav item after Gegevenswoordenboek, and a HERKOMST_PATH constant mirroring the existing WOORDENBOEK_PATH pattern. Content β four concepts (Leeftijd, Geboortedatum, Datumberekening, BSN), each with quoted legal text, annotation, rule, DMN expression and citizen-facing copy β was ported byte-identical from the design handoff's hand-authored reference content, independently verified via a field-by-field deep-equal check rather than visual proofreading alone; styling was ported into pub.css with every .k- class prefix renamed to .pub-herkomst- to match the codebase's convention.
A final whole-branch review caught three gaps a task-scoped review couldn't have: the deliberate accessibility improvement associating track headers with their cells used aria-labelledby on plain <div>s, which ARIA prohibits on the role=generic a bare div computes to β likely never reaching assistive tech, fixed by adding role="group" to all eight cells; /herkomst was never registered in the sitemap/prerender pipeline, since no single task had that file in scope; and the drill-down trail was a bare div where the spec required a real breadcrumb nav landmark. A second CSS-scoping gap slipped through the initial port too: the task brief covered renaming the reference stylesheet's hyphenated component classes but not its space-descendant-combinator rules, so bare global h1βh4/p selectors and a global .pub-nav a override (from the reference prototype's own internal preview-shell nav) collided with the real site's styling β fixed by scoping headings/paragraphs under a new .pub-herkomst-k page-root class and deleting the four unused nav rules. Separately, a test asserting the trail's no-op guard actually exercised the wrong interaction path (a nav-list click rather than a chip drill-down) and would still pass with the guard deleted; the trail-update logic was extracted as a pure, directly-unit-tested function, nextTrail.
v2026.08.14 β Build Fixes: Rollup CJS Interop and a ChangelogPanel Matcher (August 2026)¶
Two real build/tooling bugs were fixed. vite build goes through Rollup directly rather than vite dev's esbuild pre-bundler, and Rollup by default only runs CommonJSβESM interop on node_modules/**; since @ronl/shared resolves to a relative workspace path (../shared/dist), Rollup parsed it as plain ESM, found no literal export keyword, and reported every named value import (RIP_PHASE_KEYS) as not exported. This only ever surfaced once vite build --mode acceptance actually ran in CI, since local dev used an already-fixed dev-server path (optimizeDeps.include); both build:acc and build:prod are now verified to succeed with RIP_PHASE_KEYS actually present in the emitted bundle. Separately, ChangelogPanel's version-button matcher used a bare .includes() check that became ambiguous once double-digit CalVer patches existed β v2026.08.1 is a string-prefix of v2026.08.10 through v2026.08.13, so clicking one version's button could resolve to the wrong entry. Fixed with a versionButtonName() helper using a negative-lookahead regex to require an exact patch-number boundary.
v2026.08.13 β Faseladder: Rail Stats for Mijn dag, Portfolio, and Beheer (August 2026)¶
Continuing the twelve-phase RIP ladder work begun in 2026.08.4, the app shell's left rail now shows real numbers per mode instead of only navigable links: Mijn dag gets Taken vandaag / Urgent-te laat / Mijn projecten counts; Portfolio gets stage-grouped phase counts plus Overgangen (Wacht op start) and Gezondheid (groen/geel/rood) breakdowns; and Beheer's phase items gain WIP/geparkeerd count badges and a muted style for undeployed phases. A new pure-function rail-stats module computes each mode's rail content from already-fetched mock and live data, matching the pattern every other Infra-board component already uses. Comparing the deployed app against design screenshots also caught Beheer's rail phase list still rendering as one flat list, missing the R2βR6 stage headers Portfolio's rail already had, and Portfolio's rail still carrying a static "Alle projecten" link the design no longer calls for (the top-nav Portfolio tab already routes there directly) β both were corrected. A more serious gap: the new rail stats were rendering for anonymous visitors, showing live-looking project numbers beside a "please log in" main pane; all four rail-stat blocks are now gated behind login, matching the rest of the shell.
v2026.08.12 β Faseladder: Numbered-List Styling Restored (August 2026)¶
Tailwind's Preflight base reset strips list-style and margin/padding from every ol/ul app-wide. The Beheer phase detail page's "Wat er gebeurt bij starten" side panel never got counter-restoring CSS for its numbered steps and definition list, so it silently rendered as plain unnumbered running text instead of the design's numbered list and label/value rows β fixed with the missing restoration CSS.
v2026.08.11 β Faseladder: Portfolio, Mijn dag, and the Project Stepper Move onto the Real Ladder (August 2026)¶
Portfolio's Gantt timeline and per-fase Kanban board now iterate the real twelve-phase catalogue, grouped by stage, instead of the old six-phase mock model β backed by a rebuilt mock Gantt and status model spanning all twelve real phases with stage-grouped durations and a deterministic status generator, replacing the old flags-override mechanism. Mijn dag's "Mijn projecten" cards now show each project's real current RIP phase instead of the old mock label, and the project detail page's phase stepper renders all twelve real RIP phases (R2.1βR6.1) instead of six mock ones. With all three surfaces off the real ladder, the superseded six-phase PHASES model and the phaseLabels prop threading it through Portfolio, Mijn dag, and the stepper were retired entirely.
v2026.08.10 β Faseladder: Geparkeerde Projecten Page (August 2026)¶
The R5.3 "Geparkeerde projecten" placeholder in Beheer, part of the twelve-phase RIP ladder work begun in 2026.08.4, is now built out: a new getMockGeparkeerdRows selector lists the projects currently parked at that phase, each with its health status and a link back to the full project.
v2026.08.9 β Faseladder: Phase Catalogue Grows to Twelve Phases (August 2026)¶
Matching an updated design handoff, the RIP phase ladder grew from 9 phases across 4 stages to 12 phases across 5 stages (R2.1βR6.1): R5.2 became a real modelled phase (Directievoering en toezicht), and R5.3 is a new unmodelled placeholder (built out the following release). The frontend/backend-shared RIP_PHASE_KEYS list grew to match, mock projects now hash directly across all twelve real phases instead of going through the old legacy-bucket lookup table, and the Faseladder/PhaseDetail test suite was updated for the grown catalogue.
v2026.08.8 β Faseladder: WIP and Gereed Tabs on the Phase Detail Page (August 2026)¶
The Beheer phase detail page gained WIP and Gereed tabs showing real R2.1 process instances alongside mock rows, backed by a new getWipStepInfo/countReworkLoops pair deriving a running instance's current step and rework count from its Operaton activity history, and a usePhase1Completed hook feeding the Gereed tab's live rows. Both tabs were then routed through the hook layer with proper loading/error/empty states and a retry option, live "Producten" document-progress computation, a complete Gereed summary line, and automatic refetch after starting a new instance; the mock row selection for both tabs was deduplicated into infra-board.data.ts against the existing phase-counts logic so the two can't drift apart. Two real bugs were fixed along the way: the WIP tab could flag a running instance as "blocked" after its very first pass through a step with no rework loop at all, now only flagging blocked once an activity has genuinely executed more than once; and the Gereed tab's live-row map used a shorthand fragment that can't carry a key prop, switched to an explicit keyed Fragment. The now-superseded RipFase1WipSection/RipFase1GereedSection sections on the caseworker dashboard were retired.
v2026.08.7 β Faseladder: the Phase Detail Page (August 2026)¶
A new PhaseDetail page β header, side panel, and a Starten tab for beginning a new process instance β is now wired into the rail, the section router, and the Faseladder overview, so clicking a phase row opens its detail page; the old RipFase1Section, including a dead import, dispatch branch, and rail item still referencing it, was retired. New getReadyProjects/getOutOfSequenceProjects selectors back the Starten tab's eligibility checks, and each phase in the catalogue now names its kredietBeslisser where a krediet decision applies. Comparing against the design handoff's screenshots also caught the Gereed/Klaar KPI logic computing backwards β the "gereed" condition checked for projects before a phase instead of past it, feeding wrong figures into every downstream Klaar calculation β fixed and cross-checked phase by phase against the reference for an exact match; "Fasen in uitvoering" now shows total WIP across all phases rather than a phase count capped at 9, "Klaar om te starten" totals Klaar across every non-beyond phase, zero-value Klaar cells render "β" consistently, and the WIP column is relabelled "WIP / Geparkeerd" with geparkeerd counts for beyond phases. A separate runtime bug was also fixed: Vite doesn't apply CJSβESM interop to workspace-linked packages like @ronl/shared unless they're in its dependency optimizer, so the first genuine runtime import from it (RIP_PHASE_KEYS) failed silently with no build-time error, producing a blank white page β fixed by adding @ronl/shared to optimizeDeps.include.
v2026.08.6 β Faseladder: the Overview Page and Live Phase Counts (August 2026)¶
A new Faseladder overview β the Beheer landing page, one row per RIP phase grouped by stage, with live/mock combined counts and deploy status β is now wired into the rail, section router, and βK command palette. It's backed by a new backend endpoint, GET /v1/rip/phases/counts, and a matching OperatonService.getPhaseInstanceCounts for live per-phase WIP/Gereed instance counts, plus a new rip-phase-counts module holding the "Klaar" (ready-to-start) formula per phase and a combinePhaseCounts helper merging mock and live counts while keeping the live subset alongside for annotation. The mock portfolio also grew to 42 projects and gained the RIP-ladder fields the overview needs.
v2026.08.5 β Faseladder: Deployment Status and the Phase Catalogue (August 2026)¶
The first structural piece of the twelve-phase RIP ladder: a new rip-phases.catalog module holds the phase/stage data plus a getPhaseDeployStatus helper (gedeployed / ontwerp / onbekend) used across the Beheer surface, backed by a new backend endpoint, GET /v1/rip/phases/deployment-status, and a matching OperatonService.getDeployedProcessKeys. A new shared mapping from RIP phase codes to their Operaton process-definition keys is the single source of truth for which phases have a real process behind them, and a useDeployedProcessKeys hook exposes it to the frontend.
v2026.08.4 β Faseladder: Friendlier Deployment Error (August 2026)¶
Starting a RIP Fase 1 instance against an environment where the process isn't deployed now shows a clear, specific message instead of a raw engine error β the first fix in what grew into the twelve-phase RIP ladder work carried through 2026.08.13.
v2026.08.3 β Public Site: Footer Version/URL and a Facet-Group Border Fix (August 2026)¶
The footer's site line was hardcoded to publiek.open-regels.nl (prod) on every environment; it's now driven by a per-environment VITE_SITE_URL (dev shows localhost:5175, ACC acc.publiek.open-regels.nl, prod publiek.open-regels.nl), rendered as a link to that origin, and now also shows the current release β the public-site package version, injected via a Vite define (__APP_VERSION__) β so the footer always matches the latest public-site changelog entry. Separately, the Verfijn filter groups (Soort / Bron / Voor wie) are <fieldset>s whose CSS only set a bottom separator and never reset the browser's default fieldset box border, so each rendered inside a grooved border with a notch around its legend; the fieldset defaults are now reset so only the intended separator shows.
v2026.08.2 β Public Site: Section Pages Seed from Prerendered Data (August 2026)¶
The Berichten, Nieuws, Producten & Diensten, and Procesbibliotheek section pages now seed their list from the data the prerender already embeds per route, instead of rendering a "Ladenβ¦" placeholder and then fetching β content is present on first client render, removing the loading flash that grows in and shifts the footer, the same layout shift the Regelcatalogus fix in 2026.08.1 removed there. The rawβPublicHit mapping is extracted from SectionIndex into a shared mapToHits() so the prerender and the page produce identical items from one source; a cold load with no embedded blob still falls back to fetching through it.
v2026.08.1 β Public Site: Focus Ring, Prerendered Seeding, CSP and Deploy Fixes (August 2026)¶
Following the public site's launch in 2026.08.0, this release closes out several review and deploy findings. The search boxes and the Regelcatalogus dienst-filter dropdown showed a thick yellow focus ring (the Rijkshuisstijl --ro-focus token); swapped for the brand blue (--ro-link) on form-field :focus-visible, keeping the 2px dark outline plus a 4px ring so WCAG 2.4.7 (Focus Visible) is preserved β link and button focus styling is unchanged. The Regelcatalogus page now seeds from the prerender's embedded per-route JSON (<script>, <-escaped) via a pure reader instead of showing a "Ladenβ¦" placeholder and re-fetching, removing the layout shift behind the page's live CLS. Two deploy-blocking bugs were also fixed: the Regelcatalogus org-card logos are <img>s served from the RONL knowledge-graph host (api.open-regels.triply.cc), but the site's CSP img-src was 'self' data: only, so the browser silently blocked every logo β the host was added, guarded by a test parsing the shipped SWA config's CSP; and staticwebapp.config.json lived at the package root but the deploy workflow uploads only packages/public-site/dist with skip_app_build, so Azure never saw it β no SPA navigationFallback (deep-link refresh 404s), no CSP/security headers, no mimeTypes overrides β fixed by moving it into public/ so Vite copies it into dist. The Playwright config now also honours an E2E_BASE_URL env var to run the suite against an already-deployed URL (used to verify the public site against live ACC) instead of only the local dev server.
v2026.08.0 β Public Site Launched: an Unauthenticated Regelcatalogus, Search, and Content Site (August 2026)¶
A new package with no auth dependencies¶
packages/public-site is a new Vite/React/TypeScript workspace package with a dev server on :5175 and, deliberately, no keycloak-js, no @azure/msal, no @ronl/shared, and no Tailwind β a new scripts/check-bundle.mjs bundle-cleanliness gate scans every built .js file for forbidden strings (Keycloak, MSAL, oidc-client, analytics libraries) and fails the build if any are found, wired into build/build:acc/build:prod as their last step, so the "no auth" boundary is enforced mechanically rather than by convention. See Public Site for the reader-facing tour.
Federated search and the Regelcatalogus¶
A new backend search.service aggregates berichten, nieuws, producten, Regelcatalogus services, and LDE process bundles into one cached, server-side searchable index, replacing the design prototype's browser-side search, which doesn't scale past a few hundred items β proxied through a new lde.service that filters the process-bundle list to status active and boardOwner caseworker/untagged only, so internal boards and non-active drafts stay caseworker-only. A slugify utility generates deterministic slugs for rule-catalogue services (which have no natural short id), used identically on both backend (building the index) and frontend (building matching links) so the two can never disagree. The Results page implements federated search with all filter state (q/soort/bron/doelgroep/sort) living in the URL via useQueryState; facet counts come from the server, computed on the query before that facet's own filter is applied, so checking a box never makes its own count disappear. The Regelcatalogus page mirrors the caseworker version with four tabs β Organisations (with logos), Services, Rules (accordion per service, count and list from the same query, with per-rule drill-down into decision-logic descriptions added during review), and Concepts (every row linking out to Skosmos) β and review also fixed the accordion needing two clicks to switch services (the browser's native <details open>/onToggle toggling fought the React-driven open prop; now fully React-controlled via onClick+preventDefault) and the tab state resetting on every switch (both the open accordion and the Concepts dienst filter are now lifted into the parent component). A generic SectionIndex page covers the four content types with no dedicated page of their own (berichten, nieuws, producten, processen β regel has the Regelcatalogus instead).
Woordenboek, Toegankelijkheid, Open Data, and content pages¶
Woordenboek is a pure Skosmos iframe embed (with a title attribute, a visible "open in new tab" fallback, and src following the language switch) β which required a separate infrastructure fix: skosmos.open-regels.nl was importing basic_security_headers, setting X-Frame-Options: DENY and blocking iframe embedding from every origin, including this page and the caseworker app's Gegevenswoordenboek; replaced with a dedicated skosmos_security_headers snippet dropping the blanket header in favour of a CSP frame-ancestors allow-list scoped to the origins that actually embed it. Detail is the generic per-type detail page for all five content types, with a collapsed-by-default technical-details section showing the exact GET /v1/public/... path for that item; Toegankelijkheid is a static accessibility statement (WCAG 2.1 AA target, stated in both languages), and Open Data lists the real /v1/public/* GET endpoints. A typed client for /v1/public/* backs every page, with dual-runtime base-URL resolution (browser via import.meta.env, the Node prerender script via process.env) and a 404-vs-throw split between list and per-item lookups; i18n dictionaries (NL/EN, structurally enforced to declare the same keys) and five section definitions cover everything searchable β the data dictionary is deliberately excluded, since it has no search type or detail route of its own.
Prerendering, deploy, and end-to-end coverage¶
A post-build step fetches real content through the same lib/api.ts the app itself uses and writes a static, crawlable HTML fragment per section/detail route into dist/, plus sitemap.xml and robots.txt (/zoeken and /woordenboek are excluded from the sitemap by design); a duplicate <meta description> bug from an earlier version of this step, where the per-page description was inserted without removing the shell's generic one, was fixed before it ever shipped. azure-publicsite-acc.yml/-prod.yml mirror the frontend's branch-triggered Azure Static Web App deploy pattern, with staticwebapp.config.json carrying no routes/allowedRoles block at all β matching every "no auth" requirement elsewhere in this release β and public-site was added to the root npm run dev alongside backend and frontend. The backend's CORS allow-list and .env.example template were also found, via the E2E suite's first live run, to be missing the public-site dev port entirely, which would have blocked every fetch from a fresh checkout. A Playwright suite covers the full search β filter β detail β back journey, a deep link with filters pre-applied, a keyboard-only path, and three axe-core accessibility scans (home/results/detail) β 6/6 passing against real backend data during review β alongside a guard test that introspects the real Express router (not a mock) to assert /v1/public/* stays GET-only and unauthenticated, so a future change adding a write verb or auth middleware fails immediately rather than shipping unnoticed. Other review-driven fixes folded into this release: organisation logos on the Regelcatalogus Organisaties tab (the data was already fetched and unused), a fix for sort:'date' in the search index only partitioning dated-vs-undated items rather than comparing actual date values, an ACC-only PUBLIC_SHOW_WIP_PROCESSES escape hatch for previewing wip process bundles (still gated on boardOwner, off by default, must stay off in production), and a scrollbar-gutter: stable fix for horizontal layout jitter between routes of different heights.
v2026.07.0 β CalVer Adopted + Playwright E2E Harness (July 2026)¶
Versioning switches from SemVer to CalVer¶
bump-release now computes the next version as YYYY.MM.patch from the current date β patch increments on a same-month follow-up release, resets to 0 on the first release of a new month β matching the scheme already adopted by the CPSV Editor and Linked Data Explorer repos. This is a version-string convention only: no git tags and no other change to the release workflow. Historical SemVer entries (3.9.6 and earlier are the last of that line β actual releases stopped at 3.9.5) are left as-is; 2026.07.0 is the first release cut under the new scheme.
Playwright E2E harness, and two deep journeys against a real Operaton¶
@playwright/test is now wired up under packages/frontend/e2e/, with a globalSetup that checks the frontend, backend, and the sibling Linked Data Explorer backend are all reachable before any test runs β failing fast with the exact start commands rather than a confusing mid-test connection error. A new operaton service in docker-compose.yml (H2 file-based DB, host port 8081) gives the suite a real, disposable Process Engine to exercise, and npm run dev's docker:check step was extended to verify it alongside Postgres and Keycloak.
Two "deep journey" specs run a full roundtrip against that container rather than mocking the engine: a Kapvergunning request submitted via AwbShellProcess, DMN-evaluated, claimed and completed as a caseworker task, advancing to and completing a follow-up caseworker task, leaving zero open tasks or instances in Operaton; and a second journey for a Zorgtoeslag claim via AwbZorgtoeslagProcess, which also doubles as a tenant-isolation spot-check β confirming a Flevoland caseworker cannot see a task routed to the toeslagen processing authority while a toeslagen caseworker can, a genuine server-side security boundary rather than an assumption. Optional Operaton history cleanup runs from Playwright's globalTeardown rather than the test bodies themselves, since worker child processes don't forward the CLI's real TTY stdin needed for the interactive confirmation prompt.
Two real bugs came out of running these specs for real: parallel Playwright workers creating identically-named tasks for the same caseworker could race and steal each other's task mid-flight, causing a genuine Operaton save conflict (fixed by pinning workers: 1); and the pending-cleanup tracking file was being deleted unconditionally after its confirmation prompt regardless of the answer given, so a declined entry lost its tracking entirely while its Operaton history was never actually deleted β three real leftover entries had to be purged manually before the fix, after which only confirmed-and-deleted entries are dropped from the file.
Login/redirect matrix found two real gaps, and fixing them caused a regression¶
A new login/redirect matrix spec (one test per Flevoland role) and a ProtectedRoute spec surfaced two real gaps: a fresh page load of a protected route β URL bar, bookmark, or refresh β always redirected to / even with a live Keycloak SSO session, because keycloak.init() was only ever called inside AuthCallback.tsx and ProtectedRoute checked keycloak.authenticated synchronously with no initialization of its own; and /dashboard/caseworker was not wrapped in ProtectedRoute at all, so a citizen navigating there directly just stayed. Both were fixed: services/keycloak.ts now exports initializeKeycloak(), an idempotent wrapper memoizing the first keycloak.init() call, which ProtectedRoute awaits on mount; /dashboard/caseworker is now wrapped identically to /dashboard/citizen (accepted trade-off: CaseworkerDashboardV2's public "zoeken" mode for unauthenticated visitors is no longer reachable, since the route now redirects before the component mounts).
Fixing that pair introduced a real regression, caught by manual testing rather than by the automated suite: the first version of initializeKeycloak() memoized whichever options its first caller passed for the life of the page, so visiting /dashboard/caseworker while logged out and then choosing "Login met DigiD" got back the already-resolved false from ProtectedRoute's earlier check-SSO call instead of triggering a real login β the DigiD redirect never fired. Fixed by always using a fixed check-SSO init and triggering every real login redirect through an explicit keycloak.login(...) call, which carries none of .init()'s "only once" restriction.
Also shipped in v2026.07.0
TakenInbox's success message now actually renders after task completion (a same-render state-batching bug meant the confirmation banner was gated behind a branch that had already flipped away by the time it painted). Dossierbeheer's actionError banner now also renders in the edit view, not only the overview. AuditSection's load-on-mount effect is now gated behind the admin role check, so a non-admin user no longer triggers an /admin/audit fetch that only the render was previously hiding from them. scripts/check-deps.sh now compares package-lock.json content against an install-time snapshot instead of mtimes, since git checkout/merge --ff-only bump a tracked file's mtime on disk even when its content is unchanged.
v3.9.5 β Frontend Test-Coverage Backlog Closed (July 2026)¶
P7βP11: the frontend test-coverage campaign started in v3.9.4 reaches 100% file coverage¶
Building on the service-layer-through-SSE-chat phases from v3.9.4, this release works through the remaining shared component surface: the full components/CaseworkerDashboard/ section-component library reused across CaseworkerDashboardV2, InfraBoardDashboard, and PADashboardV2 (small/medium files, then the larger ones, closing that folder out entirely); the dossierbeheer PA-authoring surface (DossierEditor, TemplateGallery, ArchiveDialog, and the Dossierbeheer container); LoginChoice, AuthCallback (including its medewerker-vs-citizen IdP branches and role-to-dashboard fallback logic), and ChangelogPanel; and finally the command-palette/dock/section-router shell components shared across all four dashboards. Statement coverage rises from 46.59% to 83.39% across 888 tests, closing the entire phase-1-through-11 backlog: every component and page in the frontend now has at least a test file. Per-phase detail is intentionally not itemized here; see the dedicated frontend testing page.
Two real, documented-not-fixed gaps came out of this work and were fixed in the following release: AuditSection's load-on-mount effect had no role guard of its own (only the rendered UI was gated), and Dossierbeheer's actionError banner only rendered in the overview branch, so a failed save while still in the editor set the error state but never showed it. A genuine test-flakiness issue was fixed along the way too β ChangelogPanel.test.tsx renders the full real changelog-data.ts dataset (60+ entries), which could cross the 5s default Vitest timeout under full-suite CPU contention; fixed with a per-file testTimeout: 15000 rather than trimming the fixture.
Release tooling and line-ending hygiene¶
The bump-release skill now fast-forwards acc onto the working branch and deletes it by default once the version-bump commit lands, stopping (rather than forcing) on a diverged acc and still asking separately before pushing to origin. A .gitattributes file now pins tracked text files to LF: on Windows checkouts with core.autocrlf=true, git was silently rewriting committed LF files to CRLF on disk on every checkout, bumping mtimes even with unchanged content, which produced false positives in both check-deps.sh's staleness check and the pre-push hook's prettier --check.
v3.9.4 β Frontend Testing Infrastructure Introduced (July 2026)¶
RTL, jsdom, and msw wired up; the P1βP6 coverage backlog closed¶
The frontend previously had two pure-logic test files and no way to render a component at all. This release adds React Testing Library, jsdom, and msw, fixes Vitest's coverage config to report across the whole src tree instead of only executed files, and aligns npm test with the backend's coverage-by-default convention. A new docs/TESTING-FRONTEND.md documents the conventions, layer-by-layer patterns, and a prioritized coverage backlog.
That backlog is then worked through phase by phase: the service layer (services/*.ts, including the largest, pa.api.ts, with its ~35 exports across mock and live branches); the useProfielData hook and PaDataProvider's write-then-selective-refetch pattern; small reusable components (AltchaWidget, DecisionViewer, ProcessStartFormViewer, PersonalDataPanel, TimeLine); pure logic and config modules across infra-board, caseworker-v2, woo, and login-choice (including a seeded-PRNG-generated 218-row register in woo.data.ts); the five dashboard containers, scoped to auth/access gates and the highest-value form flow per container rather than exhaustive coverage; and finally the SSE streaming chat client, mocked by driving the ReadableStream reader directly rather than fighting msw's streamed-response API. Overall statement coverage rises from 1.6% to 26.33% across 303 tests, and a follow-up pass (P1b) covers the remaining ~40 businessApi methods plus every previously-untested PA/Woo/Caseworker-V2/InfraBoard section component, reaching 46.59% across 466 tests.
Two real findings surfaced along the way and were noted for later fixing: PADashboardV2's switchMode restores the last section visited per mode rather than always resetting to a default, and Dashboard.tsx's permit submission is a two-step flow where the child form's own success screen fires before the container's tab switch. A genuine bug was fixed directly: BronnenSection's PersonalFeedLink rendered a <div> inside a <p>, which browsers can't nest β they silently close the paragraph early β caught via validateDOMNesting warnings while writing its test file.
v3.9.3 β Notificaties: Team Watches Fixed, Modal Renamed, Explainer Page Added (July 2026)¶
Team-scoped Zoekcriteria watches were silently inert¶
computeNotifications' watch query required user_id IS NOT NULL, but the taxonomy seed rows behind team-scoped Zoekcriteria have no user_id β they're shared, unowned filters. Toggling their WatchBell persisted notify=true but could never produce a Meldingen entry, with nothing in the UI indicating the bell was inert. PATCH /v1/pa/searches/:id now detects an unowned row and, instead of writing notify on the shared row, finds or creates a personal watch derivative (source_search_id pointing at the team row) that computeNotifications can actually match against; GET /v1/pa/searches reflects the caller's own derivative state for the bell instead of the dead shared flag.
Meldingen becomes Notificaties, with an explainer page¶
The slide-over notification panel is renamed from "Meldingen" to "Notificaties" throughout, and its Beheer β Monitoring nav item moved from directly under Zoekcriteria to directly under Curatiepijplijn (order is now Signaalbronnen, Zoekcriteria, Curatiepijplijn, Notificaties). A new read-only Notificaties page, sibling to the existing Curatiepijplijn and Afwegingskader spec pages, documents how the WatchBell & Meldingen layer actually works β trigger points, matchWatch, the UNIQUE(user_id, signal_id) dedup, and the team-search-to-personal-derivative rule above β and is automatically picked up by the βK command palette. It's purely additive documentation with no change to notification runtime behaviour.
v3.9.2 β Dossier-Editing Security Fixes + Notification Reliability (July 2026)¶
Two privilege-escalation holes closed in dossier editing¶
The dossier edit endpoint checked the publish flag against a user's publish rights, but never validated or gated a status change at all. A pa-author β who can edit but not publish β could archive any live dossier by sending a status change directly, bypassing the admin-only archive route and its required legal-retention metadata, and could unpublish any already-published dossier, since the publish guard only ever fired in one direction. Both are closed by adding a status whitelist and an archive-permission guard, and by making the publish guard compare against the dossier's current value so both publishing and unpublishing now require the same publish rights.
Editing a published dossier no longer requires re-publish rights¶
A separate, related bug: a plain edit-only save always resent the dossier's current gepubliceerd value. For any already-published dossier this tripped the backend's publish guard for a pa-author, surfacing as a misleading connectivity error β and since every seeded dossier ships published, this locked pa-authors out of editing anything at all. gepubliceerd is now omitted from the save payload entirely unless the user is actually publishing, since the backend already treats a missing field as "no change."
Notifications now recompute on watch toggle, and WatchBell is hardened¶
Toggling a watch's notify flag β a Zoekcriteria bell, or a dossier's watch-everything bell β never itself recomputed notifications; an already-confirmed signal that newly matched sat silently undelivered until some unrelated later event forced a full rescan and dumped the whole backlog at once. A watch-toggle trigger point added to computeNotifications in both PATCH /v1/pa/searches/:id and POST /v1/pa/dossiers/:id/watch now surfaces the backlog the moment the watch actually turns on. On the frontend, both WatchBell call sites had been calling the API client directly instead of going through PaDataProvider, so the Meldingen badge never refetched after a toggle even though the backend had already recomputed β fixed by routing all three watch mutations through PaDataProvider, mirroring the existing confirmSignal/linkSignalDossier pattern. WatchBell was also missing an in-flight guard, so a rapid double-click could fire two overlapping requests whose local-state flips canceled out visually while the server converged on a different state than what the bell displayed; it now disables itself via a busy flag (or a per-row busy set on the saved-searches list) while a toggle is in flight.
Other fixes and coverage¶
The archive route's metadata guard checked for a missing reason before validating its type, so a non-string value passed the missing-value check and then crashed the request β and with no async-error middleware in this backend, the crash never reached the client as an error response, it just hung; an explicit type guard now returns the intended 400. Dossier deletion previously ran two independent, unlinked database statements, so a failure between them could delete a dossier but leave its version history behind β and because dossier IDs are deterministic, a later dossier recreated under the same name could silently inherit that orphaned history as its own; both deletes now run inside a single transaction. The live signal feed's source filter had no branch for eu at all, so a personal search scoped to EU alone silently returned zero results; the existing EU feed client is now wired into the live endpoint the same way the curation cycle already uses it. Dossierbeheer's narrative fields (waarom nu / waarover / ons verhaal) are stored as Markdown but were rendered as plain text on the Issuekaart, showing literal ## headers and **bold** asterisks; it now uses the same react-markdown + rehype-sanitize pipeline as the editor's own preview.
Backend test coverage also rose sharply in this release: pa-dossiers.db.ts (table creation, seed-to-Markdown conversion, the relative-time formatter used on every dossier card) went from 43.75% to 98.75% statement coverage; curation.service.ts's notification-age-label and document-reference-lookup helpers, previously only reachable incidentally through the full curation pipeline, are now directly asserted against every branch; and pa-dossiers.routes.ts gained coverage for previously-untested error and validation branches across most of its mutation routes.
v3.9.1 β eDOCS AI Assistant MCP Source (July 2026)¶
EdocsMcpProvider β a fifth AI Assistant source¶
EdocsMcpProvider added as a fifth AI Assistant source (edocs, displayed first β left of Process Engine). Unlike every other MCP source, its subprocess calls this backend's own /v1/edocs/* HTTP surface β the same routes scripts/test-edocs-live.sh already proves working β rather than the OpenText eDOCS DM server directly, so EdocsService stays the single place that knows eDOCS' auth and API quirks. It authenticates via a client_credentials flow against Keycloak using a new, dedicated edocs-mcp-client, kept separate from the existing copilot-studio-edocs client (which has its own unrelated, unresolved custom-connector OAuth constraints).
Four tools exposed, scoped strictly to the routes proven live: workspace_list, workspace_documents, document_profile, document_versions. No tool was added on the basis of the OpenAPI spec alone β there is deliberately no document_list / browse-by-author tool, since browsing documents outside a workspace has no live-tested backend route yet. See MCP AI Assistant β eDOCS tools for the full architecture.
Also: GET /v1/edocs/status now includes baseUrl alongside the existing library/stubMode/reachable/authenticated fields.
Also shipped in v3.9.1
WatchBell & Meldingen β per-user notifications for watched dossiers and searches. A PA-Cockpit feature with no developer-page surface in this section; see the Features changelog for v3.9.1.
v3.9.0 β Doccle Integration + eDOCS Live-Fixes (July 2026)¶
/v1/doccle routes added, mirroring the eDOCS integration pattern (stub mode, JWT-gated, reachability-only health check). See Doccle β Live Testing.
Five live-verified eDOCS bugs fixed against a real DM server (multipart upload shape, APP_ID default, mandatory UV_AFD_NAAM, workspace-search parsing, the getWorkspaceDocuments endpoint) β full detail already tracked in eDOCS β Live Testing.
v3.7.3 β Backend Test-Coverage Campaign (July 2026)¶
A two-phase coverage campaign (~667 β 829 tests) brought every backend feature area under test for the first time, including the standalone MCP servers (mcp-servers/lde, mcp-servers/triplydb). See Testing β Backend unit & integration tests.
v3.5.5 β Dev Tooling: Dependency Preflight Check (July 2026)¶
npm run dev now runs a deps:check preflight (scripts/check-deps.sh) before the Docker check β fails fast with a clear "run npm install" message instead of a MODULE_NOT_FOUND crash mid-boot when dependencies drift after a git pull. It compares package-lock.json's mtime against the node_modules/.package-lock.json install marker npm writes after every install; advisory only, it never installs anything on its own. See Local Development β Start development servers.
v3.4.1 β AI Assistant: Model Retirement Fix (June 2026)¶
Anthropic retired the dated claude-sonnet-4-20250514 / claude-opus-4-20250514 snapshots, which started returning a live 404 not_found_error from the API on their retirement date. AnthropicLlmProvider's model registry now uses non-expiring aliases (claude-sonnet-4-6, claude-opus-4-8) instead of dated snapshots. Provider errors (retired model, auth, rate limit, overload) are now translated to a clean, code-driven Dutch message instead of surfacing the raw Anthropic API payload. See LLM Provider Architecture β Registered providers.
v3.1.0 β Caseworker Dashboard: V1 Retired (June 2026)¶
Following the V2 shell's introduction (see v3.0.0βv3.0.6 below), /dashboard/caseworker now serves V2 exclusively β the V1 three-zone shell and its now-orphaned section components were deleted, and the temporary /dashboard/caseworker/v2 redirect route was removed in favour of the canonical /dashboard/caseworker path.
v3.0.8 β Security Hardening: Public Write Endpoints (June 2026)¶
ALTCHA proof-of-work + upload/rate-limit hardening¶
Following work item #33 β public routes /use-case, /upload-file, and /feedback accepted requests with no auth, rate limiting, or CAPTCHA β the gaps were closed:
- ALTCHA proof-of-work added to
POST /use-caseandPOST /feedbackβ visitors must complete a SHA-256 PoW puzzle (GET /v1/public/altcha/challenge, max 50,000 iterations, 10-minute expiry, viaaltcha-lib) before a GitLab work item is created.ALTCHA_HMAC_KEYconfigures the HMAC secret; when unset, the check bypasses gracefully so development environments without the key are not blocked./upload-fileis intentionally excluded β it's a pre-upload step, not the final submission gate. - Upload type whitelist tightened on
POST /upload-file: only images, PDF, plain text, Word/ODT, Excel, and XML are accepted, with MIME type and file extension checked independently to block extension spoofing. - Rate limit on public write endpoints reduced from the global 100 req/min to 10 req per 15 minutes per IP, with standardised
RateLimit-*response headers. - Build: backend
tsconfig.jsonupgraded from CommonJS/Node10 tomodule: node16/moduleResolution: node16β enables subpath-exports resolution and aligns TypeScript's module semantics with the Node.js runtime. See TypeScript path aliases.
The following gap versions (v3.1.1βv3.1.2, v3.2.0βv3.2.1, v3.3.0βv3.4.0, v3.4.2, v3.5.0βv3.5.4, v3.6.0βv3.6.1, v3.7.0βv3.7.2, v3.8.0βv3.8.3) shipped PA-Cockpit, Woo-dashboard, or Infra-board feature work only, with no developer-perspective architecture change β see the Features changelog for those versions.
v3.0.7 β Production Cutover (May 2026)¶
Production brought to parity with ACC¶
PROD (previously v2.9.2) brought up to the ACC line. Operationally notable:
- PROD backend workflow fix.
.github/workflows/azure-backend-prod.ymlcorrected to delete the@ronl/sharedworkspace dependency from the deploypackage.jsonbeforenpm install --production(npm pkg delete dependencies.@ronl/shared) and to copyshared/distintonode_modules/@ronl/shared/with the correct nesting β matching the ACC workflow. The previous PROD workflow produced a non-functional zip. - New PROD App Service settings.
ANTHROPIC_API_KEY(required β the backend aborts at startup without it), plus the MCP/TriplyDB/CPRMV/LDE, GitLab, eDOCS, andREDIS_URLsettings. See Environment Variables. - PROD Keycloak realm sync. The nine Management Capacity Claim roles and associated clients/mappers imported into the PROD realm.
- Frontend env.
.env.production/.env.acceptanceare force-tracked in the repo and travel with the merge;VITE_LDE_API_URLpoints at the standalone LDE backend, not the business API.
LDE backend is a separate deployment
The Procesbibliotheek section calls the standalone LDE backend (backend.linkeddata.open-regels.nl) directly from the browser, not via the business API. That backend has its own ACC/PROD environment split and its own CORS allowlist. Each new frontend origin (e.g. https://mijn.open-regels.nl) must be added to the LDE backend's allowlist or the Procesbibliotheek section fails with a CORS error while the rest of the dashboard works. See Troubleshooting β Procesbibliotheek CORS.
v3.0.0βv3.0.6 β V2 Caseworker Dashboard cutover (AprilβMay 2026)¶
V2 caseworker dashboard becomes the default¶
/dashboard/caseworker/v2 now serves the V2 shell. The V1 three-zone shell will be retired; /dashboard/caseworker/v2 will redirect to the canonical route for one release to catch stale bookmarks. New surface:
- 3-mode information architecture (Werk Β· Zoeken Β· Beheer) replacing the flat ~25-item left panel β
pages/caseworker-v2/modes.config.ts - βK command palette (
CommandPalette.tsx) β any section in two keystrokes, filtered by the same visibility gate as the rail - Right-side assistant dock (
AssistantDock.tsx) β replaces the full-screen chat tab; conversation persisted tosessionStorage - Single
isRailItemVisible(item, ctx)predicate used by both rail and palette;requiredRoles/requiredOrgTypescapability onRailItem - Defence-in-depth gate in
SectionRouterviafindGateFor()+<NoAccessPanel>β gated sections cannot leak via deep-link or palette SectionErrorBoundaryβ a render error in one section no longer takes down the shell
See Caseworker Dashboard (V2).
DvTP consent flow (v3.0.1)¶
DvtpStartSection / DvtpTakenSection added under Werk β DVTP, gated to municipality org types. Starts the DvtpToestemmingGevenProcess BPMN via ProcessStartFormViewer. dvtp feature flag added to tenants.json.
Management Capacity Claim (v3.0.2)¶
ManagementCapacityClaimProcess BPMN + /v1/hr-capacity/* routes (capacity.routes.ts). CapacityClaimSection (manager-gated), CapacityClaimArchiefSection, inline CapacityClaimDocumentsViewer. Nine new realm roles. Role-based candidateGroups task queue filtering.
Nieuws RSS feed migration β revert¶
nieuws.service.ts was migrated to the Rijksoverheid /api/rss?query= JSON API and then reverted to the legacy feeds.rijksoverheid.nl/nieuws.rss subdomain due to upstream technical issues. Cold-cache failure handling hardened: a 200 with zero parsed items is now treated as a failure rather than caching an empty list.
v2.9.7 β Feature Release (April 3, 2026)¶
AI Assistant β CPRMV Legislation Provider¶
CprmvMcpProvider added to the MCP registry. Connects to the CPRMV HTTP MCP server at acc.cprmv.open-regels.nl/mcp using StreamableHTTPClientTransport β a remote HTTP endpoint, not a subprocess. Enabled via CPRMV_MCP_ENABLED=true; URL overridable via CPRMV_URL.
Three tools exposed: rules_rules__rule_id_path__get (retrieve rules from BWB, CVDR, or EU CELLAR by rule ID path), ref_ref__referencemethod___reference__get (resolve rules by Juriconnect reference), celex_cellar_by_celex__celexid___language___format__get (look up EU CELLAR publications by CELEX id).
config.cprmv added to Config: enabled (CPRMV_MCP_ENABLED, default false), url (CPRMV_URL).
AI Assistant β LDE Process Library Provider¶
LdeMcpProvider added. Spawns a custom lde-mcp stdio subprocess (src/mcp-servers/lde/index.ts in dev, dist/mcp-servers/lde/index.js in prod) that connects directly to the LDE lde_assets PostgreSQL database. Enabled via LDE_MCP_ENABLED=true and LDE_DATABASE_URL.
Six tools: bundle_list, bundle_get (deployed BPMN bundles with forms, documents, subprocesses, and DMN keys), form_list, form_get (full Camunda Form JSON schema), document_list, document_get (zones and bindings).
SSL handled by stripping sslmode from the connection URL and passing ssl: { rejectUnauthorized: true } to the pg Pool constructor directly β avoids the pg-connection-string sslmode=require deprecation warning.
config.lde added to Config: enabled (LDE_MCP_ENABLED, default false), databaseUrl (LDE_DATABASE_URL).
AI Assistant β LLM Provider Architecture¶
LlmProvider interface introduced in src/services/llm/LlmProvider.ts. Decouples the agentic loop from any specific SDK β mcpChat.service.ts has no direct dependency on Anthropic or OpenAI. Provider-agnostic types: AgentMessage, AgentToolUse, AgentToolResult, LlmStreamParams, LlmTurnResult.
LlmRegistry singleton maps model IDs to their owning provider. getAvailableModels() returns only models from providers where isAvailable() is true.
AnthropicLlmProvider registered with three models: claude-sonnet-4-20250514, claude-opus-4-20250514, claude-haiku-4-5-20251001. Enabled when ANTHROPIC_API_KEY is set.
OpenAILlmProvider registered with gpt-4o and gpt-4o-mini. Enabled when OPENAI_API_KEY is set.
GET /v1/mcp/models added β returns all available models with providerId and providerDisplayName. Used by the frontend model selector dropdown.
POST /v1/mcp/chat body extended with modelId: string β required field; returns 400 INVALID_REQUEST when absent.
Frontend: model selector dropdown rendered below the subtitle in the AI Assistant header. Hidden when only one model is available. First available model pre-selected on mount.
Caseworker Dashboard β Procesbibliotheek¶
New procesbibliotheek section added to the Home tab for all tenants whose leftPanelSections.home includes the entry (currently Utrecht, Amsterdam, Rotterdam, Den Haag, Flevoland). Publicly accessible (isPublic: true).
Fetches deployed BPMN bundles from the LDE public API (VITE_LDE_API_URL/bundles/public). A dedicated ldeApi Axios instance is used β no Keycloak Authorization header is sent. Cards show process name, bpmnProcessId, status badge (WIP/Actief/Concept), role badge (Standalone/Subprocess), and deployment date; expand to reveal forms, documents, DMN keys, and deployment ID.
ProcessBundle, BundleDeployedForm, BundleDeployedDocument types exported from api.ts. VITE_LDE_API_URL added to all env files and vite-env.d.ts.
v2.9.6 β Enhancement (April 2, 2026)¶
IOU β Gebruiksscenario indienen β UX improvements¶
Sub-step number badges in step 6 (Concrete Example) changed from filled blue circles (bg-blue-600 rounded-full) to slate rounded squares (bg-slate-500 rounded-md), eliminating the visual collision with the section header badges which share the same shape and colour. The size was reduced from w-6 h-6 to w-5 h-5 to keep them visually subordinate to the section headers, and font-mono applied so the counter numerals read as distinct from section numbers.
Step 6 now has a remove button per row β only rendered when more than one step is present to prevent accidental full deletion. The button turns red on hover to signal destructive intent.
Step 9 (Existing Materials) gains an optional file attachment zone below the existing material checkboxes β drag-and-drop or file picker, any file type, up to 5 files at 10 MB each.
Backend β new endpoint¶
POST /v1/public/upload-file added to public.routes.ts. Accepts a single file of any type via multipart/form-data (field name file), uploads it to the GitLab project uploads API using GITLAB_TOKEN, and returns the GitLab-generated markdown reference ({ success: true, data: { markdown } }). Uses a dedicated uploadAny multer instance without the image-only fileFilter used by the /feedback route.
The /use-case submission remains plain JSON (Content-Type: application/json). Attachments are pre-uploaded one-by-one via POST /v1/public/upload-file before the issue is created; the returned markdown references are appended as a ## Bijlagen Β· Attachments section in the issue body. This avoids a multer v2 req.body field-parsing failure that occurred when text fields were submitted alongside files in multipart/form-data β text fields arrived as undefined regardless of file presence.
Backend β development noise fix¶
ExternalTaskWorker.asyncResponseTimeout reduced to 5 000 ms when NODE_ENV !== 'production' (was 20 000 ms). The long-poll window exceeded the TCP keep-alive timeout on the network path between the local dev machine and the remote Operaton VM, causing repeated ECONNRESET poll errors in the development log. The worker still runs locally; only the poll window is shortened.
v2.9.5 β Feature Release (April 1, 2026)¶
Caseworker Dashboard β IOU tab (Flevoland)¶
New IOU top-nav tab added, tenant-scoped to the flevoland tenant via tenants.json β leftPanelSections.iou. The tab is visible without authentication; submission sections require login. Four sections:
| Section | Auth required | Description |
|---|---|---|
| Gebruiksscenario indienen | Yes | 10-section submission form (title, submitter, description, current situation, desired outcome, concrete example, legislation, affected parties, existing materials, priority). POSTs to POST /v1/public/use-case; organisation pre-filled as "Provincie Flevoland". |
| Feedback geven | Yes | Feedback form with submitter info, description, and screenshot upload β paste (Ctrl+V), drag-and-drop, or file picker; up to 5 images at 10 MB each. POSTs to POST /v1/public/feedback. |
| Actieve zaken | No | Read-only list of open GitLab issues via GET /v1/public/use-cases?state=opened. Expandable cards rendered with react-markdown + remark-gfm; parsed sections: Indiener table, Beschrijving, and Gewenst resultaat. |
| Archief | No | Same component as Actieve zaken with state=closed. |
The IOU badge count on the top-nav IOU tab is populated by IouZakenSection via an onCountChange callback β identical pattern to the task count badge on the Projecten tab.
IouZakenSection is shared by both list views; the WORK_ITEM_FIELDS constant controls which markdown sections are extracted and displayed per card. Main content area overflow corrected from flex-col to block so all long-form sections scroll correctly.
To enable the IOU tab for another tenant, add an iou key with the four section entries to that tenant's leftPanelSections in tenants.json. No code changes are required.
Backend β IOU public endpoints¶
GET /v1/public/use-cases added to public.routes.ts. Lists GitLab issues for GITLAB_PROJECT_PATH; supports ?state=opened (default) or ?state=closed; returns up to 100 items sorted by created_at descending. Returns iid, title, state, created_at, updated_at, web_url, labels, assignees, and description per item. No authentication required.
POST /v1/public/feedback added to public.routes.ts. Accepts multipart/form-data with fields name, org, role, contact, description, and up to 5 image files under the field name screenshots. Each image is first uploaded to the GitLab project uploads API; the returned markdown references are embedded in the issue body. Uses multer in-memory storage with a per-file 10 MB limit and an image-only fileFilter. No authentication required.
Both endpoints require GITLAB_TOKEN, GITLAB_BASE_URL, and GITLAB_PROJECT_PATH to be set. Missing configuration returns 503 GITLAB_NOT_CONFIGURED.
See IOU GitLab Integration for full setup instructions, environment variable reference, and the curl verification steps.
v2.9.4 β Feature Release (March 30, 2026)¶
AI Assistant β Multi-Source MCP Registry¶
McpClientService singleton replaced by McpRegistry β a provider registry that manages multiple independent MCP sources. Each provider connects, exposes a curated set of tools, and contributes a section to the composite system prompt independently. A provider failure does not block other providers.
McpProvider interface introduced: id, displayName, description, connect(), disconnect(), getToolDefinitions(), callTool(), isConnected(), systemPromptContribution().
OperatonMcpProvider replaces McpClientService β identical stdio subprocess behaviour, 15-tool ALLOWED_TOOLS curation gate preserved.
TriplyDbMcpProvider added β spawns the bundled triplydb-mcp stdio server; connects to the RONL SPARQL endpoint (stevengort/RONL). Exposes 11 tools: dmn_list, dmn_get, dmn_chain_links, dmn_enhanced_chain_links, dmn_semantic_equivalences, organization_list, service_list, rule_list, concept_list, service_rules_metadata, sparql_query. Enabled via TRIPLYDB_MCP_ENABLED=true.
McpRegistry.getToolDefinitions(providerIds?) and callTool() accept an optional provider ID filter. buildSystemPrompt(providerIds?) assembles a composite prompt from only the selected connected providers. getProviderMeta() returns metadata and connection status for all registered providers.
POST /v1/mcp/chat extended with sources: string[] β provider IDs selected by the user for the session. GET /v1/mcp/sources added β returns provider metadata and connection status.
Frontend: source selector toggle buttons rendered below the message history. All connected sources pre-selected by default; offline providers shown greyed-out. Send button and textarea disabled when no sources are selected. Header subtitle shows active source display names dynamically.
Markdown rendering added to assistant bubbles via react-markdown + @tailwindcss/typography prose classes. In-progress streaming bubble also renders Markdown incrementally.
v2.9.3 β Feature Release (March 26, 2026)¶
Caseworker Dashboard β Berichten & Regelcatalogus¶
- Berichten endpoint switched from hardcoded seed data to the Provincie Flevoland RSS feed (
flevoland.nl/Content/Pages/Loket?rss=news) β same axios/regex pattern as the Nieuws service, 10-minute cache TTL. - HTML entities decoded server-side (
nbsp,amp,euro,lt,gt,quot); action link populated from RSS<link>element as "Lees meer". getBerichtById()now reads from the live cache instead of the removedSEEDconstant;/berichtenand/berichten/:idroutes made async.BerichtenSectionfooter row now rendersitem.actionas a "Lees meer β" anchor, matching theNieuwsSectionpattern.- Berichten section moved above Nieuws in
leftPanelSections.homefor all tenants intenants.json. - Regelcatalogus default active tab changed from
dienstentoorganisaties.
Caseworker Dashboard β Producten & Diensten Catalogus¶
- New "Producten & Diensten" section added to the Flevoland tenant home panel β publicly accessible without login.
- Backend service fetches the Provincie Flevoland SC4.0 product feed (
flevoland.nl/loket/loketoverview?sc40=true) β XML parsed server-side with no additional dependency, 30-minute cache TTL. - New
GET /v1/public/producten-dienstenendpoint; returnsid,title,description,url,audience,onlineAanvragen, andmodifiedper item. ProductenDienstenCataloguscomponent: expandable 2-column card grid styled afterRegelCatalogus, with free-text search and audience filter (Alle / Ondernemer / Particulier).- Cards show audience badges and an "Online aanvragen" badge where applicable; expanded card links directly to the product page on flevoland.nl.
- Stats row shows total visible product count and number of online-aanvraagbare items.
- Main content area overflow corrected from
overflow-hiddentooverflow-y-autoβ all sections with long content lists are now fully scrollable.
AI Assistant β SSE Streaming¶
POST /v1/mcp/chatreplaced with SSE streaming βContent-Type: text/event-stream, headers flushed immediately,X-Accel-Buffering: noset for Caddy; three event types:status(tool call starting),delta(text token),done(loop complete).client.messages.stream()used in place ofmessages.create(); text deltas emitted immediately on all rounds so the user sees tokens arrive in real time.- Tool result payloads capped at 12,000 characters before being added to the messages array β prevents prompt-too-long errors on multi-round queries that return large Operaton JSON responses.
- Timeout raised to 240s for the SSE endpoint.
POST /v1/mcp/chatexcluded from audit log middleware alongsideGET /v1/admin/audit.AbortControllerthreaded through the streaming loop and tool execution: fires on client disconnect and on timeout.businessApi.mcp.chatStream()async generator inapi.tsreplaces the axios POST β refreshes Keycloak token first, then consumes the SSEReadableStreamline-by-line and yields typedMcpChatStreamEventobjects.McpChatSection: in-progress assistant bubble updates token-by-token ondeltaevents with a blinking cursor; status line above the typing dots shows the active tool name (e.g.Calling deployment_listβ¦) between rounds; Clear chat aborts any in-flight stream;AbortControllercancelled on unmount.
v2.9.2 β Refactor (March 23, 2026)¶
Regelcatalogus β tab order¶
Tab order changed to Organisaties β Diensten β Regels β Concepten. The TABS array in RegelCatalogus.tsx was reordered; no data or API changes.
Caseworker Dashboard β component extraction¶
CaseworkerDashboard.tsx reduced from ~2 500 lines to a pure shell responsible for auth state, tenant config, navigation state, and layout only β no domain logic remains in the page file. All sections extracted to src/components/CaseworkerDashboard/:
NieuwsSection,BerichtenSectionβ own their fetch lifecycle;PRIORITY_STYLESandTYPE_LABELSmoved intoBerichtenSectionArchiefSectionβ owns task history fetch, grouping logic, variable cache, and expand stateOnboardingArchiefSectionβ role-gated tohr-medewerker; owns completed onboarding list fetch andDecisionViewerexpand stateRipFase1WipSection,RipFase1GereedSectionβ role-gated toinfra-projectteam; each owns its own project list fetch and viewer expand stateGereedschapSectionβ owns all three status API calls (eDOCS, Operaton, external);PLATFORM_TOOLSconstant moved out of the page fileTakenSectionβ owns full task queue lifecycle including list fetch, select, claim,TaskFormViewerintegration, andonCountChangecallback for the top nav badgeHrOnboardingSection,RipFase1Sectionβ each owns its started/error state, eliminating the last uses of sharedactionMessagestateAuditSectionβ handles bothaudit-overzichtandaudit-detailstabs viaactiveTabprop, owns paginated fetch and load-more stateProfielSectionβ consumesuseProfielDatahook; ownsemployeeIdInputfor manual ID lookup fallbackRollenSectionβ consumesuseProfielDataindependently; derives onboarding roles and access level displayuseProfielDatahook introduced insrc/hooks/useProfielData.tsβ shared byProfielSectionandRollenSectionformatDateextracted tosrc/utils/formatDate.tsand shared across components
v2.9.1 β Feature Release (March 21, 2026)¶
Archive β Completed tasks¶
Archief section added to the Projecten tab. Completed tasks are fetched from the Operaton historic task API (GET /history/task?finished=true) via the new GET /v1/task/history backend endpoint. The endpoint is tenant-scoped via the municipality process variable and registered before /:id to prevent route shadowing.
OperatonService.getCompletedTasks(tenantId) fetches up to 200 completed tasks sorted by endTime descending.
In the frontend, tasks are grouped by processDefinitionKey β identical to the active task queue: mono uppercase group headers, groups sorted by most recent endTime. Each task card shows name, completion date, and assignee. Expanding a card loads historic process variables via the existing historicVariables endpoint; variables are cached per processInstanceId.
businessApi.task.history() added to api.ts with HistoricTask type from @ronl/shared.
v2.9.0 β Feature Release (March 20, 2026)¶
Caseworker Dashboard β Gereedschap¶
New Gereedschap top-nav page added as a platform-scoped tab β not tenant-configured, visible to all authenticated caseworkers regardless of organisation.
Eight tool cards: CPSV Editor, CPRMV API, TriplyDB, Linked Data Explorer, Operaton Cockpit, eDOCS, SAP, KMS. Each active tool opens in a new browser tab; placeholder tools (eDOCS, SAP, KMS) show an orange Binnenkort badge with no open button. Operaton Cockpit and SAP are only visible to users with the admin role.
Live status widgets:
| Tool | Source |
|---|---|
| Operaton Cockpit | GET /v1/health β existing health endpoint |
| eDOCS | GET /v1/edocs/status β stub/live/offline |
| CPRMV API, TriplyDB, LDE | GET /v1/health/external β server-side HEAD requests to avoid CORS |
GET /v1/health/external added to health.routes.ts. It performs parallel HEAD requests (5-second timeout) to acc.cprmv.open-regels.nl, api.open-regels.triply.cc, and acc.linkeddata.open-regels.nl, returning { status: "up"|"down", latency: number } per service.
Adding a new tool requires a single entry in the PLATFORM_TOOLS constant in GereedschapSection.tsx. No other code changes are required.
businessApi.externalStatus() added to api.ts. businessApi.health() error handling hardened to extract dependency data from axios 503 responses.
v2.8.2 β March 19, 2026¶
Audit log β database persistence fixes¶
persistAuditLog() in audit.service.ts refactored to pass an explicit named-parameter object to pg-promise instead of spreading AuditLogEntry. The spread caused pg-promise to throw Property 'resourceType' doesn't exist for any field not referenced in the SQL template (specifically azp added in v2.8.1), silently suppressing all audit log writes to the database on ACC.
ipAddress port stripping now applied in the explicit object β Azure App Service appends the port to req.ip, which is invalid for PostgreSQL inet type. This error was masked by the spread error and is now also fixed.
v2.8.1 β March 19, 2026¶
Audit log β M2M tenant fallback¶
persistAuditLog() now falls back to the azp claim when tenantId is absent, preventing a NOT NULL violation on tenant_id for service account tokens. The fallback is applied only at the point of DB persistence β req.user.tenantId is unchanged.
jwt.middleware.ts reverted: tenantId is set exclusively from the municipality claim. The earlier azp fallback on req.user caused tenantMiddleware to pass M2M tokens through to tenant-scoped routes, returning empty data instead of MISSING_TENANT.
azp?: string added to AuditLogEntry in audit.types.ts and to AuthContext in auth.types.ts. azp populated on req.auth in jwt.middleware.ts and passed through createAuditLog() β eliminates type casts in audit.middleware.ts.
v2.8.0 β March 19, 2026¶
M2M API β Operaton access¶
New /v1/m2m/* route group in m2m.routes.ts applies jwtMiddleware only β no tenantMiddleware. M2M clients are system actors not scoped to a single organisation, so tenant isolation is intentionally absent.
The full Operaton surface is exposed: process (list, start, status, variables, historic-variables, history, decision-document, start-form, variable-hints, delete), task (list, get, variables, form-schema, claim, complete), and decision (evaluate, get).
A M2M_ALLOWED_OPERATIONS constant at the top of m2m.routes.ts acts as a curation gate β comment out any entry to disable that operation with no other code changes required.
Dedicated Operaton instance supported via OPERATON_M2M_BASE_URL, OPERATON_M2M_USERNAME, OPERATON_M2M_PASSWORD β falls back to the shared instance when unset. On ACC, the M2M routes point at operaton-doc.open-regels.nl.
See Operaton MCP Client for the full setup, curl verification steps, and curation instructions.
OperatonService β new public methods and constructor¶
getUserTasks() parameters made optional β tenantId omitted returns an unfiltered task list; existing callers with tenantId are unaffected.
getTaskVariables(taskId) added: resolves processInstanceId via getTask(), returns flattened process variables.
listProcessInstances(params?), queryProcessHistory(body), and getDecisionDefinition(key) added as thin pass-throughs to Operaton with no tenant filter, intended for M2M callers.
OperatonService constructor updated to accept optional baseUrl, username, and password parameters β the existing singleton instantiation is unchanged.
Keycloak β operaton-mcp-client¶
New confidential client operaton-mcp-client registered in the ronl realm: service accounts enabled, Client Credentials grant only, audience mapper targeting ronl-business-api. No municipality or organisation_type claims β M2M client has no tenant context by design.
Audit log β M2M tenant fallback¶
extractUser() in jwt.middleware.ts falls back to the azp claim when municipality is absent, preventing a NOT NULL violation on tenant_id for service account tokens. M2M audit entries record tenant_id as the Keycloak client ID (e.g. operaton-mcp-client).
v2.7.0 β March 14, 2026¶
eDOCS Service β Live Mode¶
EdocsService ported to packages/backend/src/services/edocs.service.ts: session token caching via POST /connect, automatic re-authentication on 401/403, ensureWorkspace, uploadDocument, getWorkspaceDocuments, and healthCheck. When EDOCS_STUB_MODE=true (default) all methods return realistic fake responses β the stub is fully transparent to callers.
ExternalTaskWorker ported to packages/backend/src/services/externalTaskWorker.service.ts: long-polling Operaton's external task API on topics rip-edocs-workspace and rip-edocs-document. The worker starts inside the app.listen() callback and stops cleanly on SIGTERM/SIGINT.
edocs.routes.ts rewritten to delegate to EdocsService β all four endpoints (/status, /workspaces/ensure, /documents, /workspaces/:id/documents) are now backed by the service rather than hardcoded stub responses.
config.ts extended with an edocs block reading EDOCS_BASE_URL, EDOCS_LIBRARY, EDOCS_USER_ID, EDOCS_PASSWORD, and EDOCS_STUB_MODE. utils/errors.ts added with the getErrorMessage() helper.
Copilot Studio β eDOCS OAuth Connection¶
Keycloak client copilot-studio-edocs registered in ronl-realm: confidential, service accounts enabled, Client Credentials grant only, audience mapper targeting ronl-business-api. The OAuth 2.0 connection was verified end-to-end on ACC.
See Copilot Studio β eDOCS OAuth Integration for the full setup, curl verification steps and Live Mode switch.
v2.6.0 β Feature Release (March 13, 2026)¶
RIP Phase 1 β Process Bundle (Flevoland) ποΈ
RipPhase1ProcessBPMN deployed: 17-step process covering intake β eDOCS workspace β intake meeting β intake report β approval loop β PSU β PSU report β risk file β PDP β approval loop β end.RipProjectTypeAssignmentDMN mapsdepartment+projectTypetocandidateGroups(infra-projectteam) andassignedRoles(infra-medewerker). Hit policy FIRST; structured for per-role granularity in future iterations.- Seven task forms:
rip-intake,rip-intake-meeting,rip-intake-report,rip-psu-organize,rip-psu-execution,rip-risk-file,rip-approval(reusable at both approval gateways). - Three document templates bundled in deployment:
rip-intake-report.document(Column 2),rip-psu-report.document(Column 3),rip-pdp.document(Column 4). - eDOCS integration via Operaton external task pattern β LDE backend worker polls topics
rip-edocs-workspace(writesedocsWorkspaceId) andrip-edocs-document(writesedocsIntakeReportId,edocsPsuReportId,edocsPdpId). Stub mode (EDOCS_STUB_MODE=true) active by default. EmployeeRoleAssignmentDMN updated: allinfrastructuurdepartment rules prependinfra-projectteamtocandidateGroupsso onboarded infrastructure employees can claim RIP tasks without a separate configuration step.
RIP Phase 1 β Caseworker Dashboard ποΈ
- Projecten β RIP Fase 1 starten: role-gated to
infra-projectteam; startsRipPhase1Processwith a single button; success state directs to Taken. - Projecten β RIP Fase 1 WIP: lists all active
RipPhase1Processinstances for the municipality, enriched withprojectNumber,projectName,edocsWorkspaceId, and start date. Expands to three collapsible document sections (Intakeverslag, PSU-verslag, Voorlopige Ontwerpuitgangspunten); documents not yet produced show "Nog niet beschikbaar". - Projecten β RIP Fase 1 gereed: identical layout to WIP; shows completed instances with completion date via
GET /v1/rip/phase1/completed. - Document rendering reuses the TipTap/ProseMirror zone renderer from
DecisionViewerwith zone key normalisation (signoff/signOff,contactInfo/contactInformation).
Backend β RIP Phase 1 Endpoints βοΈ
GET /v1/rip/phase1/activeβ lists activeRipPhase1Processinstances for the caseworker's municipality.GET /v1/rip/phase1/:instanceId/documentsβ returns all three document templates from the deployment bundle plus current process variables in a single response; absent documents returnnull.GET /v1/rip/phase1/completedβ lists completedRipPhase1Processinstances enriched withendTime.- All three endpoints apply municipality-based tenant isolation consistent with all other process routes.
- eDOCS endpoints:
GET /v1/edocs/status,POST /v1/edocs/workspaces/ensure,POST /v1/edocs/documents,GET /v1/edocs/workspaces/:id/documents.
Keycloak β Flevoland RIP Roles π
infra-projectteamandinfra-medewerkerrealm roles added toronl-realm.json.test-infra-flevolandtest user added with rolescaseworker,infra-projectteam,infra-medewerkerand attributesmunicipality=flevoland,employeeId=EMP-FLV-001.
Caseworker Dashboard β UX β¨
- Procesgegevens panel restyled to match RIP WIP document sections β bordered card with consistent β²/βΌ toggle.
roleResultintermediate DMN variable excluded from Procesgegevens display.- RIP WIP zone key normalisation fixes crash when expanding Intakeverslag.
Session Expiry Warning β±οΈ
SessionExpiryWarningcomponent mounted in the caseworker dashboard β polls token expiry every 15 seconds and shows a modal when fewer than 2 minutes remain.- Modal offers Sessie verlengen (forces
updateToken) and Uitloggen; unsaved form data is preserved when extending. - Axios request interceptor upgraded to proactively call
updateToken(30)before every API request; forces re-login if the SSO session is gone.
v2.5.1 β Enhancement (March 12, 2026)¶
Caseworker Dashboard β Changelog Panel π
- Changelog panel button added to the caseworker dashboard header, mirroring the button already present on the login page.
- Button positioned to the right of the authenticated user block for consistent right-side placement.
- Accessible without login β visible to unauthenticated visitors alongside the public sections.
Nieuws β Government.nl RSS Feed π°
- Nieuws endpoint switched from the Rijksoverheid JSON API to the Government.nl RSS feed (
feeds.rijksoverheid.nl/nieuws.rss). - RSS parsed server-side with no additional dependency β
axiosresponseType: textwith regex-based item extraction. - Source attribution updated to Government.nl; CDATA and plain-text description fields both handled correctly.
- 10-minute cache TTL retained; stale cache returned on feed unavailability to prevent blank UI.
v2.5.0 β Feature Release (March 12, 2026)¶
Caseworker Dashboard β Regelcatalogus π
- New public section "Regelcatalogus" added to the Home tab β accessible without caseworker login.
- Diensten tab: Public services from the RONL knowledge graph displayed as expandable cards with full description and URI link; clicking "Toon concepten" navigates to the Concepten tab pre-filtered by that service.
- Organisaties tab: Implementing organisations with logo (TriplyDB assets API), homepage, and linked services.
- Concepten tab: NL-SBB concepts searchable by label, filterable by service; each concept has a direct link to the
skos:exactMatchURI. - Regels tab: Implementation rules grouped by service; searchable by rule name and description; groups expand automatically when searching; description expandable per rule.
Backend β Regelcatalogus Endpoint βοΈ
GET /v1/public/regelcatalogusβ no authentication required; returns services, organisations, concepts, and rules in a single response.- Five parallel SPARQL queries against the RONL TriplyDB endpoint:
PublicService,PublicOrganisation, competent authority links, NL-SBB concept traversal, andcpsv:Ruleimplementations. - Organisation logos resolved via TriplyDB assets API to versioned CDN URLs.
- 5-minute in-memory cache per data slice; stale cache returned on TriplyDB failure to prevent blank UI.
RONL_SPARQL_ENDPOINTenvironment variable added for overriding the default endpoint per deployment.
v2.4.1 β Feature Release (March 11, 2026)¶
Multi-Tenant Architecture β Organisation Types ποΈ
- Platform extended beyond municipalities: provinces and national government agencies now supported as first-class tenant categories.
- New
OrganisationTypeunion type:municipality | province | nationalβ shared across frontend, backend, and Keycloak (@ronl/shared). organisationTypeJWT claim added to all tokens via Keycloak protocol mapper (organisation_typeuser attribute).organisationTypepropagated throughAuthenticatedUser,JWTPayload, and BPMN process variables.TenantConfiggainsorganisationType(required) andorganisationCode(optional, for CBS PV codes, OIN, etc.);municipalityCodemade optional.tenants.jsonextended with Provincie Flevoland (province,PV24) and UWV (national) as reference tenants.- Backend error messages generalised: "municipality mismatch" β "organisation mismatch".
- PostgreSQL
tenantstable gainsorganisation_typeandorganisation_codecolumns. - Keycloak realm:
organisation_typeattribute and protocol mapper added; test users forflevolandanduwvadded.
v2.4.0 β Feature Release (March 11, 2026)¶
HR Onboarding Process π€
HrOnboardingProcessBPMN deployed: collect employee data β DMN role assignment β HR review β notify employee.EmployeeRoleAssignmentDMN mapsdepartment+jobFunctiontoassignedRoles,candidateGroups, andaccessLevel.- All user tasks use
candidateGroups="hr-medewerker"β claim-first workflow identical to Kapvergunning. - Process started with empty variables; first task (Collect employee data) appears in the task queue immediately.
hr-medewerkerrealm role added;test-hr-denhaagandtest-onboarded-denhaagtest users added for Den Haag.employeeIdprotocol mapper added toronl-business-api-dedicatedclient scope β injectsemployee_iduser attribute asemployeeIdJWT claim.
IT Handover Document π
hr-it-handover.documentauthored and bundled inHrOnboardingProcessdeployment.- Document linked via
ronl:documentRefonTask_NotifyEmployeeinHrOnboardingProcess.bpmn. - Template includes medewerkergegevens, toegangsspecificaties, and step-by-step Keycloak account creation instructions for IT.
- Bindings cover
employeeId,firstName,lastName,municipality,department,jobFunction,assignedRoles,candidateGroups,accessLevel,startDate.
Caseworker Dashboard β HR Sections ποΈ
- Persoonlijke info β Profiel: JWT identity card + onboarding data auto-fetched via
employeeIdclaim; manual input fallback when claim absent. - Persoonlijke info β Rollen & rechten: Assigned roles from completed onboarding process with access level description card.
- Persoonlijke info β Medewerker onboarden: Role-gated to
hr-medewerker; startsHrOnboardingProcesswith a single button; success state directs to task queue. - Persoonlijke info β Afgeronde onboardingen: Role-gated to
hr-medewerker; lists all completedHrOnboardingProcessinstances for the municipality with name, employee ID, and completion date; expand to render IT handover document viaDecisionViewer. GET /v1/hr/onboarding/profileβ returns flattened historic variables for a completed onboarding byemployeeId+ municipality.GET /v1/hr/onboarding/completedβ returns list of all completed onboarding instances enriched withemployeeId,firstName,lastName.
Caseworker Dashboard β UX Fixes β¨
- Header user block shows
preferred_username, LoA badge, and all role badges dynamically β supports multiple roles. - Unauthenticated navigation to any top-nav page now defaults to the first section in the left panel, showing the login prompt immediately without a second click.
- Afgeronde onboardingen access restricted to
hr-medewerkerrole β regular caseworkers see access-denied message.
v2.3.0 β Feature Release (March 9, 2026)¶
Citizen Dashboard β Document Template Viewer π
DecisionViewernow fetchesGET /v1/process/:id/decision-documentin parallel with historic variables. When aDocumentTemplateis bundled in the Operaton deployment, it is rendered as styled HTML β TipTap/ProseMirror JSON blocks converted to React elements,{{variableKey}}placeholders substituted from historic process variables. The letter layout (letterhead + contact information side-by-side, body, closing, sign-off, optional annex) mirrors the Document Composer canvas.- Falls back to the v2.2.0 form-js readonly schema for process instances deployed before document templates were introduced.
Backend β Decision Document Endpoint βοΈ
GET /v1/process/:id/decision-documentβ reads theronl:documentRefattribute from the BPMNUserTaskelement via the process definition XML, fetches the named.documentresource from the Operaton deployment bundle, and returns{ success: true, template: DocumentTemplate }.- Tenant isolation applied via
municipalityvariable β same pattern ashistoric-variables. - Returns 404
DOCUMENT_NOT_FOUNDwhen noronl:documentRefis present or the.documentresource is absent from the deployment bundle. - Route ordering in
process.routes.tscorrected: literal/historyroute and instance-ID sub-routes registered before definition-key sub-routes.
LDE β BPMN Document Linking π
BpmnCanvasproperties panel writesronl:documentRef="<templateId>"into the BPMN XML when a document template is linked to aUserTask.- The
ronlnamespace (http://ronl.nl/schema/1.0) is declared on the BPMNdefinitionselement. - The linked document template is bundled as a
.documentJSON file in the one-click deployment alongside BPMN and.formfiles.
v2.2.0 β Feature Release (March 5, 2026)¶
Citizen Dashboard β Dynamic Start Form π³
- Kapvergunning form replaced by
@bpmn-io/form-jsviewer β schema fetched live from the deployed process viaGET /v1/process/:key/start-form. - Form renders with
applicantIdandproductTypepre-populated as hidden initial data. - On submit, form variables are passed directly to
POST /v1/process/:key/startβ no hardcoded field mapping. - Falls back gracefully when no form is deployed (404/415).
Caseworker Dashboard β Dynamic Task Forms ποΈ
CaseReviewFormandNotifyApplicantFormreplaced by a singleTaskFormViewercomponent.- Form schema fetched per task via
GET /v1/task/:id/form-schemawith tenant isolation. - Process variables pre-populated into the form at import time β caseworker sees current DMN decisions immediately.
- FEEL conditional visibility on the
tree-felling-reviewform hides override fields unless caseworker selects Wijzigen. - Falls back to a generic "Taak voltooien" button when no form is deployed (
status === 'no-form').
Citizen Dashboard β Decision Viewer π
- Completed applications in Mijn aanvragen show a Bekijk beslissing toggle.
DecisionViewerfetches final variable state viaGET /v1/process/:id/historic-variables.- Readonly form renders
status,permitDecision,finalMessage,replacementInfo, anddossierReferenceβ caseworker-only fields excluded. - Historic variables available immediately after process completion β no polling required.
Backend β Form Schema Endpoints βοΈ
GET /v1/process/:key/start-formβ fetches deployed start form schema; returns 415UNSUPPORTED_FORM_TYPEfor legacy HTMLformKeydeployments.GET /v1/task/:id/form-schemaβ fetches deployed task form schema with tenant isolation; treats Operaton 400 (noformRefset) as 404FORM_NOT_FOUND.POST /api/dmns/process/deployβ deploys BPMN + subprocess BPMNs + Camunda Forms in one multipart request.
v2.1.0 β Feature Release (March 3, 2026)¶
AWB Kapvergunning Process π³
- Full two-layer AWB process implementation.
AwbShellProcessmanages the procedural framework (Awb phases 1β6): identity recording, receipt acknowledgement withdossierReferenceand statutory 8-week deadline (Awb 4:13), admissibility check viaAwbCompletenessCheckDMN (Awb 2:3), and citizen notification confirmation. TreeFellingPermitSubProcesshandles the substantive decision: bothTreeFellingDecisionandReplacementTreeDecisionDMNs are always evaluated before the caseworker review task, giving the caseworker full context.Sub_ResolveDecisionapplies overrides whenreviewAction = "change".camunda:historyTimeToLiveset to 365 days (shell) and 180 days (subprocess).
Caseworker Task Queue β Claim-First Workflow ποΈ
- All user tasks (
Sub_CaseReview,Task_Phase6_Notify,Task_RequestMissingInfo) now usecamunda:candidateGroups="caseworker"instead ofcamunda:assignee. - Tasks appear as Openstaand in the task queue and require an explicit claim before the action form is displayed.
- Removed dead
Task_ExtractCompletenessscriptTask fromAwbShellProcess(had no incoming or outgoing flows, was never executed).
Backend β Tenant Variable Serialisation βοΈ
- Tenant middleware now stores plain scalar values.
- Process start routes wrap with
inferType()before forwarding to Operaton. - Resolves
Must provide 'null' or String value for value of SerializableValue type 'Json'500 error onAwbShellProcessstart.
Frontend β v2.0.1 β Feature Release (February 27, 2026)¶
Caseworker login π’
Added a dedicated caseworker login path to the MijnOmgeving landing page. A slate-coloured "Inloggen als Medewerker" button, visually separated from the three citizen IdP options by a "MEDEWERKERS" section divider, initiates the new flow. AuthCallback uses check-sso instead of login-required, so caseworkers with an active Keycloak SSO session bypass the login screen on subsequent visits. When a new session is required, keycloak.login({ loginHint: '__medewerker__' }) redirects to Keycloak, where the custom login.ftl theme detects the sentinel and renders an indigo "Inloggen als gemeentemedewerker" context banner with "Medewerker portaal" as the page title.
Frontend β v2.0.0 β Major Release (February 2026)¶
Frontend Redesign π¨
- New landing page with identity provider selection (DigiD / eHerkenning / eIDAS)
- Custom Keycloak theme matching MijnOmgeving design
- Blue gradient header with rounded modern inputs
- Multi-tenant theming with CSS custom properties for runtime theme switching
- Dutch language support throughout authentication flow
- Mobile-responsive design for all screen sizes
Authentication Flow π
- Identity Provider selection before Keycloak authentication
- DigiD, eHerkenning, and eIDAS support (infrastructure ready)
- Seamless redirect flow with
idpHintparameter - Session storage for IDP selection persistence
- Enhanced error handling and user feedback
Infrastructure ποΈ
- Azure Static Web Apps deployment with SPA fallback routing
- Custom Keycloak theme deployment to VM
- Theme volume mounting for ACC and PROD environments
- Version-controlled deployment configurations
- Manual deployment process for VM-hosted services
Frontend β v1.5.0 β Feature Release (February 2026)¶
Multi-Tenant Support ποΈ
- Four municipalities supported: Utrecht, Amsterdam, Rotterdam, Den Haag
- Municipality-specific theming with custom colours and logos
- Tenant configuration via JSON for runtime theme switching
- Municipality claim in JWT tokens for backend tenant isolation
- Test users for each municipality with proper attributes
Zorgtoeslag Calculator π°
- DMN-based zorgtoeslag (healthcare allowance) calculation
- Integration with Operaton BPMN/DMN engine
- Business rules evaluation via REST API
- Result display with matched rules and annotations
- Support for multiple requirement checks and income thresholds
Security & Compliance π
- JWT audience validation for API security
- Role-based access control (citizen, caseworker, admin)
- Assurance level (LoA) claims for DigiD compliance
- Audit logging with 7-year retention
- BIO (Baseline Information Security) compliance ready
Backend / Frontend β v1.0.0 β Initial Release (JanuaryβFebruary 2026)¶
Status: Production
Released: February 2026
Backend Core
- Secure Business API Layer for Dutch municipality government services
- OIDC Authorization Code Flow + PKCE via Keycloak 23
- Multi-tenant isolation for Utrecht, Amsterdam, Rotterdam, Den Haag
- JWT validation with JWKS caching (Redis)
- Zorgtoeslag calculation via Operaton BPMN/DMN
- Compliance-grade audit logging (PostgreSQL, 7-year retention)
- Rate limiting per IP and per tenant
- Helmet security headers (CSP, HSTS)
- Versioned REST API (
/v1/*) following Dutch API Design Rules - Deprecated
/api/*routes withDeprecationheaders
Frontend Core ποΈ
- Monorepo structure with frontend, backend, and shared packages
- React 18 + TypeScript frontend with Vite build
- Express + TypeScript backend with PostgreSQL
- Keycloak 23.0 for authentication and authorisation
- Operaton integration for BPMN/DMN execution
Deployment π
- Azure Static Web Apps for frontend (ACC + PROD)
- Azure App Service for backend API
- VM-hosted Keycloak with separate ACC/PROD instances
- Caddy reverse proxy for SSL termination
- GitHub Actions for automated deployments
- Multi-tenant frontend theming via CSS custom properties
- Dynamic
tenants.jsonconfiguration (no rebuild needed for theme changes)
Supported municipalities
Utrecht, Amsterdam, Rotterdam, Den Haag β each with isolated data, custom theme, role-based access, and dedicated audit logs.
Technology versions
| Component | Version |
|---|---|
| Node.js | 20 |
| React | 18 |
| TypeScript | 5.3 |
| Keycloak | 23 |
| Express | 4.18 |
| Vite | Latest |
| Caddy | 2 |
| PostgreSQL | 16 |
Roadmap¶
Completed¶
| Feature | Version |
|---|---|
| Monorepo core architecture | v1.0.0 |
| Multi-tenant municipality support | v1.5.0 |
| Zorgtoeslag DMN calculator | v1.5.0 |
| IDP selection landing page | v2.0.0 |
| Custom Keycloak MijnOmgeving theme | v2.0.0 |
| DigiD / eHerkenning / eIDAS infrastructure | v2.0.0 |
| Caseworker login with SSO session reuse | v2.0.1 |
| CI/CD Vite environment configuration | v2.0.2 |
| AWB Kapvergunning process (AwbShellProcess + subprocess) | v2.1.0 |
| Caseworker claim-first task queue | v2.1.0 |
| BPMN design criteria reference documentation | v2.1.0 |
| Dynamic Camunda Forms β citizen start form | v2.2.0 |
| Dynamic Camunda Forms β caseworker task forms | v2.2.0 |
| Decision Viewer β citizen-facing historic variables | v2.2.0 |
| Decision Document Viewer β DocumentTemplate rendering | v2.3.0 |
| Backend decision-document endpoint | v2.3.0 |
LDE BPMN document linking (ronl:documentRef) |
v2.3.0 |
| HR Onboarding Process (BPMN + DMN) | v2.4.0 |
| IT Handover Document template | v2.4.0 |
| Caseworker Dashboard β HR sections | v2.4.0 |
| Multi-tenant organisation types (province, national) | v2.4.1 |
OrganisationType claim in JWT |
v2.4.1 |
| Caseworker Dashboard β Regelcatalogus | v2.5.0 |
| Backend Regelcatalogus endpoint (SPARQL + cache) | v2.5.0 |
| Changelog Panel in caseworker dashboard header | v2.5.1 |
| Nieuws β Government.nl RSS feed | v2.5.1 |
| RIP Phase 1 process bundle (Flevoland) | v2.6.0 |
| eDOCS integration β external task worker + stub mode | v2.6.0 |
| RIP Fase 1 starten / WIP / gereed dashboard sections | v2.6.0 |
infra-projectteam and infra-medewerker realm roles |
v2.6.0 |
| Session expiry warning modal + proactive token refresh | v2.6.0 |
Audit log β database persistence (audit_logs table) |
v2.7.1 |
| Audit log tab in caseworker dashboard | v2.7.1 |
| Commercial organisation type + cross-tenant processing | v2.7.3 |
M2M API β /v1/m2m/* route group |
v2.8.0 |
operaton-mcp-client Keycloak client |
v2.8.0 |
| Audit log β database persistence fixes | v2.8.2 |
| Gereedschap platform tools hub | v2.9.0 |
| Archief β completed task history | v2.9.1 |
| CaseworkerDashboard.tsx component extraction | v2.9.2 |
| Berichten β live Provincie Flevoland RSS feed | v2.9.3 |
| Producten & Diensten Catalogus (Flevoland) | v2.9.3 |
| AI Assistant β SSE streaming + TriplyDB Knowledge Graph | v2.9.3 |
| AI Assistant β Multi-Source MCP Registry (McpRegistry) | v2.9.4 |
| TriplyDbMcpProvider + Knowledge Graph tools | v2.9.4 |
| Source selector UI + Markdown rendering in chat bubbles | v2.9.4 |
| IOU tab β GitLab integration (Flevoland) | v2.9.5 |
GET /v1/public/use-cases, POST /v1/public/feedback |
v2.9.5 |
| IOU form UX β step badges, add/remove, file attachments | v2.9.6 |
POST /v1/public/upload-file |
v2.9.6 |
| CPRMV Legislation Provider | v2.9.7 |
| LDE Process Library Provider | v2.9.7 |
| LLM Provider Architecture (LlmRegistry, OpenAI support) | v2.9.7 |
| Procesbibliotheek section | v2.9.7 |
| V2 caseworker dashboard (3-mode shell, βK, dock) | v3.0.0 |
Section gating (requiredRoles/requiredOrgTypes) |
v3.0.0 |
SectionErrorBoundary per-section crash isolation |
v3.0.0 |
DvTP consent flow (DvtpToestemmingGevenProcess) |
v3.0.1 |
dvtp tenant feature flag |
v3.0.1 |
| Management Capacity Claim process + 9 realm roles | v3.0.2 |
/v1/hr-capacity/* route group |
v3.0.2 |
| Nieuws RSS feed migration β revert | v3.0.x |
| V1 dashboard retired; V2 is the default route | v3.0.x |
| PROD brought to ACC parity (cutover) | v3.0.7 |
| RIP phase endpoints generalised off R2.1 | v2026.09.0 |
| Phase progression: finishing a phase readies the next | v2026.09.0 |
| RIP ladder complete β twelve of twelve deelprocessen | v2026.09.3 |
| 80% per-file branch floor across all five workspaces | v2026.09.2 |
| Phase swimlanes derived from deployed BPMN | v2026.09.4 |
| Per-file branch floor enforced in CI, in all five runner configs | v2026.09.6 |
| Build provenance in the changelog panel and the public-site footer | v2026.09.6 |
CI alignment closed β acc and main carry the same rules |
v2026.09.7 |
ValidSign callbacks accepted under Basic and Bearer |
v2026.09.8 |
| A new R2.1 project is named at start | v2026.09.8 |
| The install is checked against the lockfile before every push | v2026.09.8 |
| Public process library shows every deployed process | v2026.09.9 |
Build checks required on acc, filtered in a changes job |
v2026.09.9 |
Package-manager cooldown (.npmrc) and a pinned runner image |
v2026.09.9 |
| Production promotion is one ordered run, backend first | v2026.09.10 |
| The backend deploys from CI over OIDC, installing from the lockfile | v2026.09.10 |
| A preview is opt-in, and can reach the acceptance backend | v2026.09.10 |
| Input and output concepts told apart on the public site | v2026.09.11 |
OpenAPI description published at /v1/openapi.json, held to the served routes by a test |
v2026.09.12 |
Tenant access decided in one place, failing closed (403 TENANT_MISMATCH) |
v2026.09.12 |
An SBOM per release, and a daily dependency audit of acc and main |
v2026.09.12 |
This table has a gap
Rows run from v1.0.0 to v3.0.7 and then jump to the September 2026 entries above. The CalVer releases in between β roughly v2026.07 through v2026.08.36 β shipped without their roadmap-level items being recorded here. The Changelog above is complete for that period; this table is not, and backfilling it accurately means re-reading forty-odd releases rather than guessing, so it is left visible rather than quietly patched.
Planned¶
Phase 2 β Identity Provider Activation (2026 Q2)
Live DigiD integration with BSN-based citizen authentication. eHerkenning activation for business users. eIDAS support for EU residents. Full SAML federation with Dutch government identity infrastructure.
Phase 3 β Extended Business Rules (2026 Q2βQ3)
Additional DMN-based benefit calculations beyond zorgtoeslag. Parameterised rule sets loaded from TriplyDB. Integration with CPSV Editor published service definitions. Case management workflow with caseworker assignment and review.
Phase 4 β BRP Integration (2026 Q3)
Real-time citizen data retrieval from BRP (Basisregistratie Personen). Pre-populated forms using authenticated citizen profile. Timeline navigation for historische persoonsgegevens.
Phase 5 β Audit & Compliance Dashboard (2026 Q4)
Real-time audit log viewer for municipality administrators. Compliance reporting against BIO baseline. DPIA (Data Protection Impact Assessment) evidence export. Role-based access management UI.