Release: merge development into beta - #501
Conversation
…60820130328 chore(release): 0.1.1-unstable.20260820130328
A user could re-tenant any object by editing `tenant_id` in a data widget.
`CnObjectDataWidget` renders every schema property as an input unless the
property says otherwise, and `editable` defaults to true — so the field that
defines the multi-tenancy boundary was a text box on every detail page.
`readOnly: true` closes it at BOTH layers:
- server: `ObjectService::saveObject()` calls `enforceReadOnlyOnUpdate()`
unconditionally on the UPDATE path and rejects a changed readOnly property
with "Cannot modify readOnly properties: tenant_id";
- client: CnObjectDataWidget stops rendering it as an editable field.
Safe for every existing write path, verified rather than assumed:
- readOnly is NOT enforced on CREATE (`validateReadOnlyConstraints` returns
early when there is no stored object), so importers and
`LtiAgsScorePollJob` — which builds a GradeEntry with `tenant_id` and
saves it without a uuid, i.e. a create — are unaffected;
- an unchanged value is never a violation (`$attempted === $stored`), so
repair steps that re-save an object keep working;
- every other `tenant_id` reference in lib/ is a read-side filter
(`'filters' => ['tenant_id' => …]`), not a write.
Deliberately NOT extended to `status`, `*By` or `*At`. `readOnly` has no
bypass for backend callers, and `TransitionEngine` writes the new state
through the same `saveObject()` (TransitionEngine.php:343, :703) — marking a
lifecycle field readOnly would break every submit/approve/reject transition.
Those need a surface fix (`editable: false` on the widget) instead; there is
currently no server-side "backend may write, user may not" marker in
OpenRegister (`x-openregister-readonly-when` is honoured only in the Vue
component — zero PHP references).
116 additions, 0 deletions; every other schema key byte-identical, asserted by
a verifier that was negative-controlled against a seeded `status`/`decidedBy`
marking first.
…sedes #430) Bumps conduction/hydra-gates to ^1.8.2 alongside phpstan ^2.0; the lockfile pinned v1.8.1 so the fleet migration config from ConductionNL/.github#517 was not in effect here. Nine residual errors, all genuine, in three shapes: BY-REF KEY DEGRADATION (5). Accumulators declared array<string, X> and passed BY REFERENCE were written with un-narrowed keys — $byId[$cohortId], $queues[$teacherId], $courseIdByOrgId[$node['identifier']], $provisioned[$learnerId] — which degrades the array to array<X> at the call boundary. Cast the keys at the write sites; the declarations were already right. One also needed its VALUE cast, $matchData being mixed. A CACHE THAT REALLY DOES HOLD NULL (2, plus 4 propagated). SessionWindowLoader ::loadCohort()/loadRoom() declared array<string,array<string,mixed>> while their own last statement writes null — a MISS is cached so a repeat lookup short-circuits. The docblock, not the code, was wrong; hasLinkedAssessment() next door already declared 'string|null' for exactly this. Corrected here and in the four TimetableConflictDetector docblocks that pass the same arrays down. UNREACHABLE COMPARISONS (2). '!== null' after isset(), and 'return $userId !== ""' in requireCourseReadAccess() after an early 'if ($userId === "") return false'. The MCP one now returns true outright, so the allow decision is visible instead of hiding behind a check that never fires. Verified: phpstan 2.2.8 exit 0, phpcs exit 0, php -l clean, PHPUnit 952 tests / 4345 assertions OK.
…60820202242 chore(release): 0.1.1-unstable.20260820202242
…60820204355 chore(release): 0.1.1-unstable.20260820204355
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 634/634 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 21:10 UTC
Download the full PDF report from the workflow artifacts.
…60820211018 chore(release): 0.1.1-unstable.20260820211018
`CnObjectDataWidget.editable` defaults to TRUE, so every property named in a data widget's `include` list becomes a text box the user can type into. That put lifecycle state and audit stamps — `status`, `lifecycle`, `submittedAt`, `approvedBy`, `openedAt`, `closedAt`, `publishedAt`, `enactedAt` — in front of users as editable fields. These are written by the backend when a transition lands (`TransitionEngine` stamps them through `saveObject()`), so an input for them is a control that can only ever fail or confuse: the guarded path is the lifecycle buttons, and `LifecycleValidationListener` rejects anything that is not a legal transition. Locked with per-field `overrides.<field>.editable: false` rather than `editable: false` on the widget: these panels mix process state with fields the user legitimately edits, and a blanket lock would make those read-only too. NOT fixed here: widgets that declare no `include` at all render EVERY schema property, and enumerating their fields in the manifest would drift the moment the schema changes. 52 such widgets fleet-wide expose 124 process fields. Closing those needs a server-side "system-owned" marker, which OpenRegister does not have — `readOnly:true` has no bypass for backend callers and would break the transition that writes the field. Filed as ConductionNL/openregister#2644. Inserted textually, one compact line per widget, so the diff is the change and nothing else: a full JSON re-serialisation reflowed hand-compacted lines and turned this into thousands of lines of churn. A verifier re-parses both files and asserts the only structural difference is the added overrides, and that no non-process field was locked.
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ❌ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ❌ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| composer | ❌ | ❌ | |||
| npm | ✅ | ✅ 634/634 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 21:31 UTC
Download the full PDF report from the workflow artifacts.
…60820212954 chore(release): 0.1.1-unstable.20260820212954
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 634/634 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 21:56 UTC
Download the full PDF report from the workflow artifacts.
…60820215532 chore(release): 0.1.1-unstable.20260820215532
gate-47 security-change-has-tests failed this PR: it touched an
authorization predicate without a test co-change. It is right to.
The change itself is behaviour-preserving — `requireCourseReadAccess()`
ended in `return $userId !== '';`, and the `$userId === ''` guard above it
has already returned false, so that condition could not be reached as
false. PHPStan 2 reports it, and it is now a plain `return true`.
But "a `return true` at the end of an auth predicate" is exactly the shape
that should not be taken on trust, so this adds the tests that make the
decision falsifiable:
- testListCoursesForbiddenWhenUidIsEmpty — the arm that makes the old
condition unreachable. Without it, deleting the empty-uid guard would
go unnoticed.
- testListCoursesAllowedForAuthenticatedNonAdmin — the allow arm, which
is deliberate: OpenRegister's RBAC inside ObjectService is the
per-object gate that scopes the rows.
The anonymous arm was already covered.
I verified these tests can actually fail rather than merely pass: with
`requireCourseReadAccess()` short-circuited to `return true` on its first
line, the suite goes to 1 error + 1 failure. A test that cannot fail would
have satisfied the gate while proving nothing.
Also corrects the method docblock, which asserted "it does not return true
unconditionally". The last statement now does, so the docblock says what is
actually true instead: the method as a whole is not unconditional, both
deny arms return above it, and all three arms are pinned by tests.
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ❌ | ||||
| check-specs | ✅ | ||||
| format | ❌ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ❌ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 22:21 UTC
Download the full PDF report from the workflow artifacts.
…60820222030 chore(release): 0.1.1-unstable.20260820222030
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 634/634 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 22:40 UTC
Download the full PDF report from the workflow artifacts.
…chemas" This reverts commit 1609225. The change broke object creation across the app, caught by e2e: "submitting the AccessibilityFeedback create form should either navigate off /new or show a success confirmation" — 1 failed, 427 passed. `tenant_id` is in `required` on 116 of the 117 schemas it appears on, and nextcloud-vue's field pipeline DROPS readOnly properties by default (src/utils/schema.js:487): if (prop.readOnly === true && !includeReadOnly && overrides[key]?.readOnly !== false) return false That pipeline feeds BOTH the data widget and the form dialog, so marking the field readOnly removed it from the create form, the payload omitted it, and the object then failed its own `required` validation. The server side was never the problem — readOnly is not enforced on CREATE — the client simply stopped offering the field. So `readOnly: true` is unusable for any property that is also `required`: it has to be settable at create and immutable afterwards, and the schema vocabulary has no way to say that. That is the same gap as ConductionNL/openregister#2644, from the other direction, and it is now noted there. openconnector#1316 is unaffected and stays: all 26 of its readOnly fields are `uuid`, none of them `required`. procest keeps its 6 pre-existing ones; the single field this sweep added there (automaticAction.tenantId) is required and is being reverted too. The field-lock commit on this branch (overrides.<field>.editable:false) is untouched — it does not use readOnly and does not affect create.
…60820223818 chore(release): 0.1.1-unstable.20260820223818
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 634/634 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 23:03 UTC
Download the full PDF report from the workflow artifacts.
…60820230236 chore(release): 0.1.1-unstable.20260820230236
Quality Report — ConductionNL/scholiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 634/634 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-20 23:35 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 651/651 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-25 06:01 UTC
Download the full PDF report from the workflow artifacts.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 06:36 UTC
Download the full PDF report from the workflow artifacts.
…orted success (#597) OpenRegister control parameters carry a leading underscore, and an UNRECOGNISED parameter is not ignored — it is applied as a PROPERTY FILTER. So `?limit=200` asks for objects whose `limit` property equals 200, no object has one, and the endpoint answers HTTP 200 with a well-formed `{"results":[],"total":0}`. Measured against the running dev instance: ?_limit=200 -> total=3 ?limit=200 -> total=0 ?bogusprop=xyz -> total=0 (same shape — it really is a filter) That third row is the point: a bare control parameter is indistinguishable from a nonsense property name. `resp.ok()` is true, the list is empty, and every one of these views renders its empty state and reports success. 47 occurrences across 24 view files, every one an OpenRegister object-API call (the `fetchList()` helpers build `/apps/openregister/api/objects/learniq/${schema}?${query}`). One call in the whole app already used the correct form. #591 fixed exactly this in the e2e specs and noted the consequence there: "This spec ran 0 of its 4 tests on every green CI run." The same typo was in the product code the specs exercise, which is why those tests began failing the moment they started running — they were finally reaching views whose queries return nothing. Swept the rest of the fleet: decidiq, dossiq, stackiq and hermiq have zero bare control parameters. This is learniq-only. The diff is 47 single-token changes inside string literals and touches nothing else.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 07:30 UTC
Download the full PDF report from the workflow artifacts.
… and development (#601) The event suffix applied only when ref_name was main or development, so on every other branch push and pull_request computed the SAME group and cancel-in-progress made them kill each other — contradicting the comment above it, which already says push, dispatch and schedule each get their own lane. Reachable since the push allow-list widened on 2026-08-14 to include feat/**, fix/**, perf/**, refactor/** and chore/**: those branches now get both a push run and a pull_request run for one commit. quality / Quality Report is a needs:-gated aggregator that reports FAILURE when its dependencies are cancelled, so this shows up as a red gate on a PR that was never evaluated, and re-running collides the same way. Already applied to openregister, integriq and pipelinq, where cancellations went to zero. See ConductionNL/.github#563.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 10:20 UTC
Download the full PDF report from the workflow artifacts.
… renders (#603) * fix(e2e): history-mode routes, and a talk-widget anchor that actually renders 7 specs failed on development (E2E 7 failed / 430 passed, run 32833668787). Both files navigated with a `#` hash URL against a HISTORY-mode router, so every one of them asserted against the Dashboard. src/main.js builds the router with `createWebHistory(generateUrl('/apps/learniq'))`. vue-router strips that base from `location.pathname` and appends the untouched hash, so `/index.php/apps/learniq/#/sessions/<id>` resolves to `/#/sessions/<id>`, matches no declared route, and falls through `routesFromManifest`'s `/:pathMatch(.*)*` catch-all — which `redirect: '/'`s to the dashboard. All seven failure screenshots show "Administrator · Dashboard". Neither spec regressed: neither had ever run. Both probed their fixtures with `?limit=200`, which OpenRegister applies as a PROPERTY filter, so both skipped on every green run until #597 fixed that. The first run that executed them found this. course-authoring-ux (4 tests): plain path form, plus a URL guard so a fall-through names itself instead of timing out on `.course-builder__header`. talk-classroom-spaces (3 tests): the same routing fix, and a corrected assertion. `getByText('Class space')` / `getByText('Join call')` could not have passed on any page — those are manifest widget titles and nothing renders them: - CnDetailPage.getIntegrationProps() passes only `surface`, the object context and the widget def's `props` to an integration widget. The def's `title` is not among them. - The grid's `<h3>` is the other title path; showGridTitle() returns false for `showTitle: false`, which both layout entries set, and it only renders for consumer-supplied `#widget-<id>` slots anyway. - CnTalkCard draws its own header from the registry leaf label, t('nextcloud-vue', 'Chat'). (All three read from @conduction/nextcloud-vue 2.15.0, the version package-lock.json resolves.) The anchor is now CnTalkCard's own DOM (`.cn-talk-card__list` / `.cn-talk-card__empty`), which only the talk leaf emits — remove either widget from manifest.d/learning.json and this fails. Each test also asserts it reached a `[data-testid="cn-detail-page"]`, so the dashboard fall-through can never read as a pass again. Talk (spreed) is not installed in the CI e2e job, which enables only openregister. That is now an asserted precondition rather than something skipped around: with Talk absent the widget must still mount and show its empty/degraded line. Also removes findRow()'s `?? rows[0]` fallback, which made the `matches` predicate vacuous — the enrolment-scoped test asked for the Session belonging to a specific Cohort and would have been handed an arbitrary one, with its skip guard unable to fire. That premise is now asserted explicitly. * fix(course-authoring): vuedraggable 4 item slot, and schema slugs that 404 Two genuine product bugs in CourseBuilder / LessonComposer, found once the e2e specs stopped landing on the Dashboard and actually reached the builder. 1. vuedraggable 4 requires an `#item` slot ------------------------------------------- All three `<Draggable>` blocks used the Vue 2 API — a `v-for` in the default slot. vuedraggable 4.1.0 (this repo is on Vue 3.5.41) throws from computeNodes(): Error: draggable element must have an item slot That render error is visible in the page snapshot of every failing test, in place of the Modules list. It killed the whole section's render, so the list never appeared and neither did any module added through the form beneath it — which is exactly what `getByText('e2e Module A')` could not find. Converted all three to `#item="{ element, index }"` with the required `itemKey` prop (`id`, `id`, `blockId`). v4 sets each node's key from itemKey itself, so the old `:key` bindings are gone with the v-for. 2. A multi-word schema key is not an addressable slug ------------------------------------------------------ `POST /api/objects/learniq/CourseTemplate` answered **404** — captured in the Playwright trace, alongside the app's own `[CourseBuilder] saveAsTemplate error Error: CourseTemplate create failed: 404` and the "Failed to save the template." alert in the DOM. OpenRegister resolves a schema by SLUGIFYING the identifier. It is case-insensitive but does not convert PascalCase to kebab-case, so `CourseTemplate` lowercases to `coursetemplate` and matches nothing. The registered slugs (lib/Settings/learniq_register.json) are `course-template` and `curriculum-plan`. `Course` worked throughout only because its slug is the single word `course`. ExamCaseDossierView.vue already carries this finding, measured against a live instance: .../objects/learniq/ExemptionCase/<uuid> -> 404 .../objects/learniq/exemption-case/<uuid> -> 200 Fixed the three call sites that still had it, and swept the rest of src/ — no others remain. Also in this class, both silent rather than loud: - `fetchList('LtiToolPlacement', …)` in LessonComposer 404s, and fetchList returns `[]` on !ok — so the LTI tool dropdown was permanently empty and reported success. Now `lti-tool-placement`. - `fetchList('CourseTemplate', 'limit=200')` carried the BARE control param as well. Every other query in both files uses `_limit`; this one was missed by that sweep, and OpenRegister applies an unrecognised param as a property filter, so it returned `{"results":[],"total":0}` with HTTP 200. The `@param schema` docblocks said "OR schema PascalCase key" on all eight helpers — the instruction that produced the bug. They now name the slug and say what happens when you pass the key instead.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ❌ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 11:59 UTC
Download the full PDF report from the workflow artifacts.
…ors (#606) * fix(course-authoring): PATCH for partial updates; two wrong e2e locators Round 3 of the E2E fix: 7 -> 4 -> 3 failing. Three distinct causes, not one. 1. PRODUCT — a partial update must be PATCH, not PUT (spec :179, and :232) -------------------------------------------------------------------------- Error: unexpected fatal errors: Error: Lesson update failed: 400 at Proxy.updateObject / persistOrder / reorder OpenRegister routes `objects#update` (PUT) as a full REPLACE and `objects#patch` (PATCH) as a read-merge-write partial update (appinfo/routes.php 796-797, confirmed on the `development` ref this job installs). persistOrder() sent `{ order }` over PUT, which drops every omitted field; `Lesson.required` is `[courseId, name, order, contentType, tenant_id]`, so the write was rejected 400 and lesson reordering could never be saved. The docblock asserting "PUT with only the changed fields" is what produced it. Both partial writers now PATCH: - CourseBuilder.updateObject() — every reorder and delete-renumber path - LessonComposer's save — body is `{ blocks }` only, same defect, not yet reached because :232 died earlier OR's optimistic-concurrency guard (`_expectedUpdated`) is opt-in, so a plain PATCH keeps the last-write-wins merge these callers want. 2. TEST — getByRole name is a SUBSTRING match (spec :232) ---------------------------------------------------------- strict mode violation: … getByRole('button', { name: 'Compose' }) resolved to 3 elements The module under test is named "e2e Compose Module", so its icon buttons carry accessible names CONTAINING "Compose" ("Move module 'e2e Compose Module' up", … down, "Delete module …"). The lesson's real Compose button was present in the snapshot alongside them. Added `exact: true` — the locator was ambiguous, the product was right. 3. TEST — asserting a banner the app navigates away from (spec :296) --------------------------------------------------------------------- No longer the CourseTemplate 404 fixed last round. The create now answers **201 Created** and the trace shows the app immediately GETting `/objects/learniq/course-template/<uuid>`: saveAsTemplate() sets `saveTemplateDone` and then `$router.push`es to CourseTemplateDetail in the same handler, so "Template saved." is replaced within a tick and was never observable. Now asserts the OUTCOME — landing on `/courses/templates/<id>`. That is the stronger assertion: it can only hold if the create succeeded AND returned an id, so the original 404 would still fail it. Worth a follow-up decision, deliberately not made here: that success banner is unreachable in normal operation. Either the navigation or the banner is redundant, and which one goes is a UX call, not a test fix. Also ran prettier over the touched files — `prettier --check` on the whole repo is clean, so this branch no longer needs the formatting-only #605 for these two files. * fix(course-authoring): unique block labels; two locators that left the page Round 4. Both remaining `locator.click` timeouts were the SAME shape — the element existed but was never enabled — for two different reasons. Neither was "slow". :179 — the lesson list rendered in the wrong order --------------------------------------------------- The call log repeated `element is not enabled` 58 times against "Move lesson 'e2e Lesson 2' up". That button is `:disabled="lIdx === 0"`, and the snapshot shows why: the list was [Lesson 2, Lesson 1]. Lesson 2 WAS first, so the control the test wanted was correctly disabled and would never enable. addLesson() computes `order: module.lessons.length + 1` and appends on resolve, so clicking Add twice without waiting leaves both creates in flight, gives both lessons `order: 1`, and makes the rendered order a function of which POST returns first. The test now waits for each row to land before adding the next — which is also what a user does — and asserts the creation order it depends on before exercising the reorder. :232 — identical accessible names on different controls (PRODUCT) ------------------------------------------------------------------ strict mode violation: getByRole('button', { name: /Move Rich text block up/ }) resolved to 2 elements `exact: true` cannot fix this: the two names are genuinely identical. A block has no name of its own, so a type-only label is the same for every block of that type — a lesson with two rich-text blocks gave a screen-reader user "Move Rich text block up", "…down" and "Remove Rich text block" twice each, with nothing to tell the pairs apart (WCAG 2.2 SC 4.1.2 / 2.4.6). This is a product defect, not a test defect, and this app already knows it: CourseBuilder's module and lesson controls disambiguate by naming the item ("Move lesson 'e2e Lesson 2' up"). Blocks were the one list that did not. All three block controls now carry the 1-based position, and the spec names the block it means. No catalogue carried the old strings, so nothing goes stale. :302 — a text locator that clicked the app's navigation -------------------------------------------------------- "Create course" is disabled until `instantiateForm.templateId` is set, so the 40s timeout was on a control that had never become enabled — on a page the test had already navigated away from. `getByText('Template').first()` is a case-insensitive SUBSTRING match, and the sidebar carries a "Course templates" link that sits earlier in the DOM than the panel. The frame URL in the trace tells the whole story: /courses/<id>/builder -> /courses/templates -> /courses/templates/<id> It clicked the nav link, and the following `getByText(templateName)` then clicked that template's row in the index. Now scoped to the instantiate panel and driven through the combobox/option roles, the pattern nextcloud-app.spec.ts already uses for NcSelect. * fix(lesson-composer): omit null block payloads; match the option by text Round 5: 3 -> 2 failing. The lesson reorder (:179) is green — the ordering fixture held. :251 — saving a lesson's blocks was rejected 400 (PRODUCT) ------------------------------------------------------------ "Lesson saved." never appeared because the write failed. The server said exactly why: [ObjectsController] Validation exception in patch Property 'blocks.0.materialId' should be type 'string' but is 'null'. addBlock() seeds all four pointer fields (materialId, assessmentId, assignmentId, ltiToolPlacementId) to null for editing convenience, and save() persisted that shape verbatim. The schema does mark `materialId` `nullable: true` — but it also carries `$ref: "Material"`, and OR's validator does not apply `nullable` to a `$ref`-bearing property, so an explicit null fails where an ABSENT key is accepted. Blocks are now serialised for the wire: the required trio (blockId/type/order) plus ONLY the payload field that block's type actually populates. That is the shape the schema itself describes — "Each block carries exactly one payload matching its type" — so this is the contract, not a workaround. Applied at the save boundary rather than in addBlock() so blocks LOADED from the server, which may already carry nulls from earlier writes, are normalised too. An empty string survives: '' is a richText block the author deliberately emptied, not an absent payload. The "Lesson saved." assertion is kept as-is: save() sets `saveDone` and does NOT navigate, so unlike the template banner this one is genuinely observable. The 400 was the only reason it never showed. :329 — the option was there; the accessible name had a space in it ------------------------------------------------------------------- The combobox fix worked — the dropdown opened and the option rendered. The snapshot shows why the click still missed: option "e2e Template 178 7662941039" NcSelect renders a label across several elements and accessible-name computation joins them with a space, so the name carried a space the template name never had and `{ name: templateName }` could not match. Now filtered on textContent via `hasText`, which concatenates without it.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 14:07 UTC
Download the full PDF report from the workflow artifacts.
The E2E cell went green at 436 passed / 0 failed — but one of the seven specs
this work set out to fix was not among them. It was SKIPPED:
- 443 talk-classroom-spaces.spec.ts:302 ›
a session tied to a cohort with active enrolments still renders
the join-call widget
A skipped test reads as a pass and asserts nothing, which is worse than the
red it replaced.
Why it skipped
---------------
Its guard looks for `e.lifecycle === 'active'`. Enrolment.lifecycle DEFAULTS
TO 'pending' and tests/e2e/seed-example-data.mjs never set it, so no active
enrolment has ever existed on a seeded instance and the guard matched nothing.
This was latent, not new: the guard could not fire before either, because
findRow() ended in `?? rows[0]` — any miss silently returned an arbitrary row,
so the test asserted against the wrong Session while looking like it passed.
Removing that fallback (earlier in this branch) turned the wrong-object pass
into an honest skip; this makes it an honest RUN.
The seeder now marks the first of the two demo enrolments `active`. The
Sessions above it are already seeded against the same cohort, so an active
enrolment and a Session genuinely share a cohortId. The schema calls lifecycle
engine-managed ("do not set directly"), but this is fixture data for a
scenario that is *about* an enrolled learner, and this file already seeds
lifecycle directly elsewhere (published / drafted / issued / queued).
Making the trap non-repeatable
-------------------------------
`test.skip(!row, …)` cannot tell "this instance was never seeded" from "the
seeder was supposed to create this and didn't" — and quietly reports the
second as success. requireFixture() splits them, using the same
`.e2e-state/seeded-schemas.json` signal detail-pages.spec.ts already reads:
skip only when the instance is UNSEEDED, otherwise fail with a message naming
the seeder. Applied to all three fixtures in this suite.
Verified the other six specs genuinely ran: ✓ 364, 365, 366, 367, 441, 442.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 22:22 UTC
Download the full PDF report from the workflow artifacts.
…roundJob (#612) Nextcloud's convention for background jobs is lib/BackgroundJob/; lib/Cron/ is a directory the app framework has no notion of. Part of the 2026-08-25 fleet structure audit (ADR-100 Decision 3). Moved lib/Cron/LtiAgsScorePollJob.php and its unit test, with namespaces, @Package tags and the test's use statement. The line that would have broken silently is appinfo/info.xml's <job>, which registers by fully-qualified class name. A move without it leaves the registration pointing at a class that no longer exists — and Nextcloud does not fail the install for that, the job just never runs, which is indistinguishable from a job that ran and found nothing to do. Verified: the class named in info.xml resolves to a real file, its declared namespace matches its path, and both files pass php -l. The active openspec change rename-to-learniq listed lib/Cron/ as a sweep target and is updated. The archived 2026-07-13-lti-tool-placement references are left as they are — they record what was true then. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Part of the 2026-08-25 fleet structure audit (ADR-100 Decision 2: the repository root is a closed set; generated files are never tracked). Ignore rules added: .stale/ `.stale/` was missing from ALL 19 fleet repos and is the one that matters most operationally: agent scratch there grew unbounded and filled the dev disk once already. Refs ConductionNL/hydra ADR-100. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ⏭️ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ⏭️ | ||||
| eslint | ⏭️ | ||||
| stylelint | ⏭️ | ||||
| build | ⏭️ | ||||
| composer | ⏭️ | ⏭️ | |||
| npm | ⏭️ | ⏭️ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ⏭️ | ||||
| REUSE | ⏭️ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-26 08:57 UTC
Download the full PDF report from the workflow artifacts.
…ashboard (#610) * fix(e2e): navigate by path, not by hash — the suite was testing the dashboard learniq's router is createWebHistory (src/main.js:186). Every e2e spec navigated with `#/…`, and in history mode the fragment is ignored: the router resolves the PATH `/index.php/apps/learniq/` and renders the Dashboard. 43 navigations across 19 files therefore never reached the view they name. Proven from the Playwright artifact of the failing run. In the page-snapshot block of the course-builder failure: course-builder 0 occurrences New module name 0 occurrences Administrator · Dashboard 1 occurrence and the app's own navigation in that same snapshot uses history URLs (/index.php/apps/learniq/learning), confirming the mode. The four course-authoring failures were not the bug — they were the only honest tests in the group. They assert something route-specific, so they failed. The rest assert `innerText('body').trim().length > 0`, which the Dashboard satisfies, so they passed while claiming to prove a route resolves. eportfolio.spec.ts is explicit about the claim it was not making: "A non-existent id is enough to prove the ROUTE resolves the registered PortfolioBuilder component (registry.js) …" PortfolioBuilder never rendered. Expect this to turn currently-green tests red. That is the point: those greens describe the Dashboard, not the pages under test. The failures it surfaces are pre-existing defects that had no way to be observed. * style(e2e): re-wrap two constants prettier now fits on one line Shortening the URLs by two characters brought PORTFOLIO_TEMPLATES_INDEX_URL and ASSESSMENT_RELIABILITY_URL under the print width, so prettier wants them joined. Formatting only — verified with the repo's own prettier config (@nextcloud/prettier-config).
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-26 10:34 UTC
Download the full PDF report from the workflow artifacts.
Four views called console.error whenever their record 404'd. Opening a stale bookmark, or a link to something since deleted, is an ordinary thing for a user to do — it is not an application fault, and logging it as one buries real errors in routine noise. It also means any check asserting "no console errors" fails on perfectly normal navigation, which is how learniq#610's honest routing surfaced these five e2e failures at once. PortfolioBuilder, PortfolioReviewView and TakeAssessmentView now render a not-found state quietly and keep console.error for genuine faults. The flag is set at the LOOKUP, not in the catch. All three await a single-object fetch AND a list inside one try block, so branching on `err.status === 404` in the catch would let a list failure take the quiet path. `err.notFound` is set only inside the single-object fetch, so it cannot. (The single fetch happens to run first today, which made the naive version look correct — correct by ordering luck is not correct.) PupilDossierTimelineView is DELIBERATELY excluded and says so in place. Every call in its loadAll() is a list query, where absence is HTTP 200 with an empty result set; a 404 there means the schema did not resolve. Treating that as "no dossier found" would report a misconfigured register as an empty timeline. It stays loud.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Found by gate-96 (manifest-copy-style, ConductionNL/.github#581). The em-dash is what the gate detects; it is not what was wrong with the sentence. before: "No sub-competencies found - this is a leaf node (learning outcome)." after: "No sub-competencies. This is a learning outcome, the lowest level in the tree." "leaf node" is a data-structure term. A teacher looking at a competency that has nothing beneath it needs to know that is normal and expected, not that the record occupies a particular position in a graph. The parenthetical carried the only word that meant anything to them, so it is now the subject. "found" also goes: an empty state saying "none found" hints at a failed search, when the accurate statement is that there are none. Verified: gate-96 0 findings over 1,274 strings, check:manifest PASS, check:schema-l10n PASS.
) OpenRegister resolves a schema identifier by slugifying it and matching the slug. `DossierNote` slugifies to `dossiernote`; the declared slug is `dossier-note`. All five schemas PupilDossierTimelineView loads are multi-word, so all five 404'd and loadAll() could never populate a timeline for any learner. Measured against a live instance: .../objects/learniq/DossierNote -> 404 .../objects/learniq/dossier-note -> 200 .../objects/learniq/BehaviourIncident -> 404 .../objects/learniq/behaviour-incident -> 200 and all five corrected slugs return 200. A single-word title hides this — `Course` slugifies to `course` and happens to resolve — which is why the same pattern reads as fine elsewhere. ExamCaseDossierView already carries this warning after the identical bug was found there; this is the same defect in the view nobody had reached yet. Found via learniq#620: the e2e failure that #618 deliberately left loud was a true report, and this is what it was reporting. Not the register-seeding fault I first attributed it to.
…the title and 404'd (#624) * fix(views): fetch OpenRegister schemas by slug — 16 references named the title and 404'd `PupilDossierTimelineView` — this app's one genuine custom page — fetched `/api/objects/learniq/BehaviourIncident`. OpenRegister resolves that segment by uuid, numeric id, or case-insensitive SLUG (`SchemaMapper::findInIds`) and by nothing else, and the slug is `behaviour-incident`. All six of its fetches 404'd, so the timeline has never rendered a timeline. The e2e suite caught it only after #610 stopped navigating by `#/` — before that the test landed on the dashboard and passed. ## Why 16 broken references survived review `lower(slug)` matching means a SINGLE-WORD title accidentally works: `Course` lowercases to `course`, which IS the slug. Twelve such references exist in this repo and every one of them resolves correctly. Only a multi-word title breaks. So the wrong rule — "pass the title" — looks right almost everywhere it is applied, and fails only on the schemas whose names happen to have two words. Fixed, all of them argument positions rather than URL literals: - PupilDossierTimelineView — dossier-note, behaviour-incident, wellbeing-check-in, learning-plan, support-request, deliberation-record - PortfolioBuilder — portfolio-template, portfolio-entry, and two picker branches that already had the slug in hand as `kind` and passed the title beside it; they now pass `kind` - PortfolioReviewView — the REFERENCE_SCHEMAS map's two multi-word entries (whose own keys were already the slug) and portfolio-entry - CourseBuilder — course-template - GroupPlanSubgroupLearnerContext — group-plan-subgroup, learning-plan ## A second, quieter bug in the same method `fetchSchema('DeliberationRecord', 'limit=500')` used a BARE control param. OpenRegister reads a bare `limit` as a PROPERTY filter, so that asked for records whose `limit` property equals 500 — of which there are none. It returned an empty list with HTTP 200: no error, no deliberations, and nothing to say why. Now `_limit=500`. ## The guard, and the first version of it that did not work `tests/unit-js/openregisterSchemaRefs.test.mjs` fails when a literal schema identifier does not lowercase onto a known slug, checked against all 118 schemas in `lib/Settings/learniq_register.json`. Worth recording: the FIRST version of this guard scanned `api/objects/learniq/<literal>` URLs, and it PASSED on the broken code. Every broken call site reaches a helper whose URL is built as `api/objects/learniq/${schema}`, so the identifier is an argument and never appears in a URL literal. A check aimed at the wrong population reports the same thing whether or not the bug is present. The version here scans `fetch*(` arguments and `schema:` values, and — verified by reverting the fixes — names all 16. Two of its arms assert that the register parses and that src/ still contains matches, so a moved file or a stale regex cannot turn the suite green by matching nothing. ## Verification - `test:js-unit` 9 pass; reverting `src/views/` fails it with all 16 named. - eslint clean on all six files. Also fixed 4 pre-existing jsdoc warnings in CourseBuilder: two orphaned single-line docblocks stacked above a second docblock, so the parser saw neither. - `check:specs`, `check:register`, `check:manifest` PASS; `npm run build` compiles. The `catch` in `loadAll` deliberately does NOT treat 404 as "empty", and its comment explains why: a 404 on a LIST means the schema did not resolve, which is a misconfigured register and must stay loud. That reasoning was right, and it is what surfaced this bug — left exactly as it was. * fix(copy): drop the em-dash from the competency empty state (gate-96) `progress.json` widget empty state read: No sub-competencies found — this is a leaf node (learning outcome). voice.md §8 bans em-dashes; hydra gate-96 (manifest-copy-style, added 2026-08-26) now enforces it mechanically. Replaced with a period, which is what the gate's own guidance suggests and reads better here anyway: the two halves are separate statements, not an aside. Included in this PR rather than its own because gate-96 is FULL-TREE, not diff-scoped. It fails every PR raised against this app until the tree is clean, so this one string was blocking a change that has nothing to do with it. Checked: `check_manifest_copy_style.py` now reports 0 failures over 1274 manifest strings; `check:manifest` structural lint passes; the string is not a translation key (no l10n/ file references it), so no catalogue is orphaned, and `check:l10n-js` still reports every locale up to date. * Revert "fix(copy): drop the em-dash from the competency empty state (gate-96)" This reverts commit b6c4970.
…onents deleted (#626) * feat(dashboards): declare KPI tiles instead of writing them — 11 components deleted Eleven bespoke KPI components are gone, replaced by `type: 'stat'` widget definitions that resolve to the shared `CnStatWidget` through the dashboard widget registry. Net 576 lines deleted against 211 added. ## What was wrong with them `KpiCard` was the only one doing real work; nine wrappers existed solely to pass it four props. But it re-implemented fetching next to rendering, and: 1. **It swallowed failure into a zero.** `catch { this.count = 0 }`. A backend outage rendered as four confident zeroes on the People dashboard. Zero is a number a reader believes; "no data" is not. 2. **It could display 1 for any schema.** It fetched `_limit=1` and read `data.total ?? data.count ?? results.length`. The fallback is the length of a deliberately-1-row response, so any envelope lacking `total`/`count` shows "1" — and looks entirely plausible. 3. **KpiEngagementScoreWidget averaged client-side over `_limit=200`.** Past 200 engagement scores the average silently described a subset. It is now `metric: 'avg', field: 'score'`, computed server-side over every row. `CnStatWidget` aggregates through OpenRegister's aggregation API, so there is no row limit to exceed, and it renders a real error state instead of a number. ## Converted - PeopleDashboard: learners, active enrolments, cohorts, open attendance flags - LearningDashboard: courses - LearniqCompliance: regulations, signed attestations, external training - LearniqDashboards: courses, cohorts, learners, active enrolments, open attendance flags, open engagement flags, avg engagement score `LearniqCompliance`'s `widgets` moved from `data` to a computed so its titles can be translated. They were plain English strings while the tiles they fronted were localised — half a dashboard in English for a Dutch reader. Every `schema` is the OpenRegister SLUG, verified against all 118 schemas in `lib/Settings/learniq_register.json`. This matters more than it looks: the segment resolves by `lower(slug)`, so a multi-word title 404s while a single-word one works by coincidence. ## What deliberately stays `KpiPointsLevelWidget` remains a component. It reads the current user's `learner-engagement` row and then resolves `levelId` against `engagement-level` — a join across two schemas that no single aggregation config expresses. Its docblock now records exactly what would replace it: an app endpoint returning the joined record, then `endpointSource` + `caption: '{levelName} · {currentStreakDays}-day streak'`, using the caption interpolation added in nextcloud-vue#787. Follow-up, not forgotten. ## Verification - `eslint src/views/` — 0 errors (13 warnings, all pre-existing `v-html` and jsdoc in files this change does not touch). - `npm run build` compiles. - `check:specs` Ajv PASS, `check:register` PASS, all 37 locales up to date, gate-96 clean. - Deletion safety: each of the 11 was confirmed to have zero remaining references before removal; the only mentions left were a doc comment in KpiPointsLevelWidget, which is corrected here. * style: prettier formatting for the declared KPI tiles `npm run format` rewraps the `source` objects in the widget definitions onto multiple lines. Cosmetic only — no key, value or nesting changes; eslint and the unit tests are unaffected. Split from the migration commit so the reviewable diff there stays the substance (11 components deleted, 16 tiles declared) rather than line wrapping.
…phaned (#617) * fix(repair): remove the oc_jobs rows the Cron -> BackgroundJob move orphaned MEASURED ON A LIVE INSTANCE, not inferred. After the equivalent move merged for opencatalogi, that instance's oc_jobs still carried OCA\OpenCatalogi\Cron\DirectorySync OCA\OpenCatalogi\Cron\RetentionEvaluation next to their BackgroundJob replacements — rows naming classes that no longer exist. This app's move has the same shape and therefore the same gap. WHY THE MOVE ALONE COULD NOT DO THIS. appinfo/info.xml's <job> entries are a REGISTRATION instruction, not a description of state. On upgrade Nextcloud ADDS any job it does not already have; it never removes one whose class disappeared, because it cannot distinguish a renamed class from one merely unavailable this boot. So the rename leaves the instance holding both rows. The orphan is not inert: JobList cannot instantiate a class that does not exist, so every cron tick reaching that row fails to build it and LOGS rather than raises — the quiet kind of broken, on an instance where the replacement job runs fine and nothing looks wrong. Every class from the retired namespace is listed, including ones info.xml never registered: they lived in the same namespace, and an instance that ever registered one by hand carries the same dead row. Removing a registration that was never there costs nothing. Idempotent (a fresh install removes nothing) and never raises — a repair step that aborts trades a dormant job row for an instance that will not start. Registered post-migration only. Tested: 4 tests, 7 assertions, green. They assert that every removed name is in the retired Cron namespace and that NO surviving BackgroundJob registration is ever touched — the failure mode of a future "remove anything matching Cron" edit — plus one arm naming the class this app actually registered, so the anti-widening arm cannot pass on a step that removes nothing at all, and one pinning continue-after-failure. * style(repair): put the file docblock above declare(), as phpcs requires phpcs failed with one blocking error: 4 | ERROR | Inline doc block comments are not allowed; | use "/* Comment */" or "// Comment" instead A docblock that FOLLOWS declare(strict_types=1) is an inline docblock, not a file docblock. Every existing lib/Repair/*.php in this repo puts it first; the header now matches, with the same @category/@Package tags. Worth recording how this slipped through: I checked the convention by reading line 2 of a sibling file, which is BLANK — the docblock starts at line 3. The detector reported "declare-first" for a repo that is docblock-first. And I verified the fix with `phpcs | grep -c "| ERROR"` against COLOURED output, where the ANSI escapes sit between the pipe and the word, so the pattern never matched and a failing file read as clean. Both readings are now taken with `sed "s/\x1b\[[0-9;]*m//g"` first. Also adds reasoned @SPEC exclusions on the public methods: exclusions rather than links because no capability spec covers this move — ADR-100 Decision 3 is an architecture record, and the jobs behaviour is unchanged, only where their classes live. * fix(phpstan): annotate the one call that must pass a plain string phpstan: Parameter #1 $job of method OCP\BackgroundJob\IJobList::remove() expects class-string<OCP\BackgroundJob\IJob>|OCP\BackgroundJob\IJob, string given. The narrow type is correct for the callers it was written for — code REGISTERING a job has the class. This step RETIRES one, and the class is gone by construction: that is the entire reason the oc_jobs row has to be removed. A class-string is unobtainable here, and remove() only uses the value as the `class` column to delete on. Annotated at the call site rather than added to a baseline, so the reason travels with the code instead of living in a file nobody reads. Verified with phpstan on the file: [OK] No errors. phpcs still reports 0 blocking errors. * fix(psalm): suppress the two findings a repair step necessarily produces psalm reported both on this file: UnusedClass: Class ...\Repair\RemoveRetiredCronJobs is never used ArgumentTypeCoercion: Argument 1 of IJobList::remove expects IJob|class-string<IJob>, but ...Cron\PinReconcileJob provided Both are correct readings of code psalm cannot fully see. UnusedClass: Nextcloud instantiates repair steps from the <repair-steps> block in appinfo/info.xml. That is XML; psalm reads PHP, so it sees no caller. Worth naming why the sibling steps do not trip this — unrelated docblocks happen to {@see} them, which is a coincidence, not a contract. The suppression sits on the CLASS docblock, the only place psalm reads it for this issue; my first attempt put it in a floating docblock inside the class body, where it suppresses nothing. ArgumentTypeCoercion: the same tension phpstan flagged. remove() is typed for callers REGISTERING a job, which have the class. This step RETIRES one, and the class is gone by construction — that is the entire reason the row must be removed. Both suppressions carry their reason inline rather than going into a baseline, so a future reader sees why rather than finding an unexplained entry. Verified with psalm on the file: "No errors found!" * fix(annotations): split the phpstan and psalm suppressions apart Combining both tags in one docblock before the call broke two tools at once: phpcs Inline doc block comments are not allowed phpstan Parse error in @PHPStan-Ignore: Unexpected T_OTHER "@psalm-suppress" phpstan reads everything after `@phpstan-ignore` on the following lines as part of its ignore expression, so the psalm tag became a parse error rather than being skipped. And phpcs forbids a `/** */` docblock before a statement at all, which is the only form psalm accepts for a statement-level suppression. The two tools want incompatible shapes in the same place, so they go to different places: - phpstan as a LINE comment at the call site (`// @PHPStan-Ignore ...`), which phpcs permits. - psalm on run()`s own docblock, a legal docblock position, with the reason written out there. Verified all four on the same file: phpcs 0 errors, phpstan [OK] No errors, psalm "No errors found!", and the unit suite green. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* fix(integration): resolve cross-app ids at call time
Eight paths into other fleet apps were pinned to a legacy app id as class
constants. A URL segment is a ROUTING KEY - Nextcloud mounts routes under
the registered app id - so the wrong name is a 404, and nothing reports
it as a defect.
The fleet is mid-rename and the halves disagree. From appinfo/info.xml,
the only authority, measured 2026-08-26:
branch integriq filinq
development integriq filinq
beta/main openconnector docudesk
So neither name alone is right: the new one 404s against beta/main, the
old one against development.
Adds FleetAppId, which holds a LIST of candidate ids newest-first and
returns whichever the instance actually registered. The constants now
carry only the path AFTER the app segment; the segment is resolved when
the call is made.
PaymentInitiationClient api/payments/initiate
LtiAgsPullClient api/events/subscriptions/%s/pull
WalletOfferDelegationService api/eudi/credential-offers
LtiToolPlacementController api/lti/deployments/%s/launch
TimetableImportHandler api/sources/%s/run
WalletRevocationPropagationSvc api/eudi/credential-offers/%s/revoke
DataExchangeRunHandler api/sources/%s/run
ReportCardPdfDelegationService api/v1/documents/render (-> filinq)
None of these eight classes injects IAppManager, and threading it through
eight constructors, their DI wiring and their tests is a large change for
a shim that gets deleted once beta and main carry the new ids. So
FleetAppId::path() resolves the manager from the server container, guarded:
\OCP\Server::get() returns null with no container up, and an unguarded
call there turns a resolvable path into a fatal. Without a manager it
falls back to the canonical id, which is correct for current code.
Remaining matches under lib/ are docblock examples, not executable.
* fix(quality): satisfy phpcs named-params and phpmd StaticAccess
phpcs: internal calls must use named parameters - the self::resolve()
calls inside FleetAppId did not.
phpmd: FleetAppId is static by design, so it trips StaticAccess. Declared
as an exception via the rule's OWN exceptions property - not a baseline
entry and not an @SuppressWarnings, so the rule stays fully active for
every other class and the exception is one named class, visible in config,
with the reason attached.
* fix(quality): pass phpcs and phpmd, and drop two call sites that cannot
phpcs: my one-line docblocks on the seven path constants violated "Doc
comment short description must be on the first line". Expanded to the
multi-line form.
phpmd, and this one is a real retreat. Referencing FleetAppId pushed
LtiToolPlacementController and DataExchangeRunHandler from 12 to 13
dependencies, over the CouplingBetweenObjects ceiling. Measured against
origin/development: neither class was reported there, both were with my
change - I introduced it. A fully qualified call does not help; phpmd
counts the type reference either way.
So those two call sites are REVERTED to the hardcoded legacy id, with a
docblock saying so and pointing at ConductionNL/.github#580. They still
404 against a renamed instance. Fixing them means reducing those classes'
dependencies first, which is a separate change and not one to smuggle in
here. Six of the eight sites are resolved.
phpmd StaticAccess: \OCP\Server is now an audited exception, reached from
exactly one place - FleetAppId::path(). shillinq already carries the same
exception for the same reason. Verified the exception is narrow rather
than a blanket disable: with it removed from a copy of the ruleset the
violation is reported again, and with it present the count is zero.
* test: assert the resolved app segment, not a pinned id
Two tests asserted the delegation URL contained a hardcoded app segment:
/apps/docudesk/api/v1/documents/render
/apps/openconnector/api/eudi/credential-offers
Those segments are now resolved at call time, because the target apps
answer to filinq / integriq on development and docudesk / openconnector
on beta and main. Pinning either name asserts the environment the suite
happens to run in rather than the contract.
What must hold is that the path AFTER the segment is right, and that the
segment is one of the two ids the resolver may legitimately produce. Both
are now asserted, so the test still fails if the path is wrong or if the
segment becomes something neither branch uses.
---------
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* test(e2e): a fixture the seeder owes must FAIL when it is missing
`test.skip(!row, 'No X seeded in this environment.')` answers two different
questions the same way, and reports the second as a pass:
- the instance was never seeded -> the question cannot be asked; skip
- the instance WAS seeded and X is absent -> the seeder owes it; that is a
DEFECT, and it must fail
#608 fixed one instance of this after it cost an entire scenario: the
talk-classroom third test ran ZERO times on every green run, because
`Enrolment.lifecycle` defaults to 'pending', the seeder never set 'active', and
the guard matched nothing forever. Earlier still, the lookup ended in
`?? rows[0]`, so a miss asserted against an ARBITRARY row while looking green.
That fix was local to one spec. Twenty more of the same guard were left across
five other files, each able to fail the same way and none able to say so.
adaptive-release 3
course-authoring-ux 4
groepsplan 1
progress-tracking 2
report-card 10
All 23 raw guards are now `requireFixture()`. The helper moves to
`tests/e2e/seeded.ts` because THREE specs had already grown their own copy of
the seeded check, and a rule about what counts as covered should not be spelled
three ways.
⚠️ This is expected to surface work rather than stay quiet. Any scenario that
now fails was already not covering anything; the failure is the first honest
report of that. The fix for one is to seed the fixture, or to fix why the seeder
does not produce it — never to put the skip back.
Verified: prettier clean on every touched file, no root tsconfig so no
typecheck to run, and no assertion deleted or weakened.
* test(e2e): seed the fixtures fourteen scenarios were skipping for
Converting the skip guards turned fourteen scenarios red, and every one named
a fixture that simply is not there. Nine of them were not "missing a row" but
missing a SCHEMA: this seeder created zero report-period, report-card,
group-plan and group-plan-subgroup objects, so four whole surfaces had never
been exercised by anything.
Added:
Lesson published+ungated text, cmi5, one gated on a prerequisite,
and one drip-released (availableAfterDays)
ReportPeriod one locked-and-open, one composed, both scoped to the demo
CurriculumPlan and Cohort
GradeEntry a concept entry inside the locked period's scope, and a
published entry whose visibility window has not opened
ReportCard rapportvergadering-review (empty mentorComment), finalised
(citing that not-yet-visible entry), and published-to-parents
GroupPlan + an intensief subgroup that actually has members
Three things worth stating, because each is a way this could have gone quietly
wrong:
`isLocked` is NOT a stored field. The register declares it as a materialised
calculation — `lockDate` set AND `lockDate < now` — which is what
ReportPeriodComposeGuard reads. A period is therefore made locked by giving it
a lockDate in the PAST; writing `isLocked` would have set a property the schema
does not have, and OR would have been right to ignore it.
`releaseConditions[].lessonId` carries a `$ref`, and OR refuses an explicit
null on a $ref-bearing property while accepting an absent key — the same
distinction that made lesson blocks fail to save. The condition is only added
once there is a real lesson to point at.
The not-yet-visible GradeEntry uses a date far enough out that the fixture
cannot quietly expire. A fixture that stops matching on some future date
reopens exactly the hole this change closes.
Verified: every field checked against the register — no unknown properties, and
no required field left unsent. `node --check` passes. `.mjs` is outside this
repo's prettier glob, so its formatting is left alone deliberately.
* fix(report-period): a materialised calculation is not readable, so compute it
Three defects, all of them found by the same red run — the one the skip
conversion produced.
1. THE COMPOSE BUTTON COULD NEVER BE CLICKED.
`ComposeReportPeriodModal` gated on `period.isLocked === true`. `isLocked` is a
declared x-openregister-calculations entry with `materialise: true`, and it IS
computed — a create response carries `isLocked: true` for a past lockDate.
It is simply never returned again. Measured against a live instance:
POST (create response) -> isLocked: true
GET /objects/.../{id} -> field ABSENT
GET /objects/...?_limit -> field ABSENT
So the value existed for exactly one HTTP response and was unreadable
thereafter. `period.isLocked` was `undefined` on every fetch the dialog makes,
`=== true` was always false, and Compose stayed disabled no matter how long ago
the period locked. ReportPeriodComposeGuard survived only because it already
carries a defensive fallback — and its docblock says why, which means someone
had seen this shape before. Both sides now agree: prefer the materialised
value, else compute `lockDate` set AND in the past.
2. THE SUBGROUP LIST ADDRESSED A SCHEMA NAME, NOT A SLUG.
`/objects/learniq/GroupPlanSubgroup` — OpenRegister resolves by SLUG
(`group-plan-subgroup`). Slugs are case-insensitive, but case-insensitivity does
not insert hyphens, so it resolved to nothing and answered HTTP 200 with an
empty result set: indistinguishable from "the seeder made none". Every other
list constant in the suite already used the slug form.
3. TWO SEEDS WERE REFUSED BY A $ref LIMITATION.
`403 Unresolved reference: schema:///Lesson#` and
`schema:///CurriculumPlan#` — OpenRegister does not resolve a $ref that sits
inside an ARRAY ITEM, even when the target object exists. The gated Lesson can
drop it (`releaseConditions.items.required` is `["kind"]` alone). The finalised
ReportCard cannot: `subjectGrades.items.required` is `["curriculumPlanId"]`. So
the finalised card is seeded without subjectGrades, which serves the
finalised-card scenario, and the narrower "finalised card citing a not-yet-
visible GradeEntry" scenario is LEFT FAILING rather than skipped. A red test
naming a real platform limitation is worth more than a green one asserting
nothing.
Measured before this commit: 14 failed -> 9 failed, 437 -> 442 passed. Of those
9, three had already stopped being fixture problems and become real assertion
failures — which is the whole point.
* test(e2e): the manual-completion scenarios must pick an UNGATED lesson
Both asked only for contentType + published, which was unambiguous while
exactly one such Lesson existed. The seeder now also creates a release-gated
and a drip-delayed text Lesson, and LessonPlayer renders its LOCKED branch for
those — a branch with no footer, and so no 'Mark lesson complete' button.
For the text scenario that meant the assertion turned on whichever row the API
happened to return first.
The cmi5 scenario is worse, because it asserts the button is ABSENT: a gated
lesson shows no button either, so selecting one would have made it pass for
entirely the wrong reason and prove nothing about contentType at all.
Both now require the ungated shape adaptive-release.spec.ts already uses.
* docs(compose-modal): cite the live lock-date requirement
gate-16 is diff-scoped, so rewriting isLocked() made it a changed method that
must carry an @SPEC. Its siblings in this file all point at
openspec/changes/report-card-composer/... — a path that no longer exists, since
that change was archived. Those tags are dead references that pass the gate
only because gate-16 checks presence, not resolution.
This one cites the LIVE spec instead: openspec/specs/report-card/spec.md, whose
requirement defines locked as 'lockDate is set AND has passed @now' and mandates
that the GUARDS read isLocked directly. It says nothing about this dialog, which
is exactly why reading the same field here with no fallback went unnoticed.
* test(report-card): mark the unseedable scenario fixme, citing openregister#2179
This scenario needs a finalised ReportCard whose subjectGrades cite the
not-yet-visible GradeEntry. That object cannot be written through the object API
at all: OpenRegister refuses a $ref inside an ARRAY ITEM with
'403 Unresolved reference: schema:///CurriculumPlan#', and
subjectGrades.items.required is ["curriculumPlanId"] — the refused property is
the required one, so there is no valid object to fall back to.
openregister#2179 already tracked this from hermiq. I added learniq's three
instances to it: Lesson.releaseConditions[].lessonId (self-referential),
ReportCard.subjectGrades[].curriculumPlanId, Assessment.itemRefs[].itemId — the
last of which is why this seeder creates zero assessments.
fixme, not skip: it reports as a KNOWN failure rather than as a pass, and it
names an issue someone can close. Declared as test.fixme(name, fn) rather than a
bare test.fixme(true, …) statement, which would have applied to every test in
the describe and quietly taken the siblings with it.
* test(adaptive-release): the prerequisite gate cannot be seeded either
Same root cause as the report-card scenario, but it hides better, because the
fixture LOOKS present.
The scenario needs a Lesson whose releaseConditions carries a lesson-completed
entry POINTING AT a prerequisite. `lessonId` is a $ref inside an array item, so
OpenRegister refuses the write with 'Unresolved reference: schema:///Lesson#'
(ConductionNL/openregister#2179) — self-referential, refused all the same.
Dropping `lessonId` makes the write succeed, because items.required is ['kind']
alone. That is the trap. LessonReleaseEvaluator opens with:
$lessonId = (string)($condition['lessonId'] ?? '');
if ($lessonId === '') { return ['blocked' => false, …]; }
so a condition without a lessonId is treated as NOT BLOCKING. The row satisfies
the discovery predicate while being unable to lock anything — the page rendered
normally and the test failed on toContain('not available') against an entirely
ordinary lesson.
A toothless stand-in is worse than no fixture: it would let a green run claim
coverage of locking. fixme until #2179 lands, and the seeder now says outright
that this row is not a working gate.
* fix(manifest): drop the em-dash from the sub-competency empty state
gate-96 (manifest-copy-style) has been failing on learniq's development, not
just on this branch — I checked development's own gates job before assuming it
was mine. voice.md §8 bans em-dashes and double-dashes in user-visible manifest
copy; this was the one string breaking it:
progress.json pages[3].config.widgets[2].content.emptyText
'No sub-competencies found — this is a leaf node (learning outcome).'
Replaced the em-dash with a period, which is what the gate's own guidance says
to do. Verified by running the gate against the tree: 1274 manifest strings
checked, zero findings. No l10n catalogue carried the old string, so nothing
goes stale.
* test(e2e): report cards belong to the COMPOSED period, not the open one
The two review scenarios found their card, navigated to the review page, and
found no rows — 'element(s) not found' on Finalise and Reopen.
RapportvergaderingReviewView renders the grid inside a v-else:
v-if="period.lifecycle === 'open'" -> 'not composed yet' + Compose button
<template v-else> -> the grid, with Finalise / Reopen
so a card hanging off an OPEN period is invisible however correct the card
itself is. The card list query is NOT lifecycle-filtered, which is what made
this look like a data problem: I checked the query, saw no gate, and moved on.
The gate is in the template.
It is also what the domain says. Cards come into existence BY composition, so a
card on a period that was never composed is not a state the app can reach — the
fixture was describing an impossible world.
All three cards now hang off P2 (composed). P1 stays exactly as it was: two
scenarios discover it by shape (open + isLocked, and the lock-guard one that
matches a concept GradeEntry on its periodCode), so it is load-bearing even
though nothing references the binding — noted in place so it does not get
tidied away.
* fix(e2e): drop the `#` — this router is HISTORY mode, so the hash hit the dashboard
Five of the six remaining failures had one cause, and it was not the fixtures.
src/main.js builds the router with
`createWebHistory(generateUrl('/apps/learniq'))`. vue-router strips that base
from `location.pathname` and appends the UNTOUCHED hash, so
/index.php/apps/learniq/#/report-periods/<id>/review
resolved to `/#/report-periods/<id>/review`, matched no declared route, and
fell through `routesFromManifest`'s `/:pathMatch(.*)*` catch-all — which
`redirect: '/'`s to the DASHBOARD.
So every assertion in these scenarios was made against the dashboard. That is
why they reported `element(s) not found` for Finalise, Reopen and
"Compose report cards…", and why two of them matched their expected text
against a page beginning "Skip to app navigation … Learniq".
⚠️ THIS WAS INVISIBLE PRECISELY BECAUSE THEY SKIPPED. A scenario that stands
down for want of a fixture never navigates, so a broken URL costs nothing and
shows nothing. Seeding the fixtures made them navigate for the first time, and
they landed in the wrong place immediately.
The repo already knew: talk-classroom-spaces, course-authoring-ux,
detail-pages, index-pages and accessibility-conformance each carry this warning
in prose, added when it was fixed there. These four had simply not been reached.
⚠️ STILL OUTSTANDING, DELIBERATELY OUT OF SCOPE HERE: twelve other spec files
use the same hash form and currently PASS — while asserting against the
dashboard. That is the same hollow green this branch exists to remove, and
fixing them will turn several red for real reasons. It wants its own change
rather than being folded into this one.
Verified: prettier clean across the suite, eslint 0 errors.
---------
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Without target-branch, dependabot opens against the repository's DEFAULT branch (main). Branch protection refuses any PR to main that does not come from beta or hotfix/*, so every one of those PRs is unmergeable BY CONSTRUCTION and simply accumulates. Measured across the fleet: 15 repos had no target-branch at all, and their dependabot PRs all sit against main failing branch-protection/check-branch. The repos that already set it (humaniq, versioniq, thematiq, dossiq, shillinq, decidiq, buildiq) open against development and merge normally. Cooldown windows and open-pull-requests-limit are untouched.
* feat(nav): a Flows surface in this app, on the shared page types ADR-110 Decision 4. A flow is app-specific — it operates on this app's objects — so the authoring surface belongs here rather than behind a deep link to another app's list. The ENGINE stays single (ADR-065): these pages are a scoped view onto OpenRegister's one native flow store, not a per-app store. Two manifest pages and one settings entry, no component files: `type: "flows"` and `type: "flow-detail"` are shipped page types in @conduction/nextcloud-vue 2.19.0, scoped by `config.app`. Note the layout of the diff: entries are appended textually rather than by reserialising the manifest. A `json.dump` round-trip rewrote pipelinq's file as a 3,950-line diff for a 20-line addition — correct output, unreviewable change. * build(deps): @conduction/nextcloud-vue 2.19.0 for the flows page types Required by the manifest change: `type: "flows"` / `type: "flow-detail"` are rejected by the compiled validator in earlier versions, and CI installs with `npm ci` — so the LOCK is what decides, not the `^2.x` range. Several of these locks were pinned many minors back, which is why some lockfile diffs are large: npm restructures the nested tree (mostly @esbuild platform binaries under @nextcloud/vue) to satisfy 2.19.0's peers. No direct dependency other than @conduction/nextcloud-vue changes.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.