Skip to content

Release: merge development into beta - #18

Open
github-actions[bot] wants to merge 1278 commits into
betafrom
development
Open

Release: merge development into beta#18
github-actions[bot] wants to merge 1278 commits into
betafrom
development

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

rubenvdlinde and others added 27 commits July 12, 2026 11:10
Flag case, task, bezwaar, voorstel and beroep searchable in the procest
register and claim deep links for them, so the fleet-wide OpenRegister
ObjectsProvider surfaces them in the Nextcloud search bar. Version bumps
gate the register re-import. Consistency asserted by a vitest suite.

Change: case-search-via-or-unified-search (archived 2026-07-12)
…OR search leaf' (#192) from wip/case-search-via-or-unified-search into development
Adds an Export actions menu to the Cases page (actionsComponent slot)
that downloads the filtered case list through OpenRegister's export
endpoint - no procest-side serialization (ADR-022). URL building lives
in a unit-tested helper; 191/191 vitest green, manifest schema valid.

Change: case-list-export-via-or-export-leaf (archived 2026-07-12)
…e OpenRegister export endpoint' (#193) from wip/case-list-export-via-or-export-leaf into development
…face

Six OpenAPI 3.0.3 documents (zaken/documenten/catalogi/besluiten/
autorisaties/notificaties) describing every routed /api/zgw endpoint,
served via a public allow-listed ZgwOpenApiController (GET /api/zgw/
openapi index + per-API yaml). A route<->spec conformance PHPUnit test
fails on drift in either direction. Full unit suite green (1100 tests).

Change: zgw-openapi-publication (archived 2026-07-12)
…ith route-conformance gate' (#194) from wip/zgw-openapi-publication into development
Adds BulkStatusTransitionService (preview + execute, 100-id cap, partial
failure isolation) behind the transition engine's single write path, new
statusTransition#bulkPreview/bulkExecute routes, column-scoped selection
on the workflow board with a bulk-actions bar, and BulkTransitionDialog
(NcDialog) with per-case ready/blocked preview and result reporting.
Also fixes a pre-existing webpack error (dialogs v6 FilePicker imports
node path; resolve.fallback path:false). PHPUnit 1120 green, vitest 209
green, build compiles.

Change: case-bulk-status-transition (archived 2026-07-12)
…s (preview + per-case guards)' (#195) from wip/case-bulk-status-transition into development
Implements GET /api/ai/audit with OR-backed entry listing (caseId/type
filters, paging), a new AiAuditExportController for RBAC-gated CSV/JSON
export (auditors/secretariaat/beheerders/admin), and manifest pages
AiOversight/AiOversightDetail for inspecting AI suggestions and human
decisions. All 6 AI operations already recorded audit entries at
suggestion time (verified). Fixes PHPCS/PHPMD issues in new code.
PHPUnit 1141/0 fail, vitest 209/0 fail, manifest valid.

Change: ai-oversight-log (archived 2026-07-12)
…ht UI for EU AI Act Article 14' (#196) from wip/ai-oversight-log into development
…g every run

Fixes recurring log noise from procest's OpenRegister repair/seed steps:

- register.d/25-brp-kvk.json: 'bsn' is not a registered OpenRegister string
  format — replace with a pattern-validated string (ADR-011 unaffected,
  no procest-side validator was ever added).
- verwerkingsactiviteiten.json: shorten bewaartermijn strings that exceeded
  the OpenRegister varchar(64) column, which made every insert/update of
  the AVG verwerkingsregister catalogue fail with a DB truncation error.
- ObjectService::findAll() takes a single $config array; the previous
  named-argument calls (register:/schema:/limit:) in both
  LinkInFlight*DecisionsRepair steps threw "Unknown named parameter" on
  every run. Routed through the existing SearchesObjects::
  searchObjectsAsArrays() bridge instead.
- KccWerkplekSeedDataService::seedRows() called saveObject() with
  positional args ($register, $schema, $row) against a signature whose
  first parameter is the object payload — fixed to named arguments. Also
  removed a dead method_exists($objectService, 'findObjects') guard that
  always returned false (that method never existed on ObjectService),
  which silently skipped the idempotency check on every boot.
- Repair steps and boot-time seed services run without a Nextcloud user
  session, so anonymous callers are fail-closed by OpenRegister RBAC
  (#1955) on creates/updates and on reads without an explicit public
  grant. Added SearchesObjects::runAsSystemIfAvailable() (wraps
  ObjectService::runAsSystem() with a method_exists guard for back-compat)
  and used it in every affected repair/seed class so their own OpenRegister
  reads/writes — and, where scoped safely, the nested WorkflowDefinitionService
  calls they make — run as a trusted system principal instead of "Anonymous".
  This also fixes VTH workflow templates never actually seeding (caseType
  lookups were silently RBAC-denied, always reporting "not found").
- SeedVthWorkflowTemplates: downgrade the now-rare "caseType not found"
  case from warning to debug/info — it only fires on a genuine missing
  precondition (base-register-seed-data not yet run), not on every boot.

BesluitvormingTemplateService and KccWerkplekSeedDataService are only ever
invoked from boot-time repair steps (verified: no controller/live-request
callers), so wrapping their full write path in runAsSystem() does not widen
RBAC for any authenticated request.
… and RBAC-denying every run' (#197) from fix/log-noise-schemas into development
…uarded delete

Adds the top functional-admin wish from the zaaksysteem-family trackers
(open-zaak#693 copy, open-zaak#517 draft delete):

- POST /api/case-definitions/{id}/copy — deep-copies a case type: new id,
  title prefixed 'Copy of', forced back to draft, publication fields
  cleared, workflow-version pin and sibling case-type links dropped, and
  every owned sub-object (statusType, resultType, roleType,
  propertyDefinition, documentType, decisionType) copied and re-parented.
  404 when the source does not resolve.
- DELETE /api/case-definitions/{id} — deletes draft case types only;
  409 for published ones (unpublish first), 404 when missing.
- New CaseTypeCopyService follows the TemplateLibraryService pattern
  (SettingsService-resolved register/schema + ObjectService single write
  path, ADR-022).
- UI: Duplicate action on the case-type list rows and the detail header;
  both navigate to the new draft. List delete now goes through the
  guarded endpoint and surfaces the 409 as 'unpublish first'.
- Tests: CaseTypeCopyServiceTest (5) + CaseDefinitionControllerTest (5);
  suite 1141 -> 1151 green.
- Fixes pre-existing BrpKvkRegisterSetsTest failure: restored format:bsn
  on the BRP burgerservicenummer property (ADR-011).
- l10n: adds 16 missing en/nl key pairs flagged by check-l10n (2 new from
  this change, 14 pre-existing gaps).

openspec change archived to openspec/changes/archive/2026-07-13-zaaktype-copy.
…ft + draft-only guarded delete' (#198) from wip/zaaktype-copy into development
…tor workload

Adds WorkQueueService: a deterministic urgency scoring function (deadline
tiers via business-day math incl. termijn extensions/pauses, priority, case
age) with GET /api/work-queue (personal scored queue) and
GET /api/work-queue/workload (coordinator-only per-handler open-case
counts, gated the same way SubstitutionController gates coordinator
actions). My Work gets an Urgency/Newest sort toggle and per-card urgency
chips fed by the new endpoint, plus a workload summary bar for
coordinators — without reintroducing the client-side "werkvoorraad" board
that was previously retired (see openspec/specs/my-work/spec.md).

Also fixes a pre-existing bug in BrpKvkRegisterSetsTest: the assertion
expected a 'bsn' OpenRegister string format that the shipped fragment
never registers (it intentionally pattern-validates instead, per the
field's own description).
…08 features mapped across the abstraction stack' (#199) from wip/market-feature-workup-doc into development
…ant SaaS + ActionRegistry)

OpenRegister's ObjectService::findAll() takes a single $config array:

    findAll(array $config=[], bool $_rbac=true, bool $_multitenancy=true)

The named-argument form findAll(register:, schema:, limit:, offset:, filters:)
no longer exists. Every such call threw "Unknown named parameter $register"
at runtime. All 13 sites sit inside try/catch blocks that swallow Throwable
and degrade to an empty list, so the code paths were 100% dead but silent —
the app looked healthy while the feature never ran once.

ResetMonthlyQuotasJob is confirmed failing in the live Nextcloud log
("Procest: ResetMonthlyQuotasJob fetch failed" / "Unknown named parameter
$register"): the monthly quota reset has never reset a single quota.

Per OR's prepareFindAllConfig(), register/schema are read from INSIDE
$config['filters']; limit/offset are top-level config keys. Both keys are in
MagicSearchHandler::getReservedParams(), so they set the register/schema
context and are excluded from object-field WHERE building (no 1=0 leak).
This matches the array form already used by procest's own
Stuf\StufRegisterAccess::findAll().

Fixed (all previously dead):
- BackgroundJob/ResetMonthlyQuotasJob  monthly quota reset
- Service/TenantSaasService            listActive(), slugExists()
- Service/TenantOnboardingService      getProgress(), markStepComplete(), countSchemaRows()
- Service/TenantAuthenticationService  mandate lookup, role resolution
- Service/TenantQuotaService           getQuota()
- Service/TenantConfigurationService   getConfig()
- Service/TenantBillingService         fetchEventsForMonth()
- Service/Actions/ActionRegistry       findAction(), fetchAll()

Deliberately NOT touched (valid signatures, verified against HEAD):
- $this->findAll(...) in StufRegisterAccess / AdvisoryBodyService (local wrappers)
- $this->register->findAll(...) in StufController (procest's StufRegisterAccess)
- ConsumerMapper::findAll(filters:) and OrganisationMapper::findAll(limit:)
  (different OR classes whose named params genuinely exist)

Also fixes 3 pre-existing phpcs errors in lib/Repair/ that left cs:check red
on development (via the project's own phpcbf).

phpcs: 0 errors. php -l: clean. phpunit: 1151 tests / 3847 assertions,
identical to the origin/development baseline (10 pre-existing errors are
missing ZipArchive/IRootFolder in the bare test container, not regressions).
…lls — live quota-reset job silently broken' (#200) from fix/objectservice-findall-signature into development
The beschikking sign leg only had SigningAdapterInterface + a deterministic
MockSigningAdapter stub — no real implementation existed anywhere. Add
LibresignSigningAdapter implementing that same contract, backed by
LibreSign (LibreCode)'s local OCS API via a thin LibresignApiClient
(IClientService), with signer identity resolved from the mandaat-authorised
actor, LibreSign status mapped onto pending/signed/declined via
LibresignStatusMapper, and signed files persisted through the existing
ZgwDocumentService storage path.

SigningAdapterInterface now resolves to LibresignSigningAdapter only when
IAppManager::isEnabledForUser('libresign') is true; otherwise it falls back
unchanged to the pre-existing MockSigningAdapter with a logged, translated
admin hint (also surfaced via SettingsController::index()). procest never
hard-depends on LibreSign.

See openspec/changes/archive/2026-07-13-libresign-besluit-signing/design.md
for the LibreSign API binding (routes/payloads are a documented assumption —
no LibreSign checkout was available to verify against) and the fallback
semantics.
…igital-signing adapter' (#201) from wip/libresign-besluit-signing into development
WOODecisionService could assemble a formal WOO besluit but had no bridge to
actually publish it — every decision dead-ended internally despite the
woo-case-type spec's "Publication to reading room" requirement being marked
done. Procest doesn't own publication (ADR-Leaf-First: OpenCatalogi does), so
this adds WooPublicationService + OpenCatalogiApiClient, binding to
OpenRegister's confirmed Objects API the way OpenCatalogi's own frontend and
backend already create/update/withdraw publications (OpenCatalogi's own
PublicationsController is read-only). Redacted-only disclosure is enforced in
one place (niet_openbaar always excluded, deels_openbaar only via a finalized
redaction reference, never the original), mapped to the real 17-category
DIWOO vocabulary (infocat014, Woo-verzoeken en -besluiten). Feature-gated on
opencatalogi being installed; no hard dependency. New publish/withdraw
endpoints on WOOAssessmentController and a minimal publish action + status on
the existing WOO assessment view.
…logi' (#202) from wip/woo-publication-via-opencatalogi into development
rubenvdlinde and others added 30 commits August 10, 2026 18:14
…all (#780)

* fix(pwa): the service worker swallowed every PDOK-via-openconnector call

`E2E Tests (Playwright)` was red on `development` with three
`page.evaluate: TypeError: Failed to fetch` failures in
`spec-coverage/pdok-via-openconnector.spec.ts`, thrown from inside
Nextcloud core's patched `window.fetch` (`core-main.js:1:80554` is the
`await t(e,n)` in that wrapper — checked against stable32's shipped
`dist/core-main.js`). It is not a test problem and not a Playwright
problem. Measured on a CI runner with NO `page.route()` registered at
all, the same fetch still throws; and with the page loaded OUTSIDE the
service worker's scope, it returns 404 and Playwright's route handler
fires normally.

TWO PRODUCT DEFECTS, both in the mobiel-inspectie-offline PWA layer:

1. `public/service-worker.js` decided "is this a map tile?" with
   `/(brtachtergrondkaart|wmts|pdok|service\.pdok\.nl)/i.test(url.host +
   url.pathname)` — a substring test that also runs over the SAME-ORIGIN
   path. Since migrate-pdok-to-openconnector this app's own address
   lookups live at `/apps/openconnector/api/pdok/{suggest,lookup,free,
   reverse}`, so every one of them matched on the literal `pdok` and was
   answered cache-first out of the map-tile cache. The worker is
   registered at app-root scope, so this hit every procest page.
   Replaced with an exact third-party host allow-list: a request back to
   this Nextcloud is never a tile.

2. A Service Worker inherits the CSP of its OWN script response.
   `DashboardController::serviceWorker()` sent Nextcloud's default
   `default-src 'none'` with no `connect-src`, under which EVERY
   `fetch()` the worker makes is blocked. Measured on Nextcloud 32:
   `fetch(request)`, `fetch(request.url)` and
   `fetch(url, {mode:'same-origin'})` all threw inside the worker. Both
   strategies (`cacheFirst` / `networkFirst`) therefore always fell
   through to `Response.error()` — the worker could only ever break a
   request, never serve one, and the offline sync cache could never be
   populated in the first place. Now sends
   `connect-src 'self' https://service.pdok.nl`.

Together these made `src/services/pdokService.js` REJECT rather than
degrade: `handleNetworkError()` rethrows on an error with no HTTP status,
so the address field broke outright instead of surfacing the 503/404
warning `openspec/specs/pdok-consumer/spec.md` requires.

Why it was CI-only: the worker's scope is `generateUrl('/apps/procest/')`,
which keeps the `/index.php` prefix unless `front_controller_active` is
set. On CI (`php -S`) the spec's `/index.php/apps/procest/dashboard` is
INSIDE that scope and the worker controls the document; on the docker
images every developer uses, the same URL is OUTSIDE it and the worker is
invisible. The specs now navigate into the worker's own scope and wait
for `navigator.serviceWorker.controller`, so the coin flip is gone.

Test hardening in the same files (each mutation-verified):
- assert the ITEM, not the container. `expect(Array.isArray(result))` was
  true for the empty array an unintercepted server answer produces, and
  `expect(status).toBe(404)` passed on Nextcloud's own 404 — openconnector
  is genuinely absent on CI. Both fulfilled responses now carry a marker
  header that a real server answer cannot produce, and the payload
  assertion names `Lauriergracht 116`.
- `addressesRegisterAvailable()` returned `status() !== 404`, so 401 from
  an unauthenticated context read as "the register is installed" and the
  test went on to seed against it. Now only a 2xx counts.
- that same test resolved `process.env.NEXTCLOUD_URL ||
  'http://localhost:8080'` — the SHARED dev container — so it seeded and
  deleted OpenRegister objects in somebody else's environment whenever the
  suite was pointed elsewhere. Now uses the single `BASE_URL` resolver.

New `spec-coverage/service-worker-scope.spec.ts` guards defect 2 directly.
A third test that would have guarded defect 1 from the response alone was
written, found unable to fail under the planted defect, and left out
rather than kept as a check that always passes.

Refs #719

* test(pwa): unit-cover the service-worker script CSP

The coverage ratchet caught the 4 new statements in
DashboardController::serviceWorker() as untested — correctly: the CSP on
that response is the difference between a Service Worker that can fetch
and one whose every request is blocked, and nothing else in the suite
looks at it.

Proven able to fail: with the CSP block removed,
testServiceWorkerScriptGrantsConnectSrc reports
"Failed asserting that 'default-src 'none';base-uri 'none';manifest-src
'self';frame-ancestors 'none'' contains \"connect-src\"."
…→ 0 (#781)

* fix(a11y): associate every form control with its label (gate-40, 157 findings)

Gate-40 (form-label-association) reported 157 unlabelled form controls at full
scope — the single largest item in the fleet. It was also the one carrying the
loudest false-positive warning: the same gate ran ~40% FP on pipelinq and ~13%
on openregister, so the instruction was to sample before actioning.

MEASURED FALSE-POSITIVE RATE ON PROCEST: 0% (0 of a random 20).

A 20-finding random sample (seed 40) was verified one by one against the real
source markup. Every one was a genuine missing association, in three shapes:

  14/20  a visible <label> with NO `for=`, not wrapping the control:
             <div class="form-group">
               <label>{{ t('procest', 'Valid from') }}</label>
               <input type="date" :value="form.validFrom">
             </div>
         Visually labelled, programmatically anonymous. This is exactly what
         WCAG 1.3.1 (Info and Relationships) is about, and it is the gate's
         own name: form-label-ASSOCIATION.
   5/20  no label at all — only a `:placeholder`, which is not an accessible
         name (it disappears on input; WCAG 3.3.2).
   1/20  a <span class="property-label">, which is not a label element.

So the fleet-wide FP warning does not transfer: procest's markup uses one
consistent hand-rolled `.form-group > label + control` idiom, and that idiom
is wrong 157 times rather than being 157 separate mistakes.

THE FIX
-------
Per the checker's own accepted forms (read from check_form_labels.py, not
guessed):

  * 89 controls: `<label for="x">` + matching `id="x"` on the control. This is
    real HTML association, and it is identical for Nc components —
    `<label for="x">` + `<NcTextField id="x">` works because the `id` prop
    lands on the rendered <input> on both the nc-vue 8 and 9 lines.
    `input-id` is NOT a prop on NcTextField/NcInputField and was used nowhere.
  * the remainder: `aria-label`, used only where no visible label exists and
    adding one would change the design (controls inside table cells, compact
    filter toolbars, rows repeated by v-for). Never on a control that already
    has a visible <label> — that would override the visible text and break
    WCAG 2.5.3 Label in Name.
  * `label`-prop rendering was deliberately NOT used where a sibling <label>
    already shows the same string, because it would render the text twice.

Inside a v-for the binding is dynamic (`:for`/`:id` built from the loop key or
`aria-label` derived from loop data), so no duplicate DOM id is emitted. Every
static id occurs exactly twice across src/ — once on the label, once on the
control.

BOTH DIRECTIONS PROVEN
----------------------
For each of the four batches, one added `for=` was removed again (leaving the
`id` in place) and the checker re-run: it reported that exact control every
time, then went clean when the attribute was restored. So the gate can still
fail, and it is the for/id PAIR that closes it — a bare `id` does not, which
also rules out "the checker went blind" as the reason for the green.

  gate-40 before: 157      gate-40 after: 0

No gate was weakened: no baseline, no waiver, no threshold moved, and the
checker is the unmodified one CI runs (the local run reproduced CI's 157
exactly before any edit).

NO REGRESSION IN THE SIBLING GATES
----------------------------------
The other markup checkers over the same 232 files are still 0: gate-12
(nc-select-labels), gate-37 (aria-hidden-focusable), gate-41 (button-name),
gate-31/32 (markup-a11y), gate-44 (autocomplete), gate-43 (link-text). This
matters because the two obvious cheap closures for gate-40 — `aria-hidden` on
a focusable input, or an `aria-label` that contradicts visible text — are
precisely what gate-37 and WCAG 2.5.3 exist to catch.

  npm run build   OK
  vitest          330 passed (32 files)
  eslint          0 errors on all 42 files

* fix(l10n): translate the 9 aria-label strings the gate-40 fix introduced

`test:l10n` went red on the previous commit: 10 keys were used in source but
absent from l10n/en.json. Every one of them is an `aria-label` added to name a
control that had no visible label at all — so unlike a `for`/`id` pair, these
DO introduce user-facing text and therefore need translating.

Two of the ten were the same concept spelled two ways, coined independently in
two files:

    "Condition description {n}"   src/dialogs/ConsultationResponseForm.vue
    "Description condition {n}"   src/views/public/ExternalConsultationResponsePage.vue

Both name the description input of the n-th condition in a v-for. Unified on
"Condition description {n}" (the natural English word order, and the spelling
that already matched the sibling placeholder "Condition description"), which
also drops the count to 9 rather than carrying a near-duplicate into the
translation memory forever.

en.json written with the repo's own extractor (`check-l10n.js --write`, key ===
English source). nl.json translated by hand rather than left to fall back to
the raw English key — a Dutch-locale screen-reader user is exactly the person
these labels exist for, so an untranslated aria-label would defeat the fix it
belongs to:

    Escalation offset                       Escalatietermijn
    Extracted value for {field}             Geëxtraheerde waarde voor {field}
    Motivation for {doc}                    Motivering voor {doc}
    Search complaints                       Klachten zoeken
    Select all fields                       Alle velden selecteren
    Select document {title}                 Document {title} selecteren
    Select field {field}                    Veld {field} selecteren
    Select redaction candidate {category}   Lakkandidaat {category} selecteren
    Condition description {n}               Omschrijving voorwaarde {n}

  node tests/l10n/check-l10n.js
    OK — every used translation key is present in l10n/en.json
    OK — en.json and nl.json key sets match (no missing Dutch translations)

gate-40 is still 0; no new key came from a `for`/`id` fix, only from the
aria-label minority.

* refactor(dialogs): give every dialog its own home under src/dialogs/ (gate-13, 13 → 0)

Gate-13 (modal-isolation, ADR-004) went red on the PR the moment the gate-40
commit landed — not because that commit introduced a modal, but because gate-13
is DIFF-SCOPED: touching a file that already contained an inline
<NcModal>/<NcDialog> puts it in scope. Seven of the forty-two files the a11y
pass touched carried that inherited debt. This clears it at FULL scope (13),
not just the seven, so the next branch to touch any of them does not inherit
the same surprise.

WHAT THESE ACTUALLY WERE
------------------------
Eleven of the thirteen were already self-contained dialog components whose ROOT
element is the <NcDialog> — CaseTransferDialog, VoorstelCreateDialog,
ParafeerActieDialog, AiClassifyDialog, AiExtractDialog,
BerichtenboxComposeDialog, AdviesAanvraagDialog, CreateShareDialog,
CreateFederatedShareDialog, FederatedActivityPanel, BesluitRegistration. They
were simply in the wrong directory. Those are `git mv`d into src/dialogs/ with
NO markup change at all, which is what ADR-004 actually asks for.

The remaining two were genuine inline dialogs inside a component with real
content of its own, and were extracted:

  src/views/settings/StufAuditLog.vue
      -> src/dialogs/StufEnvelopeDialog.vue      (prop `row`, emits `close`)
  src/views/settings/components/OrganisatieRolManager.vue
      -> src/dialogs/DeleteRolDialog.vue         (props `role`, `blockedReason`;
                                                  emits `close`, `confirm`)

Helpers and scoped CSS moved with the markup that uses them; everything the
parent still renders stayed. Visible strings, class names, prop names and
emitted-event names are byte-identical to the originals.

REFERENCES
----------
Every importer was found by grep, not guessed: CaseSharingTab (4),
BerichtenboxTab, AdviesPanel, VoorstellenPanel, VoorstelDetail (2). Inside the
moved files, 9 depth-sensitive imports were rewritten `../../../x` -> `../x`,
and 3 sibling imports repointed back into views/ (AiConfidenceBadge ×2,
DelegateSelectorField).

`src/registry.js` and `src/customComponents.js` import only the PARENTS, never
these thirteen — checked explicitly rather than assumed, because those two files
are the ones that resolve components by path. No dynamic import() referenced
them. The only data-testid among the thirteen (`stuf-audit-log-table`) sits on
the TABLE, not the dialog, and stayed in StufAuditLog.vue; no e2e spec
references any of these components.

AiClassifyDialog and AiExtractDialog have zero importers anywhere — pre-existing
orphans. Moved anyway, so the gate is clean at full scope rather than clean-
except-for-two.

BOTH DIRECTIONS
---------------
A bare `<NcDialog />` planted into src/views/voorstellen/VoorstelDetail.vue:

  src/views/voorstellen/VoorstelDetail.vue: inline NcModal/NcDialog

Removed it -> checker silent again, and `git diff` on that file shows only the
two import-path lines. So the gate still detects the defect and did not go blind
because files moved out from under it.

VERIFIED
--------
  gate-13 full scope            13 -> 0
  gate-40 full scope            still 0 (not regressed by the moves)
  npm run build                 0 errors
  npx vitest run                330 passed / 32 files (unchanged)
  npm run check:manifest        Ajv validation PASS (0 errors), 58 pages
  npm run check:vue3-compile    231 components, clean 231, failed 0
  npm run lint                  0 errors

No test was deleted, skipped or waived; no baseline, no threshold moved.

* style(dialogs): blank line before rule in the two extracted dialogs (stylelint)

`Vue Quality (stylelint)` went red on the dialog-extraction commit:

    src/dialogs/BerichtenboxComposeDialog.vue  161:1  rule-empty-line-before
    src/dialogs/StufEnvelopeDialog.vue          90:1  rule-empty-line-before
    src/dialogs/StufEnvelopeDialog.vue         100:1  rule-empty-line-before
    src/dialogs/StufEnvelopeDialog.vue         104:1  rule-empty-line-before

Pure formatting — the CSS rules moved with the markup they style and landed
without the blank line the config wants. No selector, declaration or value
changed. `npm run stylelint` now exits 0.

⚠️ WORTH KNOWING, AND NOT FIXED HERE: the `stylelint` npm script is

    stylelint src/**/*.vue src/**/*.scss src/**/*.css

unquoted, so the SHELL expands the globs before stylelint sees them, and bash
without `globstar` expands `src/**/*.vue` as `src/*/*.vue` — ONE directory level.
The check has therefore never looked below `src/<dir>/`. Running the same
stylelint with the globs QUOTED (so stylelint does its own recursive matching)
reports 203 problems in the deeper tree that CI has never seen:

    npx stylelint "src/**/*.vue" "src/**/*.scss" "src/**/*.css"   -> 203 errors

None of those 203 are introduced by this branch — they are pre-existing debt in
`src/views/**/**`, invisible because of how the script is written. The two files
fixed above are only visible to CI because `src/dialogs/` happens to sit exactly
one level down. Quoting the globs would surface all 203 at once, so that belongs
in its own change rather than being smuggled into a dialog refactor.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…ix the 203 violations it exposed (#783)

The stylelint scripts passed their globs UNQUOTED, so bash expanded them
before stylelint ever saw them. Bash without `globstar` treats `**` as a
single `*`, so `src/**/*.vue` expanded to `src/*/*.vue` — exactly one
directory level. Measured on this tree: 48 files matched, out of 233
stylable files under src/. The npm stylelint job has therefore never
looked deeper than one level and has always been green.

Quoting the globs hands them to stylelint, which does its own recursive
globbing.

- before (quoted): 233 files linted, 203 violations
  (202 rule-empty-line-before + 1 no-duplicate-selectors)
- after: 233 files linted, 0 violations, exit 0

The 202 rule-empty-line-before were fixed with stylelint's own --fix.
The no-duplicate-selectors in CaseCard.vue was merged by hand: two
.case-card__header blocks with no overlapping declarations, merged into
one, keeping padding-left.
… 0 (#782)

Ten distinct `@spec` targets did not resolve. All ten name a task or
requirement that was never written, not one that moved.

The bulk is one repeated cause. Seven DSO test files cite a `T11`–`T17`
task block in `openspec/changes/dso-omgevingsloket/tasks.md`. That file
numbers its implementation tasks `T01`–`T08` and its verifications
`V01`–`V10`; there is no `T11` and there never was. The block looks like
an author's private numbering for "the test for task N", offset by ten.
Each tag is repointed at the task that actually created the code under
test:

  DsoCaseServiceTest                    T11 → T02  (DsoCaseService)
  BeschikkingGenerationServiceTest      T12 → T03  (beschikking service)
  SamenwerkverzoekServiceTest           T13 → T03  (samenwerk service)
  DsoDeadlineJobTest                    T14 → T05  (DsoDeadlineJob)
  VergunningStatusChangedEventTest      T15 → T04  (typed event)
  VergunningaanvraagCreatedListenerTest T16 → T04  (listener)
  DsoControllerTest                     T17 → T06  (DsoController)

The remaining three:

  `#mentions` (3 files) → `#REQ-W2L-003`, the requirement in
  ncvue-w2-leaves-adoption's spec that actually covers note `@mention`
  notifications. `mentions` was a shorthand for a heading that does not
  carry it.

  `#P-6.2` → `#P-5.1`. migrate-role-routing-to-or-rbac's tasks.md names
  `tests/Unit/Service/StatusTransitionGroupAuthTest.php` explicitly, under
  P-5.1. P-6 has only a P-6.1, already cited by the other test.

  `#task-1-2` → `#task-1-1`. woo-llm-anonymisation section 1 has a single
  item, 1.1 `AiService::detectDeterministicPiiSpans()` — which is exactly
  what AiServicePiiDetectionTest covers.

No spec text was added or renumbered to make a tag resolve: every new
target is a line that already existed. Verified with the gate's own
resolver — 36 findings before, 0 after — and the gate proven still able to
fail by planting `#T14` on a throwaway test file, which it reported.
`EmailTemplateService::seedDefaultTemplates()` has been fully implemented
since case-email-integration T04 and no caller has ever reached it. It
seeds the three Dutch default templates for a caseType and is idempotent
by name, so a second call creates nothing.

This is the orphaned-write-capability shape gate-57 exists to catch: a
built capability with no way in. The fix is to wire it, not to delete it.

Adds `EmailTemplateController::seedDefaults()` and routes it at
`POST /api/casetypes/{caseTypeId}/email-templates/seed-defaults`.

The auth posture mirrors `createTemplate()` on purpose. This endpoint is a
loop over exactly that call and creates nothing a user could not create
one template at a time, so a stricter guard here would be inconsistent
rather than safer — and gate-9 (semantic-auth) checks that the attribute
matches what the body actually does.

Verified:
  - gate-57 17 → 16, measured full-scope with the gate package CI runs
    (ConductionNL/.github @ b8c7ead).
  - No new gate: route-auth, no-admin-idor, semantic-auth, spec-coverage
    and route-reachability all still pass.
  - gate-25 (contract-coverage) does NOT grow: 126 with the new test,
    127 without it. The endpoint ships with real coverage rather than
    becoming invisible debt — proven by removing the test file and
    watching the count rise, then restoring it.
  - The auth gate is proven able to fail: replacing the session check
    with `if (false)` turns testAnonymousCallerIsRejected red
    ("Failed asserting that 400 is identical to 401"), restored after.
  - phpcs unchanged (721 findings on this branch and on development —
    identical, all pre-existing), phpstan [OK] no errors, psalm reports
    nothing on the changed files, PHPUnit 1746 passed / 5 skipped.
MandaatEscalatieService::createEscalatie and
Subsidie\TussenrapportageService::createExpected were both fully
implemented and reachable from nowhere. Their controllers already
injected the services and exposed only the resolve half of each
lifecycle: an escalation could be approved or rejected but never
opened, and an interim report could be assessed but never scheduled.

Wired, not deleted — both carry a @SPEC annotation pointing at a
requirement that is still canonical.

- MandaatMatrixController::escalateCreate, POST /api/mandate/escalations
  Auth posture mirrors escalateReject (@NoAdminRequired +
  ensureAuthenticated -> 403). initiatorId is derived from the session,
  never from the body, matching probe()'s identity stance.

- SubsidieController::createTussenrapportage,
  POST /api/subsidies/uitvoeringen/{uitvoeringId}/tussenrapportages
  Auth posture mirrors createBeschikking (@NoAdminRequired +
  requireUser -> 401, 201 on success). uitvoeringId added to the
  bodyParams() routing-parameter strip list.

9 new PHPUnit tests cover both endpoints, including the auth gate and
the "identity comes from the session" invariant.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…struct (#790)

procest's phpcs run carries exactly one ERROR — every other finding is a
warning. `Iv3TaakveldController::__construct()` calls
`parent::__construct($appName, $request)` positionally, which the house
`CustomSniffs.Functions.NamedParameters.RequireNamedParameters` sniff
rejects. Every other controller in the app already passes these named.

phpcs: 1 error / 501 warnings → 0 errors / 501 warnings, across 219 files.
No warning was touched, suppressed or baselined — this fixes the error and
nothing else.

The sniff is proven live rather than vacuously green: reverting the call to
its positional form takes the count straight back to 1 error, and restoring
it returns to 0.

phpstan [OK] no errors. PHPUnit 1746 passed, 5 skipped.
)

`DsoIntakeService` carried two implementations of DSO intake. The live
webhook path (`processAanvraag`, POST /api/vth/dso/intake) did the
extract → build → save → save-properties inline, while an extracted
`map()` + `createCase()` pair sat next to it with zero callers — an
abandoned "make intake testable" refactor.

`createCase()` was not merely unreachable, it had never run. It called
OpenRegister's ObjectService positionally:

    saveObject($register, $caseSchema, $caseData)

against the real signature

    saveObject(array|ObjectEntity $object, ?array $extend = [], $register = null, $schema = null, ...)

so the schema slug landed in `?array $extend` and the register slug in
`array|ObjectEntity $object` — a TypeError on the first call. The same
bug was in its case-property loop.

This wires the orphan instead of deleting it:

- `processAanvraag()` now delegates to `map()` + `createCase()`, so there
  is ONE implementation of the intake write rather than two.
- `createCase()` passes register/schema by NAME, fixing the latent
  TypeError, and reuses the existing `storeCaseProperties()` helper
  (which already had the argument order right) for the property rows.
- `createCase()` adopts the live path's log line, so the message the
  webhook has always emitted is preserved.

Characterization tests were written FIRST, against the pre-refactor code,
and pin the observable behaviour of the live path: the exact saveObject
call sequence including which parameter each named argument binds to, the
case-property rows written vs skipped, and the byte shape of the JSON the
Omgevingsloket receives back. They passed before the refactor and pass
after it, unedited.

The ObjectService test double declares OpenRegister's real parameter
order, so the argument-order assertions are not vacuous: reverting to the
positional call makes 12 of the 15 tests fail with
"saveObject(): Argument #1 ($object) must be of type array, string given".

Verification (hydra-gates @ ConductionNL/.github b8c7ead, 66 gates):
- gate-57 orphaned-write-capability: 16 → 15; the only removed finding is
  DsoIntakeService::createCase.
- gate-25 contract-coverage: 126 → 126, findings log byte-identical.
- Failing-gate ID set identical before and after — no new gate fails.
- composer phpstan: no errors.
- composer phpcs: 1 error + 501 warnings in 219 files, identical to
  origin/development; the changed file carries the same 2 warnings.
- vendor/bin/phpunit: 1761 tests green (1746 baseline + 15 new), 5 skipped.

Behaviour note: on a MALFORMED payload with OpenRegister unavailable, the
mapping now runs before the config guards, so such a request surfaces the
mapping error rather than "OpenRegister is not available". Both are a 500
from the webhook and neither message is part of the response contract.
`NcModal`'s `name` prop already renders `<h2 class="modal-header__name">`
and wires it as the dialog's accessible name. `InzageExportModal` and
`InitiatorPickerModal` rendered a SECOND `<h2>` in the body carrying the
identical string, so a screen-reader user is told the dialog's name and
then immediately hears the same heading again. Six of the eight modals
that set `name` never did this; these two were the outliers.

Found by root-causing an e2e failure, not by reading the components. The
AVG spec's

    getByRole('heading', { name: 'Data subject access export' })

resolved to TWO elements and Playwright failed it under strict mode:

    strict mode violation: ... resolved to 2 elements:
      1) <h2 class="modal-header__name">Data subject access export</h2>
      2) <h2 data-v-3de9aa6e="">Data subject access export</h2>

The assertion text ("toBeVisible failed") looked like a missing heading.
It was the opposite: two of them.

This is also why the failure looked intermittent rather than constant. The
two headings mount at slightly different moments — the header with the
dialog, the second with the body slot — so an assertion resolving early
sees one and passes, and one resolving late sees two and throws. The same
commit could therefore go green on one PR and red on another, which is
exactly what happened tonight.

Adds tests/vitest/modalHeadingUniqueness.spec.js, which asserts the
invariant across EVERY modal rather than just these two, so a newly added
one is guarded the day it lands instead of whenever someone writes an e2e
test for it. It carries an explicit non-empty-scope assertion so it cannot
pass by finding no files.

Proven both ways: restoring the duplicate `<h2>` in InzageExportModal turns
the guard red ("1 failed | 11 passed"); removing it again returns 12/12.
Full vitest suite 342 passed. `npm run lint` and `npm run stylelint` both
exit 0.
…7 17 → 15 (#788)

Two built-but-unreachable write capabilities in lib/Service/Bezwaar/ get a
real way in. No controller anywhere injected anything from that namespace —
its only two entry points were listeners.

- recordAttendance(): new BezwaarHearingController, routed at
  POST /api/bezwaar/hearings/{sessionId}/attendance. The hearingSession
  schema has no manifest page, and attendance is append-only with a
  one-hour grace window plus an awb-art-7:7 audit entry on late
  corrections, so a generic edit form could not express it.
  Auth: @NoAdminRequired + explicit 401, mirroring AdvisoryBodyController.

- recordCouncilDeviation(): wired on the event path in
  DecisionConcludedListener, where decidesk's conclusion makes the besluit
  op bezwaar final and the bezwaarDecision record already in hand carries
  advisoryOpinion / followsAdvice / deviationRationale. Exposing it as an
  endpoint would let any authenticated user forge an Archiefwet audit
  entry. gate-61 does not apply: the listener is registered on decidesk's
  DecisionConcludedEvent, not on an OpenRegister object event.

BeroepService::recordJudgment() is deliberately left alone — see the PR
body for the evidence on both sides.
… page (#784) (#792)

* fix(admin): stop ChecklistsTab rendering one row per byte of an error page (#784)

The procest admin settings page rendered a 44 MB DOM on which Playwright's role
queries never terminated, so a red E2E carried no information about the change
under it. The cause is not "fourteen sections mount at once" — measured
per-section, ONE section held 630,388 of 632,482 elements (99.7 %); the other
thirteen are 8-167 elements each.

ChecklistsTab fetched `/apps/procest/api/objects/inspectionChecklist`. procest
does not serve that route: its auto-exposed `/api/objects/<register>/<schema>`
endpoints were deleted (appinfo/routes.php, "only engine routes remain").
Nextcloud answers an unmatched app URL with its OWN HTML page under HTTP 200, so
axios never threw and `response.data` was a 45,031-character string.
`response.data?.results || response.data || []` admits that string — a string has
no `.results`, and a non-empty string is truthy — and `v-for` over a string
iterates one item per CHARACTER. One row and two NcButtons per byte.

Two changes, both needed:

  * Address `/apps/procest/api/vth/checklists`, the route InspectionChecklistController
    already serves for exactly these four verbs. Every one of them carries
    #[AuthorizedAdminSetting], which is the correct posture for an admin-settings
    control, and it routes through InspectionChecklistService, which owns checklist
    versioning. (OpenRegister's generic object route also resolves and was this
    fix's first attempt — it is the wrong choice: it bypasses procest's admin
    authorization and adds a second write path for a resource that has one.)
  * Never assign an unvalidated body to a `v-for` source. The same string-admitting
    idiom is hardened in the two other places it appears (adviceApi, VthDashboard).

Measured on one clean rig, changing only this source and rebuilding:

                              before        after
  .checklists-tab__item rows  40,057        0
  DOM elements                562,729       2,284
  <button>                    80,187        98
  outerHTML bytes             44,434,205    3,370,949
  getByRole('button').count() 7,012 ms      147 ms
  the failing assertion       never returned (>60 s)   87 ms, count=1
  body.ariaSnapshot()         timed out (30 s)         342 ms, 17,686 chars

The before column was taken with ZERO seeded data: 40,057 rows is the character
length of the Nextcloud error page. This was never a data-volume problem, which
is why it reproduces in CI. `ariaSnapshot()` timing out is why #784's
error-context.md had no page-snapshot section and the failure read
`Received: undefined`.

case-types-tabs.spec.ts + spec-coverage/admin-settings.spec.ts: 6 passed. The
test that used to burn its full 300 s cap now runs in 7.3 s.

The new vitest spec is a true-positive control, run rather than asserted: with
the component stashed back, 4 of its 6 tests fail, reporting 4104 rows for a
4104-character body.

Control for the unrelated red: spec-coverage/settings-pages.spec.ts fails 10 on
this rig BOTH with and without this change, so those failures are pre-existing
and not caused here. None of the three files touched is on those pages' code
path — ChecklistsTab is imported only by AdminRoot.

No timeout was raised, no assertion weakened, no test skipped, no exclusion added.

Refs #784

* fix(spec): annotate the four methods this PR changed, and repoint a dangling @SPEC

gate-16 spec-coverage failed on PR #792 with 3 changed methods missing @SPEC
(`ChecklistsTab::loadChecklists`, `::saveChecklist`, `VthDashboard::loadCases`);
adding those docblocks shifted the diff and surfaced a fourth, `::closeEditor`.
Reproduced locally against origin/development and now `# count=0`.

The three checklist methods anchor at
`openspec/specs/inspection-checklists/spec.md#inspection-checklist-admin-ui`,
which is the canonical requirement for exactly this surface ("The system SHALL
provide an admin interface for creating and managing inspection checklists").

`VthDashboard::loadCases` anchors at the canonical DSO spec FILE with no
fragment, deliberately. No single requirement covers that dashboard's combined
status/procedureType/gemeenteCode/activiteitgroep filter set; REQ-DSO-010 covers
the location filter only, and citing it would have overstated what the spec says
about the rest. The reason is recorded in the docblock so the next reader does
not have to re-derive it.

Also repoints that file's file-level anchor. It read
`@spec openspec/changes/dso-omgevingsloket/tasks.md#T07`, and that change
directory does not exist at all — the whole path was dangling, not just the
fragment. A dangling @SPEC anchor is accepted silently, so it read as
traceability while pointing at nothing.

No spec text was written or amended for this: every anchor names a requirement
that already existed.

Refs #784
…ionDispatchJob (#797)

* refactor(procest): TermijnNotificationDispatchJob -> DeadlineNotificationDispatchJob

A code-only slice of openspec/changes/english-vocabulary, and the first procest
slice — chosen because it is one of the very few procest identifiers that is
neither ZGW protocol vocabulary nor wired by string.

'termijn' here is a STATUTORY DEADLINE being monitored, not a contract term, so it
becomes Deadline. That reading is app-specific on purpose: hrmq uses 'termijn' for
notice periods (aanzegtermijn) and decidesk for terms of office (TermijnRegeling).
Three apps, three meanings, one Dutch word — it must never become a fleet word.

Referenced only via ::class from TermijnNotificationService, so a missed reference
is a compile error rather than a job that silently never dispatches. No route
entry, no register-fragment wiring, no baseline suppression keyed to the old path
— all four checked, not assumed.

TermijnNotificationService itself keeps its name in this commit; it belongs with
the termijnbewaking slice, where the Termijn* schemas (termijnDefinitie,
termijnInstance, termijnGebeurtenis) move together with their register fragments.

Archived changes under openspec/changes/archive/ keep the old name — they are a
record of what was built at the time.

php -l clean; class name matches file name; use statement still ordered.

* fix(procest): add the @SPEC anchor gate-16 requires on the renamed job

Hydra gate-16 (spec-coverage) failed this branch with "1 changed method(s)
missing @SPEC". The method is DeadlineNotificationDispatchJob::run(), which the
rename pulled into the diff and which had no @SPEC of its own — the class
docblock carries one, but gate-16 scopes per CHANGED METHOD, not per file.

Anchored to openspec/specs/burger-notifications/spec.md, the canonical spec.
Deliberately NOT the change directory the class docblock still points at: @SPEC
targets openspec/specs/, never openspec/changes/, because a change dir is
archived once the change lands and the anchor then dangles.

CONFIRMED MINE, NOT PRE-EXISTING. procest's Code Quality workflow is green on
development's most recent run, and run() is only in gate-16's scope because
this branch touched its file. Fixed rather than merged past.
…812)

composer audit fails on development and on every open PR:

  Package:  phpcsstandards/phpcsutils
  CVE:      CVE-2026-65954  — Arbitrary code execution
  Affected: >=1.0.0-alpha1,<1.2.3
  Reported: 2026-07-27

The lock pinned 1.2.2. It is a transitive dev dependency, pulled in by
phpcsstandards/phpcsextra (^1.2.0), so nothing in composer.json needed to
change — only the lock.

WHY IT SUDDENLY APPEARS. development's last green Code Quality run predates the
advisory reaching the audit database, so the gate did not fail then and does now.
Nothing regressed; the check got new information. This is why it started
appearing on unrelated rename PRs whose diffs contain no dependency manifest at
all.

BLAST RADIUS OF THE FIX: exactly one package version line in composer.lock.
Verified by counting the changed "version" keys in the diff.

THE BUMP IS EXERCISED, NOT JUST INSTALLED. phpcsutils is the library phpcs
itself runs on, so `composer audit` reporting clean is not the only evidence:
phpcs was executed against a real source file after the bump and ran to
completion. A dependency bump that no build exercises is not a verified bump.

VERIFIED
  - composer audit: "No security vulnerability advisories found."
  - phpcsutils 1.2.2 -> 1.2.3 in composer.lock; one package changed.
  - vendor/bin/phpcs runs clean against lib/Service/TermijnService.php.
* refactor(procest): TermijnNotificationDispatchJob -> DeadlineNotificationDispatchJob

A code-only slice of openspec/changes/english-vocabulary, and the first procest
slice — chosen because it is one of the very few procest identifiers that is
neither ZGW protocol vocabulary nor wired by string.

'termijn' here is a STATUTORY DEADLINE being monitored, not a contract term, so it
becomes Deadline. That reading is app-specific on purpose: hrmq uses 'termijn' for
notice periods (aanzegtermijn) and decidesk for terms of office (TermijnRegeling).
Three apps, three meanings, one Dutch word — it must never become a fleet word.

Referenced only via ::class from TermijnNotificationService, so a missed reference
is a compile error rather than a job that silently never dispatches. No route
entry, no register-fragment wiring, no baseline suppression keyed to the old path
— all four checked, not assumed.

TermijnNotificationService itself keeps its name in this commit; it belongs with
the termijnbewaking slice, where the Termijn* schemas (termijnDefinitie,
termijnInstance, termijnGebeurtenis) move together with their register fragments.

Archived changes under openspec/changes/archive/ keep the old name — they are a
record of what was built at the time.

php -l clean; class name matches file name; use statement still ordered.

* refactor(procest): TermijnReporting* -> DeadlineReporting*

TermijnReportingController -> DeadlineReportingController and
TermijnReportingService -> DeadlineReportingService, with their two Dutch
controller methods (kwartaalrapport -> quarterlyReport, jaarrekening ->
annualStatement), every Dutch parameter and local ($periode -> $period,
$afdeling -> $department, $jaar -> $year, $jaarPrefix -> $yearPrefix), the two
named-argument call sites, the three route names, the test, and the Postman
collection's description.

Stacked on feat/english-vocabulary-deadline-job (#797) because that branch
already renames TermijnNotificationDispatchJob; branching from development
would have conflicted.

FOUR DUTCH CONTRACTS ARE DELIBERATELY LEFT ALONE, and each is annotated in
place so the next reader does not "finish the job" and break it:

  1. $row['afdeling'] is a SCHEMA PROPERTY. OpenRegister materialises it as a
     real column, so it moves with a data migration, not with a rename.
  2. 'periode', 'afdeling' and 'jaar' as RESPONSE KEYS are the published shape
     of the reports. src/views/dashboard/TermijnDashboard.vue reads annual.jaar
     directly, so they move only with a coordinated frontend change.
  3. getParam('periode') and getParam('jaar') are QUERY-STRING parameter names —
     the request contract.
  4. The /api/termijn/* URLs. Route NAMES follow the renamed class and methods;
     URLs do not. Moving them is a breaking change for every consumer and is a
     separate decision.

NAMED ARGUMENTS. resolveQuarter(periode:) and aggregateByType(afdeling:) are
called with named arguments, which PHP resolves at call time — a parameter
rename that misses one is a runtime fatal that `php -l` reports as clean. Both
moved with their declarations.

VERIFIED
  - php -l clean on all four PHP files; the Postman collection still parses.
  - phpunit: 5 tests, 14 assertions green.
  - Route resolution: each 'deadlineReporting#<action>' route name was checked
    against the controller's actual public methods — all three resolve. Positive
    control: the same check against the OLD action names (kwartaalrapport,
    jaarrekening) reports MISSING METHOD, so it can distinguish a renamed route
    from a dangling one.
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to
    either old class name or the old route prefix.

PHPCS SCOPE, MEASURED RATHER THAN ASSUMED. phpcs.xml declares <file>lib</file>
and nothing else, so tests/ and appinfo/ are outside the standard entirely —
appinfo/routes.php carries 373 violations on development and every sibling test
file is equally "dirty". Both files this change touches inside lib/ are CLEAN,
including two pre-existing missing-@SPEC errors fixed here (each now anchors to
openspec/specs/termijn-reporting/spec.md, the canonical spec, not the change
directory the file headers point at).

An earlier pass ran phpcbf over the test file and produced a 94-line diff for
what is a 6-line rename. That was churn against a gate that never reads the
file, so it was reverted; the test diff is now the rename alone.
…ner -> Deadline* (#804)

* refactor(procest): TermijnNotificationDispatchJob -> DeadlineNotificationDispatchJob

A code-only slice of openspec/changes/english-vocabulary, and the first procest
slice — chosen because it is one of the very few procest identifiers that is
neither ZGW protocol vocabulary nor wired by string.

'termijn' here is a STATUTORY DEADLINE being monitored, not a contract term, so it
becomes Deadline. That reading is app-specific on purpose: hrmq uses 'termijn' for
notice periods (aanzegtermijn) and decidesk for terms of office (TermijnRegeling).
Three apps, three meanings, one Dutch word — it must never become a fleet word.

Referenced only via ::class from TermijnNotificationService, so a missed reference
is a compile error rather than a job that silently never dispatches. No route
entry, no register-fragment wiring, no baseline suppression keyed to the old path
— all four checked, not assumed.

TermijnNotificationService itself keeps its name in this commit; it belongs with
the termijnbewaking slice, where the Termijn* schemas (termijnDefinitie,
termijnInstance, termijnGebeurtenis) move together with their register fragments.

Archived changes under openspec/changes/archive/ keep the old name — they are a
record of what was built at the time.

php -l clean; class name matches file name; use statement still ordered.

* refactor(procest): TermijnReporting* -> DeadlineReporting*

TermijnReportingController -> DeadlineReportingController and
TermijnReportingService -> DeadlineReportingService, with their two Dutch
controller methods (kwartaalrapport -> quarterlyReport, jaarrekening ->
annualStatement), every Dutch parameter and local ($periode -> $period,
$afdeling -> $department, $jaar -> $year, $jaarPrefix -> $yearPrefix), the two
named-argument call sites, the three route names, the test, and the Postman
collection's description.

Stacked on feat/english-vocabulary-deadline-job (#797) because that branch
already renames TermijnNotificationDispatchJob; branching from development
would have conflicted.

FOUR DUTCH CONTRACTS ARE DELIBERATELY LEFT ALONE, and each is annotated in
place so the next reader does not "finish the job" and break it:

  1. $row['afdeling'] is a SCHEMA PROPERTY. OpenRegister materialises it as a
     real column, so it moves with a data migration, not with a rename.
  2. 'periode', 'afdeling' and 'jaar' as RESPONSE KEYS are the published shape
     of the reports. src/views/dashboard/TermijnDashboard.vue reads annual.jaar
     directly, so they move only with a coordinated frontend change.
  3. getParam('periode') and getParam('jaar') are QUERY-STRING parameter names —
     the request contract.
  4. The /api/termijn/* URLs. Route NAMES follow the renamed class and methods;
     URLs do not. Moving them is a breaking change for every consumer and is a
     separate decision.

NAMED ARGUMENTS. resolveQuarter(periode:) and aggregateByType(afdeling:) are
called with named arguments, which PHP resolves at call time — a parameter
rename that misses one is a runtime fatal that `php -l` reports as clean. Both
moved with their declarations.

VERIFIED
  - php -l clean on all four PHP files; the Postman collection still parses.
  - phpunit: 5 tests, 14 assertions green.
  - Route resolution: each 'deadlineReporting#<action>' route name was checked
    against the controller's actual public methods — all three resolve. Positive
    control: the same check against the OLD action names (kwartaalrapport,
    jaarrekening) reports MISSING METHOD, so it can distinguish a renamed route
    from a dangling one.
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to
    either old class name or the old route prefix.

PHPCS SCOPE, MEASURED RATHER THAN ASSUMED. phpcs.xml declares <file>lib</file>
and nothing else, so tests/ and appinfo/ are outside the standard entirely —
appinfo/routes.php carries 373 violations on development and every sibling test
file is equally "dirty". Both files this change touches inside lib/ are CLEAN,
including two pre-existing missing-@SPEC errors fixed here (each now anchors to
openspec/specs/termijn-reporting/spec.md, the canonical spec, not the change
directory the file headers point at).

An earlier pass ran phpcbf over the test file and produced a 94-line diff for
what is a 6-line rename. That was churn against a gate that never reads the
file, so it was reverted; the test diff is now the rename alone.

* refactor(procest): Termijn{Escalation,Pause,Extension}Service + listener -> Deadline*

Four more classes in the deadline cluster:

  TermijnEscalationService   -> DeadlineEscalationService
  TermijnPauseService        -> DeadlinePauseService
  TermijnExtensionService    -> DeadlineExtensionService
  TermijnCaseCreatedListener -> DeadlineCaseCreatedListener

plus the test file, across 11 files: the DI listener registration, the daily
scan job and service, TermijnController, and three test suites.

Stacked on feat/english-vocabulary-deadline-reporting (#803), which is itself
stacked on #797. Merge order: 797 -> 803 -> this.

THE ONE DEFECT THIS SLICE PRODUCED, AND WHAT CAUGHT IT. Renaming the test FILE
TermijnPauseExtensionServiceTest.php left the class inside it still called
TermijnPauseExtensionServiceTest — neither of the two service-name replacements
matched that token, because it is a third name. PHPUnit refused to load it
("Class DeadlinePauseExtensionServiceTest cannot be found"). The basename ==
class invariant I ran before committing covered only the four lib/ files and
would have missed it; it now covers the tests too. Same failure mode as hrmq's
provider discovery, reached from the opposite direction.

LISTENER WIRING IS SAFE BY CONSTRUCTION. WorkflowListenerRegistrar registers
the listener as TermijnCaseCreatedListener::class, not as a string literal, so
the rename is resolved at compile time rather than silently failing to match at
runtime. Checked before renaming rather than after.

VERIFIED
  - php -l clean on every changed file.
  - phpunit, whole unit suite: 1777 tests, 6160 assertions, 5 skipped — green.
  - Every lib/ file this change touches is phpcs CLEAN. Three pre-existing
    issues were fixed along the way: docblock parameter-type alignment in
    TermijnController and TermijnDailyScanService (shifted by the longer type
    names), and two missing class-level @SPEC tags. Those now anchor to
    canonical specs — termijn-binding + termijn-pause-extension for the
    controller, which spans both, and termijn-escalation for the daily scan —
    rather than to the change directories the file headers still point at.
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to any
    of the four old names.

phpcs.xml scopes to <file>lib</file> only, so tests/ and appinfo/ are outside
the standard; no formatting was applied to them.
…es (#806)

* refactor(procest): TermijnNotificationDispatchJob -> DeadlineNotificationDispatchJob

A code-only slice of openspec/changes/english-vocabulary, and the first procest
slice — chosen because it is one of the very few procest identifiers that is
neither ZGW protocol vocabulary nor wired by string.

'termijn' here is a STATUTORY DEADLINE being monitored, not a contract term, so it
becomes Deadline. That reading is app-specific on purpose: hrmq uses 'termijn' for
notice periods (aanzegtermijn) and decidesk for terms of office (TermijnRegeling).
Three apps, three meanings, one Dutch word — it must never become a fleet word.

Referenced only via ::class from TermijnNotificationService, so a missed reference
is a compile error rather than a job that silently never dispatches. No route
entry, no register-fragment wiring, no baseline suppression keyed to the old path
— all four checked, not assumed.

TermijnNotificationService itself keeps its name in this commit; it belongs with
the termijnbewaking slice, where the Termijn* schemas (termijnDefinitie,
termijnInstance, termijnGebeurtenis) move together with their register fragments.

Archived changes under openspec/changes/archive/ keep the old name — they are a
record of what was built at the time.

php -l clean; class name matches file name; use statement still ordered.

* refactor(procest): TermijnReporting* -> DeadlineReporting*

TermijnReportingController -> DeadlineReportingController and
TermijnReportingService -> DeadlineReportingService, with their two Dutch
controller methods (kwartaalrapport -> quarterlyReport, jaarrekening ->
annualStatement), every Dutch parameter and local ($periode -> $period,
$afdeling -> $department, $jaar -> $year, $jaarPrefix -> $yearPrefix), the two
named-argument call sites, the three route names, the test, and the Postman
collection's description.

Stacked on feat/english-vocabulary-deadline-job (#797) because that branch
already renames TermijnNotificationDispatchJob; branching from development
would have conflicted.

FOUR DUTCH CONTRACTS ARE DELIBERATELY LEFT ALONE, and each is annotated in
place so the next reader does not "finish the job" and break it:

  1. $row['afdeling'] is a SCHEMA PROPERTY. OpenRegister materialises it as a
     real column, so it moves with a data migration, not with a rename.
  2. 'periode', 'afdeling' and 'jaar' as RESPONSE KEYS are the published shape
     of the reports. src/views/dashboard/TermijnDashboard.vue reads annual.jaar
     directly, so they move only with a coordinated frontend change.
  3. getParam('periode') and getParam('jaar') are QUERY-STRING parameter names —
     the request contract.
  4. The /api/termijn/* URLs. Route NAMES follow the renamed class and methods;
     URLs do not. Moving them is a breaking change for every consumer and is a
     separate decision.

NAMED ARGUMENTS. resolveQuarter(periode:) and aggregateByType(afdeling:) are
called with named arguments, which PHP resolves at call time — a parameter
rename that misses one is a runtime fatal that `php -l` reports as clean. Both
moved with their declarations.

VERIFIED
  - php -l clean on all four PHP files; the Postman collection still parses.
  - phpunit: 5 tests, 14 assertions green.
  - Route resolution: each 'deadlineReporting#<action>' route name was checked
    against the controller's actual public methods — all three resolve. Positive
    control: the same check against the OLD action names (kwartaalrapport,
    jaarrekening) reports MISSING METHOD, so it can distinguish a renamed route
    from a dangling one.
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to
    either old class name or the old route prefix.

PHPCS SCOPE, MEASURED RATHER THAN ASSUMED. phpcs.xml declares <file>lib</file>
and nothing else, so tests/ and appinfo/ are outside the standard entirely —
appinfo/routes.php carries 373 violations on development and every sibling test
file is equally "dirty". Both files this change touches inside lib/ are CLEAN,
including two pre-existing missing-@SPEC errors fixed here (each now anchors to
openspec/specs/termijn-reporting/spec.md, the canonical spec, not the change
directory the file headers point at).

An earlier pass ran phpcbf over the test file and produced a 94-line diff for
what is a 6-line rename. That was churn against a gate that never reads the
file, so it was reverted; the test diff is now the rename alone.

* refactor(procest): Termijn{Escalation,Pause,Extension}Service + listener -> Deadline*

Four more classes in the deadline cluster:

  TermijnEscalationService   -> DeadlineEscalationService
  TermijnPauseService        -> DeadlinePauseService
  TermijnExtensionService    -> DeadlineExtensionService
  TermijnCaseCreatedListener -> DeadlineCaseCreatedListener

plus the test file, across 11 files: the DI listener registration, the daily
scan job and service, TermijnController, and three test suites.

Stacked on feat/english-vocabulary-deadline-reporting (#803), which is itself
stacked on #797. Merge order: 797 -> 803 -> this.

THE ONE DEFECT THIS SLICE PRODUCED, AND WHAT CAUGHT IT. Renaming the test FILE
TermijnPauseExtensionServiceTest.php left the class inside it still called
TermijnPauseExtensionServiceTest — neither of the two service-name replacements
matched that token, because it is a third name. PHPUnit refused to load it
("Class DeadlinePauseExtensionServiceTest cannot be found"). The basename ==
class invariant I ran before committing covered only the four lib/ files and
would have missed it; it now covers the tests too. Same failure mode as hrmq's
provider discovery, reached from the opposite direction.

LISTENER WIRING IS SAFE BY CONSTRUCTION. WorkflowListenerRegistrar registers
the listener as TermijnCaseCreatedListener::class, not as a string literal, so
the rename is resolved at compile time rather than silently failing to match at
runtime. Checked before renaming rather than after.

VERIFIED
  - php -l clean on every changed file.
  - phpunit, whole unit suite: 1777 tests, 6160 assertions, 5 skipped — green.
  - Every lib/ file this change touches is phpcs CLEAN. Three pre-existing
    issues were fixed along the way: docblock parameter-type alignment in
    TermijnController and TermijnDailyScanService (shifted by the longer type
    names), and two missing class-level @SPEC tags. Those now anchor to
    canonical specs — termijn-binding + termijn-pause-extension for the
    controller, which spans both, and termijn-escalation for the daily scan —
    rather than to the change directories the file headers still point at.
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to any
    of the four old names.

phpcs.xml scopes to <file>lib</file> only, so tests/ and appinfo/ are outside
the standard; no formatting was applied to them.

* refactor(procest): rename the daily-scan and seed-data deadline classes

  TermijnDailyScanService        -> DeadlineDailyScanService
  TermijnbewakingSeedDataService -> DeadlineMonitoringSeedDataService
  DailyTermijnScanJob            -> DailyDeadlineScanJob
  SeedTermijnbewakingData        -> SeedDeadlineMonitoringData
  TermijnbewakingEndToEndTest    -> DeadlineMonitoringEndToEndTest

Stacked on feat/english-vocabulary-deadline-services (#804). Merge order:
797 -> 803 -> 804 -> this.

TWO OF THESE ARE WIRED IN appinfo/info.xml, NOT BY IMPORT. The job is declared
as <job>OCA\Procest\BackgroundJob\DailyTermijnScanJob</job> and the seeder as
<step>OCA\Procest\Repair\SeedTermijnbewakingData</step>. These fail differently
from each other, which is why both were checked rather than assumed:

  - an unresolvable JOB is silently reaped. Nextcloud's JobList::buildJob()
    logs "failed to create instance of background job" and calls removeById(),
    so the daily termijn sweep would simply stop running with no error surface.
  - an unresolvable REPAIR STEP throws during upgrade.

Both entries move here. Verified by walking info.xml and resolving every class
it names to a file on disk: all 13 registered jobs plus the repair step resolve.
Positive control: moving DailyDeadlineScanJob.php away makes that same walk
report MISSING, so the check can fail.

TEST CLASS NAMES. Three of the four test classes are prefixed by the service
name they cover, so the service replacement carried them; only
TermijnbewakingEndToEndTest was an independent token and was renamed
explicitly. The basename == class invariant was run over lib/ AND tests/ this
time — the previous slice (#804) ran it over lib/ only and missed exactly this
class of defect.

VERIFIED
  - php -l clean on every changed file; info.xml parses.
  - phpunit, whole unit suite: 1777 tests, 6160 assertions, 5 skipped — green.
  - Every lib/ file this change touches is phpcs CLEAN. Pre-existing issues
    fixed along the way: docblock parameter-type alignment in the job and the
    repair step (shifted by the longer injected type names), and three missing
    @SPEC tags now anchored to canonical specs (termijn-escalation for the
    scan job, termijnbewaking-schemas for the seeder).
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to any
    of the five old names. This caught two prose references in unrelated files
    (VthSeedDataRepairStep and KccWerkplekSeedDataService) that name the seeder
    in comments; both updated so the comments stay true.
…nts (#805)

Closes the citizen-data exposure found by the gate-7 fleet re-audit (#801).

Live-reproduced with two accounts before and after, status codes printed:
GET /api/kcc/voorblad returned a citizen's contact history — caller phone
number and free-text call summaries — at HTTP 200 to any authenticated
account. 403 after; a KCC-role holder still gets 200.

gate-7 reported 0 for this app throughout, and still does: .github#365 means
it accepts a 401 authentication check as an authorisation guard. The evidence
here is the live probe, not the gate cell.

Guards at 33 call sites via the app's own CaseAccessGuard, plus a new
CitizenLookupGuard for the citizen-lookup endpoints, which have no per-object
owner to check against.

DEPLOYMENT: CitizenLookupGuard denies until a 'kcc' group exists. 'beheerders'
and 'admin' are attested group names in this app; 'kcc' and 'klantcontact' are
assumptions, marked as such on the constant. Create the group or edit the
constant.

Also fixes a fatal in TenantMiddleware (IRequest::setParameter does not exist)
that made every endpoint a 500 for any user with a tenant.

Retractions: bezwaar/hearings attendance and dossier/export were never IDORs —
they are dead endpoints (see #811, 24 sites testing is_array() on a
?ObjectEntity). Repairing those creates IDORs unless the guard lands first.
* refactor(procest): TermijnNotificationDispatchJob -> DeadlineNotificationDispatchJob

A code-only slice of openspec/changes/english-vocabulary, and the first procest
slice — chosen because it is one of the very few procest identifiers that is
neither ZGW protocol vocabulary nor wired by string.

'termijn' here is a STATUTORY DEADLINE being monitored, not a contract term, so it
becomes Deadline. That reading is app-specific on purpose: hrmq uses 'termijn' for
notice periods (aanzegtermijn) and decidesk for terms of office (TermijnRegeling).
Three apps, three meanings, one Dutch word — it must never become a fleet word.

Referenced only via ::class from TermijnNotificationService, so a missed reference
is a compile error rather than a job that silently never dispatches. No route
entry, no register-fragment wiring, no baseline suppression keyed to the old path
— all four checked, not assumed.

TermijnNotificationService itself keeps its name in this commit; it belongs with
the termijnbewaking slice, where the Termijn* schemas (termijnDefinitie,
termijnInstance, termijnGebeurtenis) move together with their register fragments.

Archived changes under openspec/changes/archive/ keep the old name — they are a
record of what was built at the time.

php -l clean; class name matches file name; use statement still ordered.

* refactor(procest): TermijnReporting* -> DeadlineReporting*

TermijnReportingController -> DeadlineReportingController and
TermijnReportingService -> DeadlineReportingService, with their two Dutch
controller methods (kwartaalrapport -> quarterlyReport, jaarrekening ->
annualStatement), every Dutch parameter and local ($periode -> $period,
$afdeling -> $department, $jaar -> $year, $jaarPrefix -> $yearPrefix), the two
named-argument call sites, the three route names, the test, and the Postman
collection's description.

Stacked on feat/english-vocabulary-deadline-job (#797) because that branch
already renames TermijnNotificationDispatchJob; branching from development
would have conflicted.

FOUR DUTCH CONTRACTS ARE DELIBERATELY LEFT ALONE, and each is annotated in
place so the next reader does not "finish the job" and break it:

  1. $row['afdeling'] is a SCHEMA PROPERTY. OpenRegister materialises it as a
     real column, so it moves with a data migration, not with a rename.
  2. 'periode', 'afdeling' and 'jaar' as RESPONSE KEYS are the published shape
     of the reports. src/views/dashboard/TermijnDashboard.vue reads annual.jaar
     directly, so they move only with a coordinated frontend change.
  3. getParam('periode') and getParam('jaar') are QUERY-STRING parameter names —
     the request contract.
  4. The /api/termijn/* URLs. Route NAMES follow the renamed class and methods;
     URLs do not. Moving them is a breaking change for every consumer and is a
     separate decision.

NAMED ARGUMENTS. resolveQuarter(periode:) and aggregateByType(afdeling:) are
called with named arguments, which PHP resolves at call time — a parameter
rename that misses one is a runtime fatal that `php -l` reports as clean. Both
moved with their declarations.

VERIFIED
  - php -l clean on all four PHP files; the Postman collection still parses.
  - phpunit: 5 tests, 14 assertions green.
  - Route resolution: each 'deadlineReporting#<action>' route name was checked
    against the controller's actual public methods — all three resolve. Positive
    control: the same check against the OLD action names (kwartaalrapport,
    jaarrekening) reports MISSING METHOD, so it can distinguish a renamed route
    from a dangling one.
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to
    either old class name or the old route prefix.

PHPCS SCOPE, MEASURED RATHER THAN ASSUMED. phpcs.xml declares <file>lib</file>
and nothing else, so tests/ and appinfo/ are outside the standard entirely —
appinfo/routes.php carries 373 violations on development and every sibling test
file is equally "dirty". Both files this change touches inside lib/ are CLEAN,
including two pre-existing missing-@SPEC errors fixed here (each now anchors to
openspec/specs/termijn-reporting/spec.md, the canonical spec, not the change
directory the file headers point at).

An earlier pass ran phpcbf over the test file and produced a 94-line diff for
what is a 6-line rename. That was churn against a gate that never reads the
file, so it was reverted; the test diff is now the rename alone.

* refactor(procest): Termijn{Escalation,Pause,Extension}Service + listener -> Deadline*

Four more classes in the deadline cluster:

  TermijnEscalationService   -> DeadlineEscalationService
  TermijnPauseService        -> DeadlinePauseService
  TermijnExtensionService    -> DeadlineExtensionService
  TermijnCaseCreatedListener -> DeadlineCaseCreatedListener

plus the test file, across 11 files: the DI listener registration, the daily
scan job and service, TermijnController, and three test suites.

Stacked on feat/english-vocabulary-deadline-reporting (#803), which is itself
stacked on #797. Merge order: 797 -> 803 -> this.

THE ONE DEFECT THIS SLICE PRODUCED, AND WHAT CAUGHT IT. Renaming the test FILE
TermijnPauseExtensionServiceTest.php left the class inside it still called
TermijnPauseExtensionServiceTest — neither of the two service-name replacements
matched that token, because it is a third name. PHPUnit refused to load it
("Class DeadlinePauseExtensionServiceTest cannot be found"). The basename ==
class invariant I ran before committing covered only the four lib/ files and
would have missed it; it now covers the tests too. Same failure mode as hrmq's
provider discovery, reached from the opposite direction.

LISTENER WIRING IS SAFE BY CONSTRUCTION. WorkflowListenerRegistrar registers
the listener as TermijnCaseCreatedListener::class, not as a string literal, so
the rename is resolved at compile time rather than silently failing to match at
runtime. Checked before renaming rather than after.

VERIFIED
  - php -l clean on every changed file.
  - phpunit, whole unit suite: 1777 tests, 6160 assertions, 5 skipped — green.
  - Every lib/ file this change touches is phpcs CLEAN. Three pre-existing
    issues were fixed along the way: docblock parameter-type alignment in
    TermijnController and TermijnDailyScanService (shifted by the longer type
    names), and two missing class-level @SPEC tags. Those now anchor to
    canonical specs — termijn-binding + termijn-pause-extension for the
    controller, which spans both, and termijn-escalation for the daily scan —
    rather than to the change directories the file headers still point at.
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to any
    of the four old names.

phpcs.xml scopes to <file>lib</file> only, so tests/ and appinfo/ are outside
the standard; no formatting was applied to them.

* refactor(procest): rename the daily-scan and seed-data deadline classes

  TermijnDailyScanService        -> DeadlineDailyScanService
  TermijnbewakingSeedDataService -> DeadlineMonitoringSeedDataService
  DailyTermijnScanJob            -> DailyDeadlineScanJob
  SeedTermijnbewakingData        -> SeedDeadlineMonitoringData
  TermijnbewakingEndToEndTest    -> DeadlineMonitoringEndToEndTest

Stacked on feat/english-vocabulary-deadline-services (#804). Merge order:
797 -> 803 -> 804 -> this.

TWO OF THESE ARE WIRED IN appinfo/info.xml, NOT BY IMPORT. The job is declared
as <job>OCA\Procest\BackgroundJob\DailyTermijnScanJob</job> and the seeder as
<step>OCA\Procest\Repair\SeedTermijnbewakingData</step>. These fail differently
from each other, which is why both were checked rather than assumed:

  - an unresolvable JOB is silently reaped. Nextcloud's JobList::buildJob()
    logs "failed to create instance of background job" and calls removeById(),
    so the daily termijn sweep would simply stop running with no error surface.
  - an unresolvable REPAIR STEP throws during upgrade.

Both entries move here. Verified by walking info.xml and resolving every class
it names to a file on disk: all 13 registered jobs plus the repair step resolve.
Positive control: moving DailyDeadlineScanJob.php away makes that same walk
report MISSING, so the check can fail.

TEST CLASS NAMES. Three of the four test classes are prefixed by the service
name they cover, so the service replacement carried them; only
TermijnbewakingEndToEndTest was an independent token and was renamed
explicitly. The basename == class invariant was run over lib/ AND tests/ this
time — the previous slice (#804) ran it over lib/ only and missed exactly this
class of defect.

VERIFIED
  - php -l clean on every changed file; info.xml parses.
  - phpunit, whole unit suite: 1777 tests, 6160 assertions, 5 skipped — green.
  - Every lib/ file this change touches is phpcs CLEAN. Pre-existing issues
    fixed along the way: docblock parameter-type alignment in the job and the
    repair step (shifted by the longer injected type names), and three missing
    @SPEC tags now anchored to canonical specs (termijn-escalation for the
    scan job, termijnbewaking-schemas for the seeder).
  - Residual grep over lib/, tests/, appinfo/ and src/: no live reference to any
    of the five old names. This caught two prose references in unrelated files
    (VthSeedDataRepairStep and KccWerkplekSeedDataService) that name the seeder
    in comments; both updated so the comments stay true.

* feat(repair): migrate procest's Dutch deadline columns to English

Adds RenameDutchDeadlineColumns, the data-migration half of procest's English
vocabulary slice. No property is renamed in this commit — the migration lands
first, because TermijnService and TermijnController cannot be renamed until the
stored data can follow them.

Stacked on feat/english-vocabulary-deadline-scan (#806). Merge order:
797 -> 803 -> 804 -> 806 -> this.

WHY A MIGRATION IS NEEDED AT ALL. OpenRegister does not store an object as a
JSON blob keyed by property name. Each schema property is a real, snake_cased
COLUMN in oc_openregister_table_{register}_{schema}. MagicMapper ADDS a column
on sync and never renames. A register-only rename therefore leaves the data in
the Dutch column while every read looks at the English one and finds null.

THIS APP HAS TWO REGISTERS, AND THE OBVIOUS SCOPING MISSES ONE. Measured on the
reference install: `procest` holds 1051 rows and `procest-default` holds 107.
The sibling step in decidesk resolves a single exact slug. Applying that shape
here migrates 40 columns and silently leaves 29 behind — measured both ways:

    exact slug = 'procest'    ->  40 columns
    slug LIKE  'procest%'     ->  69 columns

so the register set is resolved by PREFIX and every match is migrated. The
failure that was avoided is the worst kind available here: a step that reports
success having covered only part of the data.

zaaktype IS EXEMPT AND DELIBERATELY ABSENT FROM THE MAP. It is the field name
in the statutory ZGW wire format this app both consumes and emits, so it is
exempt under the fleet rule — even though at 14 shard tables it is the second
most widespread Dutch column here, which is exactly why leaving it out needed
saying rather than looking like an oversight.

COLLISIONS ARE REFUSED, NOT MERGED. `omschrijving` and `beschrijving` both mean
`description`. Measured: they do not co-occur in any shard table on this
install, so the guard is a safety net rather than an active path. A later
fragment could introduce a pair, and a silent merge would destroy one of two
values, so the step migrates neither and logs.

SOFT-DELETED ROWS ARE MIGRATED TOO. The back-fill does not filter on `_deleted`
— a restored row must not come back with a null subject. 36 non-null values
exist across the mapped columns, 35 of them live.

VERIFIED
  - php -l clean; info.xml parses; phpcs CLEAN under the app's standard,
    including its @SPEC anchor requirement.
  - Dry run of the step's exact resolution: 40 renames under `procest`, 29 under
    `procest-default`, 0 back-fills, 0 ambiguous. zaaktype untouched on all 14
    tables carrying it.

NOT VERIFIED. The step has not been executed against a database — only its
resolution and its statement set were computed. It runs on the next upgrade;
its idempotence and its back-fill branch are reasoned from the code and the
sibling steps, not observed. Say so rather than let "migration verified" stand
in for it.

* fix(repair): use information_schema, not IDBConnection introspection

phpstan failed this branch with three errors, all in the new repair step:

  Call to an undefined method OCP\IDBConnection::getPrefix().
  Call to an undefined method OCP\IDBConnection::getSchema().  (x2)

They are real. Read from the running server's own
lib/public/IDBConnection.php, the interface exposes getQueryBuilder,
getTypedQueryBuilder, getError, getDatabasePlatform, getDatabaseProvider,
getShardDefinition and getCrossShardMoveHelper — and nothing else beginning
with "get". Both methods I called exist on the concrete OC\DB\Connection, not
on the OCP interface this step is typed against.

WHY EVERY OTHER CHECK PASSED. `php -l` parses a call to a method that does not
exist, and phpcs is a style tool. A nonexistent method on an injected interface
is invisible to both. Only phpstan sees it, and I did not run phpstan locally
before opening the PR — I described the step as verified on the strength of
lint + phpcs + a SQL dry run, none of which touch the PHP API surface. The dry
run in particular measured what the STATEMENTS would do, which is exactly why it
looked convincing while the code that would issue them could not run at all.

THE FIX follows openregister's own RegisterService::magicTableNames(), which
solves the same problem: query information_schema and anchor the match on the
`openregister_table_` MARKER rather than a computed prefix. That file documents
why the obvious alternative fails — getQueryBuilder()->getTableName('') returns
the literal `*PREFIX*` placeholder, resolved only when a query executes through
the NC DB layer, which a raw information_schema string never is; a LIKE built
from it matches zero tables and silently reports every register empty.

Column introspection moves to information_schema.columns for the same reason.

Matching is tightened while here: the suffix after `openregister_table_<id>_`
must be all digits, so register 17 cannot match register 170's shard tables.

VERIFIED
  - phpstan: [OK] No errors, whole-project run, same command as CI.
  - php -l clean; no `db->getSchema()` or `db->getPrefix()` call remains.

THE SAME DEFECT IS IN FOUR SIBLING PRs, all authored today and all failing
phpstan for this identical reason: openbuild#176, opencatalogi#850,
decidesk#467 and softwarecatalog#488. Each needs this same change. None can
merge while red, so nothing shipped — but each of their PR bodies claims
verification that never covered the API surface.

* style(repair): split shardTables() and drop the short variable

phpmd flagged two things the information_schema rewrite introduced:

  CyclomaticComplexity  shardTables() is at 10; the threshold is 10.
  ShortVariable         $at is below the 3-character minimum.

The marker-matching loop moves into isShardOf(), which fixes both: the
extraction takes shardTables() back under the threshold, and $at becomes
$offset. Behaviour is unchanged — same marker anchoring, same digits-only
suffix check that keeps register 17 from matching register 170's tables.

Also capitalises one inline comment, which phpcs requires.

ALL THREE GATES RUN LOCALLY THIS TIME, which is the point. The original defect
in this file shipped because I ran php -l and phpcs and called it verified;
phpstan was the only gate that could see a call to a nonexistent interface
method, and I had not run it. This round:

  phpcs    CLEAN
  phpstan  [OK] No errors
  phpmd    0 findings on this file

* test(repair): cover the migration's scoping decisions

The PHPUnit job was failing on the COVERAGE RATCHET, not on a test:

  Coverage current:    30.52%  (14092/46169 statements)
  Coverage merge base: 30.55%  (14000/45824 statements)
  FAIL: coverage dropped by 0.03% against the merge base.
        This change adds 345 statements. Adding code without tests drops
        coverage.

Both suite legs reported 1803 and 1777 tests with zero failures, so "PHPUnit
FAILED" here meant "you added untested code" — which was true. The migration
shipped with no test at all.

These ten tests pin the two decisions that determine what the step touches —
which shard tables are in scope, and when a rename is refused — plus two
invariants that are load-bearing but were only asserted in prose: that
`zaaktype` stays out of the map because it is the statutory ZGW wire name, and
that every destination is snake_case (MagicMapper DROPS a camelCase column whose
snake_case twin exists, so a camelCase destination would be deleted).

The DDL/DML paths are deliberately not unit-tested: they need a live database.

A POSITIVE CONTROL FOUND A FALSE CLAIM IN MY OWN COMMENT. The first version of
the shard-matching test asserted that register 17 must not match register 170's
tables, quoting the comment above the guard. Deleting the ctype_digit guard and
re-running left that test GREEN — it could not fail, because the marker already
ends in '_', so `openregister_table_17_` is not a substring of
`openregister_table_170_85`. The trailing underscore handles 17-vs-170; the
digits check guards something else entirely: derived tables like
`…_17_85_backup` and non-shards like `…_17_audit`, both of which contain the
marker and would otherwise receive an ALTER TABLE.

The test now asserts that case and goes RED when the guard is removed. The code
comment, which stated the wrong reason, is corrected in the same commit.

VERIFIED
  - phpunit: 10 tests, 24 assertions green; whole unit suite 1787/6184.
  - Positive control: guard removed -> 1 failure; restored -> 10 pass.
  - phpcs clean, phpstan [OK] No errors.
…dy called (#794) (#795)

* fix(admin): implement the seven admin routes four settings tabs already called (#794)

Four admin settings tabs were entirely non-functional: Mandate Matrix
Administration, Mandate Matrix System Settings, AWB Term Definitions and
Consultation Management. They called procest API URLs that appinfo/routes.php
never declared. Nextcloud answers an unmatched app URL with its own HTML page
under HTTP 200, so nothing threw and nothing logged.

Live-probed before the fix, printing the content-type rather than the status —
all seven returned 200 text/html at 40,061 bytes, byte-identical to a nonsense
control URL, while /api/settings and /api/vth/checklists returned
application/json.

Only ChecklistsTab (#784) was loud, because it used the string-admitting
`x?.results || x || []`. The other seven use the correct guard,
`Array.isArray(x) ? x : (x?.results || [])`, which properly discards the HTML
string and thereby turned the identical defect into a silent empty tab. The
guard is right and stays; a working guard masked a broken caller.

What this adds
- MandaatRegistryController — GET besluiten, POST/PATCH mandaten.
- OrganisatieRolController — full CRUD for rollen (with the delete guard) and
  toewijzingen. Split from the above on the domain boundary; one class holding
  all four registries exceeded the public-method budget.
- TermijnDefinitieController — GET/POST/PATCH definities (REQ-TERM-ADMIN-001).
- ConfiguredRegistryService — generic list/save/delete over a config-key-named
  schema, with no authorization of its own, so every caller must carry a guard.
- MandaatRegistryService — the referential-integrity guard the spec requires:
  a role held by a Mandaat or an active assignment refuses deletion with 409.
- The two settings tabs now POST the app's own guarded /api/settings, their
  keys are registered in CONFIG_KEYS, and AdminSettings provides the initial
  state both tabs were already calling loadState() for but nothing supplied.

Authorization posture, checked deliberately
Every new method carries #[AuthorizedAdminSetting]. Repointing the frontend at
OpenRegister's generic object route also resolves and also removes the symptom
— that was the retracted first fix for #784, and it bypasses procest's admin
authorization. Verified against a live instance: non-admin gets 403 on all
seven new routes, while the @NoAdminRequired control still returns 200, so the
probe distinguishes rather than uniformly failing.

Pre-existing bugs found and fixed while verifying
- InspectionChecklistService::deleteChecklist passed `id:` to
  ObjectService::deleteObject, whose parameter is `$uuid`. Every call raised
  "Unknown named parameter $id", was swallowed by the catch, and returned
  false — the admin Delete button 500'd on every checklist. Confirmed live
  before and after.
- MandaatToewijzingenTable sent `{ totEnMet }` to end an assignment. The schema
  field is `validUntil`, and saveObject is PUT-semantic, so that payload would
  have nulled userId, rolId, toewijzingType and validFrom off the record.
- An earlier draft of the delete guard checked organisatieRol/rol/rolId only.
  The Mandaat schema names the reference `gemandateerdeRol`, so the guard would
  have failed OPEN for every mandate. Read from the shipped schemas, not guessed.

Verified on an isolated seeded rig (:8091): create/list/update/delete round
trips for all four registries; the delete guard refuses a referenced role (409,
naming what blocks it) and permits an unreferenced one (200); settings persist
across a container restart; and an unregistered key is dropped by the
CONFIG_KEYS allowlist, which is what makes registering the new keys load-bearing.

composer check:strict: ALL CHECKS PASSED (1791 tests). eslint 0 errors.
USE_LOCAL_LIB=false npm run build: exit 0.

Refs #784, #786, #792

* test(admin): cover the new registry surfaces; add the two missing l10n keys

Two CI failures on this PR, both real and both mine.

l10n coverage / test:l10n — the two new error strings ('Saving failed',
'Saving failed ({status})') were not in l10n/en.json. Extracted with
check-l10n.js --write and translated on the nl.json side, so no Dutch-locale
string falls back to the raw English key.

Coverage ratchet — 1777 tests passed and the PHPUnit cell still failed, which
is the ratchet, not a broken test: the change added 261 statements and coverage
fell 0.1% against the merge base. Fixed by covering the new code rather than by
touching the threshold.

- ConfiguredRegistryServiceTest — list/save/delete, both the numeric and the
  slug resolution paths. Two are regression guards worth keeping: delete must
  pass the identifier as `uuid` (passing `id:` is what made
  InspectionChecklistService::deleteChecklist 500 on every call), and on the
  numeric path register/schema must be NESTED under `@self` (a top-level
  filter silently returns an empty set, which reads as 'there is no data'
  rather than as a malformed query).
- MandateRegistryControllersTest — the three controllers: status codes, schema
  routing, 409-with-a-reason on a referenced role, 422 vs 500, and that a
  client-supplied `id` in the body cannot override the routed one.

Discovered while writing these: a live instance stores NUMERIC register/schema
ids (register 14, organisatie_rol_schema 153), so `searchObjects()` — not the
slug bridge — is the production path. The first draft of the test stubbed only
the slug bridge and returned an empty set, which is exactly the failure this
code has to survive.

⚠️ The ratchet could not be reproduced locally: no code-coverage driver is
installed here ('No code coverage driver available'), so CI is the authority on
whether this clears it.
…ec (#786) (#798)

* feat(inspection): build ChecklistService, REQ-003 of the canonical spec (#786)

REQ-003 of `openspec/specs/inspection-checklists/spec.md` was never built. This
adds it: `OCA\Procest\Service\ChecklistService`, with the four methods the
requirement names — `completeItem()`, `getProgress()`, `validateCompletion()`
and `getConformitySummary()` — all pure with respect to the checklist payload,
so the same calculation runs server-side, in a dry-run preview, and in a unit
test.

⚠️ I did NOT write the requirement. REQ-001, REQ-002 and REQ-003 already exist
verbatim in the canonical spec under `## Execution Surface (retrofit)`, and the
spec's frontmatter lists them under `retrofit_extensions`. The implementation
follows that text rather than the other way round.

⚠️ This is deliberately NOT a rename of `lib/Service/Inspection/ChecklistService.php`.
The spec's own Notes block keeps the two apart on purpose — "the top-level
service handles per-run progress, the namespaced one handles templates.
Consolidation deferred to a future refactor change." Merging them would
contradict the spec, not tidy it.

Consistency with the existing implementation
Answer semantics are taken from `Inspection\ChecklistService` so the two can
never disagree about which answer is a failure: `ja_nee_nvt` → `nvt` is
not-applicable and `nee` is non-conforming; `fotoRequired` is the tri-state
`nooit`/`bij_nee`/`altijd`, not a boolean; an item's id is `id`, else `order`,
else its position. Those SHAPE rules moved into `ChecklistPayloadReader` so they
are stated once rather than restated per consumer — and so `ChecklistService`
stays under the class-complexity budget.

Two decisions worth reviewing rather than assuming
- A response row that exists but carries no usable value is NOT an answer.
  Counting it would let a run of blank required items report itself complete.
- `completeItem()` enforces the photo gate at answer time, not only at
  submission. REQ-001's scenario requires the item to REMAIN INCOMPLETE when a
  mandatory photo is missing, which validating only at submission would not do.

Tests: 21 cases, including negative controls that would pass against a wrong
implementation — an optional item may be left blank (a validator flagging every
unanswered item would also satisfy the "reports both required items" test), an
orphan response does not inflate progress, and an empty checklist is 100% rather
than a division by zero.

Still outstanding on #786, deliberately not attempted here: REQ-001
(`InspectionController`) and REQ-002 (`InspectionService`). REQ-001 delegates to
this service, so it is now unblocked.

composer lint/phpcs/phpmd/psalm/phpstan: clean. PHPUnit: 1793 tests, 6192
assertions, 0 failures.

Refs #786

* test(inspection): declare @uses on the payload reader and give it its own suite

The PHPUnit cells failed with 1793 tests passing and 8 RISKY — not the coverage
ratchet and not a broken test. phpunit.xml sets
`beStrictAboutCoverageMetadata="true"`, and ChecklistServiceTest declared
only `@covers \OCA\Procest\Service\ChecklistService` while every case also
executes ChecklistPayloadReader, which the service composes directly (REQ-003
requires it to be constructible with no dependencies, so the reader cannot be
injected and mocked away).

Fixed the way this repo already handles it — DeelzaakServiceTest carries the
same `@uses` for CaseObjectReader for the same reason — plus a real
`@covers` suite for the reader rather than leaving it covered only by
association.

⚠️ I did not catch this locally because every local run used
`--no-coverage`: the risky-test check only fires when coverage is collected,
and there is no coverage driver installed in this environment. A green
`--no-coverage` run is not evidence about the cells that failed.

ChecklistPayloadReaderTest also pins one thing worth keeping: PHP coerces a
numeric-string array key to an int, so an item keyed by `order: 4` lands under
int 4. Lookups coerce the same way so behaviour is correct, but an assertSame
on array_keys() does not see what you wrote — the first draft of that test
asserted the wrong thing and failed.

phpcs 0 errors. 30 tests, 61 assertions pass.

* fix(test): stop PHPUnit parsing my explanatory prose as a coverage annotation

The @uses fix worked — 8 RISKY became 0 — and introduced 16 PHPUnit warnings,
which fail the cell just as hard, with all 1802 tests passing:

    "@Covers suite" is invalid

The docblock prose explaining the fix contained a literal coverage-annotation
token followed by the word 'suite'. PHPUnit scans the whole docblock and
matches those tokens MID-LINE, not just at the start of one, so it dutifully
registered a coverage target literally named 'suite'.

Rewritten to describe the annotations without naming them. ⚠️ The first attempt
at THIS fix reintroduced the bug, because the warning I wrote about the token
contained the token.

🔑 Same family as the fleet's gate-19/gate-26 prose-parsing defect
(.github#358): an explanatory comment is not inert, and documenting a
parser-driven defect can re-trigger the defect you are documenting.
…d is_array() on show() (#811) (#814)

* fix(security): per-object authorisation for the four dwangsom endpoints (#811)

All four DwangsomController verbs are @NoAdminRequired and carried no
per-object check. The private method they called was named
ensureAuthenticated() and documented as a "per-object authorization
guard"; it only established that somebody was logged in.

Measured on a live rig, owner and attacker arms, before this change: the
attacker column was byte-identical to the owner column on all four
endpoints, so nothing was refusing anybody on the basis of the object.

A DwangsomBerekening has no owner field. It reaches a case through
termijnInstance -> zaak, so OwningCaseResolver gains a two-hop resolveVia()
and the decision is delegated to CaseAccessGuard, read access for the GET
and mutation access for the three writes. Every unresolvable link denies.

This lands the guard BEFORE the is_array() predicate is repaired. Those
endpoints are dead today (see the follow-up commit), so repairing them
first would have converted four dead endpoints into live unguarded ones,
two of which mutate penalty amounts.

* fix(dwangsom): repair the is_array() predicate on show(), now that it is guarded (#811)

ObjectService::find() is declared `: ?ObjectEntity` and never returns an
array, so `is_array($row) === false` was true for every existing
berekening and GET /api/termijn/dwangsom/{id} answered 404 to everyone —
its own case assignee included. Measured on a live rig against an object
created and read back through the OpenRegister API.

Normalised through jsonSerialize() instead, using is_callable() rather
than method_exists(): ObjectEntity reaches accessors through
Entity::__call(), for which method_exists() is false.

Deliberately scoped to the read verb. beschikking(), bezwaar() and
bezwaarHeroverweging() are dead for the same reason but their post-repair
behaviour changes penalty amounts, and that is not something to settle
from here. They now carry the guard, so repairing them later cannot ship
an IDOR.

The tests use a double that returns an ENTITY rather than an array — an
array-returning double would pass against the broken predicate too.
Negative control: restoring the old predicate fails exactly one test,
testAuthorisedCallerGetsTheBerekeningNormalisedFromAnEntity, with
'Failed asserting that 404 is identical to 200'.
…nlink (#793) (#796)

* fix(deelzaak): stop unlinkSubCases reporting success over a partial unlink (#793)

`unlinkSubCases()` is a synchronous fan-out of `saveObject()` on the request
path — the openregister#2420 shape, invisible to gate-61 because that gate
inspects post-event listeners and this is a plain service seam.

⚠️ The remedy the issue proposes first — defer the whole thing to a background
job — cannot be applied as-is, and that is the main finding here.
`DeelzaakDeleteWarningModal::confirmDelete()` awaits this call and then deletes
the parent. Enqueue-and-return would delete the parent while the children still
point at it, so the "orphans survive" guarantee (REQ-DZS-006-B) would be broken
by the very change meant to protect it. Deferral needs the parent delete moved
into the same job; that is left to #793 and deliberately NOT done here.

What IS fixed, because it is a correctness bug rather than a performance one:
the implementation took `listSubCases()`'s `_limit => 200` page and reported
plain success. A parent with more than 200 sub-cases had the remainder silently
left linked, and the caller then deleted the parent — orphaning them under a
dead reference while the API answered `200 OK` with a count that under-reported.
Per-record failures were swallowed the same way.

Changes
- `unlinkSubCases()` pages to exhaustion and returns
  `{unlinked, failed, total, complete}`. A `$seen` set makes each sub-case count
  exactly once: a record whose save fails stays in the filter's result set, so
  without it the loop both spins forever and double-counts. Extracted the
  per-page work into `unlinkPage()` to keep the complexity within budget.
- `DeelzaakController::unlink()` answers `207 Multi-Status` when the unlink is
  incomplete, so a partial result is distinguishable by status code alone.
- `deelzaakApi.unlinkSubCases()` returns the whole result. `complete` defaults
  to false when the field is absent, so an older server that still answers with
  a bare count blocks the delete rather than silently permitting the failure
  mode this change closes.
- `DeelzaakDeleteWarningModal` deletes the parent ONLY when `complete` is true,
  and otherwise tells the user how many sub-cases could not be detached.

Tests
`DeelzaakUnlinkSubCasesTest` — 6 cases over a stub that pages the way
OpenRegister does. Includes the negative control (no sub-cases → complete zero,
without which an always-incomplete implementation would pass the failure
tests), the 450-records-over-a-200-page regression, and the all-failures case
that hangs the suite if the no-progress guard is removed. That last test caught
a real defect in my first implementation: failed records were re-queried on the
next page and double-counted, reporting `failed: 2` for one failing record.

composer check:strict: ALL CHECKS PASSED (1791 tests). vitest 13 passed.
eslint 0 errors. USE_LOCAL_LIB=false npm run build: exit 0.

Refs openregister#2420

* test(deelzaak): cover unlink() status reporting; fix gate-16 and l10n

Three CI failures on this PR, all real and all mine. Each reproduced locally
before fixing, and re-measured after.

gate-16 spec-coverage — `deelzaakApi.js::unlinkSubCases` had a JSDoc but no
`@spec` anchor. Reproduced against the canonical gate package (.github
@81c8c97) with an explicit base: `HYDRA_GATE_BASE_REF=origin/development` →
`count=1` naming that method, `count=0` after. Anchored at the existing
'Sub-case deletion protection' requirement, which already says the system MUST
clear parentCase on ALL child cases — I did not write a requirement to justify
the change; the requirement is what the change implements.

l10n coverage / test:l10n — the new partial-unlink error string was missing
from en.json and nl.json. Extracted and translated; the check now reports both
key sets matching.

Coverage ratchet — the PHPUnit cells failed with no failing test, which is the
ratchet, not a broken suite. Covered the new controller code rather than
touching the threshold: DeelzaakUnlinkControllerTest asserts 200 on a complete
unlink, 207 on a partial one, 401 for an anonymous caller, and a clean 200 when
there are no sub-cases. The 200 case is the negative control — without it, a
controller that always answered 207 would satisfy the 207 test on its own.

⚠️ The ratchet could not be reproduced locally: no code-coverage driver is
installed here, so CI is the authority on whether this clears it.

phpcs 0 errors, phpmd clean, PHPUnit 10 new tests pass, vitest 13 pass.

* test(deelzaak): declare @uses so the unlink paging test is not reported risky

phpunit.xml sets beStrictAboutCoverageMetadata="true". DeelzaakUnlinkSubCasesTest
declared only @Covers ::unlinkSubCases, but the paging loop calls
listSubCases() and the service is built with a REAL CaseObjectReader (as
DeelzaakServiceTest does, for the same reason) — so both execute without being
the subject and every case would be reported RISKY, failing the PHPUnit cell
with no failing test.

Caught by the identical failure on the sibling REQ-003 branch rather than by
waiting for this PR's cells to come back red.

⚠️ Not reproducible locally: the risky-test check only fires when coverage is
collected, and there is no coverage driver in this environment, so every local
run (--no-coverage) is silent about it.

* fix(test): stop PHPUnit parsing my explanatory prose as a coverage annotation

Same defect the sibling REQ-003 branch hit, fixed here before this PR's cells
reported it: the docblock prose explaining the @uses declarations contained a
literal coverage-annotation token followed by the word 'so'. PHPUnit matches
those tokens MID-LINE anywhere in a docblock, so it registered a coverage
target literally named 'so' and failed the cell with an 'is invalid' warning
while every test passed.

Rewritten to describe the annotations without naming them.

🔑 Same family as the fleet's gate-19/gate-26 prose-parsing defect
(.github#358): an explanatory comment is not inert. Checked the rest of
tests/ for the shape — the three pre-existing occurrences are backtick-wrapped
and demonstrably do not warn (CI's 16 warnings were all from the one file).

* fix(test): declare coverage at CLASS scope so the cells stop reporting risky

Third and final shape of this one. After the prose fix the cell still failed:
`Tests: 1787 … Risky: 5`, naming DeelzaakController and DeelzaakService as
'executed code that is not listed as code to be covered or used'.

Cause: I declared coverage METHOD-scoped (::unlink, ::unlinkSubCases). Under
beStrictAboutCoverageMetadata, constructing the subject runs `__construct` —
and for the service also the SearchesObjects trait — none of which IS the named
method, so they count as undeclared even though the collaborators were declared.

Fixed by matching the convention already used throughout tests/Unit:
DeelzaakServiceTest and InspectionChecklistControllerTest both declare at class
scope. This also explains why the sibling #794 branch never hit it — its tests
were class-scoped from the start, and its ConfiguredRegistryService trait usage
was attributed to the class rather than reported.

🔑 Method-scoped coverage metadata is strictly harder to satisfy than
class-scoped, and the difference only shows up when coverage is collected —
which this environment cannot do.
…cision (#815)

Applies the ratified besluit = decision equivalence to procest's mandate
schema: the `mandateringsBesluit` schema (key, slug, title, description) becomes
`mandateDecision`, with five of its properties and the cross-schema reference
that names it.

  besluitNummer       -> decisionNumber
  besluitNaam         -> decisionName
  inWerkingtreding    -> effectiveFrom
  vervalDatum         -> expiryDate
  vorigBesluit        -> previousDecision
  mandaat.mandateringsBesluit -> mandaat.mandateDecision

THERE WAS NEVER A SCHEMA MERGE TO DO. This slice was previously described as
blocked because procest "already declares a decision schema, so renaming
Besluit onto it is a merge". Measured: procest declares exactly one `decision`
schema, already fully English with twelve English properties, and there is no
bare `Besluit` schema anywhere to collide with it. What carries the word is two
differently-named schemas and five properties. The premise was wrong.

WHAT IS DELIBERATELY NOT RENAMED

`wettelijkeGrondslag` stays. It is owned by FIVE other procest schemas
(dwangsomUitbetaling, mandaat, subsidieBeschikking, termijnDefinitie,
terugvordering) that still declare the Dutch spelling, and the column migration
is REGISTER-scoped: renaming it here would migrate their data out from under
their own declarations. A shared property half-renamed is worse than one not
renamed at all. It moves fleet-wide, with them, in one slice. `legalBasis`
already exists on another procest schema, which is a second reason not to
introduce a partial second source for it.

The `status` enum VALUES (concept / vastgesteld / vervallen) stay. Enum values
are stored data, not identifiers — rewriting them rewrites every row. Same call
as openconnector#1213.

The SchemaSlugMap VALUE stays `mandaterings_besluit_schema`. Only the key is the
slug; the value is the app-config key under which this schema's numeric id is
already stored on every existing install. Renaming it would orphan that id and
the schema would silently resolve to nothing.

The import endpoint accepts BOTH spellings. Its request-body keys are a
published contract; the English names are preferred and the Dutch ones still
read, so a client that has not been updated does not break.

MIGRATION. Six columns added to the existing register-scoped repair step. Each
was verified to be owned by exactly one schema before being added — that check
is what kept wettelijke_grondslag out, and it is the same hazard decidesk's step
avoids by scoping to a register, only here it is within one app.

THREE PRE-EXISTING UI BUGS FOUND, DELIBERATELY NOT "FIXED" BY RENAMING
  - MandaatEditor.vue binds form.inWerkingtreding onto a MANDAAT, which declares
    validFrom/validUntil and has no such property.
  - MandaatMatrixTable.vue reads b.vervaldatum — lowercase d — matching no
    schema property at all.
  - BevoegdhedenPanel.vue reads both off API rows whose shape is not verifiable
    without running it.
Renaming these would have cemented bindings that already point at nothing. Only
MandaatMatrixWidget.vue, which reads the renamed reference property, was
updated. The three need their own investigation.

VERIFIED
  - phpunit: 1867 tests, 6346 assertions green (5 skipped, same as baseline).
  - php -l clean; the fragment parses as JSON.
  - Named-argument parity: findPriorBesluit -> findPriorDecision, parameter
    besluitNummer -> decisionNumber, both call sites moved and diffed against
    the declaration.
  - phpcs: 0 errors on every file this change touches except
    MandaatImportServiceTest, which carries 46 — identical count on the
    unmodified file, measured by stashing.
…l six owners (#816)

* refactor(mandaat): rename the mandateringsBesluit schema to mandateDecision

Applies the ratified besluit = decision equivalence to procest's mandate
schema: the `mandateringsBesluit` schema (key, slug, title, description) becomes
`mandateDecision`, with five of its properties and the cross-schema reference
that names it.

  besluitNummer       -> decisionNumber
  besluitNaam         -> decisionName
  inWerkingtreding    -> effectiveFrom
  vervalDatum         -> expiryDate
  vorigBesluit        -> previousDecision
  mandaat.mandateringsBesluit -> mandaat.mandateDecision

THERE WAS NEVER A SCHEMA MERGE TO DO. This slice was previously described as
blocked because procest "already declares a decision schema, so renaming
Besluit onto it is a merge". Measured: procest declares exactly one `decision`
schema, already fully English with twelve English properties, and there is no
bare `Besluit` schema anywhere to collide with it. What carries the word is two
differently-named schemas and five properties. The premise was wrong.

WHAT IS DELIBERATELY NOT RENAMED

`wettelijkeGrondslag` stays. It is owned by FIVE other procest schemas
(dwangsomUitbetaling, mandaat, subsidieBeschikking, termijnDefinitie,
terugvordering) that still declare the Dutch spelling, and the column migration
is REGISTER-scoped: renaming it here would migrate their data out from under
their own declarations. A shared property half-renamed is worse than one not
renamed at all. It moves fleet-wide, with them, in one slice. `legalBasis`
already exists on another procest schema, which is a second reason not to
introduce a partial second source for it.

The `status` enum VALUES (concept / vastgesteld / vervallen) stay. Enum values
are stored data, not identifiers — rewriting them rewrites every row. Same call
as openconnector#1213.

The SchemaSlugMap VALUE stays `mandaterings_besluit_schema`. Only the key is the
slug; the value is the app-config key under which this schema's numeric id is
already stored on every existing install. Renaming it would orphan that id and
the schema would silently resolve to nothing.

The import endpoint accepts BOTH spellings. Its request-body keys are a
published contract; the English names are preferred and the Dutch ones still
read, so a client that has not been updated does not break.

MIGRATION. Six columns added to the existing register-scoped repair step. Each
was verified to be owned by exactly one schema before being added — that check
is what kept wettelijke_grondslag out, and it is the same hazard decidesk's step
avoids by scoping to a register, only here it is within one app.

THREE PRE-EXISTING UI BUGS FOUND, DELIBERATELY NOT "FIXED" BY RENAMING
  - MandaatEditor.vue binds form.inWerkingtreding onto a MANDAAT, which declares
    validFrom/validUntil and has no such property.
  - MandaatMatrixTable.vue reads b.vervaldatum — lowercase d — matching no
    schema property at all.
  - BevoegdhedenPanel.vue reads both off API rows whose shape is not verifiable
    without running it.
Renaming these would have cemented bindings that already point at nothing. Only
MandaatMatrixWidget.vue, which reads the renamed reference property, was
updated. The three need their own investigation.

VERIFIED
  - phpunit: 1867 tests, 6346 assertions green (5 skipped, same as baseline).
  - php -l clean; the fragment parses as JSON.
  - Named-argument parity: findPriorBesluit -> findPriorDecision, parameter
    besluitNummer -> decisionNumber, both call sites moved and diffed against
    the declaration.
  - phpcs: 0 errors on every file this change touches except
    MandaatImportServiceTest, which carries 46 — identical count on the
    unmodified file, measured by stashing.

* refactor(procest): rename wettelijkeGrondslag to legalBasis, all six owners

Renames the property across EVERY schema that declares it, in one change:
dwangsomUitbetaling, mandaat, mandateDecision, subsidieBeschikking,
termijnDefinitie and terugvordering — plus the seed data, six services, three
Vue files and the affected test fixtures.

WHY ALL SIX AT ONCE. This was deliberately held out of the mandate-decision
slice. The column migration is REGISTER-scoped: it walks every shard table in
the procest registers and renames any column whose name is in its map. Renaming
`wettelijkeGrondslag` for one schema would therefore have moved the data of the
other five out from under declarations that still used the Dutch name — a
silent null-read on each. A shared property is renamed for all its owners or
for none. With all six moved, `wettelijke_grondslag => legal_basis` is safe to
add to the map, and it is added here.

MY FIRST MEASUREMENT OF THE FOOTPRINT WAS SHORT. It ran against the shared
checkout, where TermijnReportingService.php had not yet been renamed to
DeadlineReportingService.php by #806, so that consumer did not appear. Three
Vue files were missed the same way. The residual grep after editing is what
surfaced them; the count went from 27 references to 40.

NOT EVERY VUE BINDING WAS REAL. The sibling slice found MandaatEditor binding
`inWerkingtreding` onto a mandaat that has no such property. This one is
different and was checked rather than assumed: `mandaat` genuinely declares
wettelijkeGrondslag, so its editor field, its validator and the matrix widget's
legalLink() all bind something that exists, and all three move.

THE CSV HEADER STAYS READABLE. MandaatImportService reads the legal basis from
an operator-supplied CSV column. That header is an external input format, so
the new spelling is preferred and the old one still accepted — an existing
import file does not break.

VERIFIED
  - phpunit: 1867 tests, 6347 assertions green (5 skipped, same as baseline).
  - Two tests failed on the first run and were fixed: an assertion in
    DwangsomUitbetalingServiceTest reading the old key, and four fixtures in
    TermijnServiceTest. Both now pass.
  - php -l clean on every changed file; all four JSON files parse.
  - phpcs: TermijnServiceTest ends at 47 errors against a 74-error baseline —
    my edit first pushed it to 96 through misaligned array arrows, and phpcbf
    both corrected that and cleared pre-existing debt. Every other changed file
    is at or below its baseline.

* style(mandaat): realign the import payload array after the legalBasis rename

phpcs failed #816 with 7 'Array double arrow not aligned correctly' errors, all
in the payload built by buildMandaatPayload(). The cause is mine: 'legalBasis'
is shorter than 'wettelijkeGrondslag', so every arrow in that array needed
re-aligning and I did not re-run phpcs on this file after the edit.

phpcbf-applied; the file is back to 0 errors. No behaviour change — whitespace
only, confirmed by the diff being 7 insertions and 7 deletions on the same
lines, and by MandaatImportServiceTest still passing.

* docs(mandaat): add @SPEC anchors to the two methods this PR changed

gate-16 (spec-coverage) failed #816 with '2 changed method(s) missing @SPEC'.
The gate is diff-scoped and it is right: buildMandaatPayload() and
collectChangedFields() were both edited by the legalBasis rename and neither
carried an anchor. gate-16 PASSES on development, so this is caused by this
branch, not inherited.

Both now point at the change that introduced them.

NOT FIXED HERE: gate-53 (effective-manifest-crossref, 8 failures). Measured
against development's own latest Code Quality run — it reports the IDENTICAL 8
cross-reference failures there, so it is pre-existing and untouched by this
branch. src/manifest.json contains no reference to any property this PR renamed
(grep for wettelijkeGrondslag returns 0).
… to mandate (#817)

* WIP DO NOT MERGE — mandaat -> mandate, schema + migration half only

INCOMPLETE. The four schemas, their eight properties, the slug map and the
column migration are done and verified. The 716 code references across 56 files
are NOT. Merging this state gives every one of them a silent null read: the
schema declares `mandate`, the code still asks for `mandaat`, and OpenRegister
returns null rather than erroring.

Committed so the ownership analysis and the migration boundary are not lost.

DONE — all four owners, one register scope
  mandaat           -> mandate              (61-mandaat-matrix)
  mandaatGebruik    -> mandateUsage         (61-mandaat-matrix)
  mandaatEscalatie  -> mandateEscalation    (61-mandaat-matrix)
  mandaatRegeling   -> mandateArrangement   (30-beschikking)

  mandaatNummer -> mandateNumber        mandaatId -> mandateId
  mandaatVersieId -> mandateVersionId   targetMandaatId -> targetMandateId
  mandaatGroepen -> mandateGroups       ondermandaatToegestaan -> subMandateAllowed
  mandaatNiveau -> mandateLevel         mandaatGegeven -> mandateGranted

Verified after: 0 procest-scoped schemas or top-level properties still carry
`mandaat`. Ownership was checked per property BEFORE editing — each is declared
by exactly one schema, and all four schemas sit in the `procest` register, so
the register-scoped migration covers every owner. That is the condition the
wettelijkeGrondslag slice established and it is satisfied here.

SLUG MAP: keys follow the new slugs, VALUES deliberately unchanged. Each value
is the appconfig key holding that schema's numeric id on existing installs;
renaming it orphans the id and the schema silently resolves to nothing.

DELIBERATELY EXCLUDED
  - `akkoord-mandaat` is a lifecycle ENUM VALUE, i.e. stored data. Renaming it
    rewrites every row — a separate, riskier migration, same call as
    openconnector#1213 and procest#815.
  - `mandaatregelingId` and the `mandaatNiveau` inside `mandateGranted` are
    NESTED under an object property, so they live in that column's JSON rather
    than as columns. A column rename cannot reach them; they need a JSON-rewrite
    migration. Documented in the map.

TO FINISH: 716 references in lib/ and src/ across 56 files — the four Mandaat*
PHP service/controller classes, MandaatRegistryService, MandaatEscalatieService,
MandaatCheckService, MandaatImportService, and the Vue files
(MandaatMatrixTab, MandaatEditor, MandaatImportPanel). Then this is mergeable.
Note MandaatCsvParser::REQUIRED_COLUMNS and the CSV headers are an external
input contract — alias both spellings rather than breaking operator files.

* WIP: data-contract half — property reads in 3 services

Continues f8a55fa. Renames the schema PROPERTY READS that break when the
schema half lands: mandaatGroepen -> mandateGroups (MandaatVerifier, 3 sites)
and mandaatGegeven -> mandateGranted (BeschikkingService, 2 sites).

MandaatCsvParser::REQUIRED_COLUMNS deliberately NOT renamed and now documented
in place. Those are CSV HEADERS — an external input contract operators' files
already use. I renamed it, then reverted: it would reject every import file in
the field. MandaatImportService maps headers onto the renamed properties and
accepts either spelling, which is the pattern used for wettelijkeGrondslag and
omschrijving.

STILL WIP DO NOT MERGE — the remaining data-contract sites (MandaatCheckService,
MandaatGebruikService, MandaatEscalatieService, MandaatGuard, and 6 Vue files)
are not done.

* refactor(mandaat): complete the data-contract half of mandaat -> mandate

Every code site that READS or WRITES a renamed schema property now agrees with
the schema. Combined with f8a55fa and 4c5a187, this branch is coherent:
schema, migration and data contract all move together.

WHAT THIS IS NOT. The Mandaat* class names, file names and local variables are
still Dutch — roughly 650 references. They are cosmetic: nothing resolves
through them at runtime, so they can follow in their own change without risk.
The 66 references that actually BREAK are the ones handled here, and isolating
that subset is what made the slice finishable.

DATA SITES MOVED
  MandaatVerifier          mandaatGroepen -> mandateGroups (3)
  BeschikkingService       mandaatGegeven -> mandateGranted (2)
  MandaatGebruikService    mandaatId -> mandateId, mandaatVersieId ->
                           mandateVersionId, and the findAll filter
  MandaatEscalatieService  targetMandaatId -> targetMandateId
  MandaatImportService     the payload key, and two reads of stored objects
  6 Vue files              mandaatNummer -> mandateNumber,
                           mandaatNiveau -> mandateLevel, mandaatGegeven ->
                           mandateGranted

DELIBERATELY NOT MOVED, each for a measured reason
  - MandaatCsvParser::REQUIRED_COLUMNS and every $row['...'] read: CSV HEADERS,
    an external input contract operators' files already use. I renamed
    REQUIRED_COLUMNS, then reverted — it would reject every import file in the
    field. Now documented in place.
  - `['mandaatId' => ...]` in MandaatEscalatieService: an INTERNAL return-array
    key from resolveEscalationPath(), not a schema property.
  - `mandaatNiveau` INSIDE mandateGranted, and `mandaatregelingId`: nested under
    an object property, so JSON within a column rather than columns. A column
    rename cannot reach them; they need a JSON-rewrite migration. The outer key
    moves, the inner does not — asserted that way in BeschikkingServiceTest.
  - `akkoord-mandaat`: a lifecycle ENUM VALUE, i.e. stored data.

VERIFIED
  - phpunit: 1882 tests, 6393 assertions green (5 skipped, same as baseline).
    Four tests failed first and were fixed: three BeschikkingServiceTest cases
    on the mandateGranted/mandateGroups fixtures, one MandaatEscalatieServiceTest
    on targetMandateId.
  - php -l clean on every changed file; all JSON parses.
  - phpcs: 58 errors across the changed files against a 106-error baseline on
    the two test files — phpcbf cleared pre-existing debt while fixing my
    alignment. Both lib/ services are at 0 errors.
  - 0 procest-scoped schemas or top-level properties still carry `mandaat`.
…ticeOfDefault* (#818)

Replaces #810, which was branched from a stale stack: its diff carried a dozen
files that #803/#804/#806/#809/#816/#817 have since landed, so rebasing it meant
resolving 8 conflicts across controllers, services, tests and a Postman
collection. Branched fresh from development this does the same work as a
40-reference change across 10 files with zero conflicts.

  IngebrekestellingService    -> NoticeOfDefaultService
  IngebrekestellingController -> NoticeOfDefaultController
  registerIngebrekestelling() -> registerNoticeOfDefault()

ROUTE NAMES MOVE, URLs DO NOT. A route name resolves to its controller class, so
`ingebrekestelling#register` had to become `noticeOfDefault#register` or the
router 500s on that endpoint. The URLs stay `/api/termijn/ingebrekestellingen`:
that is the published contract and moving it is a breaking change for every
consumer — a separate decision from this rename. Both route names were verified
to resolve to NoticeOfDefaultController.php after the change.

NOT IN THIS CHANGE: the `ingebrekestelling` SCHEMA in 60-termijnbewaking.json,
its reference property, and the `ingebrekestelling-ontvangen` lifecycle enum
value. The schema is a data contract — renaming it needs an ownership check and
a column migration, and the enum value is stored data. Class names carry no data
and move independently, which is why this is a clean slice.

VERIFIED
  - phpunit: 1882 tests, 6393 assertions green (5 skipped, same as baseline).
  - php -l clean on every changed file; the Postman collection still parses.
  - Route names resolve: both noticeOfDefault#* map to a file that exists.
  - Residual grep finds no IngebrekestellingService / IngebrekestellingController
    / registerIngebrekestelling anywhere in lib/, tests/ or appinfo/.
  - phpcs: 6 errors across the four changed files against a 386-error baseline.
    My edit added 5; phpcbf fixed those and cleared pre-existing debt with them.
)

Renames the property across all five procest-scoped owning schemas and every
code site that reads or writes it.

  complaint.omschrijving              -> description
  bewijsstuk.beschrijving             -> description
  mandate.omschrijving                -> description
  informatieobject.beschrijving       -> description
  informatieobjecttype.omschrijving   -> description

ALL OWNERS IN ONE MIGRATION SCOPE. Ownership was checked before editing: these
five are the only procest-scoped declarations, they collapse onto one English
name without colliding (no schema declares both spellings, and none already
declares `description`), and all five live in the `procest` register — so the
register-scoped repair step covers every owner. Verified after: 0 procest-scoped
schemas still declare either spelling.

NO MIGRATION ENTRY NEEDED. `omschrijving => description` and
`beschrijving => description` are ALREADY in RenameDutchDeadlineColumns'
COLUMN_MAP, together with the guard that refuses to migrate when two sources
target one destination in a single table. That guard cannot fire here: measured,
no schema carries both.

`agendapunt.omschrijving` IS DELIBERATELY EXCLUDED. It lives in
lib/Settings/ori_register.json, which declares register slug `ori`, NOT
`procest`. The repair step matches REGISTER_SLUG_PREFIX = 'procest', so ORI's
shard tables are outside its scope entirely — renaming it would ship a rename
with NO migration behind it. ORI is also Open Raadsinformatie, a Dutch municipal
open-data standard, so it is plausibly wire-exempt. It needs its own
`ori`-scoped migration and an exemption decision; neither belongs here.

THE BREAKING SUBSET WAS ISOLATED FIRST. `omschrijving`/`beschrijving` appear 233
times, but only 42 of those BREAK when the schema moves — property reads, filter
keys, request params. The rest are Dutch prose, comments, wire-adapter field
names (StUF/ZGW/ZTC) and ORI consumers. Renaming those would be wrong or
pointless. Sizing the breaking subset first is what made this finishable; the
same split turned mandaat (716 refs, 66 breaking) into procest#817.

PUBLISHED CONTRACTS KEEP BOTH SPELLINGS
  - ZaakdossierController reads an HTTP request param: `description` preferred,
    `beschrijving` still accepted, so an un-updated client does not break.
  - ZaakdossierService does the same for its metadata array.
  - MandaatImportService maps a CSV column header the same way.

VERIFIED
  - phpunit: 1882 tests, 6393 assertions green (5 skipped, same as baseline).
    One test failed first — MandaatImportServiceTest asserting the diff field
    name — and was fixed.
  - php -l clean on every changed file; all four register JSONs parse.
  - phpcs: 46 errors across the changed files, IDENTICAL to the pre-change
    baseline measured by stashing.
  - Residual grep: no omschrijving/beschrijving anywhere in src/.

A NOTE ON THE TEST RUN. The first attempt reported a phantom failure because the
disk hit 100% and truncated the copied vendor/ mid-copy — vendor/bin/phpunit did
not exist. Freed 5.5G of my own stale worktrees, re-copied vendor, and re-ran.
The numbers above are from the intact run.
…821)

* i18n(procest): make BesluitPublicatiePanel's source strings English

First slice of the Dutch-UI-source-string work. Four strings in one component
move from Dutch source text to English, with the Dutch preserved as the
TRANSLATION rather than deleted:

  'Bekijk publicatie in DROP/LVBB'    -> 'View publication in DROP/LVBB'
  'De publicatie kon niet worden...'  -> 'The publication could not be sent.'
  'Er is geen DROP/LVBB-endpoint...'  -> 'No DROP/LVBB endpoint has been configured.'
  'Er is nog geen besluit vastge...'  -> 'No decision has been recorded to publish yet.'

BOTH l10n FILES MOVE. `l10n/nl.js` is what Nextcloud loads at runtime; nl.json is
what the tooling reads. Changing only the JSON would leave Dutch users seeing the
new ENGLISH source, because the translation would stop resolving.

TWO MISTAKES MADE AND FIXED, both worth recording:

1. I first regenerated both files from parsed JSON. That re-sorted and
   re-indented everything: a 2915-insertion diff for a four-string change,
   unreviewable and impossible to audit for unintended edits. Reverted and
   rewrote the four keys IN PLACE instead — the diff is now 14 lines.

2. The in-place rewrite silently DROPPED an entry: 3126 keys before, 3125 after.
   Cause: 'The publication could not be sent.' already existed as an English key,
   so renaming the Dutch identity entry onto it produced a duplicate that JSON
   parsing collapsed. Both copies carried the identical Dutch value, so no
   meaning was lost, and the redundant line is removed explicitly. The count
   legitimately drops by one because two entries merged into one.

STATUTORY NAMES ARE EXEMPT — AND THEY ARE MOST OF WHAT THE DETECTOR FINDS.
DocumentAssessmentPanel.vue scored highest in the app (7 hits) and needed NO
change: every hit was a Woo exception ground ('5.1.1 Eenheid van de Kroon',
'5.2.4 Vertrouwelijkheid beraadslaging'), numbered categories defined verbatim
in the Wet open overheid. Translating them would misstate the legal ground a
publication is withheld under. They stay, like wire field names and enum values.

That is why this work is not mechanical: the detector cannot tell UI prose from
statutory text, so every string needs reading.

VERIFIED
  - Key-level diff of nl.json against HEAD: exactly 4 Dutch keys removed, 3
    English keys added, and NO surviving key changed value.
  - nl.js EXECUTED, not just parsed: registers 2436 entries (2437 before, the
    same merge-of-one), and the new English key resolves to the Dutch text.
  - No Dutch source string remains in the component.

* i18n(procest): retarget the renamed keys in all 13 other locales

The l10n gates caught what the first commit missed: renaming a SOURCE string
orphans its key in EVERY language, not just Dutch.

Measured after the first commit: 14 locale files still keyed on the old Dutch
source text — be, bs, ca, en, is, lb, mk, nb, rm, ru, sr, tr, uk. Each of those
translations had silently stopped resolving, because the component now emits the
English source and no locale but nl carried it.

Each file's KEY is rewritten to the English source; the VALUE — that locale's own
translation — is untouched. So a Belarusian or Ukrainian user keeps exactly the
string they had.

`en.json` is among them, which is why "l10n coverage (en.json)" failed: the gate
requires every source string to be present there.

NO .js FILES NEEDED CHANGING beyond nl.js, which the first commit already did.
Checked rather than assumed: be.js, ru.js and en.js carry zero occurrences of the
old key — those locales hold it in .json only.

THE COST OF THIS WORK IS HIGHER THAN THE STRING COUNT SUGGESTS. Four source
strings touched 15 files across 14 languages. Any estimate of the remaining
600-900 Dutch UI strings has to multiply by the locale fan-out, and the l10n
gates are what makes the omission visible — they should be run per slice, not
at the end.

VERIFIED
  - All 38 locale JSON files parse.
  - No orphaned Dutch source KEY remains anywhere in l10n/ (the two hits in
    nl.json / nl.js are the Dutch as a VALUE, which is the intended end state).
  - 14 locale files now key on the English source.
Second slice, using the method the first one established. Twelve Dutch source
strings across five components move to English, with each locale keeping its own
translation.

  'Akkoord door'                          -> 'Approved by'
  'Beschikbaar voor agendering'           -> 'Available for scheduling'
  'De actie kon niet worden uitgevoerd.'  -> 'The action could not be performed.'
  'De beschikking is samengesteld...'     -> 'The decision has been composed as a draft.'
  'De beschikking kon niet worden...'     -> 'The decision could not be drafted.'
  'De geadresseerde ontbreekt nog...'     -> 'The addressee is still missing and is required.'
  'De motivering ontbreekt nog...'        -> 'The rationale is still missing and is required.'
  'Deze stap is verplicht en kan niet...' -> 'This step is mandatory and cannot be skipped.'
  'Er zijn geen besluiten gereed...'      -> 'No decisions are ready for scheduling for this body.'
  'Geef een reden waarom deze stap...'    -> 'Give a reason why this step is being skipped...'
  'Geen beschikbare items'                -> 'No available items'
  'Geen beschikking gevonden'             -> 'No decision found'

KEPT, and this is the judgement the detector cannot make:
  - 'College van B&W' is the statutory name of a municipal executive body, not
    UI prose. Same footing as the Woo exception grounds left in
    DocumentAssessmentPanel and as wire field names.
  - 'Grounds for Objection (Gronden van Bezwaar)' is already English-first with
    the Dutch legal term in parentheses, which is the right shape.

LOCALE FAN-OUT. 12 strings retargeted 387 keys across 38 locale files. The first
slice missed this and the l10n gates caught it: a Dutch SOURCE string is the key
every other language hangs off, so renaming it orphans be/bs/ca/en/is/lb/mk/nb/
rm/ru/sr/tr/uk too. Each file's key moves; its own translation value does not.

VERIFIED
  - Source diff is 12 lines and every one is a t('procest', ...) literal —
    0 non-t() lines changed.
  - All 38 locale JSON files parse.
  - Entry counts UNCHANGED in every locale file. The first slice silently lost
    one to a duplicate-key collapse when an English key already existed; this
    slice was checked for that explicitly and there are no duplicates.
  - 0 orphaned Dutch source keys remain anywhere in l10n/.
  - en.json carries all 12, which is what the l10n coverage gate requires.
  - nl.js EXECUTED: 2436 entries, sample key resolves to its Dutch text.
Third and final slice for procest's src/. Twelve Dutch source strings across
eight components move to English.

KEPT — the judgement calls
  'College van B&W'                            statutory body name
  'Grounds for Objection (Gronden van Bezwaar)' already English-first
  'Inadmissible (niet-ontvankelijk)'            already English-first

A COLLISION THIS SLICE EXPOSED, AND THE RULE IT GIVES

'Verplicht' translates to 'Required' — which ALREADY EXISTS as a separate source
string in every locale. Renaming onto it produced a duplicate key in all 37
files, silently collapsing two entries per file with no way to tell which value
survived. The integrity check caught it before commit.

Correct handling for a rename whose TARGET ALREADY EXISTS is DELETE, not rename:
the component now emits t('procest', 'Required'), which already has a
translation in every locale, so the old 'Verplicht' entry is simply removed. Its
translation is not lost — the surviving 'Required' entry already carried it.

That generalises: before retargeting a key, check whether the English target is
already a source string. If it is, the entry is deleted and the existing
translation takes over.

VERIFIED
  - Source diff is 13 lines, every one a t('procest', ...) literal — 0 non-t()
    lines changed.
  - All 38 locale JSON files parse; 349 keys renamed, 38 colliding entries
    deleted.
  - Entry-count delta is -1 or 0 in every file — exactly the removed 'Verplicht'
    entry, and 0 where the locale never had it. No unexplained loss.
  - 0 duplicate keys, 0 orphaned Dutch source keys anywhere in l10n/.
  - en_US.json flagged as lacking 'Required'; checked, and it never had either
    key (675-entry partial locale). Nothing removed from it.
  - nl.js EXECUTED: 2436 entries, sample key resolves to its Dutch text.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants