Skip to content

chore(docs): sync development into documentation - #864

Merged
rubenvdlinde merged 980 commits into
documentationfrom
development
Aug 24, 2026
Merged

chore(docs): sync development into documentation#864
rubenvdlinde merged 980 commits into
documentationfrom
development

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Routine development -> documentation promotion, the same flow this repo already uses for its docs deploys.

Why now

The docs site deploys from the documentation branch. It is 980 commits behind development, so recent docs changes are not reaching the published site.

The immediate trigger: the landing-page CTA fix (repository button pointed at codeberg.org; GitHub is the only host we publish to) merged to development and is not live. Verified against the running site before opening this.

What this ships

Everything on development that has not yet been promoted, which is more than the CTA fix. Treat it as a docs release, not a single change. Any commits made directly on documentation are preserved by the merge.

juanclaude-conduction and others added 30 commits August 16, 2026 22:17
…followed

All six PHPUnit cells and phpmd have failed on `development` since the ADR-083
(`5d20c2e8`, inject OpenRegister instead of looking it up) and ADR-084
(`46bc39a0`/`f3a1df80`, type-hint OpenRegister's published contract) refactors.
The production change was deliberate and phpcs/psalm/phpstan are green on it —
the unit suite is what drifted.

MEASURED, at 9168b2b: `Tests: 969, Errors: 177, Failures: 37` in CI. The suite
was not dying at class-load: 969 tests ran, and 214 of them died on the way in.

Four distinct breakages, all in tests/:

  1. `Error: Unknown named parameter $container` (72) — services that no longer
     take `ContainerInterface` were still being handed one.
  2. `TypeError: Argument #N ($objectService) ... null given` — a mechanical
     pass had inserted `objectService: $objectService` (and `$saved`, `$out`,
     `$items`, `$entity`, `$default`, `$object`) referring to variables that
     did not exist in scope, or existed only inside a closure.
  3. `ArgumentCountError: Too few arguments` — `objectService:` never passed at
     all (ReactionIntakeService, BudgetVotingService, MotionLifecycleTransitioner).
  4. The consequential assertion failures, and the most interesting of the four:
     the tests still parked their CONFIGURED OpenRegister double on a container
     mock and injected a FRESH UNCONFIGURED one. Production stopped asking the
     container, so every lookup answered "not found" — `Decision 'dec-1' not
     found`, `null is identical to 675.0`, `actual size 0 matches expected 2`.
     Those read as product defects and were not: the double was simply wired to
     a door nobody knocks on any more.

The fix throughout is to inject the double the test already configured, and to
express in-memory stores through `ObjectServiceInterface` doubles rather than
untyped anonymous classes. No assertion was weakened, retargeted or deleted to
produce a pass.

Two obsolete test cases removed, with the reason recorded in place of each:
`ProofPackageServiceTest::testAssembleThrowsRuntimeExceptionWhenOpenRegisterUnavailable`
and `MinutesGenerationServiceTest::testGenerateDraftThrowsRuntimeExceptionWhenOpenRegisterUnavailable`
both asserted `RuntimeException: 'OpenRegister ObjectService is not available'`.
ADR-083 deleted that failure mode — `grep -rn 'ObjectService is not available'
lib/` returns nothing, and MinutesGenerationService says so itself: "a property
read throws nothing, so the old catch was unreachable". With the contract a
REQUIRED constructor argument, OpenRegister's absence is a DI-construction
failure, not a call-time outcome, so no honest wiring can produce it.

One test was passing for the wrong reason and now earns it:
`SubmissionDeadlineListenerTest::testInfrastructureFailureFailsSoft` made a
container throw that the listener never consults, so it took the ordinary
not-found branch and never entered the `catch (\Throwable)` it exists to prove.
The injected `find()` now throws instead.

phpmd: the same refactor pushed seven `lib/Service/` classes to a coupling of
exactly 13. Measured, not assumed — the fleet ruleset over the PRE-ADR-083
revision of those same seven files (`git show 5d20c2e^:lib/Service/...`)
reports ZERO violations. Replacing one opaque `ContainerInterface` with two
named dependencies (`ObjectServiceInterface`, and `FileService` in two cases)
is +1 coupling per class in every OR-consuming app in the fleet, purely as a
consequence of the contract mandate. `CouplingBetweenObjects` is therefore
raised from phpmd's default `maximum` of 13 to 14, with the rationale written
into phpmd.xml. The rule fires on `$cbo >= $threshold`, so this permits exactly
13 and still refuses 14; positive control run at 13, which reproduces all seven.
Precedent for an app-level threshold with a written rationale: docudesk
(ShortVariable), launchpad (ExcessiveClassLength).

Local verification, full suite, PHP 8.3: `Tests: 967, Assertions: 3884,
Skipped: 37` — zero errors, zero failures. 967 rather than 969 is the two
removed cases. The 87 local `OCA\OpenRegister\Service\FileService does not
exist` errors are a workstation artefact — CI checks OpenRegister out as an
additional app, and that class produced no error in the baseline CI run — so
they were measured out with an out-of-repo `--bootstrap` stand-in rather than a
committed stub that could shadow the real class.
fix(tests): ADR-083/084 moved the constructors and the doubles never followed — 214 red, 0 red
…s untrue (2 asserted on a method the contract does not declare) (#513)

* test(adr-084): repair 212 doubles pinned to signatures the contract migration moved

decidesk's PHPUnit red was 100% test-side: phpstan reports 0 errors over
lib/, phpmd only pre-existing coupling. Every one of the 214 broken tests
was a double pinned to a pre-ADR-084 shape.

Measured with the SAME command on both sides — a detached origin/development
worktree at 43ab837 and this branch, php 8.3-equivalent config, the repo's
own phpunit.xml, openregister@development on the autoloader as CI provides it:

  base  969 tests | 177 errors | 37 failures | 17 warnings | 33 skipped  = 214 broken
  head  969 tests |   0 errors |  2 failures |  0 warnings | 33 skipped  =   2 broken

Failing test NAMES diffed, not counts: 212 fixed, 0 introduced.
Skip set is byte-identical to the base — same 33 tests, same reasons.

Four shapes, all mechanical, all verified against the real signature:

1. `container:` passed to a constructor that no longer declares it (78 tests).
   AmendmentOrderService (lib/Service/AmendmentOrderService.php:88),
   VotingRoundProjection (:47) and ParticipantUuidLookup (:45) take only
   objectService now; VotingOpenedNotifier (:52) and VoteCastingService (:78)
   genuinely keep theirs, so those were left alone.

2. `objectService:` bound to an UNDEFINED LOCAL — the migration substituted
   whichever variable happened to be in scope: `$out`, `$saved`, `$entity`,
   `$default`, `$object`, `$items`. An undefined variable is null, so every
   affected constructor got "null given" (57 tests).

3. A double served through a ContainerInterface mock that production no
   longer consults, while the constructor got a fresh empty mock. The store
   was never reached, so every lookup answered "not found" and every guard
   answered "nothing to object to".

4. Duck-typed anonymous classes that could not satisfy the contract. Three
   declared saveObject() with `string $register` FIRST; the contract has
   always started with `array $object`
   (ObjectServiceInterface.php:152). They are now generated from
   ObjectServiceInterface itself, so an unmodelled method cannot be
   configured and a wrong return shape fails at call time.

Un-skipped four MeetingServiceTest tests whose stated reason — "real
ObjectService loads instead of stub" — is no longer true; two of them
expected `updateFromArray()`, a method the contract does not declare, so
"never called" had been true for every possible run. They now assert against
saveObject() (ObjectServiceInterface.php:152) and a mutation control on
lib/Service/MeetingService.php kills them.

No lib/ change, no skipped or relaxed test, no @SuppressWarnings, no
phpstan baseline entry.

* test(meeting): un-skip 4 MeetingServiceTest tests whose stated reason is untrue

All four carried:

  markTestSkipped('… issues/90 — real ObjectService loads instead of stub.')

That reason no longer holds. ADR-084 replaced the stub-vs-real ambiguity with
a published contract, and the test now mocks OCA\OpenRegister\Contract\
ObjectServiceInterface — which IS the real thing, not a stub of it.

Two of them were worse than dormant. They expected `updateFromArray()`:

  ->method('updateFromArray')->with(id:, object:, updateVersion:, patch:)   // expects once
  ->method('updateFromArray')                                              // expects never

ObjectServiceInterface does not declare updateFromArray() at all. The "never
called" assertion was therefore true for every possible run, and the "called
once" assertion could never have matched a real call. MeetingService::
applyTransition() writes through saveObject() (lib/Service/MeetingService.php:211,
contract at openregister lib/Contract/ObjectServiceInterface.php:152).

All four now assert against saveObject() and pass. openedAt is stamped from
the wall clock (buildEfficiencyPatch(), line 470), so the payload is matched
by predicate rather than by literal.

Measured against origin/development e8cf771, same command both sides:

  development  967 tests, 0 errors, 0 failures, 37 skipped, 3884 assertions
  this branch  967 tests, 0 errors, 0 failures, 33 skipped, 3897 assertions

Same 967 tests — none added, none deleted. 4 skips removed, 0 added, 0 tests
newly broken.

Mutation control: changing register: 'decidesk' -> 'MUTANT-decidesk' at
lib/Service/MeetingService.php:213 fails testValidTransitionReturnsSuccess.
Reverted; this PR changes no lib/ file.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
… agenda, and seed a real chair for the two E2E tests that could never assert (#514)

Two decidesk E2E tests carried the skip reason "No activatable agenda item /
not chair in this environment." It was untrue twice over, both measured on the
dev instance:

1. Nothing in CI ever seeded a chair. `LiveMeeting.isChair` matches a
   Participant on `nextcloudUserId === getCurrentUser().uid && role === 'chair'`
   scoped by `@self.relations.meeting`. Participant declares no `meeting`
   property, but OpenRegister materialises a submitted `meeting` uuid into
   `@self.relations` anyway — seeding one made `.live-meeting__activate` render.

2. Even as chair the locator could not match. The control is
   `<NcButton :aria-label="Activate {title}">{{ orderNumber }}. {{ title }}</NcButton>`
   and an explicit aria-label REPLACES the text in the accessible name, so
   `getByRole('button', { name: /^1\./ })` matches nothing for anybody.
   The skip could never fail to fire: an invisible pass.

Fixing both exposed a real user-facing defect underneath. AgendaBuilder is
mounted for the chair only, and its `created()` hook fired
`agenda-item?isRecurring=true` right after LiveMeeting's own
`agenda-item?meeting=<id>`. The shared object store keeps ONE collection slot
per type, so whichever response lands last wins — the recurring templates
(belonging to other meetings) replaced the meeting's agenda, `allItems`
filtered them all away, and the CHAIR saw an empty agenda and an empty
"Activate item" list while a non-chair saw it correctly.

Measured: `agenda-item?meeting=<id>` -> total 1, `agenda-item?isRecurring=true`
-> total 2, `.live-meeting__activate-list` rendered as an empty <ul>. Forcing
the seeded item into the recurring response made the button appear, clicking it
mounted `agenda-item-timer` (no-allocation branch) and `speaker-queue-panel`
(empty state) — the exact assertions the two tests make.

The recurring read is component-local (it feeds one dialog), so it now goes
through a small axios service and never touches the shared cache.

Local run against the un-fixed deployed bundle, 4 tests: 2 passed, 1 failed,
1 skipped. The failure is the race and names it; the pass on the speaker-queue
test is the same race falling the other way, which is why these rotate.

Also: `minutes` and `agenda-item` were missing from the fixture's
TEARDOWN_ORDER, so every run leaked them while cleanupAll() returned cleanly.

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

Hydra Gates: gate-7 (no-admin-idor) 4 -> 0, measured full-tree with the
gate's own helper at package f935e2c, which reproduces CI's count exactly.

  ActionItemController::create/update/destroy — guarded downstream, and the
  guard is now named. create() takes no caller-supplied object id at all
  (server-generated uuid). update()/destroy() resolve the caller-supplied uid
  only through OCA\OpenRegister\Service\TaskService::getAllUserTasks(), which
  at TaskService.php:126-132 resolves the session user, throws when anonymous,
  and reads only principals/users/{uid}. gate-7 cannot see this: its Pattern 2b
  delegation closure is gated on the collaborator naming OpenRegister's
  ObjectService, and the enforcement lives in TaskService instead.

  DecisionController::transitions — the docblock asserted "find() returns null
  for objects the caller may not read". MEASURED FALSE. The Decision schema
  declares no authorization block and neither does the decidesk register row;
  OpenRegister's PermissionHandler::hasGroupPermission() treats an ABSENT block
  exactly as an empty one (empty() is true for both) and returns true —
  PermissionHandler.php:1227-1251, "Default-OPEN behaviour preserved". The
  enforce_default_closed flag defaults to false and even when on closes only
  create/update/delete, never read. The claim is corrected in place rather than
  left to mislead. The endpoint is exempted because it discloses a strict subset
  of what the same caller already gets from OpenRegister's own object API for
  the same UUID; a guard here could not refuse anything. The real control is a
  schema-level authorization block, which is an app-wide data change.

E2E, 3 -> 0 expected:

  integration-registry — cross-repo drift, not decidesk's. openregister
  3bc2977a6 added KvkProvider + OpenCorporatesProvider (Application.php:4077-4078)
  so OCS advertises both, while no leaf descriptor for either exists in
  @conduction/nextcloud-vue on beta, development or main — so no version bump
  could close it here. Waived by name, NOT skipped, and shrink-only: a third
  drifting id still fails, and a second assertion fails the moment either side
  is repaired, forcing the waiver to be deleted rather than outliving its defect.

  crud-persistence Meeting + Decision — budget, not a hang. These are the only
  two tests in the file performing THREE full SPA loads (~3.7s each, the figure
  already measured in this file), so ~11s is gone before their own assertions,
  ahead of 2 writes, 3 reads, a 10s toPass poll and the delete dialog. The 20s
  in tests/e2e/playwright.config.ts:103 was sized as 2.6x a 7.6s ONE-load test.
  The failure is "Test timeout exceeded" while an ordinary GET was in flight.
  Raised to 45s for those two only; trimming a load would have deleted the
  list-reflects-the-edit assertion to satisfy a stopwatch.
fix(gates,e2e): close gate-7's four findings and decidesk's three E2E reds
The two comment blocks justifying `test.setTimeout(45_000)` claimed the
20s cap "never covered this shape" and that an earlier fix removing one
page load "still did not fit", concluding the three SPA loads ARE
structurally over budget.

Measured, and that is false. Run 31907724887 passed BOTH tests under the
same 20s cap — Meeting 18.3s, Decision 18.0s.

What actually happens is runner speed. Per-test durations from the list
reporter, same file, two runs:

           274 Meeting  370 edit  403 Decision  512 dialog  568 edit  suite
  31907724887  18.3 ok    9.3 ok    18.0 ok       5.5 ok    9.4 ok   20.9m
  31979999077  22.1 FAIL 11.2 ok    22.6 FAIL     7.3 ok   11.7 ok   27.9m
  factor        1.21      1.20       1.26          1.33     1.24     1.33

Every test in the file slowed by the same 1.20-1.33x, the three that keep
passing included, in step with total suite wall clock. A decidesk code
regression could not also make the 5.5s dialog test a third slower.

So these two are not structurally over budget: they sit 1.7-2.0s UNDER a
20s cap on a fast runner and 2.1-2.6s OVER it on a slow one. The cap sits
inside their normal run-to-run spread, which is the real defect. Its
origin is visible in playwright.config.ts:75 — the 20s was derived from
run 31022933529, where "the slowest pass in the entire suite" was 7.6s;
that sample was taken while these two tests were still FAILING, so their
cost was never in the sample the cap was computed from.

45s stands (~2x the slowest observed 22.6s) and no assertion is touched.
Only the reasoning changes — a timeout raise justified by a false premise
is indistinguishable from papering over a regression, and the next reader
needs the real reason.

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

`GET /api/motions/{id}/history` carries `@NoAdminRequired` and reached
`MotionCoauthorService::getHistory()`, which called `findMotion()` and
returned `versionHistory` with no access check at all. Its three siblings
— `addCoauthor`, `removeCoauthor`, `updateMotionText` — all call
`checkMotionAccess()` first. So any authenticated user could read every
prior revision of any motion, plus the NC uid of each editor, by UUID
alone (OWASP A01:2021, Broken Access Control).

Reading the history discloses strictly more than the motion's current
body, so it is a privileged operation exactly as changing it is.

`getHistory()` now takes `?string $callerUid = null` and calls
`checkMotionAccess()`, matching the siblings' contract precisely
(null = skip, the documented admin/background-job bypass). The
controller resolves the caller the same way `addCoauthor` does and maps
`InvalidArgumentException` to 403 — the motion exists, this caller may
not read its revisions.

Positive control, not just a green tick: with the guard line removed and
everything else identical, `testHistoryIsRefusedToAStranger` FAILS
("Failed asserting that exception of type InvalidArgumentException is
thrown" — i.e. the history was returned to a stranger); with it, 4/4
pass. The other three tests pass in BOTH states, so the failure is the
guard and not the wiring.

The new tests are SERVICE-level deliberately: the controller suite mocks
MotionCoauthorService away, so a controller test can only prove that a
thrown exception becomes a 403 — it cannot prove anything throws.

How this hid: gate-7 reports 0 findings for this file. The endpoint uses
the DOCBLOCK form `@NoAdminRequired`, not `#[NoAdminRequired]` — and
across lib/Controller this app has 49 docblock-form occurrences against
93 attribute-form, in 5 controllers that use ONLY the docblock form
(AuditLog, Engagement, MotionCoauthor, NotificationPreference,
Preferences). A sweep matching one form reports zero for the other.
Coverage is a floor, not a ceiling.

Also swept, and reported rather than changed:
- `captureVersion()` likewise has no guard, but has ZERO callers in lib/,
  src/ or tests/ — dead code, latent rather than live. Left alone.
- phpcs: fixed the pre-existing missing `@param $objectService` on the
  service constructor. The two remaining "inline comments must end in
  full-stops" warnings are on the `// SPDX-License-Identifier:` lines —
  "fixing" them would change the licence identifier and break REUSE
  compliance, so they stay.

Verified: full unit suite 973 tests / 3905 assertions, 0 failures, 33
skipped (base: 967 / 3902 / 33). phpmd, phpstan and psalm clean on both
changed files — phpstan proven able to fail on this same file via an
injected type error, so the clean run is a measurement. phpcs over lib
goes 62 errors -> 61.

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

decidesk had no scripts/check-integration-parity.sh, so gate-24
integration-parity reported `SKIPPED (structural)` on every run — it
registers an integration leaf, so the gate selected it, but had no entry
point to invoke. With hydra-gates-require-full-coverage on, that single
unmeasured gate is the ONLY reason `Hydra Gates` and `Quality Report`
(a pure aggregator) are red on development: 64 gates green, one that did
not run.

Ported both halves from the canonical copy. The wrapper and checker are
byte-identical (md5) across openconnector, procest and hermiq — verified
by content, not by the equal 2103/30197 byte sizes — and the checker
contains no repo-specific string, so this is a copy, not an adaptation.

## The port alone was not enough, and said so

Run unmodified against decidesk it reported ZERO server faces and ZERO JS
registrations and then refused to pass:

  ✗ every rule had ZERO subject matter, yet gate-24 selected this repo as
    one that registers leaves. That contradiction means this checker
    failed to read what the gate can see.

That refusal was correct. decidesk registers via the DIRECT form —

  target.OCA.OpenRegister.integrations.register(decisionsLeafDescriptor)
  (src/integrations/registerDecisionsLeaf.js:181)

— while the checker matched only the `registerIntegration(` wrapper and
only an INLINE object literal. Both halves of that blind spot are fixed
here:

  * match `integrations.register(` as well as `registerIntegration(`,
    anchored on `\s*\(` so neither `registerIntegrationIcons(` nor
    `installIntegrationRegistry(` can match;
  * resolve a descriptor passed BY NAME to its `const NAME = { … }`
    literal text (new jsLocalObjectLiterals; the existing jsLocalConsts
    resolves VALUES and only reads to end-of-line for a `{`, so a
    multi-line descriptor never entered its table).

This is not a decidesk-specific accommodation: gate-24's own selector
probe already reads both forms and carries a comment recording that
matching only the wrapper "made this gate produce a FALSE ABSENCE CLAIM".
The checker was simply a generation behind its own gate.

⚠️ The three sibling copies must be updated too — they only escape this
because all three happen to use the wrapper form. Left to a fleet change
rather than silently forking three repos from here.

## Control: the change is additive, proven not assumed

Modified checker vs original, run against all three donor repos: BYTE
-IDENTICAL stdout and identical exit code (openconnector, procest, hermiq
— each `✓ … all rules pass`, rc=0). Nothing that passed before changes.

## What gate-24 now reports — a REAL finding, not suppressed

  ✗ [R2 id-correlation] id "decidesk-decisions"
    (src/integrations/registerDecisionsLeaf.js) has NO matching
    server-side face in lib/** — orphan registration: it mounts on
    window.OCA.OpenRegister.integrations but is invisible to the
    openregister.integrations.leaves capability.

Assertions run per rule: R1:1 R2:1 R3:0 R4:0 R5:0 R6:0 — the gate is
correlating real subject matter now instead of nothing.

The finding is genuine: decidesk ships no `new LeafDescriptor(` and no
IntegrationProvider in lib/, so the decisions leaf has a JS face and no
server face. Whether to add the server face or to treat this leaf as
deliberately client-only is a product decision (ADR-066 decisions 4/7),
so it is ESCALATED, not patched and not exempted here.

So `Hydra Gates` stays red — but on a substantive, named, actionable
finding rather than on a gate that never ran. `require-full-coverage` was
NOT switched off and no exemption was added: "a gate that did not run"
was the gate telling the truth about itself, and the fix is to let it
speak.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
PHPCS on development reported 63 errors and 112 warnings. The shared CI
gate maps phpcs exit 1 to success, so these have been shipping silently.
This brings errors to 0 (raw phpcs exit 1 -> 0); warnings are left alone.

By category:
- 56 PEAR.Commenting.FunctionComment.MissingParamTag — constructors that
  gained a promoted dependency (mostly the ADR-084 ObjectServiceInterface,
  plus FileService / TaskService / RegisterMapper / SchemaMapper) without
  the matching @PARAM line. Types taken from the actual signature, tags
  added in signature order.
- 4 PEAR.Commenting.FunctionComment.WrongStyle — a `//` prose block sat
  between the real docblock and the PHP attribute, so phpcs treated it as
  the function comment. The prose is folded into the docblock rather than
  deleted. Side effect: the real docblock's @SPEC tags are visible to the
  spec-tag sniff again, so 4 MissingMethodSpec warnings also clear.
- 2 Generic.Files.LineLength.MaxExceeded — a long @return continuation
  line reflowed, and ParticipationBudgetController::submitProposal()
  wrapped one-parameter-per-line in the house style.
- 1 Squiz.Commenting.InlineComment.NotCapital — reworded to start with a
  capital, meaning unchanged.

No phpcbf/--fix was used; every edit is by hand. Only one non-comment line
changed (the submitProposal signature reflow, behaviour identical).

Baseline vs after on this clone (PHP 8.3 in Docker): lint 0/0, phpmd 0/0,
psalm 0/0, phpstan "No errors"/"No errors", phpunit
967 tests / 87 errors / 1 failure / 33 skipped both before and after —
those failures are pre-existing and local-only (the OpenRegister app's
classes are absent from this checkout).

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
decidesk registered its `decidesk-decisions` leaf on the CLIENT only. Under
ADR-066 decision 1 the JS `registerIntegration()` path is the render-surface
HALF of the leaf contract, bound to a server descriptor by shared id, and the
ADR's Consequences name the job the other half does: registered descriptors
surface through OpenRegister's OCS capabilities so an admin UI or manifest app
can enumerate leaves without loading any app's JS bundle.

Without that half the leaf renders but is invisible to every server-side
consumer — an orphan registration under ADR-066 decision 4 (gate-24 R2).

Adds RegisterDecisionsLeafListener, modelled on hermiq's
RegisterAgentLeafListener (the fleet's reference shape for the same situation):
one `render-surface` kind, a null IntegrationProvider (the leaf reads and
appends through OpenRegister's own object API from the browser, ADR-022, so
decidesk holds no app-local store behind it), `renderMode: mount` matching the
JS half's mount/unmount DOM hand-off, and every metadata field equal to the JS
half's declaration.

The subscription lives in a new IntegrationLeafRegistrar rather than on
PlatformIntegrationRegistrar: that class was at a PHPMD CouplingBetweenObjects
of 12 against a threshold of 13, and the leaf's two class references would have
taken it to 14. Extraction is the move this codebase already makes at that
boundary. Registered unconditionally from register() — `::class` is a
compile-time string and registerEventListener() stores strings, so nothing
autoloads an OpenRegister class, and a class_exists() guard there would resolve
differently purely by app load order.
The JS half declared no `surfaces` key at all. ADR-066 decision 4 requires the
two halves to correspond, and a half that declares a value by OMISSION gives a
cross-layer check nothing to compare — which is exactly how hermiq's two halves
drifted apart unnoticed while both compilers stayed quiet.

All four members of LeafDescriptor::VALID_SURFACES are declared because the leaf
really does render on all four: componentForSurface() roots CnDecisionsWidget on
detail-page / app-dashboard / user-dashboard and CnDecisionsTab everywhere else.

The key is inert on the client today — the registry routes tab-vs-widget through
the `surface` mount prop and never reads this list (checked against
@conduction/nextcloud-vue 2.3.0's useIntegrationRegistry) — so this changes no
rendering. It is a declaration, and it is what the parity assertions read.
…pear

Nine tests across two files, each shown able to fail before it was shown to pass.

RegisterDecisionsLeafListenerTest asserts the leaf is discoverable server-side:
exactly one contributed leaf, the render-surface kind and ONLY that kind, the
mount render mode, a null provider, and the exact capability row
LeafRegistry::describeForCapabilities() publishes.

Two of its tests exist because of what the red control showed. With the listener
class DELETED, seven of eight tests errored and
'the listener is subscribed to the collect event' still PASSED — `::class` is a
compile-time string and registerEventListener() only stores strings, so a
subscription to a missing class is indistinguishable from a working one until
the event is dispatched. It now asserts the named class exists and implements
IEventListener. And because a registrar nobody calls registers as much as no
registrar at all, a second test reads Application::register() through reflection
to prove the composition root reaches it — with a positive control on the same
reader, so a failure means 'not wired', never 'read nothing'.

DecisionsLeafParityTest compares the two DECLARATIONS directly, reading the JS
source, because there is no runtime in this process where both exist. It covers
two fields gate-24's static reader silently skips on this repo: `requiredApp`
(written Application::APP_ID) and `label` (written $this->l10n->t(...)) are both
unresolvable to it, and it treats an unresolvable value as 'not compared, never
a failure'. Measured: with the JS `requiredApp` mutated to 'decidesk-typo',
gate-24 exits 0 and reports every rule passing while this test fails.

The three OpenRegister stubs mirror the real classes' FULL public surface,
checked against openregister development when written. They need no require_once
branch — their paths under tests/Stubs/ mirror their namespaces, so the PSR-4
root the bootstrap already registers resolves them, and adding one would
recreate the dead-guard shape #399 removed.
The behaviour this PR adds is shipped and observable, so it gets a written
requirement rather than a `@spec exclude`. It records what each half declares,
why the server half exists (capability enumeration without loading the bundle),
that the leaf declares render-surface ONLY and contributes a null provider, and
that raising a decision from another app stays the ADR-041
DecisionRequestedEvent path rather than the leaf seam.

Carries an `@e2e exclude` with its reason: one of the two declarations is a PHP
LeafDescriptor no browser ever sees, so there is no rendered state in which
Playwright could observe the server half being absent — which is precisely why
the halves were allowed to drift.
…gister objects

BEHAVIOUR CHANGE, stated up front: a user who is neither an object's owner, nor
a Nextcloud admin, nor a member of `decidesk-administrators` can no longer
UPDATE or DELETE another user's decidesk object. Reads, listings and creates are
unchanged.

Until now they could. Every decidesk object is reachable at
/apps/openregister/api/objects/decidesk/<schema> — the API the frontend uses
directly under ADR-022 — and no decidesk controller guard sits in front of it.
What decides who may write there is the `authorization` block on the schema, or
failing that on the register row. This tree had neither.

OpenRegister's PermissionHandler::hasGroupPermission() tests
`empty($authorization)`, and PHP's empty() is true for null and [] alike, so an
ABSENT block takes the same default-OPEN branch as an empty one.
`enforce_default_closed` reads IAppConfig with default:false, so on a stock
instance its deny arm never fires — and even switched on it covers only writes,
never reads.

Measured across all 25 register files on this tree: 93 schemas, 24 carrying a
block (every one of them read-only), and the register row carrying none. So 69
schemas — Decision, VotingRound, Vote, Participant and EngagementRecord among
them — granted create, update AND delete to any logged-in account. The same
shape as docudesk#631, where a plain user overwrote another user's template.

The fix sits on the REGISTER row because of the cascade:
resolveAuthorization() uses a schema's own block when it has one and falls back
to the register's only when it does not. So one declaration reaches exactly the
69 unprotected schemas and changes nothing for the 24 that already declare their
own — their public-read publication rules are untouched.

Every canonical action is written out deliberately. Once a block is non-empty,
OpenRegister DENIES any action it omits, so a half-written block breaks the app
rather than securing it: read/list/create stay `authenticated`, and only
update/delete are narrowed. The owner bypass is unconditional and SQL-side and
precedes every rule, so an author keeps full control of their own object.

Both version bumps are load-bearing and neither is cosmetic. ImportHandler's
REGISTER path skips outright when the incoming version is <= the stored one and,
unlike the schema path, has no content-differs fallback — so the register goes
0.7.0 -> 0.8.0. And InitializeSettings is a <post-migration> repair step, which
runs only on `occ upgrade`, which is a no-op when the app version has not moved
— so appinfo goes 0.4.6 -> 0.4.7. Either bump alone leaves a correct block
sitting on disk on every existing instance.
Seven tests, shown red on development's own state before they were shown green:
5 of 7 fail there (the block is absent, and both versions are behind), and the
two that stay green are the ones asserting the UNCHANGED 24 schema-level blocks
— which is what they should do.

They deliberately do NOT re-implement OpenRegister's evaluator. An instrument
built from the same source as the bug reports zero, and zero reads as a pass.
What this repository owns is the DECLARATION the evaluator reads, so that is
what is pinned: the block exists and names every canonical action with a
non-empty rule list; read/list/create still grant `authenticated` (if this goes
red the fix has become an outage); update/delete grant neither `authenticated`
nor `public`; no write action anywhere names `public`, which is the one thing
that would re-open the anonymous writes openregister#1955 closed.

Two of the seven guard the deploy path rather than the policy, because a
correct block that never reaches an instance is a fix that reports success and
changes nothing: the register/config version must be past 0.7.0 and the app
version past 0.4.6, each asserted against the last release that shipped WITHOUT
the block so the assertion survives future bumps. The app-version test carries a
positive control on its own reader (info.xml's <id> must read `decidesk`), so a
failure means 'not bumped' and never 'parsed an empty document'.

The schema-block test carries the same kind of control: it asserts the COUNT is
24, so it cannot pass vacuously if the schemas are renamed, moved, or stop being
found.

REQ-RBAC-006 records the requirement, with an @e2e exclude that names the real
reason: the owner bypass is unconditional and SQL-side, so a browser test driven
by one seeded (owning, usually admin) session cannot observe this denial at all
and would report success over the exact hole. The per-user behaviour needs a
two-account probe against a live instance, and that is recorded as verification
owed rather than claimed.
…hem, and CI proved it

Two defects in the first version of this PR, both found by CI and neither
findable locally. Recording what happened, because the first one is the exact
trap this change was supposed to avoid and I walked into it anyway.

1. THE BLOCK CLOSED ANONYMOUS READS. All six PHPUnit legs failed with
   `NotAuthorizedException: User 'Anonymous' does not have permission to 'read'
   objects in schema 'Meeting'`. Before any block existed, hasGroupPermission()
   took its default-OPEN branch for EVERY principal — the anonymous one
   included — so a block naming only `authenticated` on `read` does not
   preserve the status quo, it CLOSES anonymous reads. Omission is the deny, and
   that is as true for the principal as it is for the action.

   PHPUnit's CLI has no session, so those integration tests exercise exactly the
   path a #[PublicPage] citizen-participation surface takes. Left as it was, this
   PR would have 403'd every public consultation and budget-proposal read.

   `read` and `list` now name `public` as well, which is precisely the
   pre-change behaviour. `public` appears on NO write action, so openregister
   #1955's anonymous fail-closed rule keeps denying anonymous create/update/delete
   exactly as before, and the write hole stays closed. Closing anonymous reads may
   well be worth doing — it is a far larger policy change than this PR, and it
   does not belong smuggled inside it.

   Guarded by a new test, shown red on the omission.

2. MY OWN TEST FAILED ON AN EXTENSION CI DOES NOT HAVE. `simplexml_load_file()`
   worked locally and returned FALSE on every CI leg, failing the suite on
   'appinfo/info.xml must be readable XML'. The assertion is about one scalar in
   a file this repository owns; it now reads the file as text and preg_matches
   <version>, with the positive control moved to a string match on <id>.

⚠️ Local green could not have caught either one. The unit environment stubs
OCA\OpenRegister\*, so the integration tests that hit the real permission path
are precisely the ones that cannot run there — and the XML failure needed CI's
own PHP image. Both are the 'local green means nothing for this bug class'
shape.
Composer had no package-ecosystem entry at all, so composer dependencies
got no release-age cooldown whatsoever, unlike npm which has had one for a
while. Adds cooldown.default-days: 2 with a conduction/* exclude, matching
the fleet-wide floor gate-93 (composer-cooldown-config) enforces.

See ConductionNL/hydra openspec/changes/composer-dependency-cooldown and
ADR-093 (proposed, ConductionNL/hydra#591).

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Bumps [phpcsstandards/phpcsextra](https://github.com/PHPCSStandards/PHPCSExtra) from 1.5.0 to 1.5.1.
- [Release notes](https://github.com/PHPCSStandards/PHPCSExtra/releases)
- [Changelog](https://github.com/PHPCSStandards/PHPCSExtra/blob/develop/CHANGELOG.md)
- [Commits](PHPCSStandards/PHPCSExtra@1.5.0...1.5.1)

---
updated-dependencies:
- dependency-name: phpcsstandards/phpcsextra
  dependency-version: 1.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [phpmetrics/phpmetrics](https://github.com/phpmetrics/PhpMetrics) from 2.9.1 to 2.11.0.
- [Release notes](https://github.com/phpmetrics/PhpMetrics/releases)
- [Changelog](https://github.com/phpmetrics/PhpMetrics/blob/master/CHANGELOG.md)
- [Commits](phpmetrics/PhpMetrics@v2.9.1...v2.11.0)

---
updated-dependencies:
- dependency-name: phpmetrics/phpmetrics
  dependency-version: 2.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [nextcloud/ocp](https://github.com/nextcloud-deps/ocp) from 34.0.2 to 34.0.3.
- [Commits](nextcloud-deps/ocp@v34.0.2...v34.0.3)

---
updated-dependencies:
- dependency-name: nextcloud/ocp
  dependency-version: 34.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [postcss-html](https://github.com/ota-meshi/postcss-html) from 1.8.1 to 2.0.0.
- [Release notes](https://github.com/ota-meshi/postcss-html/releases)
- [Commits](ota-meshi/postcss-html@v1.8.1...v2.0.0)

---
updated-dependencies:
- dependency-name: postcss-html
  dependency-version: 2.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [marked](https://github.com/markedjs/marked) from 12.0.2 to 18.0.9.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](markedjs/marked@v12.0.2...v18.0.9)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 18.0.9
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [axe-core](https://github.com/dequelabs/axe-core) from 4.12.1 to 4.13.0.
- [Release notes](https://github.com/dequelabs/axe-core/releases)
- [Changelog](https://github.com/dequelabs/axe-core/blob/develop/CHANGELOG.md)
- [Commits](dequelabs/axe-core@v4.12.1...v4.13.0)

---
updated-dependencies:
- dependency-name: axe-core
  dependency-version: 4.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [@cyclonedx/cyclonedx-npm](https://github.com/CycloneDX/cyclonedx-node-npm) from 5.0.0 to 6.0.1.
- [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@v5.0.0...v6.0.1)

---
updated-dependencies:
- dependency-name: "@cyclonedx/cyclonedx-npm"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Picks up #522's composer cooldown in .github/dependabot.yml. The gate package
moved from 742f370e to 0b189e30 mid-review and added gate-93
composer-cooldown-config, which this branch failed purely by predating the fix
that development already carries.
Picks up #522's composer cooldown in .github/dependabot.yml. The gate package
moved from 742f370e to 0b189e30 mid-review and added gate-93
composer-cooldown-config; this branch's green was measured on 742f370e, before
that gate existed, and would fail it purely by predating the fix development
already carries.
…face

fix(adr-066): ship the decidesk-decisions leaf's server-side face (closes gate-24)
fix(security): close the default-open write hole on decidesk's OpenRegister objects
rubenvdlinde and others added 22 commits August 21, 2026 07:53
Supersedes the dependabot PR, which failed `PHP Quality (phpcs)` with:

    Script ./vendor/bin/phpcs --standard=phpcs.xml ... returned with error code 3

Exit 3 is a phpcs PROCESSING failure, not a verdict on the code. Reading it as a
phpcs-4 policy change (warnings starting to fail the build) and reaching for
`ignore_warnings_on_exit` would have suppressed a real breakage and left the
sniffs half-running.

The lockfile, not the sniffer
-----------------------------
The bump itself is fine. What differed was everything around it:

    dependabot branch:  conduction/hydra-gates v1.8.0 + php_codesniffer 4.0.4
    development:        conduction/hydra-gates v1.8.2 + php_codesniffer 3.13.6
    this branch:        conduction/hydra-gates v1.8.2 + php_codesniffer 4.0.4

hydra-gates v1.8.0 predates phpcs 4 and its sniffs cannot load under it.
Dependabot branched before v1.8.2 landed, so its lockfile pinned the older gates
package and carried it forward -- the bump was being tested against a sniff
bundle that no longer matches the sniffer. pipelinq's dependabot bump failed the
same way, from the same v1.8.0 pin.

Rebuilding the same bump on current development is the whole fix. Nothing in
phpcs.xml or the composer scripts changes, and no warning is suppressed: the 116
`@spec` warnings are still reported, exactly as on development today, and still
do not fail the build.

Verified locally against the exact CI invocation
------------------------------------------------
`./vendor/bin/phpcs --standard=phpcs.xml`, not a summary report -- report format
changes what is printed, and it is easy to "confirm" a pass with the wrong one.

  phpcs 3.13.6 on development:  0 errors / 116 warnings in 108 files, exit 0
  phpcs 4.0.4  on this branch:  0 errors / 116 warnings in 108 files, exit 0

Same counts, same exit, different sniffer -- what a clean linter major should
look like.

  phpstan   No errors
  psalm     No errors
  phpmd     exit 0
  phpunit   1153 tests, 4573 assertions, 0 failures

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

* style: apply php-cs-fixer across lib/ and tests/ (no behaviour change)

`composer cs:check` was red on 119 files. The fixer is wired into no
workflow, so this had drifted silently — running it now brings the tree to
the standard the repo declares.

The ruleset is exactly Nextcloud's: `Conduction\CodingStandard\Config`
extends it and its ADDITIONS array is EMPTY by design, because every rule
the fleet wants beyond Nextcloud's is semantic rather than typographic and
lives in PHP_CodeSniffer instead. So this can only move whitespace and
syntax, never meaning.

Verified rather than assumed, because an autofix CAN change meaning:

  * `git diff -w` (whitespace-blind) is NOT empty — 110 files — so the run
    did make token-level changes, and they were inspected rather than
    waved through. Every one falls into three groups: `use` statements
    REORDERED (identical text, moved lines), trailing commas added to
    multi-line signatures (PHP 8.0+; CI runs 8.3 and 8.4), and promoted
    constructor properties split across lines. No comparison operators, no
    `declare(strict_types)` insertion, nothing semantic.
  * All 119 changed files parse (`php -l`).
  * Suite identical before and after: 1153 tests, 4573 assertions, 0
    failures — the same counts, which is what a typographic change should
    produce.
  * phpmd, psalm, phpstan all exit 0. `cs:check` now exits 0.

`composer phpcs` still exits 1, unchanged by this commit and expected: the
108 SPDX-header `InvalidEndChar` warnings are DELIBERATE. A full stop after
`SPDX-License-Identifier: EUPL-1.2` makes it a different, invalid
identifier and breaks REUSE, so hydra-gates' shared ruleset downgrades that
one code to a warning on purpose. CI counts errors only.

* fix(style): keep the @return prose out of the tag so phpcs and the fixer agree

The sweep introduced ONE phpcs error, caught by CI and confirmed by
measuring both branches: development has 0 files with errors, the sweep
branch had 1.

php-cs-fixer's docblock aligner indents a tag description to clear the
longest type on the block. Behind HealthController::engineBody()'s
88-character `array{...}|null` shape that lands at column 95, producing a
162-character line — over phpcs's 150-character budget, which the shared
ruleset adds deliberately because Nextcloud enforces no line length at all.

The two tools genuinely disagree here, so the fix is to remove what they
disagree about: the prose moves into the docblock body, leaving the tag with
nothing to over-align.

Verified against BOTH tools and against the baseline: phpcs errors 0 (same
as development), php-cs-fixer clean (exit 0), warnings still 108 (unchanged
— those are the deliberate SPDX ones REUSE requires), suite green.

---------

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

`development` is red on one E2E test: "Display preferences: default view
Meetings redirects the app root to the meetings list" timed out at 20s.

It is mis-budgeted, not slow. The suite's 20s cap is calibrated in
playwright.config.ts as "2.6× the slowest observed pass", which holds for a
test that loads one page and asserts. This one cannot: proving a REDIRECT
PREFERENCE needs the settings panel plus three full app navigations — save,
app root, deep link — and those alone cost ~16s of the 20.

The evidence that it is load and not defect: the same commit range passed at
05:54 and timed out at 07:08 with no code change between, and the test's own
comment records an earlier round of exactly this, where the restore step was
moved off the UI and onto the API to buy back a fourth page load.

test.slow() triples the budget for THIS test only. The global cap is
untouched, so every other failure still costs 20s rather than 60, and
`retries: 0` stays — nothing here can convert a red into a green.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
hydra-gates v1.8.2 -> v1.8.2
nc-vue      2.8.2 -> 2.9.2

Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.

Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
hydra-gates v1.8.2 -> v1.8.2
nc-vue      2.9.2 -> 2.10.1

Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.

Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…a claimed prefix (#829)

Prepares this app for ConductionNL/.github#531, which drops
`OCA\OpenRegister\Contract\` from conduction/hydra-gates' RUNTIME psr-4
autoload. That prefix is LONGER than both openregister's own
`OCA\OpenRegister\` -> `lib/` and the stub root this bootstrap registers, and
PSR-4 is longest-prefix-wins, so whichever app's autoloader registers first
defines OpenRegister's contract for the whole process.

Without this block, once the prefix is gone the stub root resolves
`...\Contract\ObjectServiceInterface` to tests/Stubs/Contract/, which this app
does not ship. MEASURED: 662 errors, every one "Class or interface
OCA\OpenRegister\Contract\ObjectServiceInterface does not exist" out of
MockBuilder.

interface_exists() is order-independent: it asks whether the interface is
RESOLVABLE rather than who registered first. Appending a fallback autoloader
does not work, because spl_autoload_register appends relative to registration
order and that order across independently loaded apps is the thing nobody
controls.

Placed in tests/bootstrap-unit.php, which is what phpunit.xml actually loads —
this app has BOTH bootstrap.php and bootstrap-unit.php, and the first edit went
to the wrong one and changed nothing.

MEASURED both directions, with the prefix removed from the vendored package's
entry in vendor/composer/installed.json (editing the vendored composer.json does
nothing — Composer reads installed.json):

  prefix PRESENT (today)       Tests: 1108, Assertions: 4456, Skipped: 22
  prefix REMOVED (after #531)  Tests: 1108, Assertions: 4456, Skipped: 22

Safe to land now: while hydra-gates still declares the prefix this is a no-op.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* chore(deps): refresh the shared Conduction locks

hydra-gates v1.8.2 -> v1.9.0
nc-vue      2.10.1 -> 2.11.1

Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.

Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.

* fix(psalm): stub OpenRegister's contract, which v1.9.0 stopped autoloading

hydra-gates v1.9.0 removed `OCA\OpenRegister\Contract\` from its runtime psr-4
autoload (ConductionNL/.github#531). That removal was right — the prefix is
longer than openregister's own, so a vendored copy in ANY app defined the
contract for the whole process — but I verified it against PHPUnit only.

PSALM NEVER RUNS THE TEST BOOTSTRAP. It resolves types through the composer
autoload map, so the guarded require in tests/bootstrap-unit.php does nothing
for it, and this app's lib/ typehints the interface in production code:

    lib/AppInfo/Application.php:100  UndefinedClass: OCA\OpenRegister\Contract\ObjectServiceInterface
    lib/BackgroundJob/MailReplyHandler.php:67 …

204 of them, all the same class.

A stub is the right seam. It teaches the analyser the shape WITHOUT putting the
class back into the runtime autoloader, which is exactly what caused the
original defect. OpenRegister still supplies the real interface at runtime; the
files stubbed here are the copies hydra-gates ships for this purpose.

Measured in this checkout on the real v1.9.0:

    before   204 UndefinedClass errors
    after    0 — "No errors found!", psalm exit 0

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…tover Dutch default (#846)

Two defects from the Dutch->English value rename, both silent.

1. actionOverdue could never fire. Its scheduled filter was {taskStatus:
   'overdue'} — a strict-equality shortcut against a status nothing in the app
   ever writes. ActionItemWriter::mapStatus() has no 'overdue' entry, and
   overdue-ness is derived at read time in ActionItemAnalyticsService instead.
   The daily job ran, matched nothing, and notified nobody.

   Replaced with the condition the app actually means, in the dialect
   ScheduledFilterEvaluator implements (equals|notEquals|withinNext|olderThan,
   entries ANDed): dueDate olderThan PT0S AND taskStatus notEquals completed.
   Overdue is now derived by the filter rather than depending on a stored
   status that no writer maintains.

2. ProxyAuthorization.signatureStatus defaulted to 'ongetekend', which is not
   in its own enum (unsigned|signed|refused) and disagrees with the lifecycle's
   initial state 'unsigned'. The enum and lifecycle were migrated by
   RenameDutchDecideskValues; default, example and the prose were left behind.

Refs #845

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…s (19 rules) (#848)

* fix(notifications): reactionPendingModeration used the wrong filter shape

The created-trigger path reads a single clause, {field, operator, value}
(AnnotationNotificationDispatcher::createdFilterMatches). This rule passed a
field=>value map — the shape the SCHEDULED path takes — so the dispatcher
looked up $filter['field'], found nothing, and returned false for every
reaction ever created. Moderators have never been told a reaction is waiting.

Rewritten as a clause. Verified against the dispatcher's own semantics: a
pending reaction notifies, an approved one does not, and a reaction with no
moderationStatus does not.

Found by a fleet sweep of created-trigger filters after the same class of
defect turned up in 24 scheduled filters (ConductionNL/openregister#2787).

* fix(notifications): updated triggers carried a filter the engine ignores

15 updated-trigger rules declared a `filter`. The dispatcher does not read
`filter` on an updated trigger — it reads `condition`, and its own comment
records the consequence: "condition-less `updated` rules match on type alone
(back-compat)". `filter` is consulted only for `created` triggers.

So every one of these fired on EVERY update to the object, not on the state
change they name. Anyone subscribed has been notified for each edit. This is
the opposite failure from the silent ones fixed elsewhere in this sweep: not
too quiet, far too loud.

Each is rewritten as a condition. Where the lifecycle admits exactly one
predecessor for the target state, `from` is included as well, so the rule
fires on the transition itself rather than on any update while the state
holds — 10 of the 15 qualify. mvIngepland and geheimhoudingOpgeheven have two
possible predecessors and the grammar takes a single `from`, so they use
equals alone and say so in a _note. consultationBesluitAfwijkend and the two
Transcript rules have no lifecycle on that field.

Three further rules in the same register were dead for an adjacent reason —
an operator fieldChangeConditionMatches does not implement (it has only
`changed` and `equals`, and an unknown operator falls through to false):

- decisionSuperseded, decisionRepealed: isNotEmpty -> changed, which is a
  faithful reading of "a link was set".
- outcomeEmitted: `in` has no equivalent. Left declared with a _note rather
  than split into three near-identical rules, because the scheduled path
  gained `in` in ConductionNL/openregister#2794 and the updated path should
  follow; splitting now would only have to be undone.

Refs #849

---------

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

* fix(l10n): ship the browser catalogue, so the translations actually render

This app has a complete Dutch catalogue that no user has ever seen.

Nextcloud reads `l10n/<locale>.json` server-side for PHP `$l->t()`, but the
browser only ever gets `l10n/<locale>.js` — the `OC.L10N.register()` file.
Raw JSON is not served out of an app directory at all:

    GET /custom_apps/<app>/l10n/nl.json  ->  404   (measured)

With no `.js` half, `t('<app>', …)` has nothing registered, so it returns the
key unchanged. Every string in the interface renders in English no matter what
language the user picked, while every server-rendered string is translated.
Nothing errors, nothing logs, and a catalogue check that only reads the JSON
reports full coverage.

Three parts:

  - `scripts/build-l10n-js.js` GENERATES the .js from the .json, so the pair
    cannot drift. It reads the app id from appinfo/info.xml rather than
    hardcoding it — a catalogue registered under a stale id after a rename is
    silently ignored, which is the same failure one level down.
  - `pluralForm` added to both catalogues. Core's shape is
    {translations, pluralForm}; without it plural strings fall back.
  - `check:l10n-js` in CI fails when the committed .js is stale and names the
    command that regenerates it. Verified must-fail: mutate one value in the
    JSON and it exits 1 naming the file.

Generated, never hand-edited: run `npm run l10n:build` after touching a
catalogue.

* fix(l10n): generate EVERY locale, not just en/nl

The first commit generated `en.js` and `nl.js`, which is what humaniq needed.
This app ships far more than two catalogues, and all of the others were in the
same position: present as JSON, absent as JS, therefore unreachable.

  larpinq   37 locale catalogues, .js for 0 of them
  keepiq    .js present for all 37 — and STALE across the board:
            595 keys and 17 corrected translations never reached a browser

The generator now discovers locales from `l10n/*.json` instead of a hardcoded
pair, so adding a language is a JSON file and nothing else.

`pluralForm` is taken from the catalogue when it declares one. When it does
not, the fallback is the two-form rule `nplurals=2; plural=(n != 1);` — which
is what every generated catalogue in this fleet already carries, including for
languages that genuinely have more forms (cs, pl, ru). That is a known
simplification rather than a verified per-language rule, and it is documented
as such in the script: a catalogue that starts using plural strings in one of
those languages needs its real rule in the JSON, which the generator honours.

Verified non-destructive: across keepiq's 37 regenerated catalogues, 0 keys
lost, 595 added, 17 values corrected.

Also formatted the script with this repo's prettier config.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* feat(rename): move the app id, namespace and bootstrap from decidesk to decidiq

Phase-3 of the decidesk -> decidiq rename: appinfo/info.xml <id>, <namespace>,
navigation id and route name; the composer PSR-4 autoload prefix; the npm
package name; Application::APP_ID; and the OCA\Decidesk -> OCA\Decidiq
namespace across AppInfo.

Registers MigrateAppConfigKeys and MigrateUserPreferences under BOTH <install>
and <post-migration>, ahead of InitializeSettings. The ordering is load-bearing:
InitializeSettings mints a fresh voter_token_secret when it finds none, and that
value is the HMAC key signing every voting token and mail-reply link, so it has
to run after the copy or it silently invalidates every outstanding vote link.

The MCP provider container alias moves to IMcpToolProvider::decidiq. OpenRegister
builds that lookup key as '...IMcpToolProvider::' . $appId over the installed
apps, so a stale suffix is not cosmetic -- the provider is never discovered and
all five tools disappear without an error.

Deliberately frozen here: the 'decidesk' OpenRegister register slug, and the
decidesk-decisions integration leaf id (LeafRegistry validates only the id shape,
never an app-id prefix, and the id is named in the REQ-DCDH-008 requirement
heading that six @SPEC anchors dereference).

* feat(rename): carry appconfig and per-user preferences across the app-id rename

A rename IS a data migration. oc_appconfig and oc_preferences are namespaced by
app id, so renaming <id> does not rename the rows -- it makes the app ask for
its data under a name nothing answers to.

MigrateAppConfigKeys enumerates IAppConfig::getKeys() (exhaustive by
construction), skips the Nextcloud-reserved keys, and carries the SENSITIVE flag
across the copy. Dropping that flag would print voter_token_secret -- the HMAC
key signing every vote token -- in cleartext in occ config:list and in every
support dump.

MigrateUserPreferences walks IUserManager::callForSeenUsers() and asks
IConfig::getUserKeys() per user. It deliberately never enumerates by value:
PreferencesController stores under an open-ended 'pref_' . $safeKey namespace
sanitised only to [a-z0-9-]{1,64}, so neither the keys NOR the values are
knowable up front and a getUsersForUserValue() implementation would migrate
nothing while reporting success. A hardcoded key list would be equally
incomplete. A test pins the choice by asserting the value-enumerating call is
never made.

Every read sits INSIDE the try alongside the write. Both steps are registered
under <install>, where a throwing repair step does not merely fail an upgrade --
the app never enables and every route goes with it.

The tests were verified to be capable of failing: emptying RESERVED_KEYS,
moving a read outside the try, dropping the sensitive flag, and switching the
preference walk to value-enumeration each turned the relevant test red, and all
four were then restored.

* feat(rename): move lib/Repair to the decidiq namespace, freezing the register slug

Renames the namespace, class names and files (RenameDutchDecideskValues ->
RenameDutchDecidiqValues and its Decisions twin), and fixes each renamed file's
CONTENTS -- namespace, imports, class name -- not just its path.

Fixes a real trap in InitializeSettings: it read and wrote voter_token_secret
against the bare literal 'decidesk', which is the APP-CONFIG NAMESPACE and not
the OpenRegister register slug. Two different 'decidesk' literals that grep
cannot tell apart. Left alone it would have kept reading the secret under the
pre-rename namespace while VotingService looked under the new one. It now uses
Application::APP_ID, so it cannot drift again.

Frozen with an explanatory comment at each definition site, because a future
reader would otherwise 'finish the job' and orphan the data:
  - RenameDutchVocabularyColumns::REGISTER_SLUG
  - RepointConflictOfInterestBoardMember::REGISTER
  - MigrateBoardProxyToProxyAuthorization::REGISTER
All three are the OpenRegister register slug. OpenRegister matches registers by
slug, so a renamed slug resolves no register and the step reports 'nothing to
do' over data it was meant to migrate.

The @SPEC anchors into openspec/changes/archive/ stay byte-identical: the
archive is history and the anchors still resolve against it.

* wip(rename): sweep decidesk -> decidiq across lib, src, tests and docs

Checkpoint commit of in-flight rename work left uncommitted by a previous
session. Includes the Activity provider rename (DecideskProvider ->
DecidiqProvider). Not yet verified end to end.

* refactor(rename): move the PHP namespace and class names to Decidiq

Converts OCA\Decidesk -> OCA\Decidiq across lib/ and tests/ (171 files still
declared the old namespace, which composer's PSR-4 map no longer resolves), plus
class names, log prefixes and prose.

FROZEN: the Nextcloud Files folder root stays 'Decidesk'. MeetingFolderService
and BoardEvaluationReportService keep $segments = ['Decidesk'], and every
Decidesk/-rooted path literal in seeds, schema examples and tests stays with it —
renaming it would create a new empty folder and strand every existing meeting
document, silently.

* refactor(rename): move the app id, l10n domain, URLs and bundle names to decidiq

Covers src/, templates/, webpack, docs/, CI workflows and all 38 l10n files.

- webpack emitted decidesk-*.js while Util::addScript already asked for
  decidiq-* — the whole bundle 404'd. appId is now decidiq.
- ~490 t('decidesk', ...) l10n call sites moved, and every l10n msgid KEY moved
  with them (a stale key silently renders untranslated English).
- VoterTokenSecret read the HMAC secret from the OLD appconfig namespace; it
  would have minted a replacement over the migrated key and invalidated every
  outstanding ballot link.

FROZEN: docs host decidesk.conduction.nl (measured: the old host answers 200,
decidiq.conduction.nl does not resolve), the OpenRegister register slug, MCP
tool ids, RBAC group ids, the dashboard widget id, X-DECIDESK-* iCal
properties and the Files folder root.

* fix(rename): move app-id-derived identifiers that had gone stale

The app id already read decidiq, but a set of identifiers derived from it did
not move with it. Each fails quietly rather than loudly:

- 11 appconfig call sites still read/wrote under the 'decidesk' namespace, so
  every one of them returned its DEFAULT after the migration copied the rows —
  chair_group, motion_min_cosigners, the participation catalog and two HMAC
  secrets among them.
- AdminSettings::getSection() named a settings section that no longer exists.
- linkToRoute('decidesk.dashboard.page') would throw; imagePath() and the
  /apps/decidesk/ deep links would 404.
- 4 notification setApp() ids and 2 eIDAS return paths.

FROZEN with comments: the dashboard widget id (NC stores per-user widget layout
under it in the dashboard app's namespace, which our repair steps cannot reach)
and X-Decidesk-Export-Sha256 (a response wire header regulator clients read by
name; an unrecognised header reads as absent, not as an error).

* wip(decidiq): checkpoint in-flight rename work before session limit

* fix(register): point x-openregister.app at the new app id

The register descriptor attributes the register to an owning app through
x-openregister.app. It was left on the old app id when the id moved, so the
descriptor claimed ownership by an app that no longer answers to that name.

Safe to move now: these instances are development-only, so there is no live
register whose attribution could be split.

The register SLUG is deliberately NOT touched here — that is the key objects
are stored against, and it is a separate decision from attribution. Other
apps' ids appearing in the same file (e.g. opencatalogi) are cross-app
references and stay as they are.

* fix(tests): assert register attribution against APP_ID; reflow after rename

Two PR-run failures.

PHPUnit: RegisterJsonTest hardcoded 'decidesk' as the expected
x-openregister.app. When the descriptor moved to the new id, the TEST became
the stale half and reported the correct descriptor as a failure. It now asserts
against Application::APP_ID, so the two cannot drift apart again.

Frontend format: 'decidiq' is shorter than 'decidesk', so lines that had been
wrapped now fit and prettier rejoins them. Measured 24 files failing on this
branch against 2 on development, so 22 are rename reflow and 2 pre-existing;
the project's own format:fix clears all of them (35 insertions, 80 deletions --
a reflow, not a reformat).

* fix(l10n): restore the 38 translation artifacts the branch had deleted

The most dangerous thing found in this rename.

The branch had deleted ALL 38 l10n/*.js files, plus scripts/build-l10n-js.js
and the l10n:build / check:l10n-js npm scripts that maintain them. Those .js
files are what Nextcloud actually SERVES to the browser; the .json files are
only their source. Merging this would have shipped an app with no translations
in any language -- and the l10n check that reads the JSON would have stayed
green throughout, because it never looks at the artifact.

It surfaced only because check:l10n-js is a shared frontend check from
.github@main, and the job failed with "npm script does not exist" rather than
with anything about translations.

Restored the generator, both scripts and all 38 artifacts, then repointed the
locale data: the l10n KEY is the English source string, so renaming a
user-visible string renames its key. 572 entries across 38 locales moved to the
new product name, values included -- a straight substitution of the proper noun
preserves each language's declension (Decidesk-gebeurtenissen ->
Decidiq-gebeurtenissen). Artifacts regenerated from that JSON, and they now
register the decidiq domain; on the old domain no translation would resolve at
all.

Note on .gitignore: l10n/nl.js matches '!**/*.js', a NEGATION. git check-ignore
prints the rule and exits 0 either way, so the rule text -- not the exit code --
is what says these files are tracked.

Local: l10n OK, l10n-js up to date, prettier 0, no deletions against
development.

* fix(spec): tag the 27 methods the reflow put back in gate-16's scope

gate-16 is diff-scoped, so the prettier reflow from the previous commit --
which only rewrapped lines -- pulled 27 frontend methods back into 'changed'
and surfaced their long-standing missing @SPEC. Each now cites the requirement
it implements: dashboard widgets to the widget requirements, DecisionRouteTab
to declarative route progress, ConsultationReactionsTab to the reaction
moderation queue, deckProjection to decision list and search.

Two corrections to my own first pass, both caught before pushing:
  - The tagger inserted a docblock INSIDE the Vue template of
    PendingVotesListWidget, around the countdownLabel(round) call rather than
    its definition. Restored, then tagged the method itself.
  - It cited openspec/specs/consultation-management, which does not exist.
    gate-46 dereferences @SPEC targets, so it failed loudly -- repointed at
    citizen-participation#requirement-reaction-moderation-queue, the spec that
    actually describes that surface.

Local: gate-16 0, gate-46 0, eslint 0, prettier 0, 367/367 vitest, l10n OK,
l10n-js up to date.

* fix(spec): tag voteTitle at its definition, not inside the template

Same tagger bug as countdownLabel: the docblock landed inside the Vue TEMPLATE
around the voteTitle(round) CALL rather than above the method. gate-16 reads
definitions, so it still counted the method as untagged -- and the template
carried a stray comment. Both corrected.

* fix(e2e): the walkthrough-seen key is app-id scoped

~40 specs cascade-failed with 'locator.click: Test timeout', every one of them
reporting the same interceptor:

  <div class="cn-walkthrough__dim cn-walkthrough__dim--full"> from
  <div role="dialog" aria-label="Welcome to Decidiq"> subtree intercepts
  pointer events

global-setup seeds localStorage to mark the first-visit tour as seen, and
CnAppRoot reads that under 'cn-walkthrough-seen:<appId>'. The key still said
decidesk, so after the rename the app looked up decidiq, found nothing, and
auto-started the tour whose full-viewport dim overlay swallows every click.

The comment directly above the line already predicted the symptom -- '~40 specs
cascade-fail' -- which is exactly what the run showed. The key is app-id
scoped, so it moves with the id.

E2E passes on development and failed here, so this was rename damage, not a
pre-existing failure. No other app-id-keyed browser storage in this suite;
buildiq, thematiq and stackiq have no walkthrough key at all.

* test(experiment): revert x-openregister.app to decidesk to isolate the goals failure

EXPERIMENT, not a conclusion. 'Goals: index lists all five seeded goals' fails
reproducibly on this branch (re-ran the job; it failed identically) and passes
on development, and I could not explain it from the diff:

  - the goals manifest fragment src/manifest.d/organisation-goals.json is
    UNCHANGED and its registers correctly read decidesk
  - lib/Settings/register.d/66-organisation-goals.json changed only in a prose
    _note
  - the goal SCHEMA imports (ci-seed lists it among schemas present)
  - the page mounts: the cn-index-page assertion passes, only the rows are absent
  - other seeded rows DO render — 'meetings list shows multiple meeting rows'
    passes

That leaves the app ATTRIBUTION as the only functional difference in the import
path: SettingsService calls importFromApp(appId: Application::APP_ID), which is
now decidiq, and the register descriptor's x-openregister.app was changed to
decidiq in the same branch.

If goals render with this reverted, the field is load-bearing at import time and
NOT the pure metadata I described when it was authorised — which would also
explain dossiq #1333, whose only change is that same field and whose E2E fails
the same way.

If they still fail, this is ruled out and the field goes back to decidiq.

* fix: x-openregister.app is load-bearing — it stays decidesk

The experiment answered it. With x-openregister.app set to 'decidiq', 'Goals:
index lists all five seeded goals' failed twice in a row (58.9s, burning the
full 45s row timeout). Reverting that ONE line to 'decidesk' — nothing else
changed — turned E2E green.

So the field is not the descriptive metadata it looks like. It participates in
resolving which register the register.d fragment seedData is imported into: the
goal SCHEMA still imported (ci-seed listed it), the page still mounted, and
other fragment-seeded objects still rendered (facets-decision-detail reads a
toezegging from fragment 45) — only the goal OBJECTS never arrived.

RegisterJsonTest now pins the frozen literal instead of Application::APP_ID,
with that measurement recorded next to it. I had changed it to track APP_ID
earlier on the reasoning that attribution 'must follow the app id'; that
reasoning was wrong, and the test would have re-broken this the moment someone
trusted it.

The register slug and its attribution move together, and neither moves with the
app id.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
This is the app the freeze came from, so it is the one that has to change its
own record.

`tests/Unit/RegisterJsonTest.php` pinned `x-openregister.app` to `decidesk` on
the strength of a controlled experiment: with that one field on the new app id
the seeded Goal objects stopped appearing on the Goals index, twice in a row,
and came back the moment it was reverted. The observation was right. The
conclusion drawn from it — that the field, and the register slug with it, could
not move — was one step too far.

The mechanism is now known rather than inferred. For a `type: application`
configuration, ImportHandler::autoCreateRegisterIfApplication() reads
`$slug = $xOpenregister['app'] ?? $appId`: the field IS a register slug. Moving
it alone pointed the import at a register that did not exist, and OpenRegister's
not-found branch CREATES an empty one rather than failing — which is exactly the
empty Goals index that was observed. What makes it movable is renaming the
register ROW first, which MigrateRegisterSlug now does ahead of
InitializeSettings in both hooks.

The comment has been rewritten rather than deleted, and it keeps asserting the
LITERAL rather than Application::APP_ID — pinning it to the constant would
re-break this the next time an app id moves without its register.

WHY IT MOVES NO DATA. Measured: an object is bound to its register by NUMERIC id
— `_register` in every shard table, and the tables are named
`oc_openregister_table_<registerId>_<schemaId>`.

Two sweep patterns showed up here for the first time and are now part of the
tooling: Postman `path` ARRAYS (`"objects","decidesk","meeting"` — 94 of them,
and Postman builds the request from the array, not from `raw`), and
`setRegister('decidesk')` (63). A plain-URL grep sees neither.

Still frozen: the dashboard widget id `decidesk` (per-user layout), the MCP tool
provider's app id (it namespaces every tool id), `decidesk.*` event names, the
`decidesk_*` SBOM bom-refs, the `x-decidesk-*` schema extension keys, the docs
host, and `lib/Settings/decidesk_register.json`'s FILENAME — app-owned and
movable, but not by this PR.

PHPUnit 1177 passed · vitest 367 passed · PHPCS 0 errors · PHPMD clean ·
Psalm 0 errors · PHPStan 0 errors · gate-16 count=0 · gate-46 clean.
The coverage ratchet caught these on larpinq — 94.96% head vs 97.13% base,
−2.17% — and it was right: the two catch blocks in migrateStoredSlugValues()
had no test at all.

Both matter more than an ordinary catch. This step is registered under
<install>, where an escaping exception aborts the install and the app never
enables, so "IAppConfig threw" must mean leave the value alone rather than take
the upgrade down with it. The write branch also has to keep the summary count
honest: a write that failed is not a value re-pointed.

Applied to all five apps in the series so the same ratchet does not fail them
one at a time.
The E2E and Newman legs both failed at "Seed test data", and the seed log shows
the import itself worked perfectly:

  [ci-seed] registers present: [... 'larpinq' ...]
  ::error::Larpinq registers missing after import: ['larpingapp']

The register was created under its new slug and the script went looking for the
old one. Same shape in every app in the series: a hard-coded slug in the
post-import assertion, in the `registers:` list the fallback importer sends, and
in `appId=` on the OpenRegister importer call — the last of which is the schema
`application` value, so it has to match what the app's own SettingsService
passes (Application::APP_ID).

Left alone on purpose, because these are not the register slug:
  - `lib/Settings/<old>_register.json` — the FILE name, app-owned but not moved
    by this PR;
  - `decidesk-action-items` — a VTODO calendar URI, already on users' calendars;
  - `oc_openconnector_*` table names in the explanatory prose;
  - the `<old>.conduction.nl` docs hosts.
…the old slug

The Newman leg failed with every request hitting
`/apps/openregister/api/objects/<old-slug>/<schema>` even though no URL in the
collection names a slug: they are all built from `{{register}}`, and the variable
was defined once as

  { "key": "register", "value": "<old-slug>" }

That is a seventh distinct syntax a register slug hides behind, and the one that
matters most, because ONE definition silently drives every request in the file.
A grep for the plain URL finds nothing, and the diff of a swept collection looks
complete.

Now a sweep rule, so the remaining apps get it without another CI round.
Eighth syntax, and the reason the E2E leg failed while the diff looked complete:

  const OR_OBJECTS = `${NC_URL}/index.php/apps/openregister/api/objects/hrmq`

The sweep rule for object URLs required a trailing slash — `objects/<slug>/` —
because every occurrence found so far had the schema right after it. Here the
slug ends the constant and the schema is appended at the call site, so the rule
matched nothing and every request built from the constant kept hitting the old
register. Playwright reported it as `expect(listed.ok()).toBeTruthy()` failing,
which reads as a broken assertion rather than a missed rename.

The rule now accepts a slash, a quote, a backtick, whitespace or end-of-line
after the slug. Re-scanning the whole series on it found 11 more in integriq
(including the Postman `orBase` variable) and 7 in decidiq.
`Integration Tests (Newman)` failed with 404s on
`objects/decidesk/participatory-budget` and `objects/decidesk/budget-proposal`,
while `apps/decidiq/api/...` in the same run answered 200 — the app id had moved
and the register slug had not followed in the test data.

The slug was not sitting in the URLs. It comes from a Postman ENVIRONMENT
variable, `register`, in decidiq-environment.json, which every request
interpolates as {{register}}; a grep for `objects/decidesk` across the
collections finds nothing at all. The other eight were escaped inside raw
request bodies (`\"register\": \"decidesk\"`), which is invisible to a search
for the plain string.

DELIBERATELY LEFT ALONE — every other `decidesk` under tests/ is something else
wearing the same word: `decidesk-no-such-group` and
`nobody-matches-this@decidesk-test.invalid` are fixtures chosen precisely
BECAUSE nothing matches them, `decidesk-admset-nonadmin` and
`decidesk-proccfg-nonadmin` are test usernames, and `decidesk#443` is a
historical issue reference. Renaming any of them would be churn, and renaming
the first two would quietly weaken the negative assertions they exist for.

All collections still parse.
…o-decidiq

feat(register): rename the register slug decidesk -> decidiq
…#858)

* test(e2e): pin the browser-catalogue contract from the browser's side

The l10n rollout fixed a defect no existing check could see: `l10n/<locale>.js`
was missing, so `t('<app>', key)` had nothing registered and handed the key
back — the whole interface rendered English regardless of the user's language,
while every server-rendered string was translated. Nothing errored.

Six apps in the fleet ran an l10n check that passed the entire time, because
it reads the JSON — the half that was never broken. A check that validates the
SOURCE cannot see that the ARTEFACT the runtime loads does not exist, so this
test asserts from the browser instead:

  1. GET l10n/<locale>.js returns 200, is an OC.L10N.register call, and names
     the CURRENT app id. (Raw JSON out of an app directory is a 404, which is
     what made every translation unreachable.)
  2. The running app has that catalogue registered, and t() resolves a real
     key through it rather than falling back to returning the key.

Must-fail verified: delete l10n/nl.js and both scenarios fail — the first on
404, the second on the missing registration.

Written to be identical in every app: the app id is read from
appinfo/info.xml at run time rather than hardcoded, so it survives a rename —
and a catalogue registered under a pre-rename id, which `t()` silently
ignores, fails scenario 1. No fixture strings either: the assertion picks a
translated key out of the app's own registered catalogue at run time, so it
does not need editing when copy changes.

* style(e2e): prettier-normalise the browser-catalogue spec

The file is meant to be byte-identical in every app, so it has to satisfy the
strictest formatter in the fleet. decidiq's format check objected; this is its
prettier output, verified to also satisfy every other app that runs one.

* fix(e2e): ask the instance where the app is served, do not assume

The spec fetched /custom_apps/<app>/l10n/<locale>.js. That is right on a dev
box, where apps are bind-mounted under custom_apps — and wrong in CI, which
checks the app out under apps/. So it failed for a reason that had nothing to
do with the catalogue.

It now reads OC.appswebroots[appId] from the running instance and fetches
relative to that. The assertion gets stronger rather than weaker: an app that
does not resolve at all has no webroot entry, which is how the decidesk ->
decidiq mount drift surfaced.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
The landing page shipped a button sending visitors to
codeberg.org. GitHub is the only host we publish to, so the link
opened a repository we no longer read.

Now points at https://github.com/ConductionNL/decidiq.
* test(l10n): ratchet the untranslated schema strings

Every string inside a form comes from the OpenRegister schema, not from the
manifest: `fieldsFromSchema()` runs a property `title` and `description`
through the injected `cnTranslate`, which CnAppRoot binds to THIS app's id. So
a schema title is a key in THIS catalogue — and when the key is absent, `t()`
hands the source string back and the field renders in English inside an
otherwise translated form. Nothing errors, and no existing check looks.

Measured across the fleet on 2026-08-23: 30,459 schema strings had no
catalogue key. Far too much to translate in one pass, and the descriptions
need rewriting for the person filling in the form before translating them is
even worth doing — humaniq's own pass rewrote 592 of 739 before a word was
translated.

So this is a RATCHET, not a gate: it records how many strings are currently
uncovered and fails only when that number GROWS. The debt is measured and
cannot expand, while burning it down stays an ordinary PR. Same shape as the
JSDoc baseline in @conduction/nextcloud-vue.

Counted: schema titles, property titles, property descriptions, and the VALUES
of `x-enum-labels`. NOT counted: enum values themselves (stored contract
values, several non-English by design, never rendered once a property declares
its labels) and `x-notes` (engineering rationale, never rendered).

Verified must-fail: adding one untranslated title takes the count past the
baseline and exits 1, naming the file and property and the command that lists
what is uncovered.

Lower the baseline as strings get translated:
  npm run check:schema-l10n -- --update

* fix(l10n): the baseline file is not a locale catalogue; format for this repo

Two things the fleet CI caught.

`build-l10n-js.js` discovers locales by globbing `l10n/*.json`, which now also
matches `l10n/.schema-l10n-baseline.json` — the ratchet's own state file, kept
there so prettier ignores it. The generator read it as a locale named
`.schema-l10n-baseline` and exited 1 for having no `translations`. Dotfiles are
never locale catalogues, so it skips them.

Also prettier-normalised both scripts to this repo's config; several apps run a
format check over scripts/.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Comment on lines +32 to +39
if: github.ref == 'refs/heads/development'
uses: ConductionNL/.github/.github/workflows/release.yml@main
with:
release-type: unstable
app-name: decidiq
secrets: inherit

beta:
Comment on lines +40 to +47
if: github.ref == 'refs/heads/beta'
uses: ConductionNL/.github/.github/workflows/release.yml@main
with:
release-type: beta
app-name: decidiq
secrets: inherit

stable:
Comment on lines +48 to +53
if: github.ref == 'refs/heads/main'
uses: ConductionNL/.github/.github/workflows/release.yml@main
with:
release-type: stable
app-name: decidiq
secrets: inherit
@rubenvdlinde
rubenvdlinde merged commit ab6a760 into documentation Aug 24, 2026
93 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants