Skip to content

Release: merge development into beta - #1573

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

Release: merge development into beta#1573
github-actions[bot] wants to merge 69 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.

github-actions Bot and others added 2 commits August 31, 2026 10:28
The 0.3.9-beta.20260831102631 release bumped the version on beta. Without this,
development stays behind beta and the next development -> beta promotion
conflicts on the version file.

Version files resolve to development's side, which is the higher line,
so this never moves a version backwards.
…1572)

Per-widget `valueColor` is applied as an INLINE style on the number, so it beats
the canonical card accent, and a hex literal opts the card out of NL Design
System theming — which kpi-card.css forbids outright, because the nldesign app
re-themes by overriding the Nextcloud tokens.

Two outcomes, no third:

  * a colour that merely restated the default accent is deleted — `#0082c9` IS
    `--color-primary-element` in the default theme, so the tile looks identical
    and now re-themes correctly;
  * a colour that carried meaning becomes `variant`, which is themed and drives
    the icon tint and the number together so the two cannot disagree.

`variant` on a stat/delta resolves through VARIANT_COLORS, which was moved onto
the `-text` tokens first (nextcloud-vue#888) — the plain fill tokens failed WCAG
AA at 1.08:1 as a foreground colour, and these conversions would have been the
first tiles to hit that path.

Edited as text rather than re-serialised, so the manifest keeps its hand-laid-out
formatting; every `valueColor` in this file was confirmed to sit on a KPI widget
first. Manifest schema validation passes.
Dependabot cannot propose composer updates for this app. Its updater fails
with:

  Your requirements could not be resolved to an installable set of packages.
    - edgedesign/phpqa[v1.27.0, ..., v1.27.2] require ext-xsl * ->
      it is missing from your system.

config.platform pins php 8.3 so composer resolves against a known PHP version,
but says nothing about extensions. edgedesign/phpqa requires ext-xsl and the
resolving environment does not have it, so the resolve fails before any bump
can be computed.

CI is unaffected, which is why this went unnoticed: composer install replays
the committed lock and never re-resolves, so the pipeline stays green while
dependabot -- which does re-resolve -- fails every time. The shared quality.yml
installs no xsl extension anywhere and never invokes phpqa.

Declaring ext-xsl beside the php pin makes resolution assume exactly what the
committed lockfile already assumes. Verified in a clean composer:2 container
without --ignore-platform-reqs: the same file fails without this line and
resolves (103 installs, lock written) with it.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde and others added 6 commits August 31, 2026 13:14
….3.9-beta.20260831102631

chore(release): sync beta back into development
Levels this app with the fleet. 2.27.2 adds two fixes the earlier 2.27.0 pin does
not carry:

  * headerless is no longer chromeless — a flat KPI card in a borderless wrapper
    had no card, border or background at all;
  * a stat `variant` paints from the `-text` tokens rather than the fill tokens,
    which failed WCAG AA at 1.08:1 as a foreground colour. The KPI colour
    cleanup converted hardcoded values to `variant`, so this is what makes those
    conversions contrast-safe.

Lockfile only, and npm pruned nothing.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Dependabot cannot update these packages. Its npm updater fails with:

  npm error code EOVERRIDE
  npm error Override for <pkg> conflicts with direct dependency

The package is listed BOTH as a direct dependency and as a literal overrides
entry pinned to the same range. Dependabot bumps the direct dependency and
does not move the override, npm rejects the mismatched pair, and the update
is abandoned -- so these packages can never be updated, security advisories
included.

npm's self-reference is the idiom for exactly this: $<name> tells the
override to follow whatever the direct dependency resolves to, instead of a
range that has to be kept in lockstep by hand. This repo already uses it (see
the vue entry under @vue/test-utils).

Transitive consumers still get pinned to one version -- the direct
dependency's -- which is what the override was there to do. What changes is
that the pin tracks the dependency instead of duplicating it.

Verified in a clean container: bumping the direct dependency with the literal
override reproduces the EOVERRIDE above; with the self-reference the same
bump resolves.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* feat(parafering): an approval route is a flow, not a schema

An approval route is a sequence of manual sign-offs that must be taken before a
decision may be attached to a case. That is a flow with a person at each step.
It was modelled instead as a schema, twice: dossiq's `parafeerroute` and
decidiq's `ApprovalRoute`. The second was mine, added last week in decidiq#1028,
and it was the wrong shape. This supersedes it.

Each step becomes a `dossiq.askPerson` node rather than a bare
`openregister.awaitSignal`. The difference is the one an approver notices:
askPerson raises a real dossiq TASK against them and waits for the answer in the
same node, so the step lands in the work queue they already read. A raw await
signal waits for an answer nobody was asked for.

The chain ends at `dossiq.requestDecision`, because that is what an approval
route is FOR. The case schema already carries `decisions` as a relation to
decidiq Decision objects, so the destination was already there.

A route whose step names no actor is refused whole rather than projected partly:
askPerson rejects an empty assignee, and a flow that quietly omits a sign-off
somebody expects is worse than no flow.

Verified against the live instance: 7 real routes, 2 to 6 steps each, all
projected; a re-run updates 7 and creates 0. "Collegeadvies - Omgevingsvergunning"
came out as juridische dienst, teamleider VTH, afdelingshoofd VTH,
portefeuillehouder, then the decision.

The projections arrive DISABLED. The routes still drive parafering, and two live
copies would ask every approver twice. Retiring the route, the schema and the
settings page is the next change, once these have been checked against real
work. The visible symptom this exists to fix is a settings menu with too many
entries, and this change removes none of them; it makes removing one possible.

Also: WorkflowTemplateFlowMigrator and this one shared 133 lines of identical
machinery, now the ProjectsOntoFlows trait. Extracting it broke the working
migrator — I dropped the `\Flow\` segment from the FlowService id, and every
unit test stayed green because the container is a mock that answers to any id.
The live dry-run caught it, reporting "OpenRegister exposes no FlowService" and
exiting 0. The id is now a named constant with a test that asserts the string.

* fix(quality): count outcomes apart from rows, and test the occ command

Two CI findings on the previous commit, both real.

psalm: "Cannot add an array to a non-array" on
`$summary[$row['outcome']] + 1`. The summary held counts and the row LIST in
one array, so incrementing by key reads as "add 1 to whatever is at that key",
and nothing stopped the key being `rows`. An outcome named `rows` would have
appended to the row list instead of counting. Counts and rows are now separate
structures, merged only in the return.

Coverage ratchet: the change added 216 statements, 157 of them an occ command
with no test. This repo had no command tests at all, so there was nothing to
mirror; there is now a CommandTester suite covering what the command actually
owns, which the migrator's own tests do not reach: it refuses without --user
because a flow's owner is permanent, it refuses an unknown uid rather than
guessing, it exits non-zero on a partial run, and `--dry-run` reaches the
migrator AS a dry run.

That last one earned its place — I pinned `$dryRun = false` and it went red. A
command that accepted the flag and passed false would write while reporting it
had not.
rubenvdlinde and others added 14 commits August 31, 2026 23:22
…ation (#1584)

The dossiq `tenant` schema has been DEPRECATED for a while, and says so itself:
"Tenant identity now lives on OpenRegister's Organisation entity; this schema
is retained only so pre-migration rows remain readable until sunset."
`occ dossiq:migrate-tenants` moves the rows. OpenRegister carries the
Organisation model, and openregister#3247 is giving it the chain-partner fields
dossiq's partner schema still holds.

What never happened is the last step: the Organisations settings entry stayed in
the menu, pointing at an index over the deprecated schema. On this instance that
index lists zero rows, because the data has already moved. An admin opening it
learns nothing and edits nothing.

The page stays routable for deep links and e2e specs; only the entry goes,
declared in menu-layout.json under `removals` per ADR-044 Decision 2.

gate-53 warns, correctly, that a cross-app waiver is a REDUCED GUARANTEE: it
checks only that `openregister` is a known fleet app id, and cannot check that
page `organisation` exists there, is reachable, or carries this functionality —
nor can the waiver ever rot. So the judgement is written into
removalsCoverageNote to be re-checkable, the way BesluitvormingAgenda's is.
Measured against the running instance: openregister declares page id
`organisation` (route /organisation) with a live `Organisations` menu entry, and
ships OrganisationsIndex.vue, OrganisationDetails.vue and JoinOrganisation.vue.
Unlike the decidesk case, dossiq DOES declare a manifest dependency on
openregister, so the target is guaranteed present.
…ROCEEDING (#1585)

gate-60 reported 18 Tier B warnings. Sixteen adopted, two refused. 18 -> 2.

Adopted, across dossiq_register, dossiq_mock_register and 62-handler-vervanging:
  Case          BriefcaseOutline         -> FolderAccountOutline
  Case Type     BriefcaseVariantOutline  -> FolderCogOutline
  Consultation  MessageAlertOutline      -> CommentQuestionOutline
  Map Layer     MapOutline               -> LayersOutline
  Objection     FileDocumentAlertOutline -> FileAlertOutline
  Role          AccountHardHat           -> AccountKeyOutline
  Task          CheckboxMarkedOutline    -> CheckboxMarkedCircleOutline
  Substitution  AccountSwitch            -> AccountSwitchOutline

Map Layer and Role were the clear wins. A map LAYER drawn as a map, and a
role assignment drawn as a construction worker in a hard hat, were both just
wrong.

REFUSED: objectionProceeding, which keeps Gavel. TWO schemas carry the title
"Objection", and the gate can only see the title, so it asks both to become
FileAlertOutline. They are different things:

  objection            Bezwaarschrift, the objection LETTER  -> FileAlertOutline, adopted
  objectionProceeding  the AWB objection CASE: statutory deadlines,
                       hearing waiver, dwangsom accrual      -> Gavel, kept

A gavel is right for a legal proceeding and a document icon is not. The two
sharing a title is the underlying oddity; renaming one is a UI-visible change
and belongs in its own PR.

⚠️ AccountKeyOutline had to be REGISTERED in src/icons.js in the same commit.
It was not there, and an icon a manifest names but the registry does not hold
renders as NOTHING.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
)

* chore(register): retire catalog, rename location to case-location

Two slug collisions.

`catalog` collided with opencatalogi, which owns the word: 15 properties
against this register's 5, and zero property overlap. Dossiq does not
publish, so it has no business owning a publication catalog. Nothing in
src/ or lib/ read it; the only references were three `catalog` properties
on caseType, documentType and decisionType, which go with it.

`location` collided with shillinq. This register already TITLED its own
"Case Location"; the slug simply never followed. Renaming it to
`case-location` says what it is and clears the collision, and one manifest
binding follows.

  occ openregister:schemas:prune-retired --app dossiq --slug catalog --apply

* fix(register): move the schema KEY with the slug, this register keys by slug

Measured on the descriptor: key equals slug for every other schema here.
Renaming only the slug made this one the single exception, and a reference
that names a schema by bare string then has no way to stay right.

The key is anchored to the schema-entry indent, so a nested property with
the same name cannot be caught by it: dossiq has four `location`
PROPERTIES, and an unanchored rename reshaped all four.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
)

The dashboard shipped two Refresh controls side by side. CnActionsMenu
renders one as the first item of the page-level Actions overflow menu, and
the manifest declared another as a header button next to that menu.

They did the same thing. A "type": "refresh" headerAction is dispatched as
emit(PAGE_REFRESH_CHANNEL, {}), which is the exact signal CnActionsMenu
broadcasts on refresh-channel="cn:page:refresh". Same channel, same
subscribers. Removing the header action takes away the duplicate and nothing
else.

An app that wants to keep a prominent Refresh button can stand the menu item
down with showRefresh: false instead. openregister's dashboard does that.
Shipping both is what this removes.
…ssiq (#1594)

dossiq#1531 made dossiq SEND its objection advisory committees to decidiq as
GovernanceBody objects, so the decision app holds them alongside the meetings
and decisions they exist for. It left dossiq's own settings page in place,
which meant the fleet had two places to edit a committee and only one of them
was authoritative.

This is the other half of that decision. The menu entry goes; decidiq's
Organisation surface is where a committee is designed and maintained, with the
members, evaluations, efficiency and template tabs dossiq's index never had.

What dossiq keeps is the seeding and the delegation. The
`bezwaaradviescommissie` schema stays because the seed still writes it. What
goes is the pretence that dossiq is where you edit one.

The pages stay routable for deep links and e2e specs; only the entry is
retired, under `removals` per ADR-044 Decision 2.

gate-53 warns that a cross-app waiver is a reduced guarantee it cannot verify
and cannot rot, so the judgement is written into removalsCoverageNote to be
re-checkable. Measured on the running instance: decidiq declares
`GovernanceBodies` (route /governance-bodies) and `GovernanceBodyDetail` behind
a live menu entry labelled Organisation.

⚠️ dossiq declares NO manifest dependency on decidiq, deliberately — decidiq is
an optional peer and CommitteeDelegationService degrades when it is absent — so
nothing in this repo corroborates the claim and gate-53 says so. Adding a
dependency to quieten that warning would change runtime semantics to flatter a
gate.
…#1596)

The shipped Landelijke Handhavingsstrategie matrix declares its actor-type axis
as burger / bedrijf / overheid / recidivist, and labels all twelve of its
government cells `actorType: "government"`.

LhsRecommendationService indexes cells by "severity:behaviour:actorType" and
THROWS when the triple misses:

    throw new RuntimeException('Geen LHS-cel gevonden voor combinatie ' . $key)

So it does not degrade. A quarter of the matrix — every combination involving a
government actor, twelve of forty-eight — was unreachable, while the axis went
on offering `overheid` as a choice. An inspector picking it got an exception
instead of an enforcement recommendation.

Measured against the running instance before the fix: burger, bedrijf and
recidivist resolve; overheid resolves to nothing.

Found while sizing the move of these matrices onto OpenRegister's decision
tables. Nothing in the app compared the cells against the axes they belong to,
so three tests now do: every cell value exists on its axis, every axis value
has at least one cell, and the grid is complete with no duplicate triple.

The first two are the same defect seen from opposite ends, and either alone can
be satisfied by half a fix — which is why both are asserted.
* feat(lhs): an enforcement matrix is a decision table

The Landelijke Handhavingsstrategie matrix is a three-axis lookup — severity by
behaviour by actor type, yielding an intervention. That is a decision table,
and openregister#3186 gave the fleet one evaluator for those, whose own suite
proves this exact shape evaluates.

dossiq instead indexes the cells by hand into a
"severity:behaviour:actorType" dictionary and throws when the triple misses.
That is not merely duplicate work: it is what let the shipped matrix label all
twelve government cells `government` while the axis said `overheid`, leaving a
quarter of the strategy unreachable with nothing to notice (dossiq#1596).

Each cell becomes one rule keyed by its own triple. The table declares UNIQUE,
because a grid has exactly one cell per triple — and UNIQUE turns an
overlapping pair into a refusal rather than something silently resolved by
declaration order, which is what the hand-indexed dictionary quietly gave up.

🔴 A matrix whose cell names a value absent from its own axis is SKIPPED, not
projected. Projecting it would carry the defect across while looking like a
migration that worked: the rule would be as unreachable in the table as the
cell is in the matrix. Verified against the running instance — the shipped
matrix is refused today, twelve cells off-axis, and becomes projectable once
dossiq#1596 lands.

The projection arrives disabled, like its two siblings. The matrix still drives
recommendations, and a table that also answered would be a second source of
truth for an enforcement decision. Retiring the bespoke lookup and the settings
page is the next change.

2628 tests green; phpcs, phpmd and phpstan clean.

* style(lhs): pull the empty-summary shape out of the two guards

phpcs: one line at 164 characters. The two early returns each spelled the whole
summary inline, so the shape was duplicated as well as long. One helper now,
which also gave the runAs guard somewhere to say WHY it refuses: a written
table inherits its owner and organisation from whoever wrote it, permanently.
…stranded every run (#1597)

* feat(flow): finish case-flow-human-steps: document step config, decision resume tests, task waiting-case section

The shipped besluit-document node carried config keys its node class refuses
(template/outputName where DossiqMergeTemplateNode requires templateSlug and
targetField), so every run stranded at the document step. Fix the declaration,
declare case.besluitDocument so the store keeps the rendered text, and pin both
in CaseFlowDeclarationTest.

Add the missing 4.2 verification to DecisionConcludedListenerTest (a concluded
decision resumes the run naming its ref; an unrelated one leaves it suspended),
the task half of 6.1 (TaskWaitingCaseSection on the TaskDetail page, rendering
nothing for a task without a flowRun), the single-copy assertion for 2.2 in the
e2e, Dutch keys for the new strings, and tick tasks.md by content.

* style(flow): prettier formatting for the waiting-case section and its spec

* fix(flow): satisfy the hydra gates on the waiting-case section

gate-16: tag mounted() with its spec. gate-55/60: the manifest icon
TimerSandComplete was outside both icon registries and would have rendered
nothing; use CheckboxMarkedCircleOutline, the ADR-077 canonical task icon,
which src/icons.js already registers, in the manifest and the component.
gate-52: record the documented ratchet exception with its reason (a
conditional cross-object link no built-in widget expresses). gate-105,
pre-existing: drop the Dashboard's second Refresh, which CnActionsMenu
already renders.
* feat(parafering): a flow can ask for a paraaf, not just a task

Projecting an approval route onto a flow of dossiq.askPerson nodes made
the route runnable, but not yet a replacement. askPerson raises a generic
task, and parafering reads a parafeeractie. The two carry different
fields, and the two that matter are onBehalfOf and mandate: who signed on
whose behalf, under which mandate. That is administrative-law record.

Switching the projection on as it stood would have put generic tasks in
approvers' queues, left the parafering screens empty, and stopped
recording the mandate chain: a loss of record dressed as an engine change.

So dossiq.askParaaf raises a parafeeractie and waits for it. The
parafeeractie gains flowRun and flowNode, because resuming has to name
the node, not just the run: a run holds one awaiting slot per node and
cannot say which of them a paraaf answers. askPerson records both on its
task for the same reason.

The projected node also carries the route step's own order rather than
its position in the chain. The parafering screens read step, so it has to
mean what the route meant, and the two differ as soon as a route numbers
its steps from anything but one.

Nothing is enabled or retired here. The route still drives parafering.
What remains is a dual-path service so a voorstel already carrying a
routeSnapshot finishes the way it started; a hard cutover would strand
whatever is mid-parafering.

* test(parafering): cover the node's refusals, and stop testing a return shape production never sends

The coverage ratchet failed the PR, and it was right to: the node added 96
statements and the tests reached 89.45% of the changed files against a
93.62% base. Sixteen statements had no test.

They were not padding. Every refusal path was among them: no voorstel to
attach a paraaf to, OpenRegister absent, the parafeeractie schema
unconfigured, a stored paraaf that came back without an id. Each is a case
where raising or suspending anyway would leave a run waiting on a question
nobody was asked, so each is now pinned.

One of them was worth more than its line count. ObjectService::saveObject()
returns an ObjectEntity; the fake returned a plain array, so
 was the branch production ALWAYS takes and the
suite NEVER did. There is now a fake that returns an entity, and it
asserts the id is read from INSIDE it: reading the entity itself would
store something no resume can match.

Completing the FlowNodeResumeState stub came out of the same look. The
real class carries set(), isResuming() and clear(); the stub carried none
of them. Nothing in lib/ calls them today, so this was latent rather than
live, but a stub missing a method the real class has is exactly what lets
a future call site pass here and fatal against OpenRegister.
… screen (#1590)

* feat(reassignment): reassign from the cases page, not from a settings screen

Reassignment is an act on CASES, so it belongs on the cases somebody is looking
at. It lived behind a settings page that asked first which handler to empty,
which is the wrong question when what you actually have is a handful of cases
in front of you.

The Cases index now carries a `reassign` bulk action: tick the rows, pick who
takes them.

WHY A NEW SERVICE RATHER THAN A SECOND METHOD. The existing operation answers
"move everything open that belongs to handler A to handler B". This answers
"these rows, to this person", and the rows are hand-picked so their assignees
may all differ. Putting both on one class took its complexity from under the
threshold to 62 against a limit of 50, and phpmd was right to say so: they are
two operations that happen to share a write. The write is now the
WritesReassignments trait, so the audit entry cannot drift between them.

🔴 The audit records each case's OWN previous assignee, not one batch value. A
batch-level `from` is truthful only when every row came from the same handler,
which is exactly what a hand-picked selection does not guarantee — it would
name the wrong person on most rows. The rows still share a batchId, so the
selection stays recoverable as one act. There is a test, and it goes red if the
per-case read is replaced by a constant.

A case already assigned to the receiver is counted as done and NOT rewritten:
rewriting it would append an audit entry saying it moved from somebody to
themselves.

WHY THE DIALOG IS MOUNTED AND NOT DECLARED. The manifest supports
`handler: "open-modal"` with a `target`, which reads like the right answer. It
is not, yet: CnIndexPage emits an `open-modal` event and nothing in the library
listens for it, so declaring it would ship a bulk action that does nothing when
clicked. This uses the function-handler path that dossiq already uses for the
Voorstellen row action, and the comment says why so the next person does not
"fix" it back.

The Substitutions & reassignment admin entry is retired; self-service
substitution already lives in personal settings, where it belongs. The page
stays routable.

2628 tests green; phpcs, phpmd, psalm and manifest validation all clean.

* fix(quality): move the new tests to their own file, format, and translate

Three CI findings on the previous commit.

PHPUnit reported three of my tests RISKY, not failed: "executed code that is
not listed as code to be covered or used: SelectionReassignmentService". I had
put tests for a NEW class into the OLD class's test file, whose @Covers
restricts what it may touch. They now live in
SelectionReassignmentServiceTest with @Covers on the class they actually test,
which is where a reader would look for them anyway.

Prettier: the dialog was unformatted. Ran the repo's own formatter.

l10n: seven strings were used and not declared. Extracted into en.json, and the
Dutch side written rather than left as English — dossiq is a Dutch-facing app,
and "Reassign" already had "Opnieuw toewijzen", so the new strings follow it.
One of the seven, "Where the automation lives", was pre-existing debt the
extractor surfaced; it is translated too rather than left half-done.

en.json and nl.json key sets now match with no missing Dutch translations.

* test(reassignment): cover the endpoint, and rebuild the browser catalogues

Two CI findings.

The coverage ratchet: the change adds 124 statements and the new controller
action had no test, so coverage of the code this change touches fell 8.36%.
Four tests now cover what the action owns rather than what the service already
proves: it forwards the ticked ids and the session actor, it answers 400 when
`caseIds` arrives as something other than an array (it comes off the wire, so a
caller can send a string), it surfaces the service's refusal as a 400, and an
unexpected failure is a logged 500 — a selection that half moved must not be
reported as a clean 200.

check:l10n-js: the browser catalogues were stale after the seven new keys. Ran
the repo's own `l10n:build`.

* test(reassignment): cover the failure paths, and let the fixture stand alone

The coverage ratchet was still short: 124 added statements, 1.73% down. The
uncovered half was the failure paths, which is the half worth testing — four
tests now cover them: a write that refuses is reported per case and does NOT
abort the rest of the selection (the user ticked them all; a silent stop leaves
them believing every one moved), a missing receiver is refused, an absent
OpenRegister refuses rather than reporting a clean run, and a case the store
returns as an ENTITY rather than an array is still read.

That last one matters beyond coverage: OpenRegister's find() returns an
ObjectEntity in production and an array in some paths, and a reader that
handled only one shape would report every case "not found" against the other.

Separately, the shared ObjectService stub interface was declared INSIDE
CaseReassignmentServiceTest. Two test classes mock it now, so the second could
only run when the first happened to load: `phpunit <that file>` alone died on
"Class or interface does not exist" while the full suite passed. It is now its
own file, and composer's autoload-dev maps OCA\Dossiq\Tests\ so PHPUnit can
find it without loading a sibling test first.

2636 tests green with --fail-on-risky; phpcs and phpstan clean.

* fix(quality): create the spec the tags point at, isolate the dialog, tag the trait

Three gate findings, all real and all mine.

gate-46 spec-anchor-existence: four @SPEC tags named
openspec/changes/reassignment-is-a-bulk-action/... and that change did not
exist. A reference that resolves to nothing is worse than no reference: it
reads as though the rationale is written down and sends the reader looking. The
change is now written, including the two things worth recording — why the
selection operation is its own service, and why the audit reads each case's own
previous assignee rather than one batch value.

gate-13 modal-isolation: the dialog sat in src/components/. ADR-004 puts
NcDialog-based components in src/dialogs/, so it moved there.

gate-16 spec-coverage: the four trait methods and readCase carried no @SPEC.
Tagged against the change above, which now exists to be tagged against.

* style(imports): keep the import order the linter expects after the dialog move

* fix(reassignment): tag the dialog's methods with the spec they implement

gate-16 named three: the dialog's title, its lead line and its submit.
Moving the component into src/dialogs for gate-13 made it a changed file,
and a changed method without an @SPEC is what gate-16 exists to catch.

The two rendered strings point at REQ-RBA-004, which is the requirement
that reassignment is reachable from the cases page at all; submit points
at REQ-RBA-001, the one that makes a selection reassign as a single act.
* feat(reports): one Reports page of cards, in the footer

ADR-112. Reports was a menu PARENT at order 55, in the middle of the
operational entries a case handler uses all day, with three report pages
beneath it. Reports are not an operational surface: you go there deliberately,
occasionally, and having them among the daily entries costs the daily entries
the top of the list.

It is now a leaf in the footer group at order 95, so it renders between
Documentation (90) and Features & roadmap (100), and it points at one page
carrying the three reports as cards: Processing time, Deadline monitoring,
Process mining.

A card carries what a menu item cannot — a description and a category — so a
reader can tell the throughput reports from the process analysis without
opening either.

The three pages stay ROUTABLE. Deep links, e2e specs and the cards all address
them by route name; only their menu entries go, retired in menu-layout.json
under `removals` with `removalsReplacedBy` naming Reports, which is how every
other placement decision in this app is declared (ADR-044 Decision 2).

Verified: gate-104 reports 0 findings, gate-53 passes and raises nothing for
the three new removals, and the assembled menu has no report entry left in the
main nav.

🔴 BLOCKED ON nextcloud-vue#893. `type: "reports"` is not in the installed
schema (2.27.0), so `npm run check:manifest` fails with "/pages/45/type must be
equal to one of the allowed values". This cannot merge until that PR releases
and dossiq takes the bump. Left as a draft rather than merged behind a green
that would only mean the validator had not looked.

* fix(reports): require the nc-vue that knows the page type, and stop shipping Dutch as source

Three things the merge with development surfaced.

The manifest declared `type: reports`, and the pinned nextcloud-vue did
not know that type: its schema enum stopped at `custom`, so
`check:manifest` failed on the one page this branch exists to add. The
range said ^2.24.3, which a fresh install could satisfy with a version
that has no reports page at all. It now says ^2.28.0, the first release
carrying the type in its schema, the component, and the built bundle.

The four card and page descriptions had no translation keys. The l10n
parity checker reads the manifest precisely because it is data rather
than source, and it caught them; the Dutch is written rather than
extracted.

The category names were written in Dutch and used as source strings, so
an English instance read `Doorlooptijd en termijnen`. They are English
now, with the Dutch in nl.json where the rest of it lives.

That last one only reaches the screen once nextcloud-vue#904 lands:
CnReportsPage currently renders its manifest strings untranslated, so
today the keys sit unused. English source is the right value either way,
and the fleet checker would have flagged the Dutch the moment it learned
to read this field.

* fix(reports): give the cards the icons their menu entries carried

gate-60 failed on CalendarClockOutline: it is not registered in
src/icons.js, and an unregistered icon renders as nothing at all rather
than falling back to something. The deadline card would have shipped
blank.

The fix that is also the better design is to reuse what the retired menu
entries used. Analytics carried ChartLine, TermijnDashboardMenu carried
ClockAlertOutline and ProcessMiningDashboardMenu carried ChartSankey, all
three already registered. A reader who knew those entries now finds the
same glyph on the card that replaced each one.

ChartSankey also settles a gate-60 Tier B warning on its own: the
process-mining concept has a canonical icon under ADR-077, and
SitemapOutline was not it.
…tops writing an unsigned signature (#1609)

* fix(parafering): a returned voorstel goes back, and a fully-signed one is accorded

Three status literals in BesluitvormingParafeerService are not values the
schema allows, and OpenRegister runs hard validation by default, so two of
the service's three outcomes could not be written at all.

A parafeeractie's action enum is parafered, returned, advised, skipped,
accorded. The service compared against 'retour', which is not in it. That
branch was therefore unreachable, and an approver who sent a voorstel back
fell through to the advance below: the voorstel moved FORWARD to the next
approver. A rejection was read as an approval, which is the worst way for
this to fail.

Had it been reachable it would have written status 'retour', and the
voorstel status enum has no such value either. The spec says what both
should be, and has all along: action 'returned' sets status
'teruggestuurd'. VoorstelDetail.vue keys the steller's resubmit button off
exactly that value, so nothing offered a way back.

The completion path wrote 'gereed_voor_agendering', which is not a
voorstel status. getStatusAfterAdvance() in src/utils/parafeerEngine.js
has returned 'geaccordeerd' for this transition all along, so the
front end and the back end disagreed about the end of the chain.

The unresolvable-action default moves from 'approved' to 'parafered' for
the same reason: every value the service handles should be one the schema
declares. Behaviour is unchanged, since neither equals 'returned'.

The existing test could not have caught any of it. It mocks saveObject
with a canned return and asserts the canned value, so it passes whatever
the service writes, and its own fixture used action 'approved', which is
not in the enum either. The new tests record what was SAVED and assert
that. The fourth reads the register JSON and checks every status literal
the service writes against the enum, which is the version of this that
catches the class rather than these two instances.

Reverting the three literals turns three of the four red.

* fix(parafering): the paraaf node waits, it does not write an unsigned signature

dossiq#1602 shipped dossiq.askParaaf creating the parafeeractie up front,
as a standing request. It could not have worked, and the tests could not
have told us.

parafeeractie declares action among its required properties and
OpenRegister runs hard validation by default, so a paraaf raised without
one is rejected on save. openspec/specs/parafering-actions says the same
from the other side: the schema SHALL enforce voorstel, step, actor,
action. The node's tests passed because their fake accepted whatever it
was handed; teaching that fake the schema's required properties turned
five of eighteen red at once, all of them the raise path.

The schema was right. A parafeeractie is the record of a sign-off somebody
gave, not a request that they give one, and the enum has no value meaning
"not yet signed" because there should not be one. Writing a blank paraaf
to stand for "awaiting" would put an unsigned signature into an
administrative-law record.

So the node records the ask in the run's own awaiting slot, which already
carries the assignee that OpenRegister's resume guard consults, and
creates nothing. It keeps refusing a step with no actor, a run with no
voorstel and a run with no resume slot: the slot is where the assignee
lives, and an awaiting step that cannot say who may answer it is one
anybody can answer.

The node no longer needs SettingsService or an object service at all,
which is the shape of the correction: it had a dependency only because it
was doing something that was not its job.

A test now reads the register JSON and asserts action is required, so the
reason lives next to the decision. If someone later makes it optional,
that fails and asks them to reconsider the node rather than finding out in
production.

Remaining, unchanged: the approver's paraaf still has to carry flowRun and
flowNode so the run can be resumed from it. Those fields are on the schema
already; what stamps them is the next step.
`npm run lint` was `eslint src`, so two whole trees were never linted. Across
the fleet that hid roughly 3,900 errors, none of which any CI leg had shown.

Most of it was the config, not the code, and the same two defects were in
every app.

**scripts/ had no config block at all.** These are CommonJS Node CLI checkers,
and flat config defaults every `.js` to ESM with browser-ish globals, so eslint
read the CommonJS wrapper itself as undefined identifiers: `require`,
`process`, `__dirname`, `__filename`, `module`. A `scripts/**` block now
declares the environment. Declaring beats suppressing here: `no-undef` is the
rule that catches a genuinely misspelled identifier, and dozens of fake
findings would bury a real one. A second block covers `scripts/**/*.mjs`, which
is ESM and needs Node's globals without the CommonJS wrapper.

**The tests block applied a non-TypeScript-aware rule to TypeScript.** It named
`tests/**/*.ts` while setting the CORE `no-unused-vars`, which v9 deliberately
turns off for `.ts` in favour of the `@typescript-eslint` version. The core
rule reads the parameter names inside a function TYPE as bindings, so

    t?: (app: string, key: string) => string

reports `app` and `key` as unused variables, and every unused `catch (e)` in a
`.ts` spec reports twice. The block is split now: `.js`/`.mjs` on the core
rule, `.ts`/`.tsx` on the TypeScript one, same patterns on both.

Also: stale `eslint-disable` comments naming plugins eslint 10 no longer
registers, which are themselves errors ("Definition for rule ... was not
found"), and a rule that must not parse shell scripts.

The genuinely real findings were the useful part: dead locals, unused imports,
dead helper functions, unused `catch` bindings, extensionless relative imports,
and a handful of `== null` comparisons spelled out so they still match null AND
undefined.

Verified per app: `npm run lint` 0 errors over src + tests + scripts,
`prettier --check` clean, and the unit suite still green.
* wip: checkpoint before rate-limit pause (2026-09-01 09:45)

* test(e2e): satisfy the repo lint on the flow runs spec imports

---------

Co-authored-by: Ruben van der Linde <release-bot@conduction.nl>
rubenvdlinde and others added 30 commits September 2, 2026 07:37
…ntity (#1644)

* fix(flow): run every flow step's storage work as the run's acting identity

MergeTemplateHandler saved its merged case bare, so under FlowRunWorker the
write was refused as "User 'Anonymous' does not have permission to 'update'
objects in schema 'Case'" and the seeded journey stopped at besluit-document
(merge_template_failed, run f087ae22): the case never got its decision
document and never closed. DQ#1625 fixed this defect for SetStatusHandler and
two nodes, and this fourth instance shipped anyway — so this change fixes the
class, not the instance.

Every remaining flow handler and node that performs ObjectService storage on
the worker path now routes it through FlowRunAsScope, delegated writes
included: MergeTemplateHandler, SetFieldHandler, CreateTaskHandler,
CreateSubCaseHandler, EvaluateDecisionHandler, BesluitvormingActivateHandler,
BesluitvormingPublishHandler, DossiqEnsureCommitteeNode and
DossiqSetVoorstelStatusNode. A structural test walks all three flow-facing
directories and fails any storage-performing file that ships bare, with a
closed, reason-bearing allowlist for the four files the worker never
executes; a per-handler regression test pins MergeTemplateHandler's write to
the run's runAs identity and the loud refusal of one that resolves to
nobody.

* test(flow): name what the committee node test covers and leans on
…snapshot (#1647)

* fix(flow): a handler's save no longer clobbers writes made after its snapshot

Every case-saving flow handler (setStatus, setField, evaluateDecision,
mergeTemplate) full-saved its flow-item snapshot through the PUT-semantic
saveObject(), erasing whatever other writers stored after the snapshot was
taken. Measured live on the closure rig (case a53cfc92/dc16d6dd, audits
512->515 and 725->728, same second): the document step wrote besluitDocument
and the status step one hop later saved the older snapshot over it.

- CaseFieldWriter: handlers now apply ONLY their own fields to the STORED
  case, through patchObject() when the installed OpenRegister has it, else
  by re-reading and applying the delta to the fresh read.
- ActionResult.caseChanges + DossiqFlowNodeBase: a handler's writes travel
  with the outgoing item, so the next step's snapshot is current.
- Case schema: declare the five signal-written fields (aanvulling,
  voorbereiding, toetsRegisterB, tweedeToets, commissieBesluit) the store
  silently dropped; extend the declaration test's writer walk to
  handler-written fields (signalKey, setField, evaluateDecision outputs,
  targetField) so the next undeclared one reds before it ships.
- e2e closure: assert run status 'stopped' per the engine contract
  (EndNode throws FlowStop; 'completed' is only a drained marking).

* fix(flow): give the partial-write closures an honest void shape

PHPStan flagged the arrow functions: a fn returning the result of a void
method returns null while declaring mixed. Statement closures with a void
return say what happens.

* test(flow): list CaseFieldWriter as used code in the strict-coverage suites

All six PHPUnit matrix cells failed on 4 risky tests: the handler suites
carry @Covers metadata, phpunit.xml sets beStrictAboutCoverageMetadata and
failOnRisky, and under CI's Xdebug coverage driver the new CaseFieldWriter
counts as executed-but-unlisted code. Local runs had no coverage driver, so
the risky verdict never fired; reproduced red in a php:8.3 container with
pcov scoped to lib/ (pcov's auto directory picks src/ and hides lib/), and
green with these @uses lines. The real openregister at development was also
checked: patchObject(objectId, data, register, schema), find(id, register:,
schema:) and runAs(user, operation) all match the writer's calls, and the
functional suite passed in every cell.
…age (#1649)

The flows index is a named-source page, so it has no register+schema and
CnPageRenderer cannot infer a detail page for it: without an explicit
target the row click and the row menu's Edit fall back to the source's
/flows path instead of the page this manifest declares. rowRoute names
the FlowDetail route, the same wiring openregister#3315 gave its own
flows page — CnPageRenderer then auto-sets rowClickToView and routes
row-click / edit-open through onRowOpen to FlowDetail.

Pairs with the nextcloud-vue fix that collapses the flows row menu to
one live Edit plus Copy and Delete; the note is updated to describe the
page as it is (an index over the named source, not a custom page).
…er apps register case types (#1646)

* feat(nav): gather the work surfaces under "My work"

"Work queue" becomes "My work", and it now holds the four surfaces a
handler actually works from: the cases assigned to them, every case,
their tasks, and the workflow board.

WHAT MOVED, AND WHY THE INNER PAGE WAS RENAMED TOO
--------------------------------------------------
The group and one of its children were BOTH called "My work" once the
group took the name. A sidebar reading "My work > My work" says nothing
about what the inner entry holds, so the page — a case index scoped to
`assignee = current user` — is now "Assigned to me", which is what it
actually shows.

"Cases" is no longer a separate top-level entry. It moves into the group
as "All issues", which is the same index unscoped. That is a relocation
rather than a removal, so its ROUTE is untouched: /cases still resolves
for bookmarks, shared links and the specs in this suite that target it.

"Tasks" gains a menu entry. The PAGE already existed and was reachable
only by URL.

SENTENCE CASE
-------------
"All issues", not "All Issues". Voice rule 8, and every existing label
in this sidebar already follows it ("My work", "Workflow board").

THE TRANSLATION KEYS ARE THE LABELS
-----------------------------------
"Assigned to me" and "All issues" are new keys, so both needed en.json
and nl.json and a catalogue rebuild. Nothing automated catches a miss
here: the l10n check scans `t()` calls in source, and a manifest label is
translated per-field by nc-vue rather than through a literal in code. A
renamed label passes every gate and then shows up in English on a Dutch
instance.

Verified: manifest Ajv PASS (0 errors), lint 0, l10n en/nl key sets
match, catalogues rebuilt and current.

* feat(case-types): let other apps register the work they handle

* feat(case-types): let other apps register the work they handle

* fix(icons): register FormatListChecks, so the Tasks entry has one

gate-60 caught this: the icon IS in the ADR-077 vocabulary but was not
registered in src/icons.js, and an unregistered name renders as NO ICON
AT ALL rather than falling back to something. The new Tasks menu entry
would have shipped blank.

Registering it is the fix rather than swapping to a glyph dossiq already
has: pipelinq uses FormatListChecks for its Tasks entry, and ADR-077
exists so the same concept carries the same glyph in every app. Picking a
different one here to dodge the gate would have traded a missing icon for
an inconsistent one.
…hem (#1654)

* refactor(decisions): dossiq stops authoring decisions; decidiq owns them

The audit, the umbrella change, and the unambiguous retirements.

- openspec change dossiq-decisions-to-decidiq: full A/B/C/BLOCKED audit
  table, decidiq capability matrix, grey areas recorded for a ruling.
- Remove CaseDecisionsTab (local decision create/edit/delete, mounted on
  no page) and VoorstelCreateDialog (orphaned local proposal dialog).
  The besluitvorming leaf authors; the read-only case-decisions widget
  displays outcomes.
- Deprecate the DMN decision-table stack in place; retirement is blocked
  on openregister flow-decision-tables and recorded as such.
- LocalDecisionAuthoringTest: closed, reason-bearing allowlists for
  decision-schema writers and DecisionTableEvaluator consumers. A new
  local decision writer or evaluator consumer fails the suite.

* refactor(decisions): dossiq stops authoring decisions; decidiq owns them

The audit, the umbrella change, and the unambiguous retirements.

- openspec change dossiq-decisions-to-decidiq: full A/B/C/BLOCKED audit
  table, decidiq capability matrix, grey areas recorded for a ruling.
- Remove CaseDecisionsTab (local decision create/edit/delete, mounted on
  no page) and VoorstelCreateDialog (orphaned local proposal dialog).
  The besluitvorming leaf authors; the read-only case-decisions widget
  displays outcomes.
- Deprecate the DMN decision-table stack in place; retirement is blocked
  on openregister flow-decision-tables and recorded as such.
- LocalDecisionAuthoringTest: closed, reason-bearing allowlists for
  decision-schema writers and DecisionTableEvaluator consumers. A new
  local decision writer or evaluator consumer fails the suite.
…nregister's case layer) (#1657)

* spec(openspec): open retire-cmmn-caseplanstate, draining the CMMN runtime onto openregister's case layer

* fix(manifest): declare requiredApp on the humaniq hours tile

Gate-55 (detail-page-discipline) now requires a widget reading another
app's register to declare requiredApp. The case-kpis-hours tile on
CaseDetail sums humaniq TimeEntry hours; without the declaration it
queries an app that may not be installed and renders 0 instead of a
set-up state. CnDetailWidgetHost reads the flag and shows the
not-installed state without issuing the request.
…ck (#1651)

* feat(case-detail): one strip for the case's collections, one data block

Twelve grid cells where there were twenty-two, nine tabs where there
were six.

Dropped:
  case-process        the second Data block. Its Status field is FOLDED
                      into case-core rather than lost, carrying the
                      editable:false override with it — without that the
                      field becomes click-to-edit and a caseworker can
                      set a status the workflow owns.
  case-documents      documents
  case-voorstellen    proposals
  case-assistant      case assistant
  cmmn-case-plan      adaptive case plan

Folded into the tabs widget:
  case-decidesk-decisions   Decisions   (decidiq's)
  case-contacts             Contacts
  case-locaties             Locations

Dossiq stops rendering its OWN decisions. case-decisions (an object-list
over dossiq's decision schema) and case-kpis-decisions (its count) are
gone; the Decisions tab is decidiq's widget. This is the surface-only
first step of moving decisions to decidiq wholesale — the schemas,
services and ZGW controllers are untouched here and follow in their own
change.

Checked after rebuilding the layout: no widget referenced but undefined,
none placed twice, none both tabbed and placed, none defined but
unplaced. Each dropped widget had exactly two manifest hits — its
definition and its layout cell — so nothing else referenced them, and no
e2e asserted them. The Proposals/Voorstellen hits in the suite are the
standalone /voorstellen page, untouched here.

'Contacts' is a new l10n key (nl: Contacten); 'Connected contacts' was
too long for a tab in a nine-tab strip. Inserted textually rather than
by re-serialising the catalogue — round-tripping it through a dict and
sorting rewrote all 3437 keys and buried the change in a 1171-line diff.

check:manifest, test:l10n, prettier, eslint over src+tests+scripts, and
all 19 applicable hydra gates pass.

* fix(case-detail): clean up after dropping the widgets, and gate the hours tile

Four things the full-repo gate run caught that the diff-scoped one did
not. CI forces a full run (77 gates); scoping to the diff only opened
19, which is why this branch went out red.

1. gate-53 — dropping case-voorstellen orphaned the Voorstellen route.
   Its menu entry had been removed earlier on the basis that something
   else linked it, and that something was this widget's viewAllRoute.
   Voorstellen was the ONLY removal in menu-layout.json with no
   removalsReplacedBy entry. The menu entry is restored: proposals leave
   the case page as intended, and the index stays reachable.

   Not deleted, deliberately. Voorstellen is the parafering approval
   surface — VoorstelDetail is the parafeerroute multi-step approver
   flow — and #1632 moved parafering onto the flow engine end to end
   yesterday. Parafering is to be rebuilt on flows and decidiq with the
   current code DEPRECATED, which is a migration, not a deletion, and
   tearing out the UI mid-migration would take the work with it.

2. The slot map still pointed widget-case-assistant and
   widget-cmmn-case-plan at components whose widgets no longer exist.
   Removed, along with their registry entries and the three now
   unreachable components (CaseAssistantPanel, CmmnCasePlanPanel,
   CmmnPlanItemNode — they referenced only each other).

3. gate-55 — case-kpis-hours now declares requiredApp: humaniq. This is
   the gap left open when the tile shipped: it aggregates humaniq's
   register, so on an install without humaniq it 404s and renders 0,
   which is what a real zero renders. nextcloud-vue#926 makes the host
   render a set-up state instead, and .github#663 makes the gate refuse
   the next one.

4. e2e — the Locations spec asserts the case-location REQUEST rather
   than the empty state, because the two look identical. Locations is
   now in a LAZY tab, so the widget never mounted and never queried; the
   poll timed out on zero responses and read as the 404 returning. The
   spec opens the tab first.

All 77 applicable gates green, check:manifest, test:l10n, prettier,
eslint over src+tests+scripts, and all 6 specs still collect.

* style(manifest): keep the 2-space indentation development uses

A direct 'npx prettier --write src/manifest.json' reformatted the file
to tabs. The repo's own 'npm run format' leaves this file alone, so
development keeps it at two spaces — calling prettier on it directly
rewrote all 9391 lines around a three-line change.

Third time today a re-serialising or reformatting writer has buried a
small change: the l10n catalogue, the manifest schema, and now this.
The diff against the previous commit is the 5 lines it should be.

* docs(menu): record why Voorstellen came back, and correct the count

The removalsCoverageNote still said 15 entries and still described
Voorstellen as needing no waiver 'because CaseDetail's case-voorstellen
widget carries viewAllRoute'. This branch dropped that widget, so the
sentence documented a route that no longer existed.

Voorstellen was the only removal in that list resting on a single widget
rather than on a waiver, which is exactly why a layout change could
orphan it and none of the other thirteen.

Restored rather than waived: naming a replacement surface would have
asserted something untrue, since no other surface carries proposals.
Parafering is moving onto flows and decidiq under parafering-to-decidiq
and approval-routes-are-flows; whichever change retires the proposals
surface for real should re-remove this entry with a waiver that is true
at that point.

Also restores the multi-line integrationsSection that a direct prettier
call collapsed.

All 77 applicable gates green.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* spec(termijnbewaking): umbrella change, deadlines move onto engine timers

* feat(termijn): phase 1, the core termijn engine moves onto engine timers

* fix(termijn): quality pass, registrar split and derived accrual simplification

* chore(deps): hydra-gates 1.11.0, the vendored manifest schema catches up with the page types

* docs(termijn): task 1.1 reflects the reused generic resolver
* fix(e2e): follow the nav labels #1646 renamed

dossiq#1646 gathered the work surfaces under a 'My work' group and
renamed three entries at once: Cases became 'All issues', the My work
PAGE became 'Assigned to me' once the GROUP took that name, and
Voorstellen became 'Proposals'. It added work-navigation.spec.ts for the
new structure but left every OTHER spec asserting the old labels.

Seven specs have been red on development ever since — navigation.spec's
three tests and pages.spec's Cases page — and this branch is where they
get fixed rather than inherited.

Worse than the seven: three call sites wrapped navTo in
. Those did not go red. navTo throws by name when no
label matches, the catch swallowed it, and the spec carried on against
whatever the Dashboard renders — deelzaak-support, related-case-linking
and cases-crud have been asserting against the wrong page rather than
failing.

navigation.spec now matches what the nav renders: Dashboard is the only
top-level visible leaf; 'All issues' is asserted with the other
collapsed-group leaves by href, because display:none removes an element
from the accessibility tree and getByRole resolves to zero even under
toHaveCount; the group header is 'My work'.

The click test EXPANDS the group and then clicks, rather than navigating
to the href. Going straight to the href would pass without the group
ever opening, and opening it is precisely the interaction a reader
depends on.

navTo now accepts a RegExp as well as an exact string, and the call
sites pass one covering both locales. A pinned exact label is a standing
tripwire: this rename broke every call site naming the old string at
once, and the next one will too.

217 tests still collect across 48 files; all 74 applicable gates green.

* fix(e2e): follow the nav labels #1646 renamed

dossiq#1646 gathered the work surfaces under a "My work" group and
renamed three entries at once: Cases became "All issues", the My work
PAGE became "Assigned to me" once the GROUP took that name, and
Voorstellen became "Proposals". It added work-navigation.spec.ts for the
new structure but left every OTHER spec asserting the old labels.

Seven specs have been red on development ever since — navigation.spec's
three tests and pages.spec's Cases page — and this branch fixes them
rather than inheriting them.

Worse than the seven: three call sites wrapped navTo in a catch that
swallows everything. Those did NOT go red. navTo throws by name when no
label matches, the catch discarded it, and the spec carried on against
whatever the Dashboard renders — deelzaak-support, related-case-linking
and cases-crud have been asserting against the wrong page rather than
failing.

navigation.spec now matches what the nav renders: Dashboard is the only
top-level visible leaf; "All issues" is asserted with the other
collapsed-group leaves by href, because display:none removes an element
from the accessibility tree and getByRole resolves to zero even under
toHaveCount; the group header is "My work".

The click test EXPANDS the group and then clicks, rather than navigating
to the href. Going straight to the href would pass without the group
ever opening, and opening it is precisely the interaction a reader
depends on.

navTo now accepts a RegExp as well as an exact string, and the call
sites pass one covering both locales. A pinned exact label is a standing
tripwire: this rename broke every call site naming the old string at
once, and the next one will too.

217 tests still collect across 48 files; all 74 applicable gates green.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…els via integriq's ADR-041 seam (#1664)

* feat(publication): deliver besluit publications through integriq's ADR-041 seam; declare publications on the case schema

* style(publication): analyzer stubs for the optional integriq contract, simpler terminal projection
… and records (#1666)

* feat(parafering): the runtime moves to the decision app; dossiq raises and records

dossiq delegates parafering exactly like it delegates decisions now: raise a
voorstel's chain in the decision app, wait for its ApprovalRouteConcludedEvent,
and keep only case-data records. The local engine retires with no facade —
BesluitvormingParafeerService, ParafeerActieService, the parafeer-actie
controller and routes, and the whole dossiq-local flow projection
(ParaferingFlowGateway and its per-route cutover flag, the paraaf nodes, the
resume listener, the projection migrator). The front-end sign-off surface goes
with it; the case page now shows the recorded history read from OpenRegister.

ParaferingRaiseService raises and fails closed. ParaferingConclusionService +
ParaferingConcludedListener record the outcome, preserving onBehalfOf and
mandate and keeping the frozen procest.parafering.* audit trail.
RaiseInFlightParaferingenInDecidiq re-raises voorstellen already mid-parafering.
LocalParaferingRuntimeTest pins that no local engine returns.

Depends on decidiq's parafering-route-runtime, which MUST merge first.

* docs(parafering): tag the parafeeractie read service with its spec

* style(parafering): prettier-format the reworked surfaces and prune stale eslint suppressions

The retirement deleted files whose suppression entries then pointed at
nothing, and eslint refuses a suppression that no longer occurs. Pruned with
eslint's own --prune-suppressions, which also dropped entries already stale
on the base; the tree lints clean (warnings only) and formats clean.
…esting (#1671)

Reported on dossiq: hovering a KPI tile paints a second, smaller card inside
the tile. decidiq does not, which made it look app-specific. It was not.

⚠️ THE DIFFERENCE IS AN INSET, NOT A COMPONENT. A card widget renders
`flush` and then has its padding put BACK by the card-fit rule
(`padding: 8px 14px`), so the KPI sits inside the wrapper while the WRAPPER
draws the border, radius and background the user reads as "the card". The
library's `--clickable:hover` rule then drew a 2px border and a drop shadow
on the KPI itself, 8-14px in from the edge being hovered.

Measured live, while genuinely hovered:

  dossiq   card-fit stat widget      inset 9px top / 15px left
                                     2px rgb(0,103,158) + its own shadow
  decidiq  stats block, no card-fit  inset 1px all round
                                     border lands on the wrapper's edge

decidiq was never a different component. Its tile is not card-fit, so it has
no padding to be inset by. That is the whole difference.

nextcloud-vue#932 moves the affordance to the wrapper: the inner border and
shadow are suppressed on card-fit hosts and the wrapper takes them instead.
No layout change, nothing moves, one card outlines. Verified in the browser
with a real pointer hover before and after:

  before   inner 2px rgb(0,103,158) + shadow   wrapper grey, no shadow
  after    inner transparent, no shadow        wrapper rgb(0,103,158) + shadow

Released as 2.31.1 and confirmed present in the published
dist/nextcloud-vue.css, which is what `@conduction/nextcloud-vue/css/index.css`
imports, and in this app's built bundle after the bump.

⚠️ This is not a dossiq-only fix. Every app with clickable stat, gauge or
delta tiles had it, pipelinq included. They pick it up on their next
nextcloud-vue bump.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…pped (#1669)

The consolidation rebuilt the CaseDetail layout from a list of tuples
carrying only id, widgetId and the four grid coordinates. Seven cells had
also carried a showTitle flag, and rebuilding silently dropped it.

The result is visible on every case: the three KPI tiles, the core data
block, the tabs strip and the hours tile each draw a CnWidgetWrapper
header on top of content that already names itself. "Time left" renders
twice, and a read-only KPI tile grows an Actions menu the Cards-vs-
Widgets split says a card must not have.

Restored on the six survivors as false, and on initiator as true.
case-process, case-assistant and cmmn-case-plan also carried a flag and
are gone, so theirs goes with them.

Nothing caught this. Every gate passed, the manifest validated, and the
E2E was green, because no assertion described what a KPI tile is
supposed to LOOK like — only that its value renders. So this adds one:
the label appears exactly once in the tile's own grid cell, and the cell
carries no Actions button. It fails against the dropped-flag manifest
and passes against this one.

The wider lesson is the same one that rewrote 3437 l10n keys and 6612
schema lines earlier today: rebuilding a structure from a narrower
in-memory shape silently discards whatever the original carried that the
shape has no field for. Diff the result against the original, not
against the intent.

All 74 applicable gates green; 7 tests collect in the spec (was 6).

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…e copies

Four changes that all reduce to the same thing: dossiq had its own answer to
questions the platform already answers, and its own word for things it
already had a word for. They interleave in the manifest and the l10n files,
which is why they arrive together.

VOCABULARY. The sidebar said "All issues" for the list of cases, the case
detail's document tab said "Dossier", and the demo-data step offered "example
dossiers". Three words for one thing, in an app named after the case file.
The Dutch was right all along: "Alle zaken" was already the translation of
"All issues", which is what a drifted source string looks like. Left alone
deliberately: "Issue advice" is the verb, and "Systemic issues" in process
mining names a defect, not a case. Also fixes a half-Dutch screen, where the
enforcement page's title was "Handhavingsstrategie" under an English label.

STORE (ADR-080). OpenRegister owns store discovery and shipped
GenericStoreService; dossiq never adopted it, so an administrator who wants a
case type builds it by hand. StoreController injects the engine client by
composition, never extends: a cross-app base is resolved by the autoloader,
and Nextcloud's router reflects every controller during route matching, so an
absent OpenRegister would 500 every route in this app rather than only the
store's. Install is dossiq's own and writes case CONFIGURATION only. That
allowlist is a security boundary: without it, Install is a remote write
primitive pointed at live case records. Its refusal tests carry a negative
control. The registry token is write-only, and an empty field on save keeps
the stored one, because the form cannot show it and so posts empty on every
unrelated edit. With no registry the engine answers not_configured without a
network call, which is the only reason this surface may carry the word Store.

PARTNERS. A ketenpartner is an organisation, and OpenRegister owns
Organisation. The superset claim was verified against a RUNNING instance
rather than the entity class, because a property dropped by jsonSerialize()
is invisible to the picker either way. The rows move on upgrade rather than
waiting for an occ command, which is what makes retiring the page safe in the
same change.

ENFORCEMENT VOCABULARY (dossiq#1596). RenameDutchValues translated ONE member
of the LHS actorType axis, overheid -> government, and could not reach the
matrix cells because they live inside a JSON column. The recommendation
schema then offered a value no cell could match, so a quarter of the national
enforcement strategy threw "Geen LHS-cel gevonden" — which reads like bad
input, not a broken axis. The tell that this was a slip: the other three axis
values were never translated. A set is translated whole or not at all.

Four gate and test findings on the way, all real. StoreOutline was not
registered in src/icons.js, which renders NO icon rather than a fallback. The
"cases list" visual baseline resolved the label 'Cases', renamed in #1646;
shootByNav guards the click behind isVisible, so it had been shooting the
DASHBOARD under the name cases.png ever since. work-navigation's "reachable
by direct link" test used a hash deep link under createWebHistory, so it too
asserted against the Dashboard. And two spec-coverage helpers wrapped the
fail-fast nav helper in .catch(() => {}), which would have run those specs
against whatever rendered.
…'s signalAs and native runAs scoping (#1674)

* refactor(flow): adopt the engine's signalAs and native runAs scoping

openregister#3332 gave the flow engine the two seams every consumer was
rebuilding; this is the dossiq follow-up it named.

- Delete lib/Service/FlowRunAsScope.php and its test: RegistryStepDispatcher
  now executes every contributed node inside ObjectService::runAs() as the
  run's validated acting identity, so the twelve local wraps (3 nodes, 8
  handlers, 1 guidance comment) unwrap.
- TaskCompletionResumeListener collapses the assignee guard + mapper resolve
  + bare signal() into one FlowRunSignalService::signalAs() call addressed at
  the task's node, obeying FlowSignalRefused per reason.
- The DQ#1644 scanner inverts from 'flow files must wrap' to 'flow files must
  NOT wrap', and pins the wrapper file as deleted.
- Test stubs for FlowRunSignalService and FlowSignalRefused match the real
  dev signatures; per-handler runAs tests retire with the wrapper, each suite
  noting the duty moved to the engine (RegistryStepDispatcherRunAsTest).

* feat(kcc): route contact moments through OpenRegister's decision tables

Wave 4 of the fleet's engine consolidation (engine: openregister#3329,
reference adopter: humaniq#289). The KCC routing rules were dossiq's last
private rule matcher; they now compile into one inline decision table (hit
policy FIRST, enabled rules ascending by priority) evaluated by the shared
pure DecisionTableEvaluator, constructed directly.

- The KCC dialect stays app-side as derivations: the subject+summary
  haystack, keyword/regex predicates as boolean columns, the KvK
  customer-type rule, folded time-of-day windows, day of week.
- Equality cells are quoted literals so a channel named '-' stays a literal;
  a rule the legacy engine could never match compiles to nothing.
- Fails closed without the shared engine; no fallback matcher.
- RoutingEngine::evaluate() stays only as the parity oracle (deprecated, no
  new callers): KccRoutingParityTest sweeps a 40-cell matrix over both paths.
  Agent ranking is untouched. Staged retirement in the change's tasks.
- tests/Stubs/Service/Dmn are now verbatim copies of the engine's pure Dmn
  classes (openregister@2839ab901a), so the standalone suite proves the real
  semantics.

* style(kcc): expand the day-of-week ternary the shared phpcs ruleset refuses
… retires

Phase 2 of lhs-matrix-is-a-decision-table. The LHS matrix is severity by
behaviour by actor type yielding an intervention, which is a decision table
and nothing more exotic, and OpenRegister already carries one evaluator for
that shape.

recommend() now evaluates the projected table and reads the matrix only where
no projection exists. The fallback is not hedging: projecting a table needs an
owner for the object it writes, so it is an occ command a person runs and
cannot happen unattended on upgrade, and an instance that has not run it must
still be able to enforce. The Enforcement strategy settings page and menu
entry are retired; authoring lives in the Decision Tables (DMN) admin tab.

The projection now arrives ENABLED. Shipping it disabled was right in phase 1,
when the matrix was still the lookup and a second answering table would have
been a second source of truth. It is wrong now: with the evaluator as the
lookup, a disabled table silently hands the question back to the matrix and
makes the migration a no-op that reports success. The test that asserted
`enabled === false` is INVERTED rather than deleted, because the flag still
matters, just the other way round.

Two defects in phase 1 that only phase 2 could expose:

  - a re-run minted a SECOND table. saveObject() was called with no id, and
    the lookup resolves by provenance marker and takes the first match, so
    which duplicate answered an enforcement question was arbitrary. A re-run
    now resolves the existing table and updates it.

  - nothing protected an edited table. The projection is one-way and the
    matrix no longer has a settings page, so an overwrite would replace an
    administrator's work with a source they cannot read. A re-run whose rules
    differ now refuses and says so.

That guard could NOT be a byte comparison, and the first version was.
OpenRegister drops an empty-string property on save, so a rule projected with
`annotation: ""` comes back without the key. Measured against a running
instance, all 48 rules "differed" immediately after the run that wrote them —
a guard that refuses every re-run is not cautious, it is broken, and it would
have read as the migration being permanently stuck. It compares the decision
each rule encodes now: id, inputs, outputs, keyed and sorted.

Verified live on a dedicated instance: first run created 48 rules, second
reported updated with exactly one marked table, an edited rule was refused and
survived, and a restored table updated again.

Also fixes the third declaration site of the vocabulary split, which gate-101
found after the first two were fixed by hand: a demo lhsRecommendation still
carried `government`, so a fresh instance shipped a record the lookup could
never resolve. LhsAxisVocabularyTest now asserts every site rather than the
two that happened to be noticed.
The commit before this one carried only the new LhsDecisionTableLookup file.
`git commit` without paths commits the INDEX, and the index held just the one
`git add`ed file, so the fifteen modified files it described were left in the
working tree and never reached the branch. The message was accurate about the
work; the commit was not. This is the rest of it, pushed forward rather than
amended because the branch is shared.

  - recommend() evaluates the projected decision table and reads the matrix
    only where no projection exists
  - the projection arrives ENABLED, and its test is inverted rather than
    deleted, because the flag still matters the other way round
  - a re-run resolves the existing table by marker and updates it instead of
    minting a second one carrying the same marker
  - a re-run REFUSES a table whose rules were edited, comparing the decision
    each rule encodes rather than its bytes: OpenRegister drops an
    empty-string property on save, so a byte comparison called all 48 rules
    edited immediately after the run that wrote them
  - the Enforcement strategy page, its menu entry and its menu-layout entry
    retire; authoring moves to the Decision Tables (DMN) admin tab
  - the demo lhsRecommendation stops carrying `government`, the third
    declaration site of the vocabulary split and the one gate-101 found after
    two were fixed by hand
Resolves the l10n conflicts, which were the reason CI had not run.

A CONFLICTED PR DOES NOT GET pull_request WORKFLOW RUNS. GitHub cannot
compute the merge commit those workflows run against, so it dispatches
nothing — and the PR still showed four green checks, from the push and
dynamic events, against the forty-nine its siblings were running. Four green
looked like a pass and was the absence of the test.

Both sides appended new keys to the tail of en.json and nl.json, so the
resolution is a UNION: taking either side alone drops the other change's
strings silently, and every one of them is a string a user reads. The .js
files are generated, so they are regenerated from the resolved .json rather
than merged by hand. Verified afterwards: no duplicate keys on either side,
en/nl key sets still match, and no conflict marker survives anywhere in the
tree.
`npm run format` is a real CI leg here, listed deliberately because the shared
workflow has none: without it the tree drifts back out of format between
merges. These three were written by hand and never passed through it.

Formatting only — eslint, vitest, the manifest validator and the l10n check
all still pass unchanged.
…ine (#1678)

The inventory (openspec/changes/case-status-onto-engine-lifecycle) maps
every transition source and records the verdicts. What this commit takes:

- delete WorkflowEngineService, a dead second entry point to the
  transition engine (zero production callers, only its own test)
- retire the dead vergadering mini-engine (VergaderingCaseService,
  VergaderingDeadlineJob, tests, info.xml registration): it wrote
  literal status strings into case.status, a statusType-reference
  field, and scanned for cases that cannot exist; termijnbewaking
  task 2.4 is amended to record the retirement (nothing to migrate)
- fix the complaint x-openregister-lifecycle in both register
  manifests: it was in the legacy list dialect, at the wrong nesting
  level, with Dutch state names against an English enum, so the
  declared machine was dead as written; now object-form, enum-anchored
  and equal edge for edge to ComplaintService::TRANSITIONS
- add LocalStatusMachineryTest, the class-catching scanner: retired
  classes stay retired, the transition-table census is closed and
  reason-bearing, and the complaint declaration parity is pinned

StatusTransitionService stays the single write path for the
per-caseType dynamic case machine, with the line recorded in the
change design.
* feat(cases): a New case dialog a case handler can actually fill

The button opened a properties-and-JSON table listing all 48 of the case
schema's properties, unordered, with `qualityScore`, `casePlanState` and
`archiveNomination` among the first screenful. Three changes, in the three
places that were actually wrong.

The schema now says which of its properties a person deals with. Fourteen
carry an `order`, so every surface that reads `fieldsFromSchema` (the create
form, the detail data widget, generated table columns) puts case type,
title and description where a handler looks for them. Twenty are marked
`visible: false`: engine-written JSON, computed quality scores, flow signal
payloads, denormalised projections. Nobody types those and nobody reads them
raw. `extensionCount`, `handoffSource` and `workflowVersion` are deliberately
NOT among them: the case detail Process widget lists them explicitly, and
`visible: false` is evaluated before the include whitelist, so hiding them
would have blanked three cells with nothing to show for it.

The New case action then narrows to the nine fields somebody filing a case
types. The detail page still edits the rest; one schema, two surfaces.

`case.caseType` declares `x-openregister-extends-form`, so choosing a case
type adds that type's own propertyDefinitions to the form and writes the
answers as caseProperty rows once the case exists. dossiq has modelled the
ZGW eigenschap pattern for a long time and never rendered it anywhere.

And the properties tab, which is where those definitions are authored, was
writing three fields into a void. `format` was never a propertyDefinition
field; the schema has always called it `propertyType`, so every type an
admin chose was dropped and read back as the default. `maxLength` and
`requiredAtStatus` were not declared at all, so a 200-character cap read
back as no cap. The tab now writes `propertyType` from the schema's own
enum (its old `text` and `datetime` were not even members of it), the schema
declares the other two, and `requiredAtStatus` stores the status reference
rather than its display name, which would have broken on the first rename.
A Required toggle is new: without `isRequired` nothing could ever be made
mandatory.

Also prunes 10 files' worth of eslint suppressions that no longer fire.

* fix(l10n): the new properties tab strings, in both catalogues

The type dropdown, the Required toggle and the resolved requiredAtStatus
label are eight new source strings. Adds them to en.json and nl.json, rebuilds
the .js mirrors, and lowers the schema-l10n baseline by the 41 it improved.

* fix(cases): un-hide the eight properties a handler or a widget is meant to see

Eight case properties carried `visible: false` while something in the app
asked to display them, which `fieldsFromSchema` resolves in the worst order:
the visibility check runs BEFORE the include whitelist, so a widget that
names the property renders a blank cell and says nothing.

  description, startDate, endDate, plannedEndDate, result  ordered by this change
  extensionCount, handoffSource, workflowVersion           named by the Process widget

The case DESCRIPTION was on no surface at all: not the create form, not the
detail data widget, not the table. The case detail Process widget lists ten
fields and six of them could never render.

The rule applied is mechanical rather than taste: a property that carries an
`order`, or that a manifest `include`/`columns` list names, must not be
hidden. Twenty-nine engine-written properties stay hidden.

Also drives the case-type picker from the combobox rather than the field
wrapper in the e2e. Clicking `[data-cn-field]` leaves NcSelect closed, so the
run failed twenty seconds later on a missing option instead of at the missed
click.

* build(deps): @conduction/nextcloud-vue 2.31.0, for the open-form action keys

The manifest declares includeFields on the New case action, and the v2
schema is additionalProperties:false, so gate-22 and gate-53 both rejected
it against 2.29.0. 2.31.0 carries manifest schema 2.28.0.

The range moves too, not just the lockfile: `^2.29.0` would let a fresh
resolve pick a version whose schema does not know the key, and the failure
would read as a manifest defect rather than a dependency one.

* docs(openspec): the change the create-form work implements

The e2e tags pointed at openspec/changes/friendly-case-create-form, which
did not exist — a dangling anchor that spec-anchor-existence rejects and
that tells a later reader nothing.

Adds the proposal, tasks and spec, and re-anchors each e2e test at the
requirement it actually exercises rather than at tasks.md.

Also corrects a comment in the spec that asserted the seeded case type
sorts last in the picker's preloaded page. The picker does cap that page,
which is why the test types; the ordering claim was never measured.

* docs(openspec): drop the em-dashes, which also broke the e2e anchors

The requirement headings read `REQ-FCF-001 — The New Case Dialog…`, and a
heading's anchor slug renders that em-dash as a DOUBLE hyphen
(`req-fcf-001--the-new-case`). The @e2e tags pointed at the single-hyphen
form, so both anchors dangled: spec-anchor-existence would have rejected
them and a reader clicking through would have landed nowhere.

Removing the em-dash fixes the anchor and satisfies the voice rule that bans
em-dashes in documentation, which is the same edit either way. Verified by
slugifying every heading and checking each tag against the set.

* fix(e2e): type in the case type picker only when the option is not preloaded

The spec typed the run prefix unconditionally. Typing sends the term to the
server and REPLACES the preloaded options with whatever comes back, so on CI
it turned a list that already held the seeded case type into an empty one and
the option was 'not found' twenty seconds later.

Now it types only when the option is absent from the preloaded page, which is
both what a handler does and what keeps the preloaded path intact.

* fix(e2e): match the picker option by text, and name the answer row

Two defects in this spec, both found by running it rather than reading it.

vue-select splits an option label into adjacent spans, and Playwright joins
those with a space when it computes an accessible name — so
`E2EZAAK-…-4944 Subsidie` computed as `E2EZAAK-…-494 4 Subsidie` and an exact
name match never hit while the option sat plainly on screen. The same
rendering shows in every snapshot ("Omgevingsv ergunning"), which is easy to
read past as cosmetic. textContent concatenates without the separator, so
hasText sees the label the way a reader does.

The last assertion indexed `answers[0]` and read back the enum's default
'Sport'. Both questions ARE answered and the API promises no order, so it
looked like the ceiling had never been written when the feature was correct
all along. It now finds the row whose propertyDefinition is the ceiling's.

All four tests pass against a clean instance.

* fix(l10n): the new schema strings read like helper text, and are translated

A schema property's description renders as helper text under its field. I had
written these two for a code reviewer — 'OpenRegister has always discarded it,
because the schema did not declare it' — which is the right thing to say in a
commit and the wrong thing to put under an input.

Rewritten short and user-facing, then added to en.json and nl.json with the
titles, so check:schema-l10n counts them as covered.

The baseline moves 2814 -> 2825 for the 11 strings that arrived with the
development merge; those are other schemas' additions. It stays well under
development's own 2855, so the ratchet still tightens rather than slackens.

* fix(e2e): keep the case type, delete only what this spec filed

The afterAll deleted the case type it created. That is what a tidy fixture
looks like and it is why an unrelated spec went red: anything still holding
that id — a case another spec filed, the dashboard table resolving caseType
for a label — then fetched an object that was gone.

It surfaced as ui-pages.spec.ts failing its 'dashboard mounts without console
errors' assertion on a 404, a different file asserting about a different
page. The trace named it:

  404 /apps/openregister/api/objects/dossiq/caseType/7bf85ae4-…

Cases and their answers are still cleaned up. The case type and its property
definitions stay: CI throws the instance away after the run, so a leftover
type costs nothing, while a dangling reference costs a red suite pointing at
the wrong file.

(The other 404 in that trace is /apps/hermiq/api/chat/health — hermiq is not
installed on the runner. Pre-existing, unrelated.)

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The coverage ratchet failed with "coverage of the code this change KEEPS or
ADDS dropped by 11.79%; this change adds 324 statements to those files", and
it was right: LhsDecisionTableLookup, MigratePartnersToOrganisations and
RealignLhsActorTypeVocabulary all shipped untested.

The lookup is tested against OpenRegister's OWN evaluator, the verbatim copy
under tests/Stubs, not a hand-written fake. That mattered immediately: the
first fixture used quoted FEEL literals and failed, which sent me to check
what the migrator actually writes — bare values. Aligning the fixture to the
migrator's real output is what makes these tests evidence that the projection
EVALUATES, rather than evidence that a fake agrees with its caller. The
projection and the overwrite guard had been proven live; evaluation had not.

What each file pins is the behaviour that would be invisible if it broke:

  - the lookup DECLINES rather than answers — on a disabled table, on another
    matrix's table, on an unresolvable evaluator, on an unconfigured instance.
    Every decline hands the question back to the matrix, so a decline it
    should not have made leaves no trace in the result.
  - it resolves by MARKER, so a renamed table still answers.
  - the realign step re-checks each row before writing, because the filter is
    sent server-side: a backend that cannot express it returns everything, and
    a naive rewrite would relabel every citizen and business as a government
    actor — worse than the defect being repaired.
  - the partner step NEVER throws. An exception aborts the upgrade, and a
    partner that has not moved is recoverable where a half-upgraded instance
    is not.

2778 tests now, up from 2761. phpstan, prettier and eslint unchanged.
Say cases, adopt the store, and stop keeping private copies of what OpenRegister owns
… none

Two loose ends from the label fix that shipped in #1677.

THE CASES BASELINE WAS A SCREENSHOT OF THE DASHBOARD, and now there is proof
rather than an inference: `cases-visual-linux.png` and
`dashboard-visual-linux.png` were byte-identical, same md5. `shootByNav`
resolves its label behind `if (isVisible)`, so when dossiq#1646 renamed the
entry the click silently never happened and the shot was taken wherever the
app had landed — the Dashboard, under the name cases.png, for as long as that
rename has been in.

#1677 fixed the LABEL, which made the test correct and left the baseline
wrong: the next visual run would have compared the real cases page against a
dashboard image and failed, looking like a regression in the page rather than
a stale artefact. Regenerated, and it now differs from the dashboard, which is
the assertion that was never available before.

The store baseline is new. gate-26 accepted the visual TEST added in #1677,
but every other visual test carries a committed baseline and this one did not,
so the first person to run the visual project would have hit a missing
snapshot.

Both shot against a dedicated instance. The `visual` project is deliberately
excluded from CI — tests/e2e/playwright.config.ts is the config the workflow
resolves, precisely so a CI Linux runner never tries to byte-match a
dev-container PNG — so these are for local runs and carry no CI risk.
Found reviewing my own merged code from #1677, and it is a hole in the
boundary that PR was largely about.

OpenRegister resolves the object it writes FROM THE PAYLOAD:
`extractUuidAndNormalizeObject()` reads `$object['@self']['id'] ?? $object['id']`
and treats a match as the uuid to UPDATE. So a store item whose component
carried the uuid of this municipality's live case type would replace it — and
`saveObject` is PUT-semantic, so keys the payload omits are nulled rather than
left alone. The case type would not merely change, it would be gutted.

THE SCHEMA ALLOWLIST DOES NOT COVER THIS. It governs which schema a component
may write, never whether the write creates or replaces. A component naming
`caseType` — entirely legitimate, exactly what the store is for — is the
attack. I wrote that allowlist and called it the boundary; it was half of one.

Identity is not a remote registry's to supply. `id`, `uuid` and `@self` are
stripped before the write, so an installed component is always a NEW local
object. If install ever needs to be idempotent it must key on something dossiq
controls.

The test carries a negative control: removing the strip makes it fail.
`global task list page renders with add button and empty state` asserted
`No items found` outright, so it could only pass on a register with zero tasks.
The shared instance carries 8 task rows, measured on 2026-09-02, so the
assertion could only ever fail there and had nothing to do with the feature.

The scenario it covers is `task-management/spec.md#view-the-global-task-list`,
and rows satisfy "view the global task list" better than emptiness does. It now
accepts either the rendered rows or the empty state, and rejects neither. The
Add Task button and the no-server-error assertion are unchanged, so the spec
still fails if the page does not mount.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The cases visual baseline was a screenshot of the dashboard
…verwrite

A store install creates, and can never replace a local object
…ired arg order and the id filter that matches nothing (#1686)

* fix(or-contract): repair the retired saveObject arg order and dead id filters the fakes kept green

Two OpenRegister contract drifts shipped as green-CI/dead-runtime defects
(live-rig proof, DQ#1663):

- saveObject: 29 call sites still used the retired (register, schema,
  object) positional order; the real signature takes the object FIRST, so
  every one of them threw at runtime and, among other things, no
  beslistermijn timer ever armed. All 29 now go through named arguments
  or the SearchesObjects saveObjectAsArray bridge (which gains a uuid
  pass-through for update sites).

- searchObjects: 7 call sites filtered on a top-level 'id' key, which
  addresses a schema property no schema declares and silently matches
  zero rows (the parafering raise never found its voorstel). They now use
  the resolving get-by-id path (ObjectService::find via findObjectAsArray,
  plus a StufRegisterAccess::findById for the StUF wrapper).

- The adjacent shape drift the honest fakes then exposed: find() returns
  an ObjectEntity, never an array, so an is_array() check after it is a
  dead path. Fixed at the 12 sites the reworked fakes cover; the
  remaining sites of that class are catalogued for a follow-up.

Tests that cannot lie again: FakeTermijnStore (and the per-test fakes it
exposed) now declare the REAL saveObject signature - the retired order
fatals in tests the way it does live - and their searches resolve ids
only through @self/find, returning zero rows for top-level id filters
exactly like the live service. OrContractRegressionTest pins both
contracts explicitly.

Also folded in: the live-journey e2e list assertion was pagination-blind
(identifier-asc, 20 per page - the created case lands on the LAST page of
any lived-in rig); it now uses the ?title= deep-link filter.

* chore(ci): re-fire the pull_request dispatch that never arrived for this branch

* test(repair): pin the LHS realign fake to the real saveObject signature

The merged repair test's fake declared saveObject without the uuid
parameter, so the trait's named-argument call errored and zero writes
landed.
…tity-returning ': array' methods (#1694)

* fix(or-contract): finish the is_array-on-find sweep the honest fakes catalogued

PR #1686 fixed 12 sites of the class 'is_array() on the return of
ObjectService::find(), which returns an ObjectEntity and never an array'
and catalogued the rest for a follow-up. This is that follow-up, swept
repo-wide rather than trusting the catalogue:

- Bezwaar/* (15 find sites): AdvisoryCommitteeService, BeroepService,
  BezwaarCreationHook, DecisionService, HearingService and
  PanelIndependenceChecker all guarded find() results with is_array(),
  so every 'not found' guard fired on FOUND objects (committee referral,
  advice-request transitions, hearing attendance/minutes, beroep filing
  and cascade, decision publication) — the features always threw, or the
  dead true-branch silently skipped the work (panel independence author
  resolution, hearing case resolution).
- Subsidie/* (3 find sites): BeschikkingService.publish,
  SubsidieService.transitionAanvraag and VaststellingService.finalize
  answered 'niet gevonden' for records that exist.
- Singles (6): NoticeOfDefaultController.show (always 404),
  VoorstelBesluitController's IDOR gate (every voorstel unresolvable),
  DossierCompiler.compile (always 'Case not found'),
  CaseEmailRepository.findTemplate (always null),
  BeroepEscalationListener (dwingend derivation never ran), and
  InspectionChecklistService, whose get_object_vars() on an ObjectEntity
  read an empty array so the photo-required guard never fired.

All go through the SearchesObjects bridge (findObjectAsArray + null
check), as #1686 chose. The same files carried the adjacent drift #1686
named: ': array' methods returning saveObject()'s ObjectEntity — a
TypeError on every live call. 22 such sites (Bezwaar 11, Subsidie 11)
now go through saveObjectAsArray with an honest local fallback.

Deliberate non-sites, left alone: ZgwService/ZgwRulesBase/
ZgwZrcRulesService and the sharing/transfer services handle both shapes
via an explicit jsonSerialize() fallback, and
SelectionReassignmentService reads the entity via getObject().

The per-file fakes that kept this green are repinned to the real
contract (entity-shaped returns, DoesNotExistException on a miss, real
argument orders) in BezwaarCreationHookTest, VaststellingServiceTest,
DossierCompilerTest and VoorstelBesluitControllerContractTest, matching
the shared FakeTermijnStore.

PHPUnit 2771 green; php -l, PHPCS, Psalm, PHPStan, PHPMD (both
rulesets, per subdir, fresh pdepend cache) all clean.

* test(beroep): cover the repaired find/save contract, which the coverage ratchet demanded

The sweep added statements to files with zero unit coverage (the Bezwaar
services), and the changed-files coverage ratchet correctly read that as
'adding code without tests': 18.64% head vs 18.84% base, scoped to the
22 changed files. BeroepService was the largest untested surface (7
repaired sites), so it gets the contract pins: register() resolves an
EXISTING contested beslissing (pre-fix: always threw), a truly missing
one still refuses, addFileInspectionRequest() appends to the existing
beroep, and the reopen_objection cascade resolves the source bezwaar and
fires the beroep-reopen transition. All four run against the shared
entity-shaped FakeTermijnStore, so an is_array regression fails the way
it fails live.
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.

1 participant