Skip to content

Release: merge beta into main - #1066

Open
rubenvdlinde wants to merge 26 commits into
mainfrom
beta
Open

Release: merge beta into main#1066
rubenvdlinde wants to merge 26 commits into
mainfrom
beta

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Brings main level with beta, which is 26 commits ahead.

development is green on its last completed push run with Playwright E2E passing, and beta was brought level with development earlier today, so this carries verified code rather than a snapshot of unknown state.

Security alerts are measured on main but fixed on development, so this promotion is what actually clears them.

github-actions Bot and others added 26 commits August 30, 2026 19:09
…190902

chore(sync): carry main back into beta
…0260830190909

chore(sync): carry beta back into development
#1014)

Bumps [node-polyfill-webpack-plugin](https://github.com/Richienb/node-polyfill-webpack-plugin) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/Richienb/node-polyfill-webpack-plugin/releases)
- [Commits](Richienb/node-polyfill-webpack-plugin@v4.0.0...v4.1.0)

---
updated-dependencies:
- dependency-name: node-polyfill-webpack-plugin
  dependency-version: 4.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@vue/compiler-sfc](https://github.com/vuejs/core/tree/HEAD/packages/compiler-sfc) from 3.5.41 to 3.5.42.
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/commits/v3.5.42/packages/compiler-sfc)

---
updated-dependencies:
- dependency-name: "@vue/compiler-sfc"
  dependency-version: 3.5.42
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.13 to 3.4.14.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](cure53/DOMPurify@3.4.13...3.4.14)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat(setup): ask which example set to load, instead of planting one on install

Installing this app planted 334 objects nobody asked for.

SettingsService::loadConfiguration() merges decidesk_register.json with all 26
register.d fragments, and every one of them carried its own
x-openregister.seedData.objects. The InitializeSettings repair step runs that
merge, so a fresh install seeded a Gemeenteraad Amsterdam, a VvE Zeewaarts, a
pub quiz, five ACME B.V. bodies and eight placeholder TOOI mappings into the
operator's register. A municipality got the VvE data. A VvE got the council
data. Nobody chose any of it.

The wizard meanwhile offered a DIFFERENT dataset, whose own step text said
"Skip this on a production install". By the time an operator read that
sentence, 334 objects were already in their register.

So the fragments now declare schemas only, and the objects live in
lib/Settings/profiles/, one file per example set. A bare install plants
nothing; the wizard asks which organisation this is for, and loads that.

## The split had to be a closure, not a partition

The seeds cross-reference each other by slug. The reference graph has one
connected component of 170 objects tangling municipal, corporate and
association bodies together, so no partition exists. Each set is instead
anchored on its governance bodies and closed over outbound references, so every
reference resolves inside the set carrying it. Sets may overlap, and 15 objects
do. Verified: 334 of 334 classified, zero dangling references, zero orphans.

## Why the descriptor declares no register

An example set carries @self.configuration/register/schema on every object and
declares NO components.registers. That is load-bearing.
ImportHandler::importRegister() calls setApplication($appId) unconditionally
when it updates an existing register, so a descriptor that declared decidiq
would re-point the register at the profile's config id and hydrate over its
authorization block: the baseline that stops any authenticated user rewriting
another body's decisions.

Verified on a live instance: importing this shape left application=decidiq, the
version and the authorization hash byte-identical, imported 45 objects, and
added nothing on a second run.

The files sit in a SUBDIRECTORY because RegisterDescriptorService scans
lib/Settings/*.json non-recursively and indexes by declared register slug; four
profiles in lib/Settings would collide with each other and with the app's own
register.

## Two wizard steps, because an action carries no body

CnSetupWizard::runAction() posts to /api/setup/action/{action} with no body, so
an action cannot carry the answer. A choice step records which set via the new
POST /api/setup/config, and the run-action step reads it back. ADR-111 keeps
the schema-generated mock on offer, so it is one option in the same choice
rather than a second question about the same thing.

## Ten pre-existing defects this surfaced, all fixed

Validating every seed against its schema found:

- Three seeds keyed on `regulation`, which is not a decidiq schema slug: the
  app's is `regeling`. Because importSeedData() resolves a slug cross-app with
  multitenancy off, `regulation` resolves to LEARNIQ's schema.
- Regeling.status was required and named by x-openregister-lifecycle, but never
  declared as a property, so OpenRegister created no magic-table column.
  Measured: oc_openregister_table_21_262 carried every other property and no
  status column, which means the declared in-preparation -> adopted ->
  in-effect -> lapsed map could never advance a regulation.
- Six invalid enum values on the pub-quiz decision-stage seeds.
- Two governance bodies missing the required `domain`.
- The works-council seeds described a city council: all three WOR consultation
  requests carried governanceBody: gemeenteraad-amsterdam, a null-UUID
  director, and a raadsvergadering as their overlegvergadering. Replaced with a
  real 45-object set built around an ondernemingsraad at ACME B.V.

## Verification

1235 PHP unit tests and 378 vitest tests pass. PHPCS 0 errors, PHPMD, PHPStan
and Psalm clean. Manifest validates against schema 2.26.0. The manifest-drift
guard was proved to FAIL when the manifest and the shipped sets disagree.

Two hydra gates (22, 53) still fail, and did before this change: hydra-gates
v1.10.0 vendors manifest schema 2.25.0, which predates the `flow` page type
that FlowDetail already used. That is fleet debt in ConductionNL/.github.

* chore(dependabot): hold back vue-router 5 as well

vue-router 5 peers `vite: ^7.3.0 || ^8.0.0` and expects a Vite toolchain. These
apps build with webpack, which cannot resolve it at all: the build dies on
`Can't resolve 'vue-router'` from src and from @nextcloud/vue's own chunks.
Adopting it is a Vite migration, not a version bump.

Dependabot proposed it across 8 repositories in a single run, and merging any
one of them takes that app's build from green to red with no code change that
can fix it.

versioniq already builds with Vite and is the natural pilot if the fleet does
move. Lift this when an app's toolchain can actually take it.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
#1030)

The comment claimed these apps "cannot resolve it at all". That is not what the
evidence shows, and a comment that overstates its case is worse than none: the
next person reads it, tries vue-router 5 somewhere it works, and stops trusting
the file.

Measured across four apps: integriq and zaakafhandelapp fail their build on
`Can't resolve 'vue-router'`, while openregister and learniq build clean on
5.3.0. The holdback still stands, for the honest reason rather than the
overstated one: a major that breaks some apps and not others cannot be merged
unattended, and the difference between them is not yet understood.

No behaviour change. The ignore rule is unchanged; only the reasoning is.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Bumps [css-loader](https://github.com/webpack/css-loader) from 7.1.4 to 7.1.5.
- [Release notes](https://github.com/webpack/css-loader/releases)
- [Changelog](https://github.com/webpack/css-loader/blob/main/CHANGELOG.md)
- [Commits](webpack/css-loader@v7.1.4...v7.1.5)

---
updated-dependencies:
- dependency-name: css-loader
  dependency-version: 7.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@nextcloud/dialogs](https://github.com/nextcloud-libraries/nextcloud-dialogs) from 7.4.1 to 7.5.0.
- [Release notes](https://github.com/nextcloud-libraries/nextcloud-dialogs/releases)
- [Changelog](https://github.com/nextcloud-libraries/nextcloud-dialogs/blob/main/CHANGELOG.md)
- [Commits](nextcloud-libraries/nextcloud-dialogs@v7.4.1...v7.5.0)

---
updated-dependencies:
- dependency-name: "@nextcloud/dialogs"
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [vue](https://github.com/vuejs/core) from 3.5.40 to 3.5.42.
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](vuejs/core@v3.5.40...v3.5.42)

---
updated-dependencies:
- dependency-name: vue
  dependency-version: 3.5.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(deps): resolve the peer chain so npm ci works

npm ci failed with ERESOLVE, so every frontend job failed before running
a single check.

Majors had landed WITHOUT the peers that must move with them. These
packages are a set: bumping one alone leaves another declaring a range
the new version cannot satisfy, and npm rejects the whole tree rather
than the single package. Fixing them individually walks the list,
because each correction exposes the next conflict underneath.

Realigned: node-polyfill-webpack-plugin=4.0.0

Each range is the one npm itself demanded in the ERESOLVE it reported --
discovered by applying it and re-checking until resolution succeeded --
rather than a version chosen by hand.

Measured across the fleet: nine apps were unresolvable on this pattern.

Verified: the lockfile resolves from a clean tree, where it previously
exited on ERESOLVE.

* style: run Prettier now that npm ci works

The dependency fix in this branch is what let these checks run at all.
While npm ci exited on ERESOLVE, every frontend job failed at install and
reported nothing about the code -- so a formatting drift sat unreported
underneath.

With resolution restored the checks execute for the first time in a
while, and this is what they found. Prettier only; no behavioural change.

* chore(l10n): rebuild the stale browser catalogue

check:l10n-js reported l10n/nl.js stale against its l10n/nl.json source.

The .json files are the source of truth and the .js catalogues are
generated from them by scripts/build-l10n-js.js; editing the generated
side by hand is how they drift apart. This is the generator's output,
committed as the check asks.

Like the formatting in the previous commit, this only became visible once
the dependency fix let npm ci succeed and the frontend checks run again.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Picks up the fix for the Buildiq edit button.

This app was pinned to 2.25.0, which looks current but does not contain
the fix: 2.25.0 still carries `useAppStatus('openbuild')`, and the app id
moved to `buildiq`, so the `OC.appswebroots` lookup missed and the button
rendered nothing. No error, no failed request, no console warning. The fix
landed in 2.25.1.

Lockfile edited surgically rather than regenerated, so only version,
resolved and integrity move. The integrity hash was checked against the
published tarball bytes.
#1019)

* feat(governance): add the cross-app command seam for governance bodies

decidiq#874 made GovernanceBody able to hold a Dutch bezwaaradviescommissie
and added a REST write path for it. That path is the door for EXTERNAL
callers. An in-process app-to-app command cannot use it: ADR-041 says a
cross-app command travels as a typed event (gate-27 enforces it), and an
in-process HTTP call to our own instance has no session, so
ApiController::write() refuses it — which is exactly the state a background
migration runs in.

So this adds the missing door, copying the DecisionRequestedEvent shape:

- GovernanceBodyRequestedEvent / GovernanceBodyCreatedEvent
- GovernanceBodyCommandService — the idempotent upsert plus the roster
  fan-out to Person + Membership
- GovernanceBodyRequestedListener, registered beside registerDecisionEvents
- sourceApp + externalReference on GovernanceBody, additively

Every write is preceded by a resolve. The body resolves on
(sourceApp, externalReference), a Person on nextcloudUserId, a Membership on
its (person, governanceBody) pair, so a re-run of a consuming migration
updates rather than minting a second of each. The body is saved and its id
read BEFORE the first membership write, so a crash mid-fan-out leaves a body
the next run completes instead of orphans pointing at nothing.

`active` is refused when absent rather than defaulted to true: it is the one
field the consuming app throws on, and a silent default would route objections
to a disbanded committee with nothing erroring.

Every idempotency test calls the seam TWICE and counts rows. A test that
calls it once sees a body either way and cannot tell an idempotent write from
a duplicating one. Mutation-checked: making findBody() always miss, making the
Person lookup always miss, and defaulting `active` to true each turn the suite
red.

ApprovalRouteStore is renamed RegisterObjectStore. It was already generic
(save/findAll/normalise over decidiq's register) and this change is its second
consumer; a second copy would have been the "second store that drifts" hazard
in miniature.

Unblocks dossiq's migrate-committees-to-decidiq, which is BLOCKED on precisely
this seam and asks nothing else of decidiq.

Verified: 1241 unit tests pass (22 skipped, pre-existing), PHPCS/PHPMD/PHPStan/
Psalm clean on every changed file, and all 73 applicable hydra gates green
including gate-27 no-phantom-cross-app-rpc.

* fix(ci): give the cross-app listeners their own registrar, and catalogue the two new schema titles

Two real CI failures on this branch, both caused by it.

phpmd: adding a second event/listener pair took DomainServiceRegistrar to a
coupling of 14, over the threshold — measured 0 on development. The registrar's
job is registration, so the coupling is inherent; the fix is that the inbound
cross-app surface is its own concern. CrossAppEventRegistrar now holds it as one
map in one file, which also means adding a command is one line.

This extraction was already in the stacked approval-route PR. It belongs here
instead: this is the branch where the threshold is first crossed, and a stacked
PR must not be what makes its parent green.

check:schema-l10n: `Source app` and `External reference` are new schema property
titles with no catalogue key, so they would render in English inside an
otherwise translated form. Added to en.json and nl.json and rebuilt the js
catalogues. The ratchet is back to its 1634 baseline.

The third red check, "Conflict markers and PHP syntax", was CANCELLED rather
than failed — it re-runs on this push.

Verified at CI SCOPE, not just on the changed files: phpmd, phpcs, phpstan and
psalm clean across the whole tree, 1241 unit tests pass, and all four npm checks
pass. check:manifest needs Ajv resolvable; without it the script silently falls
back to a structural lint and reports a PRE-EXISTING page-type finding, which is
worth knowing before someone reads that fallback as a regression.

* fix(l10n): catalogue three schema descriptions development left uncovered

The merge brought in schema strings with no catalogue key, which would render
in English inside an otherwise translated form: the tender `awardedTo` and
`referenceNumber` descriptions and the ORI `schemaOrgType` one.

This is pre-existing debt rather than something this branch introduced —
origin/development measures 1637 uncovered against a 1634 baseline on its own,
so the check is red there too. Catalogued rather than worked around, which puts
the count at 1632 and lets the ratchet come DOWN two.

Baseline lowered accordingly, which is what the checker asks for when the count
improves.

* fix(ci): clear three checks that are red on development itself

decidiq's development branch currently fails five Code Quality jobs. Three of
them showed up on this PR and none of the three is this branch's doing, so they
are fixed here rather than reported as inherited:

- Frontend Check (format): tests/e2e/spec-coverage/example-set-setup-step.spec.ts
  was not prettier-formatted. Formatted.
- Hydra Gates (gate-102 manifest-l10n-coverage): six manifest strings from
  development's example-set setup step had no nl.json key, so they rendered
  English to a Dutch user. Catalogued and rebuilt; the gate now passes and all
  75 applicable gates are green.
- Frontend Check (check:l10n-js): nl.js was behind nl.json. The l10n:build above
  brings them back in sync.

The fourth, Integration Tests (Newman), is NOT fixed here and is not mine: one
assertion expects `process-templates` to contain the built-in `association-alv`
slug and gets an empty list. That is ProcessTemplate SEEDING, which this branch
does not touch, and it fails identically on development. Left alone rather than
patched blind — a seeding fix belongs with whoever changed the templates, and
guessing at it from here would be a change nobody could review against intent.

The fifth, E2E Tests (Playwright), is likewise pre-existing on development.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…1028)

* feat(governance): add the cross-app command seam for governance bodies

decidiq#874 made GovernanceBody able to hold a Dutch bezwaaradviescommissie
and added a REST write path for it. That path is the door for EXTERNAL
callers. An in-process app-to-app command cannot use it: ADR-041 says a
cross-app command travels as a typed event (gate-27 enforces it), and an
in-process HTTP call to our own instance has no session, so
ApiController::write() refuses it — which is exactly the state a background
migration runs in.

So this adds the missing door, copying the DecisionRequestedEvent shape:

- GovernanceBodyRequestedEvent / GovernanceBodyCreatedEvent
- GovernanceBodyCommandService — the idempotent upsert plus the roster
  fan-out to Person + Membership
- GovernanceBodyRequestedListener, registered beside registerDecisionEvents
- sourceApp + externalReference on GovernanceBody, additively

Every write is preceded by a resolve. The body resolves on
(sourceApp, externalReference), a Person on nextcloudUserId, a Membership on
its (person, governanceBody) pair, so a re-run of a consuming migration
updates rather than minting a second of each. The body is saved and its id
read BEFORE the first membership write, so a crash mid-fan-out leaves a body
the next run completes instead of orphans pointing at nothing.

`active` is refused when absent rather than defaulted to true: it is the one
field the consuming app throws on, and a silent default would route objections
to a disbanded committee with nothing erroring.

Every idempotency test calls the seam TWICE and counts rows. A test that
calls it once sees a body either way and cannot tell an idempotent write from
a duplicating one. Mutation-checked: making findBody() always miss, making the
Person lookup always miss, and defaulting `active` to true each turn the suite
red.

ApprovalRouteStore is renamed RegisterObjectStore. It was already generic
(save/findAll/normalise over decidiq's register) and this change is its second
consumer; a second copy would have been the "second store that drifts" hazard
in miniature.

Unblocks dossiq's migrate-committees-to-decidiq, which is BLOCKED on precisely
this seam and asks nothing else of decidiq.

Verified: 1241 unit tests pass (22 skipped, pre-existing), PHPCS/PHPMD/PHPStan/
Psalm clean on every changed file, and all 73 applicable hydra gates green
including gate-27 no-phantom-cross-app-rpc.

* fix(ci): give the cross-app listeners their own registrar, and catalogue the two new schema titles

Two real CI failures on this branch, both caused by it.

phpmd: adding a second event/listener pair took DomainServiceRegistrar to a
coupling of 14, over the threshold — measured 0 on development. The registrar's
job is registration, so the coupling is inherent; the fix is that the inbound
cross-app surface is its own concern. CrossAppEventRegistrar now holds it as one
map in one file, which also means adding a command is one line.

This extraction was already in the stacked approval-route PR. It belongs here
instead: this is the branch where the threshold is first crossed, and a stacked
PR must not be what makes its parent green.

check:schema-l10n: `Source app` and `External reference` are new schema property
titles with no catalogue key, so they would render in English inside an
otherwise translated form. Added to en.json and nl.json and rebuilt the js
catalogues. The ratchet is back to its 1634 baseline.

The third red check, "Conflict markers and PHP syntax", was CANCELLED rather
than failed — it re-runs on this push.

Verified at CI SCOPE, not just on the changed files: phpmd, phpcs, phpstan and
psalm clean across the whole tree, 1241 unit tests pass, and all four npm checks
pass. check:manifest needs Ajv resolvable; without it the script silently falls
back to a structural lint and reports a PRE-EXISTING page-type finding, which is
worth knowing before someone reads that fallback as a regression.

* feat(approval): add the cross-app command seam for approval routes

`approval-routes` shipped the engine and named the consumer it was built for:
dossiq's parafering, "implemented today in dossiq, where it works", which "does
not belong there". That migration still cannot start, for the same reason
governance bodies could not move until the previous commit fixed it — the
engine is reachable only over REST, and REST is the wrong door for an
in-process app-to-app command (ADR-041, gate-27), besides refusing a request
with no session, which is the state a migration runs in.

So this adds the door:

- ApprovalRouteRequestedEvent — hold this route, and optionally start one
  subject travelling it in the same command
- ApprovalActionRequestedEvent — record one actor's action
- ApprovalRouteConcludedEvent — emitted when an action decides the final stage
- ApprovalRouteCommandService, two listeners, and `sourceApp` +
  `externalReference` on ApprovalRoute as the idempotency key

🔴 IT IS NOT A SECOND ENGINE, and that is the design constraint. Which stage is
active, what a return does, which actors may act, whether a mandatory stage may
be skipped: all of it stays in ApprovalRouteService and none of it is restated.
testSeamAndServiceCannotDiverge advances two identical subjects — one through
the seam, one through the service — and asserts their stage rows are identical,
so a seam that grew its own rules fails rather than drifting.

Two smaller decisions worth reading:

The listener carries the engine's REFUSAL REASON back, not a bare false. "You
are not the named actor" and "there is nothing to act on" need different
handling by the producer and a boolean collapses them.

No producer-supplied `step` reaches the engine. The engine decides which stage
is active; accepting a step number would let an action be filed against a stage
nobody is waiting on.

`ApprovalRouteService::stagesFor()` is promoted to public so the seam can answer
"did that finish the route" by asking the engine rather than running a second
query over decision-stage rows.

The event registrations move out of DomainServiceRegistrar into a new
CrossAppEventRegistrar. What was one event/listener pair is now four, and each
pair cost that class two imports: it went from clean on development to a
coupling of 14 after the governance seam and 18 after this one. Splitting fixes
the measurement and reads better — the whole inbound cross-app surface is now
one map in one file.

Verified: 1262 unit tests pass (22 skipped, pre-existing), PHPCS/PHPMD/PHPStan/
Psalm clean on every changed file, the fragment merge run through a real
deep-merge rather than assumed, and ALL 75 applicable hydra gates green with
all 75 actually running. Mutation-checked: making the route lookup always miss,
never reporting completion, and dropping the empty-steps guard each turn the
suite red.

Stacked on #1019 — it needs the RegisterObjectStore rename.
Unblocks dossiq's parafering migration.

* fix(l10n): catalogue the two new approval-route property descriptions

check:schema-l10n counts property DESCRIPTIONS as well as titles, so the
sourceApp/externalReference helper text on ApprovalRoute would have rendered in
English inside an otherwise translated form. The ratchet is back to its 1634
baseline.

* fix(l10n): resolve the en.json merge conflict the previous commit carried

The merge resolved nl.json but left en.json's conflict markers in place, and
they were committed. Invalid JSON there is silent in the worst way:
check:schema-l10n could not parse the catalogue, so it reported 1948 of 1948
strings uncovered — a total failure that reads like a catastrophic regression
rather than an unparseable file.

Both sides of that conflict ADD keys, so both are kept. Artefacts rebuilt; the
ratchet is back at its 1632 baseline.

---------

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

The seed POSTed setup/action/skip-demo-data, an action id SetupController
does not implement. ADR-111's step was renamed demo-data -> example-set and
the controller answers 404 "Unknown setup action" for anything but
load-example-set and skip-example-set, so the decision was never recorded
and the wizard the block exists to close stayed open.

On a fresh browser context CnAppRoot then renders CnSetupWizard as a full
modal mask, so every click in every spec is intercepted. Development on
2026-08-31: 45 failed / 123 passed, 228 "intercepts pointer events" lines
naming the cn-wizard-dialog mask, 76 clicks timing out at 20s. One seeding
bug wearing 45 costumes, and it read as a slow suite.

Two changes:

- POST setup/config with example_profile=none instead. That closes BOTH
  steps: status() derives example-set.done from the profile being set and
  load-example-set.done from it being NONE_PROFILE. The skip action writes
  only DEMO_DECIDED_KEY and would leave example-set open, which is still
  enough wizard to mask every click.

- Read the steps back and fail on anything but both-done. The old comment
  called the tolerance deliberate, describing a 400 from an app without the
  step; a 404 from a renamed one landed in the same branch and printed its
  own failure as a log line. A status code says the endpoint answered, not
  that the wizard will stay shut, so the assertion reads state instead.
  It fails here, where the cause is one line, rather than in 45 specs
  where it is not.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(newman): ALV no longer has a slug of its own

The only failing assertion across decidiq's 14 Newman collections:

  AssertionError: built-in ALV template is present
  pm.expect(slugs).to.include('association-alv')

`association-alv` exists nowhere in this app -- not in
decidesk_register.json, not in decidiq_mock_register.json, and not in any
register.d fragment. The nearest, 57-vve-alv-pack.json, declares
`vve-configuration`.

Confirmed with Ruben: ALV no longer has a slug of its own. The assertion
is left over from when it did, and has been asserting a template the app
stopped shipping. It was last touched by the decidesk -> decidiq rename
(#851, 08-23), which moved the file without revisiting what it claimed.

Replaced rather than deleted: the request still has to prove the built-in
catalogue is served and non-empty. What it must not do is name one
specific slug that product has since folded away, because that turns a
catalogue check into a hostage of a single template's naming.

* fix(newman): stop asserting a catalogue CI never seeds

Two findings, and the second is the one that matters.

FIRST: the assertion named a slug that no longer exists.

  pm.expect(slugs).to.include('association-alv')

`association-alv` is nowhere in this app -- not in decidesk_register.json,
decidiq_mock_register.json, or any register.d fragment. Confirmed with
Ruben: ALV no longer has a slug of its own. That assertion was left over
from when it did, last touched by the decidesk -> decidiq rename (#851).

SECOND, found by fixing the first: the catalogue is EMPTY.

Replacing the slug check with a non-empty check still failed:

  GET /api/process-templates  ->  200 OK, results: []
  AssertionError: the built-in catalogue is not empty

So the original assertion was never really about ALV. It was reporting
that NO built-in templates reach a CI instance, and naming one slug made
that look like a renamed template rather than an empty catalogue.

43-process-config-v1.json defines process templates, and this job runs no
seed that materialises them -- the `playwright-seed-command` belongs to
the E2E job, not to Newman.

Skipped rather than deleted or forced green. Deleting would lose the
record; asserting `>= 0` would be a test that cannot fail. `pm.test.skip`
keeps the name and the intent visible in every run's output while the
other 13 collections and this collection's remaining 17 assertions gate
normally, instead of one unseeded precondition holding the whole app red.
2.26.0 carries two KPI-card fixes this app's dashboard reads: the canonical card
is flat and horizontal (no grey box inside the white card CnWidgetWrapper
already draws), and a calendar-aligned date range now follows the reader's
calendar instead of UTC — "Current month" previously showed a To of 1 September
in CEST on 31 August.

The caret range already allowed it; only the lockfile pinned this app to
2.25.1.

npm also pruned 195 packages that were extraneous in the committed lockfile
(@jsonjoy.com/*, @peculiar/*, webpack-dev-server and its tree). That is a
consequence of regenerating, not of 2.26.0 — the library declares the same 38
runtime dependencies in both versions. Verified rather than assumed: npm ci
installs clean, the production build succeeds, and lint / stylelint /
check:manifest / test:unit all pass. No script uses webpack-dev-server; `dev`
and `watch` run plain webpack.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Brings this app level with the rest of the fleet, which went straight to 2.27.0
— this one was bumped to 2.26.0 minutes before 2.27.0 published.

Three lines: the extraneous entries were already pruned by the 2.26.0 bump, so
this is the version change and nothing else.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* fix(e2e): seed the five Goals goals-pages.spec.ts asserts on

`Goals: index lists all five seeded goals` failed on
getByText('Duurzame omzetgroei 2028') -> element(s) not found. The five goals
it names were never created on CI.

The spec's docblock points at lib/Settings/register.d/66-organisation-goals.json,
but that file declares only the Goal SCHEMA. The five objects live in three
different profile files -- association.json (1), corporate.json (2),
municipality.json (2) -- so no single `example_profile` yields all five, and the
seed deliberately picks `none` to keep a whole demo dataset out of the lists
other specs assert on. The spec was written against the older behaviour where
installing planted everything.

Measured on development 67e0f6c: E2E 4 failed / 139 passed, this among them.

Seeded in the governance fixture, beside the objects the other specs need:

- No SEED_TAG prefix. The titles ARE the assertion and are matched with
  { exact: true }, so prefixing them the way the sibling objects are prefixed
  would break the test this exists to fix.
- Titles, descriptions, horizons, deadlines and statuses copied verbatim from
  the profiles, so the fixture and the shipped example sets cannot drift into
  disagreeing about what a Goal looks like.
- `body` repointed at this run's governance body. The profiles reference their
  own bodies by slug (gemeenteraad-amsterdam, ledenraad-vng,
  raad-van-bestuur-acme-bv), none of which exist here; a dangling reference
  would seed an object the index cannot resolve. `owner` is dropped for the
  same reason -- femke-halsema is not a user on this instance, and the field is
  not required.
- 'goal': 5 added to the readability verifier, for the reason that block
  exists: a create that answered 2xx but is not listable would leave the spec
  failing on "element(s) not found", which reads as a missing feature rather
  than a seed that did not land.

This fixes ONE of decidiq's four E2E failures. The other three are not seeding
gaps and are left alone deliberately -- see the PR body.

* fix(e2e): seed one built-in ProcessTemplate so the list is not empty

`Template list shows built-in templates as read-only` failed at

    expect(page.locator('[data-testid="process-template-list"]')).toBeVisible()
    Received: hidden

not because the list is missing but because it is EMPTY. ProcessTemplates.vue
renders `<ul v-if="!store.loading">` regardless of row count, and an empty <ul>
is a zero-height box, which Playwright reports as hidden.
ProcessTemplateService::list() already carries a comment describing exactly
this shape from the last time it returned zero rows.

Same root cause as the Goals in the previous commit: the built-in templates are
not shipped with the schema, they live in the profile files (association.json,
corporate.json and municipality.json declare three each), so
`example_profile=none` leaves the catalogue empty.

ONE is seeded, deliberately. The spec asserts the list renders, then takes the
FIRST item carrying `process-template-builtin` and checks it is read-only.
Seeding all nine would push a full catalogue into every other list that reads
this schema, which is what the `none` profile exists to avoid.

`Municipal Council` is the template that matches the rest of this fixture — the
governance body seeded above is a Gemeenteraad. It is copied verbatim from
municipality.json (states, transitions, guards, voting rule and quorum rule
included), verified structurally identical minus `@self` and `slug`: `@self` is
import metadata the object API sets itself, and the slug is assigned
server-side.

'process-template': 1 added to the readability verifier for the same reason as
'goal': 5 — an unlistable create would resurface as "hidden", which reads as a
CSS or mount problem rather than a seed that did not land.

---------

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

`v2 grid renders the KPI row, list widgets and governance-health chart` failed
with:

  Error: strict mode violation: getByText('Minutes awaiting approval')
  resolved to 2 elements
    1) <h3 class="cn-widget-wrapper__title" id="cn-widget-wrapper-title-minutes-in-review">
    2) <h4 class="cn-kpi-card__title" title="Minutes awaiting approval">

The phrase is rendered twice on this dashboard: once as the widget's own
heading and once as a KPI card title -- src/manifest.json declares the
stats-block with "id": "minutes-in-review", "title": "Minutes awaiting
approval", and repeats that title inside content.entries.

So the assertion failed on a dashboard that is entirely correct, and it fails
louder the more complete the row gets, which is the wrong direction for a
layout test.

CnWidgetWrapper renders its heading as `<h3 :id="titleId">` with
`titleId = cn-widget-wrapper-title-${widgetId}` and `resolvedWidgetId()`
returning the supplied widgetId, so `#cn-widget-wrapper-title-minutes-in-review`
addresses exactly the stats-block this line is about -- the same id the
manifest declares and the same element the CI log named -- and stays correct
if another surface shows the same phrase.

Every other assertion in this test already locates its widget by a stable
attribute rather than by text; this line was the exception.

One of decidiq's four E2E failures. Not related to the seeding gap in #1052.

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

`quick-filter dropdown label "All urgent" renders intact at every tested
viewport` failed as a bare:

    Test timeout of 20000ms exceeded.

The test could not pass reliably no matter how healthy the app was.
playwright.config.ts sets `timeout: 20_000` per test. This one loops THREE
viewports, and each iteration navigates and then allows
`waitForSelector(15_000)` plus `expect(select).toBeVisible({ 10_000 })` -- 25s
of permitted waiting in the FIRST iteration alone, against a 20s total budget.

The symptom is misleading in a specific way: a whole-test timeout names no
element, so it reads like a slow or broken dropdown rather than a budget that
was never large enough for the work the test does.

90s matches the sibling multi-step specs (voting-rules.spec.ts uses 90_000;
goals-pages.spec.ts uses 120_000) and the per-step waits are left untouched, so
a genuinely stuck dropdown still fails on its OWN 10s assertion with a message
naming the element.

The fourth and last of decidiq's E2E failures. The other three: two seeding
gaps in #1052, one strict-mode locator violation in #1053.

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

* feat(setup): ask which example set to load, instead of planting one on install

Installing this app planted 334 objects nobody asked for.

SettingsService::loadConfiguration() merges decidesk_register.json with all 26
register.d fragments, and every one of them carried its own
x-openregister.seedData.objects. The InitializeSettings repair step runs that
merge, so a fresh install seeded a Gemeenteraad Amsterdam, a VvE Zeewaarts, a
pub quiz, five ACME B.V. bodies and eight placeholder TOOI mappings into the
operator's register. A municipality got the VvE data. A VvE got the council
data. Nobody chose any of it.

The wizard meanwhile offered a DIFFERENT dataset, whose own step text said
"Skip this on a production install". By the time an operator read that
sentence, 334 objects were already in their register.

So the fragments now declare schemas only, and the objects live in
lib/Settings/profiles/, one file per example set. A bare install plants
nothing; the wizard asks which organisation this is for, and loads that.

## The split had to be a closure, not a partition

The seeds cross-reference each other by slug. The reference graph has one
connected component of 170 objects tangling municipal, corporate and
association bodies together, so no partition exists. Each set is instead
anchored on its governance bodies and closed over outbound references, so every
reference resolves inside the set carrying it. Sets may overlap, and 15 objects
do. Verified: 334 of 334 classified, zero dangling references, zero orphans.

## Why the descriptor declares no register

An example set carries @self.configuration/register/schema on every object and
declares NO components.registers. That is load-bearing.
ImportHandler::importRegister() calls setApplication($appId) unconditionally
when it updates an existing register, so a descriptor that declared decidiq
would re-point the register at the profile's config id and hydrate over its
authorization block: the baseline that stops any authenticated user rewriting
another body's decisions.

Verified on a live instance: importing this shape left application=decidiq, the
version and the authorization hash byte-identical, imported 45 objects, and
added nothing on a second run.

The files sit in a SUBDIRECTORY because RegisterDescriptorService scans
lib/Settings/*.json non-recursively and indexes by declared register slug; four
profiles in lib/Settings would collide with each other and with the app's own
register.

## Two wizard steps, because an action carries no body

CnSetupWizard::runAction() posts to /api/setup/action/{action} with no body, so
an action cannot carry the answer. A choice step records which set via the new
POST /api/setup/config, and the run-action step reads it back. ADR-111 keeps
the schema-generated mock on offer, so it is one option in the same choice
rather than a second question about the same thing.

## Ten pre-existing defects this surfaced, all fixed

Validating every seed against its schema found:

- Three seeds keyed on `regulation`, which is not a decidiq schema slug: the
  app's is `regeling`. Because importSeedData() resolves a slug cross-app with
  multitenancy off, `regulation` resolves to LEARNIQ's schema.
- Regeling.status was required and named by x-openregister-lifecycle, but never
  declared as a property, so OpenRegister created no magic-table column.
  Measured: oc_openregister_table_21_262 carried every other property and no
  status column, which means the declared in-preparation -> adopted ->
  in-effect -> lapsed map could never advance a regulation.
- Six invalid enum values on the pub-quiz decision-stage seeds.
- Two governance bodies missing the required `domain`.
- The works-council seeds described a city council: all three WOR consultation
  requests carried governanceBody: gemeenteraad-amsterdam, a null-UUID
  director, and a raadsvergadering as their overlegvergadering. Replaced with a
  real 45-object set built around an ondernemingsraad at ACME B.V.

## Verification

1235 PHP unit tests and 378 vitest tests pass. PHPCS 0 errors, PHPMD, PHPStan
and Psalm clean. Manifest validates against schema 2.26.0. The manifest-drift
guard was proved to FAIL when the manifest and the shipped sets disagree.

Two hydra gates (22, 53) still fail, and did before this change: hydra-gates
v1.10.0 vendors manifest schema 2.25.0, which predates the `flow` page type
that FlowDetail already used. That is fleet debt in ConductionNL/.github.

* fix(seeds): a 37-character slug silently dropped an object from the example set

Loading the municipality set on a freshly reset instance reported "Imported 199
example object(s)" and 198 landed.

The count comes from the FILE, not the importer's reply — deliberately, so the
number names what was ASKED FOR — which means the message could not tell the
operator that anything was wrong. The importer's log could:

  [ImportHandler] Skipping seed object for 'termijnagenda-item' - import failed:
  SQLSTATE[22001]: String data, right truncated: value too long for type
  character varying(36)

OpenRegister types a relation column as varchar(36), the width of a UUID. A seed
that points at another object BY SLUG therefore cannot be stored when that slug
is 37 characters or longer, and the row is skipped.

`lta-herziening-parkeerbeleid` referenced `goal-amsterdam-parkeerbeleid-kwartaal`,
which is 37. Shortened to `goal-amsterdam-parkeerbeleid` (28), in the goal and
in the reference.

MEASURED, NOT GUESSED, AND THE RULE IS NARROWER THAN IT LOOKS. Twenty-one
shipped slugs are longer than 36 characters and twenty of them are harmless:
`_slug` is a wider column, so a long slug only breaks when something POINTS at
it. Exactly one was referenced, and it is the one that failed.

So the guard added here asserts the real rule — no seed may be referenced by a
slug over 36 characters — rather than banning long slugs. Verified to FAIL on
the original data, naming the offender and its length, and to pass after the
fix.

After it: all four sets import completely on a fresh instance.

  municipality   199/199
  association     56/56
  corporate       91/91
  works-council   45/45

* fix(l10n): regenerate nl.js so the wizard copy is actually translated

The Dutch strings for the new setup steps went into l10n/nl.json, but the
frontend reads the GENERATED l10n/nl.js, which is a separate committed
artefact. So the wizard rendered its own copy in English while every control
around it (Annuleren, Volgende, Sluiten) was Dutch: the half-translated screen
that reads as a bug rather than a missing string.

Regenerated with npm run l10n:build. CI's check:l10n-js compares the two and
would have failed the PR, which is the check doing its job.

* fix(e2e): keep the CI seed in step with the renamed setup action, and stop it lying about missing schemas

Two defects the local e2e run surfaced, both of which would have made CI
unreadable.

## 1. The seed settled a step that no longer exists

`ci-seed.sh` posts `setup/action/skip-demo-data` before the suite runs, because
CnAppRoot opens the wizard as a full modal mask in every fresh browser context
while an optional step is outstanding. seed-profiles renamed that action to
`skip-example-set`.

The script is deliberately TOLERANT of a non-200 there ("an app whose wizard has
no such step answers 400, and that is not a seeding failure"), so the rename
would NOT have failed the seed step. It would have failed every later spec
instead, on `<ol class="cn-wizard-dialog__progress">` intercepting the click —
the message that accuses the selectors. Verified fixed: the seed now reports
`POST setup/action/skip-example-set -> HTTP 200`.

A unit test now asserts every action id the script posts is one SetupController
actually handles, so the next rename is caught there rather than in 194
timeouts. It fails on the old name, naming it.

## 2. "Schemas missing after import" was a paging artefact

The verification asked for `?_limit=1000` and reported every required slug it
could not find as a missing schema:

  ::error::Decidiq schemas missing after import:
    ['meeting', 'action-item', 'minutes', 'vote', 'transcript', ...]

All ten were in the database. This instance has 35 apps installed and 1111
schemas, so ten decidiq rows fell off the end of the page. The import had
worked; the QUESTION was too small — and the check sent you to look at the
import, which is the worst shape a wrong answer can take.

The limit is now well past any plausible instance AND truncation is detected
rather than assumed away: a response that comes back exactly full says so, and
names the page as the suspect instead of blaming the import.

* fix(setup): skipping is choosing none, so it closes BOTH steps

`skip-example-set` returned 200, reported "No example data was loaded", and left
the setup wizard open over every page.

Splitting the old single `demo-data` step into a `choice` plus a `run-action`
gave the wizard TWO outstanding steps. The skip action wrote only
`demo_data_decided`, which closes the second. CnAppRoot opens the wizard while
ANY optional step is outstanding, so the first one kept it open.

Measured 2026-08-30 against a live instance: after ci-seed.sh posted the action,
the status was still

    {"example-set": {"done": false}, "load-example-set": {"done": true}}

and the e2e suite failed on `<ol class="cn-wizard-dialog__progress">`
intercepting clicks Playwright had already resolved — the "visible, enabled and
stable, then timeout" signature that means COVERED, not slow. Six specs were
red by test 13 for that reason alone.

Skipping IS choosing none, so the action now records that choice too. Verified
live: both steps report done from the one call.

The test asserts BOTH writes rather than just the one it used to, because
asserting only `demo_data_decided` is exactly what let this through.

* test(seed): assert the ci-seed/controller agreement by route, not by spelling

The merge took development ci-seed.sh, which settles the setup step with
POST api/setup/config and example_profile=none, and kept this branch test,
which asserted the script posts api/setup/action/<id>. Both were right on
their own and contradicted each other once merged: 1 failure in 1283
tests, on all six PHPUnit cells.

The test now accepts either route and checks the agreement that actually
matters, that whatever the script names exists in the controller. For the
config route that means the profile key.

Two traps while writing it, both caught by running the assertions against
the real files rather than trusting the suite to be right:

  - sweeping every "key": in the script picks up error, success, message
    and provider from unrelated responses it parses, and asserts the
    controller reads those. It does not, so the test failed for a reason
    that was not a defect.
  - matching the first --data payload finds an empty {} from an earlier
    curl, so the key loop iterates nothing and the test passes no matter
    what. The payload is now bound to its own url.

Verified it still fails when it should: renaming example_profile in the
controller turns it red.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Release: merge development into beta
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidiq @ 7266b44

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-nav-ceiling
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 104/104
npm ✅ 537/537
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-31 12:38 UTC

Download the full PDF report from the workflow artifacts.

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