Skip to content

Release: merge development into beta - #501

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

Release: merge development into beta#501
github-actions[bot] wants to merge 120 commits into
betafrom
development

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

github-actions Bot and others added 8 commits August 20, 2026 13:05
…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
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/scholiq @ 97b7666

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.

github-actions Bot and others added 4 commits August 20, 2026 21:11
…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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/scholiq @ b16e1ab

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
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/scholiq @ 4f3315a

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.

github-actions Bot and others added 3 commits August 20, 2026 21:57
…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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/scholiq @ 0c24d62

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.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/scholiq @ a572ea5

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
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/scholiq @ 3cbe25d

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.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/scholiq @ 22c06ef

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.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 9bc6edd

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 6521db8

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 91af73a

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 437bb46

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ c8656fe

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ c274866

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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 70f9a05

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.

rubenvdlinde and others added 3 commits August 26, 2026 10:52
…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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ e0b67f2

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).
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ ebc0aca

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.

rubenvdlinde and others added 11 commits August 26, 2026 13:29
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.
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.

2 participants