Release: merge development into beta - #2
Conversation
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit TestsPHPUnit tests were not enabled for this run. Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit TestsPHPUnit tests were not enabled for this run. Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit TestsPHPUnit tests were not enabled for this run. Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit TestsPHPUnit tests were not enabled for this run. Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit TestsPHPUnit tests were not enabled for this run. Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (215 total)
PHPUnit TestsPHPUnit tests were not enabled for this run. Integration Tests (Newman)Newman integration tests were not enabled for this run. E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
npm dependencies (416 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
npm dependencies (416 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
npm dependencies (416 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)
Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report
Summary
PHP Quality
Vue Quality
Security
License Compliance
composer dependencies (100 total)
npm dependencies (416 total)
PHPUnit Tests
Code coverage: 0% (0 / 3 statements) Integration Tests (Newman)
E2E Tests (Playwright)Playwright E2E tests were not enabled for this run. Generated automatically by the Quality workflow.
|
Quality Report — ConductionNL/decidesk @
|
| Check | Result |
|---|---|
| PHP lint | ✅ |
| PHP phpcs | ✅ |
| PHP phpmd | ✅ |
| PHP psalm | ✅ |
| PHP phpstan | ✅ |
| PHP phpmetrics | ✅ |
| eslint | ✅ |
| stylelint | ✅ |
| Security (composer) | ✅ |
| Security (npm) | ✅ |
| License (composer) | ✅ 100/100 |
| License (npm) | ✅ 416/416 |
| PHPUnit | ✅ |
| Newman | ✅ |
| Playwright | ⏭️ |
Coverage: 0% (0/3 statements)
Quality workflow — 2026-04-13 18:03 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ❌ | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ |
Quality workflow — 2026-04-13 18:11 UTC
Download the full PDF report from the workflow artifacts.
…to the relation filter
Two regressions this branch introduced, both mechanical, neither a real
behaviour change.
PHPUnit (2 errors of 806 tests, identical on all four legs)
Error: Call to undefined method MockObject_ObjectService::matching()
lib/Service/ParticipationPublicationService.php:318
ParticipationPublicationService resolves TWO collaborators from the DI
container -- OpenRegister's ObjectService and, new on this branch,
decidesk's own ObjectRelationFilter. The test's container stub was
`->method('get')->willReturn($this->objectService)`: unconditional, so it
answered BOTH ids with the ObjectService mock, and the relation-filter call
landed on an object that has no such method. Dispatch on the requested id.
The real ObjectRelationFilter is used rather than a mock -- it is a
dependency-free pure filter, and a mock would assert nothing about the
disclosure boundary the scoping exists to enforce. The digest fixtures
therefore now carry the structured `relations` array ReactionIntakeService
actually writes, and a third reaction pinned to a DIFFERENT consultation
asserts it is excluded: the OpenRegister filter pins the related id but not
the related schema, and this digest is public output.
Proven to fail: making `references()` return true unconditionally fails
testReactionDigestIsPiiFree (3 != 2); restoring the blanket container stub
reproduces both original errors verbatim.
eslint / lint-check (1 error, shared by both jobs)
StateMachineEditor.vue 15:65 Parsing error: nested-comment
The component's header comment quotes the literal `<!---->` while explaining
that Vue 3 substitutes a comment node for a crashed subtree. `<!--` inside an
HTML comment is a nested-comment parse error, so the whole SFC failed to
parse. Reworded; the explanation is unchanged.
Proven to fail: reinstating the literal restores the error at 15:65.
Verified: 806 tests, 0 errors (PHP 8.4), matching development.
fix(e2e): seed the fixture, key relation filters correctly, wire the orphan dashboard widgets
) The 32 floor was raised on the premise that nothing tested below it. That is false here: this repo's own CI runs stable31, and min-version is enforced at install time, so occ app:enable refuses on 31 and the e2e seed fails with "is not installed or enabled". The original reason for a 32 floor no longer holds either. It came from openregister implementing OCP\ContextChat\IContentProvider, an interface absent before NC 32. openregister#2372 removed every eager reference to that class, so it is only loaded inside interface_exists() guards and the header is never read on an older server. openregister#2380 restored its own 28 floor on that evidence.
…ects
Two tools in the same pipeline gave OPPOSITE instructions about where an
`@spec` tag should point, and following the one that runs FIRST manufactured
findings for the one that runs SECOND.
`SpecTagSniff` runs as a blocking `PHP Quality (phpcs)` job and told every
developer, in its file docblock and in its own warning text:
@SPEC openspec/changes/{change-name}/tasks.md#task-N
A change directory is temporary by definition — completing a change moves it
to `openspec/changes/archive/<date>-<name>/`, and renaming or dropping one
removes the target outright. Every tag written to that instruction dangles
from that moment on, and gate-46 (spec-anchor-existence) reports it. The
developer who wrote the tag had followed this sniff's own advice.
Measured on portaliq: 100 unresolved gate-46 targets, and 260 of its 385 live
tags pointing into a change directory. The sniff ships identically in 20
ConductionNL repos, so grinding the tags without fixing the sniff regenerates
them at the rate changes are archived.
This changes the docblock example and BOTH warning messages to the canonical
form gate-46 and the project rule agree on:
@SPEC openspec/specs/{capability}/spec.md#requirement-{slug}
The method-level message previously carried no guidance at all, so a developer
reading it had only the class message to copy from; it now names the same
canonical shape.
Behaviour is unchanged: severity stays WARNING (verified via phpcs — an
untagged class and public method still report 0 errors / 2 warnings, and a
tagged file still reports nothing), and an `openspec/changes/...` target is
still accepted, since this sniff only checks that a tag is PRESENT.
No `@spec` tags are repointed here — this repo's existing tags are untouched.
Refs ConductionNL/.github#228
Fleet-wide Playwright instrument sweep, ConductionNL/.github#188. Neither change can alter a verdict; both change whether you can see why a verdict happened. This repo's `trace` is already `retain-on-failure`, so only the timeout half applies here. No repo in the fleet set `globalTimeout`. The shared quality.yml Playwright job is `timeout-minutes: 45`, and a job cancelled by that cap produces no verdict and no artifacts: the trace upload is `if: failure()` and the report upload is `if: always()`, and neither runs on a cancelled job, while `gh pr checks` still renders it as "fail". Runs cancelled at ~45m16s have been observed in this fleet. Measured overhead in that job before the `Run Playwright tests` step starts is 2.0-2.4 min, so 38m leaves ~7 min of margin while guaranteeing a tally and its artifacts.
…l-target fix(phpcs): stop the SpecTagSniff instructing the pattern gate-46 rejects
decidesk was the last repo in the fleet still installing NC stable31, and it is the leg that blocked #425. openregister declares min-version="32" (openregister#2384, merged today), and decidesk installs it as an `additional-apps` entry while `src/manifest.json` names it a hard dependency. On stable31 `occ app:enable openregister` refuses with "not compatible with this version of the server" — but the shared workflow runs it as php occ app:enable "$name" || echo "::warning::Failed to enable $name, continuing..." so the refusal is a WARNING, the run continues without its data layer, and dies ~70s later on missing schemas. That reads like an app fault. It is not. Order compounded it: the newman, playwright and journeydoc-capture jobs each check the server out at `fromJSON(inputs.nextcloud-test-refs)[0]`, so stable31 sitting first put all three on the one version openregister cannot load. Two halves, both required: - matrix: '["stable31","stable32"]' -> '["stable32"]'. stable33 deliberately not added; this removes an impossible leg, it does not widen the matrix. - info.xml: <nextcloud min-version> 28 -> 32, per the rule that an app's floor must be >= the maximum floor of every app it hard-depends on (openconnector#1172/#1173). <php min-version="8.3"/> and max-version="34" are unchanged. openspec/app-config.json carries the same matrix and moves with it, so /app-verify does not report drift. This reverses #424, whose premise has expired: it restored the 28 floor on the grounds that openregister had gone back to 28 in openregister#2380. #2384 moved it back to 32 deliberately and fleet-wide, and names decidesk as one of eight consumers that must move their matrices to stable32.
) The canonical AppHost route table (`OCA\OpenRegister\AppHost\Routes::standard()`) ships `['name' => 'settings#update', 'url' => '/api/settings', 'verb' => 'PUT']`, and decidesk's own openregister-absent fallback in `appinfo/routes.php` re-declares it verbatim (decidesk#377) — so the route is live on BOTH paths. `AppHost\Bootstrap::aliasControllerUnlessLeafDefinesIt()` only substitutes OpenRegister's `GenericSettingsController` when the leaf does NOT ship a class of that name. decidesk ships `lib/Controller/SettingsController.php`, so the alias is skipped and decidesk owes every method the canonical table routes to `settings#`. It had index/create/load but no update(). Measured 2026-08-08 on the dev instance: GET /apps/decidesk/api/settings -> 200 (positive control) PUT /apps/decidesk/api/settings -> 500 ReflectionException: Method OCA\Decidesk\Controller\SettingsController::update() does not exist at lib/private/AppFramework/Utility/ControllerMethodReflector.php:40 Moves the create() body into update() and makes create() a delegate, so the POST alias (still used by src/store/modules/settings.js::saveSettings) keeps byte-identical behaviour. Both carry #[AuthorizedAdminSetting] — the middleware only evaluates attributes on the DISPATCHED method, so delegation inherits nothing, and the write reaches instance-wide IAppConfig. Adds two unit test files, each with a positive control so a green cannot be produced vacuously. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…, not in flight (#436) * fix(decision): require outcome + decisionDate only in terminal states, not in flight `Decision` declared `outcome` (enum adopted|rejected) and `decisionDate` as unconditionally `required`, so every motion/amendment/resolution remapped onto the supertype by ADR-005 was refused at creation: 400 "The required properties (decisionDate, outcome) are missing." An in-flight motion has no legal outcome. That contradicts the schema's own `lifecycle` description ("outcome ... the voting result, set when reaching 'decided'") and ADR-005's orthogonality of lifecycle and outcome. PO decision: required only at the end, not in flight. Terminal states, derived not guessed ------------------------------------ `decided`, `enacted`, `archived` — from the register's own `lifecycle` enum and `x-openregister-lifecycle` map: `decided` is the first state past the vote and the other two are reachable only through it. `withdrawn` is deliberately NOT terminal-for-outcome: it ends the lifecycle but the decision was never decided, so demanding an adopted/rejected result there would forbid withdrawing a draft. OpenRegister does NOT enforce conditional `required` — measured --------------------------------------------------------------- JSON Schema `if`/`then` is the idiomatic shape, and it is inert here. `Db\Schema` has no if/then field, and `Schema::getSchemaObject()` rebuilds the validated object from a fixed key list (title/description/version/type/required/ $schema/$id/properties), so the block never reaches the validator. Live in-memory probe against the installed OpenRegister, with positive controls: CONTROL unconditional required missing (no title) valid=false CONTROL enum violation (outcome=banana) valid=false in-flight draft, no outcome valid=true TERMINAL decided, no outcome/decisionDate valid=true <- if/then ignored The controls prove the probe can report false; the terminal case still passes. So an `if`/`then` on the schema would have been decorative. It is not shipped. Enforcement lives where the transition happens ---------------------------------------------- - `DecisionTransitionGuard::TERMINAL_OUTCOME_STATES` + `getMissingTerminalFields()` (pure, no DI, exhaustively unit-tested). An out-of-vocabulary `outcome` such as the `pending` placeholder counts as missing — recording "pending" as the result of a vote is the same defect as recording nothing. - `DecisionLifecycleService::resolveStateGateRejection()` refuses entry into any terminal outcome state and names the missing fields. Schema `required` is now [title, text, decisionType]. Proved both directions ---------------------- - in-flight motion (lifecycle=voting, no outcome/decisionDate) is ACCEPTED - decision entering decided/enacted without them is REJECTED, not persisted Can-fail proofs, both for the right reason: - restore the old `required[]` on the fixed register -> the in-flight seed goes red with exactly the original message "The required properties (decisionDate, outcome) are missing"; with the new list it validates true - neutralise `getMissingTerminalFields()` -> the three terminal tests plus the guard test go red, each printing the defective object that got written Seeds ----- Measured with OpenRegister's own validator, `@ref:` tokens resolved as ImportHandler does: 8 of 19 decision seeds imported before, 19 of 19 after. The 11 refusals had three distinct causes, only one of which was this decision: - 8 x missing decisionDate (+ text on 6) -- the PO issue - 1 x `motionType: "motie"`, not in the enum [motion|amendment|order|procedural] - 2 x bare slugs in `route`/`supersedes` where `format: uuid` applies; only `@ref:`-prefixed strings are resolved by ImportHandler::replaceRefTokens() Also `voteType: open|secret` -> `named|anonymous` (enum is named|anonymous|unanimous-consent|acclamation). `motie-woonlasten-2025` keeps NO outcome and NO decisionDate: it is the in-flight case this change unblocks, and the register would otherwise never exercise it. `RegisterJsonTest::testDecisionSeedsRespectTerminalCompleteness` pins both halves so relaxing `required[]` cannot quietly become a licence to ship a decided decision with no result. Scope ----- Register change, checked for blast radius: no other app declares a Decision with the outcome/decisionDate pair. procest has a `decision` schema but with `required: []` and neither property. Untouched. Known gap: a raw write straight to OpenRegister's object API bypasses this gate, exactly as it already bypasses the chair-only and quorum gates. Recorded in the schema's `x-decidesk-terminal-completeness` note. Quality: phpcs 0 errors (5 pre-existing warnings), phpmd 0, psalm 0, phpstan 0, phpunit 808/808 (29 skipped). * fix(e2e+seeds): assert the in-flight contract on the form; revert an @ref seed change that lost a seed Two corrections to the previous commit, both driven by CI evidence rather than by my own model of the import path. 1. The Add Decision dialog test encoded the OLD contract -------------------------------------------------------- `decision-management.spec.ts` asserted `'Decision date *'` and `'Outcome *'` were visible. The asterisk is CnFormDialog rendering `field.label + (field.required ? ' *' : '')`, so it is a direct UI read of the schema's `required[]`. Removing those two from `required[]` correctly removed their asterisks and the test went red — the change working, not breaking. The test now asserts the new contract in both directions: `Title *`, `Text *` and `Decision type *` are present, `Decision date *` and `Outcome *` have count 0, and both fields are still ON the form (optional, not removed). `Decision date` needs `.first()` now that the asterisk no longer disambiguates the datetime-picker's two labels. 2. Reverted the `@ref:` seed tokens — they LOST a seed ------------------------------------------------------ The previous commit rewrote three seeds' `amends`/`route`/`supersedes` bare slugs into `@ref:<schema>:<slug>` tokens. Measured consequence in CI: [ImportHandler] Skipping seed object for 'decision' - import failed: SQLSTATE[22001]: String data, right truncated: value too long for type character varying(36) `@ref:decision:motie-duurzaamheid-2025` is 41 characters and the relation column is `varchar(36)`: the token is written BEFORE any resolution on this path. `Amendement Cultuursubsidie verhogen` appears 4x in development's CI log and 0x in mine, and the live decision count went 25 -> 24. Reverted; the bare-slug form is what actually imports. RETRACTION: the previous commit's "8 of 19 seeds imported before, 19 of 19 after" is WRONG and is withdrawn ------------------------------------------------------------------------- That number came from an in-memory probe that ran OpenRegister's validator over the raw seed JSON. It is not a faithful model of the seed import path. Measured against the live CI instance on development, all ten seeds the probe called "REFUSED" are present — including every one missing `decisionDate`, and both bare-slug relation seeds. The seed import path does NOT enforce the schema's `required[]`. What the probe DID model correctly is the object-API path (`POST /api/objects/decidesk/decision`), which does validate — that is where `400 "The required properties (decisionDate, outcome) are missing"` comes from, it is what #425's Newman fixtures and the e2e fixtures hit, and it is what the form's `*` reflects. So this change unblocks API/UI creation of in-flight decisions; it was never a seed-import fix. The seed edits are therefore DATA-QUALITY corrections, not import unblockers: terminal seeds gain the `decisionDate` the new rule requires, the in-flight motion drops its out-of-enum `outcome: "pending"`, and `motionType`/`voteType` gain in-enum values. `RegisterJsonTest::testDecisionSeedsRespectTerminalCompleteness` pins them. Quality: phpcs 0 errors, phpmd 0, phpunit 808/808 (29 skipped). * test(decision): pin every transition target as terminal-or-in-flight (fail-closed) The terminal-completeness gate keys off the transition's TARGET state, so a transition added later whose target nobody classified would skip the gate silently — the same fail-open shape `transitionLifecycle()` had when it chose MOTION_TRANSITIONS for every objectType that was not literally 'amendment'. `testEveryTransitionTargetIsClassified` walks the transition map and asserts each target is classified exactly once (XOR) as terminal or in-flight, so adding a state without deciding which it is fails the suite instead of quietly disabling the check. Can-fail proof: adding an unclassified `ratify -> ratified` transition makes it red naming the state and both options. phpunit 809/809 (29 skipped). --------- Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… become 404/400, and migrate the lifecycle vocabulary (#425) * fix(adr-005): migrate the PHP half of the Decision-supertype fold ADR-005 folded Motion and Amendment into Decision as decisionType values and deleted the motion/amendment/resolution schemas from decidesk_register.json. The frontend migrated (src/store/store.js remaps both logical types onto the decision schema) and tests/Unit/RegisterJsonTest.php asserts the schemas are gone. The PHP never migrated: 13 lib/ files still addressed the deleted slugs. That is not a silent miss. ObjectService::setSchema() rethrows DoesNotExistException, which is neither InvalidArgumentException nor RuntimeException, so it escapes every controller catch clause and the endpoint answers 500 where it owes 404 or 400. Measured: "unknown motion is 404 not 500" returned 500, and the amendment-order, minutes-draft and voting-behaviour paths all reached a dead schema. Mapping implemented, taken from ADR-005's discriminator table and the register: motion -> schema `decision`, decisionType=motion amendment -> schema `decision`, decisionType=amendment resolution -> schema `decision`, decisionType=resolution Amendment.parentMotion -> Decision.amends (the register declares `amends` as "replaces the retired Amendment -> Motion relation") DecisionSchema holds that mapping in one place. Reads by id re-establish the type through the discriminator, because an id no longer proves what it points at; writes stamp decisionType, which is `required` and defaults to `meeting-outcome` — omitting it would silently mistype every motion. Also closes a fail-open uncovered while migrating: transitionLifecycle() chose MOTION_TRANSITIONS for any objectType that was not literally 'amendment', so an unknown value got the motion table instead of a refusal. It is now a match with a throwing default, evaluated before the register is touched. SubmissionDeadlineListener subscribed to schemas ['motion','amendment'] — both deleted — so the deadline gate fired on nothing at all. It now subscribes to `decision` and narrows on the discriminator inside the handler. * refactor(adr-005): spell the decision vocabulary inline, per the repo's own idiom The DecisionSchema helper class introduced in the previous commit cost 29 PHPMD findings against a baseline of ZERO: 21 StaticAccess (the project forbids static access outright) and CouplingBetweenObjects 13 in three classes that sat at 12 — any new class reference trips them. The class is gone; the tokens are spelled where they are used, which is how OriController::DECISION_TYPE_MAP and the rest of decidesk already spell schema slugs. Every site carries its ADR-005 rationale. Two complexity findings were also mine and are fixed by construction rather than by threshold: - detectConflicts() reached CC 10 / NPath 264 because the discriminator check added a branch to a method that already carried the whole overlap comparison. The comparison moves to hasTextOverlap() + significantWords(). - AmendmentOrderService hit class complexity 51. The added guard collapses to one condition: a missing object and a decision of the wrong type are the same answer — this id is not an amendment — and an absent object has no discriminator, so `($amendment['decisionType'] ?? null) !== 'amendment'` covers both without the `$entity === null ||` arm. Measured on this tree, all against origin/development's own numbers: phpcs 0 errors / 97 warnings / 97 files (identical to baseline) phpmd 0 findings (identical to baseline) psalm 0 errors phpstan 0 errors phpunit 812 tests / 2771 assertions / 0 errors (baseline 806 / 2761 / 0) * test(newman): seed decisions, not the schemas ADR-005 deleted Three collections seeded their fixtures straight into /apps/openregister/api/objects/decidesk/{motion,amendment} — schemas that no longer exist. Every seed answered 404, so `motionId` / `amendmentA` / `amendmentB` interpolated to the empty string and the downstream requests measured nothing about the code under test. Measured on a live instance: "Setup: motion created (201)" failed in decidesk-motion-amendment, decidesk-voting-rules and decidesk alike. Seeds and teardowns now address `decision` and carry `decisionType`, and the amendment fixtures link through `amends` rather than the retired `parentMotion`. board-portal is deliberately untouched: it targets routes retired by 2bb71af, and that commit enumerates retargetings as well as deletions, so some of its 24 assertions may describe behaviour that still exists elsewhere. Deciding that collection's fate is a separate question for a human. * fix(voting): an unknown meetingId is a fail-closed 403, not a 500 Measured live while verifying the ADR-005 work: POST /api/voting-rounds with "meetingId": "meeting-x" answered 500, and the trace showed it never reached the subjectType validation it was supposed to fail on: MagicMapper::findInRegisterSchemaTable "Object not found in magic table" -> ObjectService::find -> ParticipantResolver::resolveGovernanceBodyId -> ParticipantResolver::resolveMeetingParticipants -> VotingRoundGuard::hasRole / requireRoles / requireChairOrSecretary -> VotingController::open Two contracts were being broken at once. resolveGovernanceBodyId() documents "returns null when the meeting cannot be found", but OpenRegister's find() THROWS for an unknown id rather than returning null — so its `=== null` branch was unreachable and the documented answer was never delivered. VotingRoundGuard's docblock promises "fail closed: any failure yields a 401/403", and instead an auth guard emitted a 500. Not-found is translated where it is documented, and nowhere else: the catch is narrowed to DoesNotExistException, so every other failure still propagates. This is NOT the ADR-005 fold — it is a second, independent cause of the same symptom, and it is why the Newman assertion "Open: invalid subjectType rejected (400)" reported 500 both before and after the schema migration. The test double now models find() faithfully (it throws), which is what let this surface at all; a double that returned null would have hidden every caller that treats not-found as a return value. * fix(adr-005): migrate the lifecycle VOCABULARY, not just the schema slug #425 moved the PHP off the retired `motion`/`amendment` schemas onto `decision`. It did not move the words. Thirteen files went on writing `submitted | debating | adopted | rejected`, and all four are outside the `Decision.lifecycle` enum — so every transition wrote a value OpenRegister refuses. Measured: the identical payload with `deliberating` validates where `debating` is rejected. The mapping, per ADR-005: submitted -> proposed debating -> deliberating voting -> voting (unchanged) adopted -> lifecycle `decided` + outcome `adopted` rejected -> lifecycle `decided` + outcome `rejected` withdrawn -> withdrawn (unchanged) `adopted`/`rejected` are values of `Decision.outcome`, an axis orthogonal to `lifecycle` — the schema says so in as many words. They are therefore gone from the transition tables entirely and arrive as a new `$outcome` argument alongside `newState: 'decided'`. Keeping them as pseudo-states would have rebuilt the conflation the fold exists to remove and made the two-dimensional truth (decided AND rejected) inexpressible. Two guards were broken by the stale vocabulary in OPPOSITE directions, which is why neither announced itself: - AmendmentOrderService::UNDECIDED_STATES could only ever match `voting`, so assertAmendmentsDecided() had become close to a no-op and FAILED OPEN — a motion could be voted while its amendments were in flight. - DECIDED_STATES (`adopted|rejected`) could match nothing, so the ordering check FAILED CLOSED on every sibling. The two lists now partition the enum, asserted as such. - MotionAmendmentService::hasTextOverlap() filtered on two impossible states, so amendment conflict detection matched nothing at all. - RUNNING_MOTION_LIFECYCLES was a THIRD vocabulary ('under-discussion' appears nowhere else); the widget filtered the API on values no object can hold and was permanently empty. - Three tab components each carried their own copy of a lifecycle->badge map in the retired vocabulary. The map now lives in one place. Terminal completeness is enforced at the transition boundary, reusing DecisionTransitionGuard rather than a second copy: entering decided/enacted/archived without a valid outcome is refused, and decisionDate is stamped once and never restamped. This does not fail open — that shape is what #425 was repairing in the first place. DecisionLifecycleVocabularyTest reads the enum out of the SHIPPED register and holds the service constants against it. A hardcoded list in a test would just be a fourteenth place for the vocabulary to drift, and the reason the original defect was invisible is that every test used the same retired words as the code it tested. It carries an autoloader positive control: NC hijacks OCA\Decidesk\* to the DEPLOYED app once base.php loads, so the test asserts the classes it reflects resolve to this worktree. Can-fail proof: restoring `voting => [adopted, rejected]` turns the new test red naming 'adopted' in MOTION_TRANSITIONS on both the enum and the outcome-axis assertions; dropping `withdrawn` from DECIDED_STATES turns the partition assertion red. Both restored. Measured on PHP 8.3: phpunit 828 tests / 3006 assertions / 0 failures (was 823 with 5 errors + 5 failures against the stale fixtures); phpcs 0 errors / 97 warnings (baseline); phpstan 0. * refactor(motion): extract the lifecycle state machine out of MotionService Adding the ADR-005 outcome axis pushed MotionService past three PHPMD limits that were clean on the parent commit — class complexity 57 (max 50), coupling 15 (max 13), and applyOutcome() cyclomatic complexity 10. Measured both sides: `phpmd lib text phpmd.xml` is empty at HEAD~1 and reported those three at HEAD, so they were new debt, not inherited. The metric was reporting something real. A state machine, an authorization gate (the co-signer minimum) and a persistence path had accreted inside a class whose subject is "a motion" and which also owns co-signatures, budget-impact notes, amendment application and forwarding. They move to MotionLifecycleTransitioner whole, rather than being trimmed to fit under a threshold. MotionService::transitionLifecycle() stays as a thin delegate, so the seam is unchanged: every caller (MotionController, VotingRoundCloser, VotingRoundPreflight) and every existing test still addresses the state machine through MotionService. The two strict test containers now build the REAL transitioner rather than a double. Both files exist to test logic that now lives inside it — the co-signer gate, an unknown decisionType refused before the register is touched, a decision of another type answering Not Found — so a double would have them assert against a stand-in for their own subject. Both containers deliberately throw on unregistered ids; that discipline is kept and the new id is registered explicitly. Can-fail re-proven after the move: restoring `voting => [adopted, rejected]` in the new home turns DecisionLifecycleVocabularyTest red on both the enum and the outcome-axis assertions, naming 'adopted' in MOTION_TRANSITIONS. Restored. Measured: phpmd 0 (both rulesets), phpcs 0 errors / 98 warnings, phpstan 0, psalm 0, phpunit 828 tests / 3006 assertions / 0 failures. * fix(dashboard): the running-processes stage vocabulary existed nowhere but here The widget filtered the API on `[submitted, under-discussion, voting]`. That is a THIRD vocabulary — not the retired Motion states, not the ADR-005 Decision enum. 'under-discussion' appears in no schema, no service and no fixture; it was invented in the change that built the widget and copied into the canonical spec. Since the array is passed straight to `getMotions({ lifecycle: ... })`, the widget queried for values no stored object can hold and was permanently empty. The vitest suite could not catch it: its fixtures used the same invented words as the code, so the two halves agreed with each other. That is not a measurement. A stage-key assertion against the real enum is added, and it is the only assertion in the file that could have failed. openspec/specs/dashboard/spec.md carried the same three words in two scenarios and is corrected — it is the canonical spec, and leaving it would have made the fixed code contradict its own requirement. `stages()` gains the @SPEC tag gate-16 asked for, pointing at the canonical spec rather than the archived change that introduced the defect. Measured: gate-16 spec-coverage FAIL(1) -> PASS at gate package 566ac8c; the full-repo failing-gate set is now name-for-name identical to origin/development's (19 gates), with gate-25 down 27 -> 26. --------- Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…nore that eats source (#437) Six independent defects, each measured at gate package 651e5c5 with NODE_PATH set (require.resolve('ajv') → /home/rubenlinde/.../node_modules/ajv/dist/ajv.js; without it gates 22 and 53 fail for the wrong reason and prove nothing). **MotionIntegrations rendered nothing** (gate-53, 1 → 0). src/manifest.json page 15 names the component on /motions/:id/integrations but nothing registered it, so resolution fell through to a blank page. The component already existed; only the registration was missing. Its three siblings were registered all along. **Five dashboard rows were mouse-only** (gate-32, 5 → 0). Each row navigates on @click with no role, no tabindex and no key handler: not reachable by keyboard at all (WCAG 2.1.1). They now carry role="button", tabindex="0" and enter/space handlers invoking the same method, with .prevent so space activates instead of scrolling. Each also gains a :focus-visible style — a row that can be tabbed to but not seen is worse than one that cannot be reached (WCAG 2.4.7). **A proxy field labelled only by placeholder** (gate-40, 1 → 0). A placeholder is not an accessible name and vanishes on first keystroke, so the field lost its only description exactly while being filled in (WCAG 3.3.2, 4.1.2). Replaced with NcTextField, which carries a real <label for> — the idiom the rest of this repo already uses — rather than bolting aria-label onto a raw input. gate-40's fleet-average FP rate is ~58%, so this was adjudicated on its own evidence rather than dismissed: the finding is genuine. **Nine unscoped <th>** (gate-43, 2 → 0). All are column headers in a <thead><tr>, so scope="col" throughout — checked per table, not assumed. **An indefinite pulse with no escape** (gate-45, 1 → 0). The over-time clock pulses forever. Removing it under prefers-reduced-motion loses no information: "over time" is also carried by --color-error and by the visible "Over time" tag. Opacity is pinned to 1 so an interrupted animation cannot settle at 0.4 and read as disabled. **.gitignore silently swallowed real source.** `**/*Analysis*`, `**/*encoding*` and `**/update*Settings*` carried no negations. This repo already lost this argument once — `**/*references*` ate userPreferences.js because "Preferences" embeds "references". Four plausible names were created and their status read back with the only instruments that answer honestly (`git ls-files --others --exclude-standard` + `git status --porcelain --ignored`; `git check-ignore -q` is unusable, it exits 0 when a NEGATION matches): lib/Service/VotingAnalysisService.php !! IGNORED src/utils/updateUserSettings.js !! IGNORED lib/Service/encodingHelper.php !! IGNORED src/utils/dataAnalysisChart.vue !! IGNORED All four are now ?? untracked-and-visible, while "lib/PR adds stuff" stays !! ignored — the negations rescue source without un-ignoring the scratch files the globs exist for. Can-fail proof, one item reverted at a time, each gate re-run and required to name that exact item: gate-43 → "unscoped=1/6" on MemberCsvImportDialog.vue gate-45 → AgendaItemTimer.vue motion-without-reduced-motion-fallback gate-32 → RecentDecisionsWidget.vue:40 rule=missing[role=,tabindex=] gate-53 → "MotionIntegrations … registered in neither" gate-40 → VotingRoundPanel.vue rule=input-without-label All restored; all five back to PASS. Measured: full-repo failing gates 19 → 14. gate-60 icon-vocabulary went from SKIPPED(wiring) to PASS — it had never actually run here, because vue-material-design-icons was absent; installed, it passes on its own evidence. eslint on the touched files: 0 errors, 15 warnings (baseline on development: 0 errors, 20). vitest 283 passed. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…, and two dead seams (#438) Measured at gate package 365fa31 with NODE_PATH set (require.resolve('ajv') → .../node_modules/ajv/dist/ajv.js). Every run had a 0-byte .err and verdicts were read from stdout, never the exit byte. gate-3 stub-scan 1 -> 0 gate-5 route-auth 13 -> 0 gate-9 semantic-auth 1 -> 0 gate-18 notification-dialect WARNING -> PASS gate-49 controller-exception-translation 1 -> 0 gate-57 orphaned-write-capability 2 -> 0 full-repo failing gates 19 -> 14 **Thirteen admin endpoints declared nothing** (gate-5). Audit-log query, governance reports, multilingual reconciliation and regulator export all call requireAdmin() in the body but carried no auth attribute at all. Nextcloud treats "no attribute" as admin-required, so they worked — but "deliberately admin-only" and "someone forgot" look identical in the source, and AuditLogController's own class docblock had already noticed, warning that the absence "is silently bypassed in some test setups". They now carry #[AuthorizedAdminSetting(AdminSettings::class)], which is this repo's existing, live idiom for an admin-only REST endpoint (MemberImportController, SettingsController) — not a new invention. It also lets an admin DELEGATE these to a group rather than hardcoding "is a server admin". Body guards are kept as defence in depth. `#[NoCSRFRequired]` would also have satisfied gate-5. It was not used: it says nothing about admin and would have weakened CSRF protection to quiet a checker. **DecisionController::publish said the opposite of what it did** (gate-9). It carried #[NoAdminRequired] — "any authenticated user" — while the next statements refuse non-administrators and its own docblock said "Requires Nextcloud admin role". The attribute is what a reader, an auditor and the middleware all see first. **getStats 500'd on an unknown participant** (gate-49). OpenRegister's find() THROWS DoesNotExistException; it does not return null. The `!== null` branch was unreachable for the case it was written for, and the exception escaped as a 500 on an ordinary "no such participant" request — the same defect class as ParticipantResolver, fixed in #425. An absent participant now answers 403, deliberately fail-CLOSED: a 404 would let any authenticated user enumerate participant UUIDs. The service call translates DoesNotExist to 404 and InvalidArgument to 400. The catches are narrowed, and a third test asserts a RuntimeException still PROPAGATES — narrowing is only a fix if the narrowing is real; a blanket catch would turn an OpenRegister outage into a tidy 403 and hide it from monitoring. **Nobody was ever told a decision went public** (gate-57 + gate-18). DecisionNotificationService::notifyOnPublish had zero callers, zero tests and no DI registration, and `isPublished` was the one Decision event with NO declarative rule — so the notification simply never happened. ADR-031 keeps notifications declarative and gate-18 flagged the class by name, so the rule lands in the register as `decisionPublished` (mirroring its six siblings) and the dead imperative class is removed rather than wired up. **createPreference was a second name for one write** (gate-57). A self-described "(alias)" delegating to updatePreference, which is itself an upsert. No callers in lib/, src/ or tests/ — nothing wrote through it, so nothing can be orphaned by its removal. **A retired job that could never retire** (gate-3). OverdueActionItemsJob was emptied to a no-op with a comment saying it was kept "so the registered oc_jobs row reaps cleanly" — while still declared in appinfo/info.xml, so Nextcloud re-registered it on every app update and cron ran an empty job every 24 hours, forever. Both halves are removed. No repair step is needed: read from the running server's own lib/private/BackgroundJob/JobList.php::buildJob(), an unresolvable job class is logged and then dropped via removeById() — "Remove job from disabled app or old version of an app". Measured, not assumed; a repair step was written first and deleted once the source said it was redundant. Can-fail proof — one item reverted at a time, each gate naming it: gate-5 AuditLogController.php:126 method=verify rule=missing-auth-attribute gate-9 DecisionController.php:225 method=publish rule=no-admin-required-annotation-with-admin-body gate-57 NotificationPreferenceService.php:194 method=createPreference gate-49 removing the catch makes the new test error with the escaping DoesNotExistException — the live 500, reproduced All restored; all back to PASS. Measured: phpunit 826 tests / 3009 assertions / 0 failures (823 + 3 new, minus the 5 belonging to the deleted job); phpcs 0 errors / 98 warnings; phpstan 0; psalm 0; phpmd 0 on both rulesets. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Measured at gate package 365fa31, NODE_PATH set, 0-byte .err, 0 gates SKIPPED(wiring): gate-46 spec-anchor-existence 18 -> 4, and the last 4 live in lib/BackgroundJob/OverdueActionItemsJob.php, which #438 deletes — so the pair takes this gate to 0. Three distinct causes, none of them "the tag was missing": **A trailing full stop swallowed into the path — 6 tags.** `@spec openspec/specs/decision-management/spec.md.` resolves to a filename ending in a dot. All three targets exist; only the sentence punctuation was wrong. This is the cheapest possible defect and it had made three real, current, canonical specs unreachable. **Tags pointing INTO archived changes — 8 tags.** `@spec` targets the canonical `openspec/specs/`, never a change directory: a change is archived once it lands, and the tag then dangles. Each was retargeted to the canonical spec that survived its change, verified to exist on disk: publish-decisions-via-opencatalogi -> specs/public-publication meeting-transcription-ai-minutes -> specs/meeting-transcription citizen-participation -> specs/citizen-participation authorizedadminsetting-fix-fleet x2 -> specs/admin-settings board-meeting-resolutions -> specs/decision-management retrofit-2026-05-26-preferences-api -> specs/user-settings (x2) pluggable-integration-registry -> specs/nextcloud-integration **A path that was never valid and a placeholder anchor — 4 tags.** MeetingService carried `openspec/changes/spec/tasks.md#task-1` three times — a directory literally named "spec" that has never existed, so this tag could not have resolved on any commit. Retargeted to specs/meeting-management. ActionItemController and actionItemApi.js carried `#task-2.x`, an anchor with a literal "x" placeholder in it; both now point at specs/action-item-board-via-deck-leaf, whose requirements they implement. No spec file was created to make an anchor resolve. Every target was checked to exist before the tag was pointed at it, and where a canonical spec genuinely did not exist the tag was moved to the one that describes the code rather than to an invented file. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…am that was meant to prevent it never fired (#441) OpenRegister's ObjectService::find() THROWS DoesNotExistException for an unknown id — it does not return null. Three places were written against the opposite contract, each with an `if ($entity === null) return 404;` branch that could not run for the case it was written for: MinutesCorrectionController::addCorrection -> 500 "Internal server error" MinutesCorrectionController::resolveCorrection -> 500 "Internal server error" TranscriptionController::retentionConfig -> 500 + stack trace (uncaught) The first two were not even visibly broken: DoesNotExistException extends \Exception, so the method's own `catch (Exception)` swallowed it and returned a tidy server error. The third had no try/catch at all and the exception escaped the controller. **The seam that should have caught all of this had the same defect.** TranscriptRepository is where the transcription surface converts "absent object" into the app's own MissingObjectException — the exception every TranscriptionController action already renders as 404. Both of its fetchers were written as `if ($entity === null) throw …`, so the conversion NEVER happened and every `catch (MissingObjectException) -> 404` on that surface was unreachable for the case it exists to serve. fetchMeeting() and fetchTranscript() now share one fetchObject() that actually converts. Only DoesNotExistException is converted, and the null branch is kept alongside the catch: find() is typed `?ObjectEntity`, so null stays reachable in principle, and both answers mean the same thing to the caller. Any other failure still propagates — turning an OpenRegister outage into "not found" would tell the caller, and monitoring, that data is absent when the data layer is down. retentionConfig's read-modify-write moved into the repository. Two reasons, the second being the one that mattered: a controller doing plain object CRUD is what ADR-022 exists to stop, AND the controller's private copy of the lookup is precisely where the defect lived. TranscriptionController no longer depends on ObjectService at all (coupling 13 -> 11, back under the PHPMD threshold it had crossed). Can-fail proof — all three reverted at once, and each reproduced its own live failure mode: the two Minutes tests failed "500 is not identical to 404", and the Transcription test ERRORED with the escaping exception, which is the stack-trace 500 exactly. Restored; all four green. The assertions name the HTTP STATUS the caller is owed, not "a JSONResponse came back" — a container-level assertion passes just as happily on the 500, which is how this survived. A fourth test pins that a non-DoesNotExist failure is STILL a 500, so the narrow catch cannot quietly widen into a catch-all. Measured: phpunit 832 tests / 3010 assertions / 0 failures; phpcs 0 errors / 98 warnings; phpstan 0; psalm 0; phpmd 0 on both rulesets. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…gs menu (#439) * fix(e2e,manifest): a wait that never settles, and a Settings > Settings menu Measured at gate package 365fa31, NODE_PATH set, 0-byte .err, 0 gates SKIPPED(wiring). gate-58 e2e-networkidle 1 -> 0 gate-63 settings-surface 2 -> 1 **A wait that could only ever time out** (gate-58). The docs-screenshot helper navigated and then called `waitForLoadState('networkidle').catch(() => {})`. networkidle never settles on Nextcloud — long-polling endpoints and the notification stream keep a request open indefinitely (ADR-074 rule 4) — and the line's own comment admitted it: "idle never fires on some pages". So every navigation burned the full timeout and then swallowed the failure. It bought a delay, not a guarantee, and the screenshot's actual precondition — content painted — was never asserted, only waited out. Replaced with the settle this repo already uses and trusts: `waitUntil: 'domcontentloaded'` on the goto, then the existing `waitForContentReady()` from tests/e2e/visual/_visual-helpers, which asserts the header and content root are visible and polls spinners and "Loading …" placeholders away. A positive signal about the page rather than the absence of a signal about the network. Reused rather than reinvented so the two capture suites cannot drift. **The gear foldout rendered Settings > Settings** (gate-63 D4). The `section: "settings"` entry was labelled "Settings" while the foldout button is already called Settings (ADR-079 D4). Renamed to "App configuration", which names the surface rather than repeating its parent. Can-fail proof: restoring the networkidle line turns gate-58 red naming `tests/e2e/docs-screenshots.spec.ts:112`; restoring the label turns gate-63 back to 2 findings, the second naming the D4 collision verbatim. Both restored. ADR-079 D1 is deliberately NOT closed here — see the PR body. Closing it means migrating the register-mapping admin UI into the NC settings framework, which is real work, not a rename. * revert(manifest): defer the ADR-079 D4 rename until D1 can land with it The D4 rename ("Settings" -> "App configuration" on the settings-foldout entry) is correct and its can-fail proof held. It cannot land ALONE. CI runs the gates DIFF-SCOPED. On development, src/manifest.json is untouched, so gate-63 never enters scope and the job passes. This PR touched src/manifest.json, which pulled gate-63 into scope — and with it the ADR-079 D1 finding that this PR deliberately did NOT close. The result was a Hydra Gates failure on a job that passes on development: a NEW failing job name, which is exactly what the merge rule forbids, caused by a change that is itself an improvement. D1 is not a rename. The in-app /settings page carries instance administration (a version widget and a substantial register-mapping widget configuring OpenRegister schema mappings for every Decidesk object type), and lib/Settings/AdminSettings.php does NOT render register mapping — it provides version, publicationConfig and transcript-retention defaults only. Deleting the in-app page today would drop that admin UI outright, and renaming it would dodge D1 rather than satisfy it, because register mapping is not the "operational domain data" the D1 exception covers. So D4 travels with D1, in a PR that ports the register-mapping surface into the NC settings framework and verifies it in a browser. This PR keeps the gate-58 fix, which touches no manifest and is independently correct. --------- Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…8 assertions) (#420) * fix(newman): two Meeting seeds violated the shipped Meeting schema Both collections open by seeding a Meeting through OpenRegister's object API. Both payloads were rejected with 400, the captured id interpolated to `undefined`, and every downstream request then missed its route and fell through to the SPA catch-all (a GET-only route), which answers 405. That is why 18 assertions failed on two one-line fixture bugs. decidesk-meeting-agenda: `meetingMode: "physical"` is not a member of the Meeting.meetingMode enum `["in-person", "digital", "hybrid"]`. decidesk-minutes: the seed omitted `meetingMode` entirely. It is listed in Meeting.required and, unlike Decision.decisionType (default "meeting-outcome"), carries no default — so nothing filled it in. The neighbouring decidesk-lifecycle seed is likewise missing a required property and still returns 201 precisely because that property has a default; required alone does not predict a 400. Verified against lib/Settings/decidesk_register.json. No assertion was relaxed, skipped or removed. * fix(newman): retire the board-portal collection, and three seeds that pointed at deleted schemas Four independent defects, each verified against the shipped register and appinfo/routes.php rather than against the test's own expectations. **board-portal drove 40 requests at routes that do not exist.** Commit 2bb71af ("C3 retire-board-portal") deleted the board-CRUD controllers outright: `boards`, `resolutions`, `board-member` and `board-meeting` appear ZERO times in appinfo/routes.php. Every write 404'd to an HTML error page, `pm.response.json()` threw, and the file cascaded — 24 of the repo's 76 Newman failures, the single largest block. That commit enumerates DELETIONS and RETARGETINGS separately, so the collection was adjudicated per request rather than deleted wholesale: 32 requests exercised the DELETED parallel CRUD (board = governance-body, board-meeting = meeting, resolution = decision, board-member = Person+Membership). They assert on endpoints the app deliberately no longer has; the equivalent behaviour is covered against the universal entities by decidesk.postman_collection.json and its siblings. 8 requests exercised RETARGETED, still-live routes — regulator export and multilingual reconciliation, both present in routes.php. All 8 are PRESERVED verbatim in the new decidesk-governance-reporting collection: same methods, same bodies, same assertions. The only change is that `boardId` is now seeded from a real governance-body instead of a retired board. RegulatorExportController still names the parameter `boardId`, so the wire contract is unchanged. Two of those preserved assertions were wrong about the app and are corrected against the controllers: status() returns {summary, results}, not the `queue` property the old test demanded; process() returns 200 or 422 depending on whether there was anything to process, and the response SHAPE is now asserted either way so a 422 must still be a well-formed answer rather than an HTML page. **Three voting-rules seeds POSTed to a schema ADR-005 deleted.** They targeted /objects/decidesk/motion — `motion` is absent from the register (only `decision` survives) — so all three 404'd and cascaded into 22 failures. Fixed to `decision`; the bodies already carried `decisionType: motion` from #425.⚠️ The reason this was not caught earlier: the `raw` URL had ALREADY been corrected to `.../decidesk/decision` while `path[]` still said `motion`. Newman resolves from path[], so the fix looked done in review and every request still went to the deleted schema. A repo-wide scan now confirms 0 raw/path[] mismatches across all collections. **Two decision seeds omitted the required `decisionType`.** Found by validating every object seed against the shipped register rather than by reading failures: 34 seeds checked, and these two were rejected before any assertion could run. The decidesk seed also set outcome/decisionDate with no lifecycle, so it now states `lifecycle: decided` explicitly — terminal fields without a terminal state is the shape #436 and #425 spent two PRs separating. **Left red deliberately — 2 board-proxy seeds.** They target the deleted `board-proxy` schema, so they 404. There is no honest replacement: ProxyVoteService::SCHEMA is `vote`, but the `vote` schema declares NONE of the fields the proxy row uses (meetingKoppeling, grantorKoppeling, holderKoppeling, scope, proxyStatus, registeredAt) and REQUIRES two the row never sets (value, castAt) — while proxyAuthorization's own description says it is "a sibling … never a replacement". Retargeting to either would be a guess the register contradicts, so the seeds are left as they are and the underlying inconsistency is reported rather than papered over. **Left red deliberately — 7 authz assertions** (4 admin-settings, 3 process-config) expecting 403 for a non-admin and receiving 200. I could not establish the cause and will not change an assertion over a live authz surface on a guess. Read from the running server's own lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php: `AuthorizedAdminSetting` is strictly enforced (admin, else delegated group, else NotAdminException), and an attribute-less method is refused too — so the endpoints ARE protected and the routes/attributes are wired correctly. A cookie-jar hypothesis (admin session outranking basic auth) was tested directly against a live instance and DISPROVEN: bogus basic credentials returned 401 even when replaying an admin session cookie. Changing these to expect 200 would convert a false red into a false green over real authorization. --------- Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… updates (#369) * build(deps): bump the npm_and_yarn group across 2 directories with 16 updates Bumps the npm_and_yarn group with 2 updates in the / directory: [@cyclonedx/cyclonedx-npm](https://github.com/CycloneDX/cyclonedx-node-npm) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). Bumps the npm_and_yarn group with 11 updates in the /docs directory: | Package | From | To | | --- | --- | --- | | [dompurify](https://github.com/cure53/DOMPurify) | `3.3.1` | `3.4.12` | | [fast-uri](https://github.com/fastify/fast-uri) | `3.1.0` | `3.1.5` | | [follow-redirects](https://github.com/follow-redirects/follow-redirects) | `1.15.11` | `1.16.0` | | [postcss](https://github.com/postcss/postcss) | `8.5.6` | `8.5.25` | | [qs](https://github.com/ljharb/qs) | `6.14.2` | `6.15.3` | | [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) | `7.29.0` | `7.29.8` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.12.3` | `11.16.0` | | [shell-quote](https://github.com/ljharb/shell-quote) | `1.8.3` | `1.10.0` | | [svgo](https://github.com/svg/svgo) | `3.3.2` | `3.3.4` | | [ws](https://github.com/websockets/ws) | `7.5.10` | `7.5.13` | | [websocket-driver](https://github.com/faye/websocket-driver-node) | `0.7.4` | `0.7.5` | Updates `@cyclonedx/cyclonedx-npm` from 4.2.1 to 5.0.0 - [Release notes](https://github.com/CycloneDX/cyclonedx-node-npm/releases) - [Changelog](https://github.com/CycloneDX/cyclonedx-node-npm/blob/main/HISTORY.md) - [Commits](CycloneDX/cyclonedx-node-npm@v4.2.1...v5.0.0) Updates `vitest` from 1.6.1 to 3.2.6 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.6/packages/vitest) Updates `esbuild` from 0.21.5 to 0.28.1 - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md) - [Commits](evanw/esbuild@v0.21.5...v0.28.1) Updates `vite` from 5.4.21 to 7.3.6 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.6/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.6/packages/vite) Updates `dompurify` from 3.3.1 to 3.4.12 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](cure53/DOMPurify@3.3.1...3.4.12) Updates `fast-uri` from 3.1.0 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](fastify/fast-uri@v3.1.0...v3.1.5) Updates `follow-redirects` from 1.15.11 to 1.16.0 - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](follow-redirects/follow-redirects@v1.15.11...v1.16.0) Updates `postcss` from 8.5.6 to 8.5.25 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](postcss/postcss@8.5.6...8.5.25) Updates `qs` from 6.14.2 to 6.15.3 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](ljharb/qs@v6.14.2...v6.15.3) Updates `@babel/plugin-transform-modules-systemjs` from 7.29.0 to 7.29.8 - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.29.8/packages/babel-plugin-transform-modules-systemjs) Updates `body-parser` from 1.20.4 to 1.20.6 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](expressjs/body-parser@1.20.4...1.20.6) Updates `dompurify` from 3.3.1 to 3.4.12 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](cure53/DOMPurify@3.3.1...3.4.12) Updates `fast-uri` from 3.1.0 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](fastify/fast-uri@v3.1.0...v3.1.5) Updates `follow-redirects` from 1.15.11 to 1.16.0 - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](follow-redirects/follow-redirects@v1.15.11...v1.16.0) Updates `mermaid` from 11.12.3 to 11.16.0 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.3...mermaid@11.16.0) Updates `postcss` from 8.5.6 to 8.5.25 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](postcss/postcss@8.5.6...8.5.25) Updates `qs` from 6.14.2 to 6.15.3 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](ljharb/qs@v6.14.2...v6.15.3) Updates `shell-quote` from 1.8.3 to 1.10.0 - [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md) - [Commits](ljharb/shell-quote@v1.8.3...v1.10.0) Updates `svgo` from 3.3.2 to 3.3.4 - [Commits](https://github.com/svg/svgo/commits) Updates `ws` from 7.5.10 to 7.5.13 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](websockets/ws@7.5.10...7.5.13) Updates `websocket-driver` from 0.7.4 to 0.7.5 - [Changelog](https://github.com/faye/websocket-driver-node/blob/main/CHANGELOG.md) - [Commits](faye/websocket-driver-node@0.7.4...0.7.5) --- updated-dependencies: - dependency-name: "@cyclonedx/cyclonedx-npm" dependency-version: 5.0.0 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: vitest dependency-version: 3.2.6 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: esbuild dependency-version: 0.28.1 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: vite dependency-version: 7.3.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: dompurify dependency-version: 3.4.12 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: postcss dependency-version: 8.5.25 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: qs dependency-version: 6.15.3 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: "@babel/plugin-transform-modules-systemjs" dependency-version: 7.29.8 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: body-parser dependency-version: 1.20.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: dompurify dependency-version: 3.4.12 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: mermaid dependency-version: 11.16.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: postcss dependency-version: 8.5.25 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: qs dependency-version: 6.15.3 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: shell-quote dependency-version: 1.10.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: svgo dependency-version: 3.3.4 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: ws dependency-version: 7.5.13 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: websocket-driver dependency-version: 0.7.5 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] <support@github.com> * build(deps): exercise the vitest 1 -> 3 bump and add the coverage peer it needs Dependabot's bump crosses two majors on vitest (^1.6.1 -> ^3.2.6) and one on @cyclonedx/cyclonedx-npm (^4.2.1 -> ^5.0.0). A bump no build exercises is not verified, so this branch was updated onto development and every consumer of those tools was actually run: npm ci 1178 packages, exit 0 npm run build webpack 5.109.2, exit 0 (3 pre-existing warnings) npm run test:unit 18 files, 283/283 passed on vitest 3.2.6 npx eslint src 0 errors, 194 warnings npm run test:coverage see below coverage ratchet 11.67% vs baseline 1.62% — passes Versions are read back from node_modules on disk, not from the lockfile: npm 11 prunes lockfile peers, so a lockfile entry is not evidence that a package is installed. **Added @vitest/coverage-v8.** `npm run test:coverage` and therefore `test:coverage-ratchet` could not run at all — "Cannot find dependency '@vitest/coverage-v8'". MEASURED on both sides: this is NOT a regression from the bump, it fails identically on development with vitest 1.6.1, where the package is equally undeclared. It is added here because this change moves vitest across two majors, and shipping that without the coverage plugin the ratchet depends on leaves a CI gate resting on a command that cannot execute locally. With it, coverage runs and the ratchet reports a real number. It is pinned EXACT to 3.2.6 rather than ^3.2.6 deliberately: the caret resolved to 3.2.7, whose peer requirement is `vitest 3.2.7` exactly, which made the lockfile's vitest 3.2.6 report `invalid` in `npm ls`. The exact pin keeps the two in lockstep. **cyclonedx v5 was checked and is not newly broken.** `npx cyclonedx-npm` exits with "npm-ls exited with errors: 1", because `npm ls` itself exits 1 on this tree. That is pre-existing: development reports 9 tree problems (missing babel-loader, ts-loader, webpack-dev-server, eslint-import-resolver-typescript peers — the npm 11 pruning — plus several `invalid` entries) and this branch reports 8, i.e. one FEWER after the coverage-v8 pin. The bump neither introduces nor worsens it. @conduction/nextcloud-vue is untouched by this bump and stays pinned to the exact 2.1.0-vue3.16 on the vue3 line. Checked its dist-tags first: `latest` is 1.0.0-beta.3, an OLD VUE 2 build, so a bare `npm i` would install Vue 2 — the exact pin is what keeps this repo off that path. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* chore(deps): @conduction/nextcloud-vue 2.2.0-vue3.3 -> 2.2.0-vue3.6 Moves decidesk to the head of the `vue3` dist-tag. Verified rather than assumed, and the lockfile diff was separated from npm's own churn with a control run first. **Control run — the diff is entirely the bump.** Regenerating the lockfile with package.json COMPLETELY UNCHANGED produced ZERO changed lines here. openregister (#2409) saw 53 packages flip dev -> production on that same control, but decidesk's lockfile was already rewritten by npm 11 during #369, so there was no npm 10 -> 11 rewrite left to absorb. With that established, the post-bump diff is 8 lines — version, resolved and integrity for this one package, in both places — and npm reported "changed 1 package". No collateral, no dev/optional flips. **Positive controls (the check can fail).** npm view @conduction/nextcloud-vue@2.2.0-vue3.6 -> 2.2.0-vue3.6 npm view @conduction/nextcloud-vue@2.2.0-vue3.3 -> 2.2.0-vue3.3 npm view @conduction/nextcloud-vue@3.0.0-vue3.4 -> E404 npm view @conduction/nextcloud-vue@3.0.0-vue3.1 -> E404 The whole 3.x line is depublished. That is worth recording because it is the mechanism behind the stale-checkout report that prompted this work: a tree pinning 3.0.0-vue3.4 cannot fetch it, so whatever was already in node_modules survives — in that checkout, 1.0.0-beta.220, a Vue 2 build. `origin/development` was never affected; it pinned 2.2.0-vue3.3 and had 2.2.0-vue3.3 installed. **Read off disk after a clean npm ci, not out of the lockfile:** version 2.2.0-vue3.6 copies in tree 1 vue peer ^3.5.0 (installed vue 3.5.40) 3.x / beta none anywhere under node_modules/@conduction **Exercised.** `npm run build` exits 0 — webpack 5.109.2 compiled with 3 warnings, the same count as before the bump. All 26 symbols decidesk imports from the package are among the 499 it exports, so no import breaks. vitest is 283/283 before and after.⚠️ Those 283 tests are NOT evidence for this bump: zero of the 18 vitest suites import @conduction/nextcloud-vue. What genuinely exercises it is the webpack build (50 source files import it) and the e2e job, which renders the real components in a browser. Recorded so the passing unit count is not mistaken for UI verification. **gate-55 does not move: 28 -> 28, findings byte-identical.** Checked because CnWidgetGrid/widgetIcons.js — the registry those findings are measured against — ships inside the package being bumped, and three patches could have moved it. It did not: DASHBOARD_ICONS is 56 keys in both 2.2.0-vue3.3 and 2.2.0-vue3.6, DEFAULT_ICON is still ViewDashboard, and every flagged icon is still absent. Run the way the runner invokes it (`<logfile> <files...>`, 24 manifests in scope, verdict read from the LOG — the helper exits 0 either way). The first build attempt was SIGKILL'd (137). That was not the bump: dmesg recorded `Out of memory: Killed process (webpack)` at 9.9 GB RSS while two unrelated webpack processes held 10.3 GB. Rebuilt once memory freed; exit 0. * chore(deps): advance the pin to 2.2.0-vue3.7 2.2.0-vue3.7 is the new vue3 dist-tag head and supersedes vue3.6. Lockfile control (pin unchanged) is a 0-line diff, so the +5/-5 here is entirely the version move. unit 283 passed / 18 files -> 283 passed / 18 files test:l10n exit 0, output identical; check:manifest exit 0 gate-55 detail-page-discipline 28 -> 28, findings byte-identical build 3 warnings -> 3 warnings bundle 151,328,450 -> 151,403,299 bytes * chore(deps): advance the pin to 2.2.0-vue3.9 The vue3 dist-tag moved 3.7 -> 3.8 -> 3.9 while this was in flight. unit 283 passed / 18 files (unchanged) test:l10n exit 0; check:manifest exit 0 gate-55 detail-page-discipline 28 at vue3.6, 28 at vue3.9, byte-identical build 3 warnings -> 3 warnings bundle 151,328,450 (vue3.6) -> 151,449,798 (vue3.9) --------- Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…cts (#447) * fix(newman): decidesk#443 was a cookie jar, and it hid five real defects All 13 Newman collections now run 310 assertions with 0 failures, measured on a disposable NC 32.0.12 + openregister instance that first reproduced CI's 22 failures exactly (same collections, same counts). decidesk#443 — NOT an authorization hole The four "non-admin gets 200 on an AuthorizedAdminSetting endpoint" assertions were true observations of a false premise. Newman keeps ONE cookie jar per host; Nextcloud resolves a logged-in session from that jar BEFORE it reads the Basic auth header, so a request carrying both runs as whoever authenticated last. Measured, not inferred: an inserted GET /ocs/v2.php/cloud/user on the same request chain answered id="admin", groups=["admin"]. Clearing the jar first turns the same request into 403 and the identity read into "decidesk-admset-nonadmin". The attributes were always enforced. Fixed by a collection-level jar clear in the four collections that assert on WHO is calling, plus an "Identity guard" request in each that asserts the probe really is the non-admin. Removing the clear turns the guard red first, so this cannot silently come back. The same jar also disarmed the IDOR guard decidesk-security-flow-e2e exists to prove: "register on behalf of an unrelated grantor" and "revoke by an unrelated participant" were running as the GRANTOR. They now genuinely 403. Five product defects the false green was sitting on 1. board-proxy schema never existed. ProxyVoteService wrote proxy rows into the `vote` schema, whose required value/castAt are not part of a delegation, so EVERY POST /api/proxies returned 422 "Failed to register proxy." Added the BoardProxy schema the service and both collections were already written against. 2. governance-report schema never existed. persistReport() swallowed "Schema not found", so generate() returned 201 with no id and /api/governance-reports/{id}/export could never be addressed. Added GovernanceReport. 3. The per-holder proxy cap never counted anything. ObjectService reads its register/schema context from filters.register / filters.schema; a TOP-LEVEL 'register'/'schema' key is silently ignored and findAll() returns [] without throwing. forMeeting() used the top-level shape, so a third proxy was accepted at a cap of 2. Live: now 422 with the maximum-reached message, and a different holder still registers. 4. Anonymous ORI show returned 500, not 404. OpenRegister's published-predicate RBAC hides a future-dated payload by making find() THROW, so the not-live 404 branch was unreachable and the blanket Throwable catch produced a 500 — itself a disclosure, since it separates "exists but hidden" from "unknown". 5. Meeting close could never complete. openedAt/closedAt/meetingCost were declared readOnly, which OpenRegister enforces as immutable-after-create for every writer including MeetingService, so opened -> closed failed with "Cannot modify readOnly property: closedAt" (422). A meeting is created before it is opened, so a readOnly stamp can never be written. Seed data: 8 objects were silently skipped on every install Five seed slugs exceeded 36 characters and were referenced by other objects; the relation column is varchar(36), so the write truncated and ImportHandler skipped the object. Shortened the five slugs and their references (and the openspec design/tasks tables that quote them). Locally: 8 skipped -> 0. Test-side changes - decidesk-motion-amendment now seeds a real governance body + chair participant + meeting. VotingController::open() authorizes before it validates (correct order), so the placeholder meetingId 'meeting-x' meant the subjectType contract was never reached — the test only ever saw the 403. - ProxyVoteServiceTest's ObjectService mock treated every filter key as a row field, so a caller using the broken top-level shape still got rows: the mock could not tell a working call from one that returns [] in production. It now models the real contract and returns [] without register/schema context. Reverting forMeeting() to the old shape turns 3 tests red. - RegisterJsonTest schema-count ratchet 37 -> 39 with the reason. Both directions proven per fix, one mutation at a time: swapping AuthorizedAdminSetting for NoAdminRequired on MemberImportController::groups and on ProcessTemplateController::index each turned exactly one assertion red; removing the security-flow jar clear turned the identity guard red; reverting forMeeting() turned the unit suite red. (Note: opcache.revalidate_freq=60 made one mutation run look green — the probe instance now runs revalidate_freq=0.) Left red and not touched here: 17 Playwright specs (95 pass) and the systemic findAll config shape — ~50 call sites across lib/ still pass register/schema at the top level and therefore read nothing. Filed separately; only the call site covered by a red assertion is fixed here. * fix(gates): close gate-54 and the coverage ratchet this branch opened Two jobs went red on #447 that the first commit caused, both real: quality / PHPUnit — coverage ratchet The new `catch (DoesNotExistException)` branch in OriController::show() added 4 statements with no test, so coverage fell against the merge base (8866/15168 -> 8868/15172). Added the missing unit test: find() throwing DoesNotExistException must produce 404, not 500. It is the case the existing testShowFutureDatedPayloadIs404 never covered — that one hands the controller a row and exercises the not-live branch, while on a real instance the anonymous caller never gets the row at all. Swapping the catch to \LogicException turns the new test red with "500 is identical to 404". quality / Hydra Gates — gate-54 relation-dialect, 4 findings Renaming the over-36-character seed slugs pulled two register fragments into the gate's diff scope and exposed four properties that declare `format: uuid` + `$ref` at a schema that does not exist in this register: Fractie and SchriftelijkeVraag are owned by the unlanded fractievoorzitter-fractie-koppeling change, and ArchivalDossier by records-management-archiving. The pair is unsatisfiable while the target is absent — keeping the $ref is a dangling target, dropping it while keeping format:uuid is a relation-shaped property with no $ref. Until the owning change lands these are plain string placeholders (the descriptions already say they are seeded as nil-UUID placeholders), so they are now declared as plain strings, each carrying the instruction to restore format:uuid + $ref in the same commit that introduces the schema. No waiver, no baseline: gate-54 goes from 4 findings to 0 in the files this branch touches. (One finding remains repo-wide: ConsultationRequest.achterbanraadpleging in 47-works-council-consultation.json, a file this branch does not touch and which the gate's ADR-020 diff scoping therefore excludes.) Re-verified after the register edits: all 13 Newman collections, 310 assertions, 0 failures; unit suite 831 tests green. --------- Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…alendar (#450) E2E: development baseline 17 failed / 95 passed / 59 skipped -> this branch 12 failed / 101 passed / 59 skipped. Skip count identical, so nothing was skipped away; 5 previously-failing specs now pass and one new spec was added (tie-break split into default vs revote). PHPUnit 8.3+8.4, phpstan/psalm/phpcs/phpmd, Newman and Hydra Gates all pass. Remaining 12 E2E failures are pre-existing and tracked.
#451) Full-scope Hydra Gates on post-merge development confirms both findings are real: gate-51 FAIL 1 and gate-60 FAIL 1 (run 31459783989, gate package b8c7eade). Local full-scope run with the same package reproduces CI gate-for-gate and count-for-count, and shows both flipping to PASS with this change and no other gate moving. E2E on this PR: 12 failed / 101 passed / 59 skipped — byte-identical to post-merge development, and the failing spec set is the same 12 specs. No regression. All 27 jobs reported; the only failures are E2E (identical to base) and Quality Report (pure aggregator).
…ic dashboard glyph (#452) gate-55 (detail-page-discipline) FAIL 28 was one idiom repeated 28 times: detail-page widget `icon` fields were written as vue-material-design-icons FILE names (`GiftOutline`, `OfficeBuildingOutline`, `CalendarClockOutline`) rather than keys of the shared widget registry. CnWidgetIcon resolves a widget icon as `DASHBOARD_ICONS[name] || DASHBOARD_ICONS['ViewDashboard']` — the app's own registerIcons() registry is NOT consulted on that path — so all 28 widgets were rendering the generic dashboard glyph, with no visible breakage to notice. The repo's own convention already agrees: 124 of the 152 widget icons were registry keys. These 28 were drift. Each is mapped to the closest registry key (OfficeBuildingOutline → OfficeBuilding, FileDocumentOutline → FileDocument, CommentQuestionOutline → MessageTextOutline, CalendarClockOutline → Calendar, GiftOutline → Package, …). No page ends up with two widgets sharing an icon. Two of the chosen keys (Briefcase, Package) were not yet imported in src/icons.js, which gate-60 correctly caught on the next run — an unregistered name renders NO icon in the nav. Both added; both verified to exist in vue-material-design-icons. gate-46 (spec-anchor-existence) FAIL 1: RegisterFragmentMergeTest pointed at openspec/changes/modular-register-manifest-fragments/… which does not exist in this tree and never did (`ls` and `git log --diff-filter=D` both empty). The fragment deep-merge it exercises is specified canonically by openspec/specs/register-seed-data/spec.md (REQ-SEED-002, "Seeds declared in a register fragment reach the importer" — the merge in SettingsService::loadConfiguration()). Retargeted there, per the rule that @SPEC anchors point at openspec/specs/, never at a change dir. Measured with the gate package CI uses (ConductionNL/.github @ b8c7eade), full scope, against post-merge development: before 19=984 25=25 26=5 46=1 54=2 55=28 63=2 51 PASS, 6 skip after 19=984 25=25 26=5 54=2 63=2 52 PASS, 6 skip The local baseline reproduces CI run 31459783989 gate-for-gate and count-for-count. Skip set is unchanged (24/29/33/47/48/61), so no failure was converted into a skip. Both directions proven: reverting ONE of the 28 icons (geschenk-data back to GiftOutline) reproduces `[gate-55] FAIL — 1` on the same runner. gate-54 and gate-63 are deliberately untouched — both are filed human decisions, not defects.
…had a default order (#453) `crud-persistence` has been failing three ways on development — Meeting create, Decision create, Decision edit — and all three are the same defect, in the app rather than in the tests: create an object, return to the list, and it is not there. Measured against the live API, not inferred: GET /apps/openregister/api/objects/decidesk/meeting?_limit=20&_page=1 -> total=25 returned=20, oldest first (2026-05-13, 05-13, 05-16, ...) CnIndexPage's self-fetch mode asks for `_limit=20&_page=1` and sends no `_order`, so OpenRegister answers in insertion order. The 21st object a user creates is therefore invisible on the page they are returned to. The test was right; the list was wrong. `_order[@self.created]=desc` is the ordering that works, verified against the same endpoint — `asc` and `desc` return genuinely inverted windows: desc -> 2026-07-26T15:09:22, 07-26T14:41:31, 07-17T20:34:31, ... asc -> 2026-05-13T07:32:12, 05-13T07:32:14, 05-16T10:50:58, ... Note `_order[created]` (without the `@self.` prefix) is NOT that ordering: `asc` and `desc` returned byte-identical windows, neither of them sorted. It names a JSON data property that does not exist, and OpenRegister sorts by it silently. Only the `@self.`-nested metadata form works — the same nesting rule that applies to metadata filters. So all 45 `type:"index"` pages now declare `sortKey: "@self.created"` + `sortOrder: "desc"`. Declared in the manifest rather than injected at merge time in main.js, so the ordering is greppable, reviewable and visible to the manifest gates. CnPageRenderer spreads `config` onto the dispatched component and CnIndexPage declares both as typed props (verified in the exact published @conduction/nextcloud-vue@2.2.0-vue3.9 tarball CI installs, not in the working copy of the library): `sortKey` -> `useSelfFetchList`'s `defaultSort` -> `useListView.buildParams` -> `params._order = { '@self.created': 'desc' }`. A user's own column sort still wins: `useSelfFetchList` restores `$route.query._order` ahead of the declared default. Scope check, machine-verified rather than eyeballed: 45 of 45 index pages carry the default, 0 missing, and 0 leaked into a non-index page. `npm run check:manifest` reports `Ajv validation: PASS (0 errors)` against app-manifest-v2 schema 2.13.0 — real schema validation, not the structural fallback. The remaining upstream candidate is left deliberately unfiled here: newest-first is arguably the right DEFAULT for CnIndexPage self-fetch when a host declares no sort, which would fix this for every app instead of one. That is a nextcloud-vue change plus a publish, and it is not this repo's to make.
…e machine (#454) `development` tracks `node_modules` as a mode-120000 symlink to `/home/rubenlinde/nextcloud-docker-dev/workspace/server/apps-extra/decidesk/node_modules` — one developer's absolute path, on one machine. Anywhere else that is a dangling link sitting exactly where npm expects a directory. I put it there. It rode in on `git add -A` in #452 (`3511f0f6`, `A node_modules`), and it was the only unintended entry in that commit. WHY .GITIGNORE DID NOT CATCH IT `.gitignore` said `/node_modules/`. A pattern ending in `/` matches a DIRECTORY only. The thing added was a SYMLINK, so the rule did not apply to it and it went straight into the index. Proven both directions on this tree: with the old pattern: git check-ignore -v node_modules -> no match with the new pattern: git check-ignore -v node_modules -> .gitignore:19:/node_modules So the fix is not only to untrack the link but to close the hole: `/node_modules` and `/website/node_modules` lose their trailing slashes, which makes them match a directory AND a symlink. The comment above them says why, because the next person to "tidy up" that pattern will want to put the slash back. This is a foreseeable hazard rather than bad luck: pointing a git worktree at a sibling checkout's install is the normal way to avoid a second 534 MB `node_modules`, and it produces exactly this symlink in a tree where `git add -A` will take it. Untracking it does not delete anyone's local install — the link stays on disk and is now ignored. Scope: `.gitignore` plus removing the tracked entry. No source file is touched, so no gate and no test can move.
gate-25 (contract-coverage) FAIL 25 — twenty-five public endpoints reachable through appinfo/routes.php with nothing asserting their contract. Ten new PHPUnit controller tests close twenty-one of them. WHAT WAS ACTUALLY MISSING Not obscure corners. `transcription#transcribe`, `#realign`, `#attach`, `#sources`; the whole `motionCoauthor` surface (add, remove, updateText, history); `boardEvaluation#respond`; `liveMeeting#recordLiveDecision`; `integration#getOutcome` and `#subscribe`; `participation#submitAnonymousReaction`; plus the framework endpoints (`api#preflight`, `ori#preflight*`, `dashboard#page`/`#catchAll`, `health#statusOptions`, `actionItem#destroy`). The tests assert the CONTRACT, not that a method exists: status codes on the happy path, the shape of the JSON body, and the refusal paths — 422 without consent, 503 without a provider, 404 for an unknown id, and the authorisation denial for a non-staff caller. Roughly half the assertions are refusals, which is the half an endpoint written without tests usually gets wrong. NO WAIVERS Zero `@contract exclude` in this change. Every one of the twenty-one is closed by a test that runs. That includes the endpoints where an exclude would have been easy to argue — the CORS preflights get real tests asserting their headers and 204, because "it is only a preflight" is a claim about behaviour and behaviour is testable. THE TESTS RUN, WHICH IS THE ONLY THING THAT MAKES THEM COVERAGE ./vendor/bin/phpunit tests/Unit/Controller/<the ten new files> OK (72 tests, 176 assertions) <- 0 skipped Whole directory, to show nothing else moved: Tests: 250, Assertions: 662, Skipped: 1 The single skip is pre-existing and unrelated — `DecisionControllerTest::testPublishSucceedsReturns200`, already parked against decidesk#90. No test in this change is skipped, incomplete, or risky. MEASURED (gate package ConductionNL/.github @ b8c7eade, full scope, rebased onto 012f96b): before 19=984 25=25 26=5 54=2 63=2 53 PASS, skips 24/29/33/47/48/61 after 19=984 25=4 26=5 54=2 63=2 53 PASS, skips 24/29/33/47/48/61 Skip set byte-identical; no other gate moved. PASS is unchanged because gate-25 is still red — which is the honest outcome, not a rounding-up. WHAT REMAINS, DELIBERATELY UNCLOSED Four endpoints, named rather than waved at: participationBudget#castAdvisoryVote /api/participation/proposals/{id}/vote participationBudget#publishBudgetResults /api/participation/budgets/{id}/publish settings#getPublicationConfig /api/settings/publication-config settings#setPublicationConfig /api/settings/publication-config They are not excluded and not annotated. gate-25 stays red until they have real tests, which is the correct signal.
…he eleven E2E failures (#458) Closes gate-63 by DELETION, never a rename: gate-63 compares a settings page's id and title against a reserved word list, so a rename ships the duplicate while turning the gate green. Positive control, --full on the same tree: pristine development FAIL — 2 settings-placement violation(s), this branch PASS. Full-scope dispatch (run 31488483644) established the honest baseline: COVERAGE 58 of 64 declared, 58 of 58 applicable ran, 5 real failures — not the 25-gate push-scoped green CI advertised. Rehomes organisatie_modus onto the Nextcloud admin page. ADR-079 assumes the lib/Settings section already covers the deleted page's keys; measured, it covered two of three, and that key drives mode-specific labels app-wide. Backed by an e2e test that selects, saves, reloads and requires the value to survive. Retracts the premise I was given: DecisionIntegrations, AgendaItemIntegrations and MotionIntegrations are all LIVE — registered in registry.js, named as a manifest page component, targets of action handlers. Only MeetingIntegrations.vue is dead and only it is deleted. E2E 11 failures to 7, a strict subset with zero new: the two decision-management tab locators, motion-amendment's heading and field labels, and publication-workflow's textarea descendant. No timeouts widened, no tests skipped or deleted, no serial mode. Remaining 7 are pre-existing and filed rather than forced: nextcloud-vue#630 (kvk/opencorporates JS registration), decidesk#460 (the board-evaluation scoped-responder fix shipped and the symptom is byte-identical — hypothesis disproven), decidesk#459 (app-navigation spec still requires the deleted entry; reconciling it needs real @e2e work on 23 legacy scenarios, not blanket tags). Gate defect filed at source: .github#349.
…ects the contract test found (#465) gate-54 2 to 0, gate-25 4 to 0, gate-26 5 to 0, gate-19 984 to 983, all at full-tree scope against the empty-tree base. Three product defects found by writing the gate-25 contract test, all previously answering HTTP 200: unlimited duplicate advisory voting (now 409), a tally writing votesFor=0 over real votes, and publishBudgetResults publishing an empty proposals array. Root cause was a relation filter keyed on the schema slug, which matches nothing in what OpenRegister actually stores. Controls: committed gate plants for gate-54 and gate-25, each observed FAIL naming the plant, then reverted byte-identical. Newman on a live rig fails exactly the 6 of 35 assertions describing the three defects when only the two service files are reverted. Playwright mutation matrix, with exact: true, where each test fails when and only when its own target breaks. E2E is red with the same 7 failures as the merge base 461952a, byte-identical set, and 112 passed against its 108. Nothing skipped, no timeout widened, no exclusion added.
… and three locators (#469) E2E 7 failed -> 3 failed, 112 -> 116 passed, skipped UNCHANGED at 58. Same-tree comparison: `git merge-base origin/development HEAD` == `443a77ed` == development's tip, and `git log origin/development ^HEAD` is empty, so the 7-failure baseline (run 31521531981) was produced from this exact tree. 58 tests skip in this suite, so absence from the failure list proves nothing. All four fixed tests confirmed POSITIVELY by their own check lines and durations: 137 board-evaluation-workflow.spec.ts:84 (13.5s) 138 board-evaluation-workflow.spec.ts:133 (12.2s) 139 board-evaluation-workflow.spec.ts:168 (13.3s) 157 meeting-transcription-workflow.spec.ts:220 (7.8s) Two of those three product defects meant a whole capability was dead: no likert board-evaluation response could EVER be stored (every save rejected on an explicit null against a non-nullable typed property, returned as a 422 the UI rendered outside the card), and publishing persisted nothing while answering HTTP 200. The three remaining reds, none worsened: - integration-registry.spec.ts:342 — left red DELIBERATELY. OCS advertises `kvk` and `opencorporates`; the JS registry declares neither. Positive-controlled that @conduction/nextcloud-vue ships no leaf for either. Real cross-app drift, filed as nextcloud-vue#630. Its fix does not live in decidesk and weakening the assertion would delete a true finding. - crud-persistence.spec.ts:236 / :352 — the SAME two tests, and the strict-mode fix WORKED: the failure moved from the Close click in `deleteRowViaUi` (line 192) to the post-delete verification at line 310, i.e. the delete-and-close now succeeds and the test gets four steps further before exhausting its 20s budget on three full SPA boots. Not fixed, because every available remedy is a weakening: widening the budget is forbidden, and the three page loads each carry distinct coverage (create-appears-in-list, detail-renders-values, edit-surfaces-in-list-then-delete). Recorded rather than papered over. Hydra Gates is red and is NOT caused by this change: `[gate-24] integration-parity: SKIPPED (structural)` — `.github#370` has propagated, so gate-24 now recognises decidesk's direct `integrations.register(` form and moves from NOT APPLICABLE to a counted structural skip. `37 GATE(S) GREEN`, zero FAIL lines, `1 of 38 APPLICABLE gates DID NOT RUN`. This diff touches none of gate-24's inputs — no `scripts/`, no `src/integrations/`, no `lib/` LeafDescriptor — so it reproduces identically on development. Predicted by decidesk#466, which now also carries the measurement that copying the sibling checker as-is exits 1 on the same one-spelling blind spot. Everything else green: phpcs, phpmd, psalm, phpstan, PHPUnit on both cells (the coverage ratchet is satisfied — the new statements are covered), Newman, Frontend Build, Frontend Tests, CodeQL, Coverage Baseline Protection. Controls: three committed mutations, each reverted, each failing ONLY its own target — the `scoreSummary` array branch (`Failed asserting that null is identical to 4`, the production symptom exactly), `depublicatiedatum` restored to null, and `sanitiseAnswers` restored to writing nulls (2 failures naming both keys). Two green-for-the-wrong-reason tests corrected: one PINNED the defect by asserting `depublicatiedatum` was present AND null, and one used a fixture label (`E2E-Published`) that made its own assertion unfailable. Weakening checks all negative: no test.skip / test.fixme / .only / mode:'serial' / testIgnore / networkidle / force:true added, zero `@e2e exclude` added, and no removed line contains `timeout`.
…472) Two independent defects, and repairing either one alone leaves the listener dead. A four-arm control over one production-shaped entity, printed: A origin/development (method_exists) ......... '' no match B probe swapped to is_callable() ............. THREW no match C probe fixed to property_exists() only ...... '93' no match D this change ................................ 'meeting' match 1. The probe. ObjectEntity declares getSchema() only as an @method docblock tag; Entity::__call serves it, so method_exists() is false for it and the getter tier was skipped for every entity that actually has a schema. is_callable() is not the remedy: arm B shows it is true for any name and the call then raises BadFunctionCallException. Entity::__call routes get* to Entity::getter(), which decides on property_exists(), so that is the instrument used here, with the call additionally made exception-safe. 2. The value. MagicMapper and SaveObject stamp the schema's numeric database id onto every entity they materialise, so arm C reads '93' where the guard compares against the slug 'meeting'. The id is now resolved back to its slug through OpenRegister's SchemaMapper, memoised per request because the unfiltered-registration fallback invokes listeners on every object write. Why the suite was green over a listener that could not fire: the stub is faithful — tests/Stubs/Db/ObjectEntity.php honours the decidesk#399 parity contract and does not declare the magic accessors — but every fixture fed a _schemaSlug key that OpenRegister never emits. The parity contract constrains the double; it does not constrain the payload. The new tests build the entity the way MagicMapper does and assert through the real resolver. Both halves are proven able to fail. Removing the id-to-slug resolution fails exactly 5 tests; restoring method_exists as the only probe fails exactly 6. Both predictions were written before the reverts and matched. Behaviour change: meeting creates now get their Files folder tree, which they never have. The call is fail-soft and bounded (one folder tree per meeting create), and an unresolvable schema fails closed - no tree is created for an object that might not be a meeting. Not touched, same defect, filed separately: SubmissionDeadlineListener and GovernanceRoleProjectionListener resolve their slug the same dead way. Waking those two changes what the write path permits rather than what it creates, so they are sequenced behind their own verification. Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.