Skip to content

Release: merge development into beta - #647

Merged
rubenvdlinde merged 43 commits into
betafrom
development
Aug 30, 2026
Merged

Release: merge development into beta#647
rubenvdlinde merged 43 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.

rubenvdlinde and others added 5 commits August 27, 2026 08:52
`fetchEngagement()` answered any failure with `catch { this.totalPoints = 0 }`,
so a learner whose engagement row could not be read was told they had zero
points — identical on screen to a learner who genuinely has none. On a tile
whose entire purpose is showing someone their own progress, that is the worst
available wrong answer.

The catch now records the error and the tile passes `:error` to CnStatsBlock,
which renders a dash and "Unavailable" instead of a number.

Two things deliberately left alone:

- The `else` branch still sets 0. An ABSENT engagement row really does mean no
  points; only the failed read was lying.
- `fetchLevelName()`'s `catch { this.levelName = null }` stays. A level name
  that will not resolve is legitimately absent — the tile just omits it — and
  it is a secondary lookup that should not blank out a points total it already
  read successfully.

The detail line (level, streak) is hidden while errored, so a stale level name
cannot sit under an "Unavailable" figure.

Verified against what is installed: `^2.19.0` ranged, `node_modules` holds
2.19.0, and that copy contains `hasError()` — without which `:error` would be
an inert attribute that renders nothing and still passes CI.
The manifest is data the renderer walks, not source the l10n extractor scans,
so nav labels, page titles and descriptions looked up keys that were never in
the catalogue. A missing key falls back to the English source, so a Dutch user
reads English across almost the whole app.

427 strings covering admissions, curriculum, assessment, proctoring,
portfolios, BSA, competencies and the BPV chain.

Dutch education has its own vocabulary and the literal translation is usually
the wrong one, so the domain terms are used:

  Learning plan template -> Sjabloon ontwikkelingsperspectief (OPP)
  Pass mark              -> Cesuur
  Credits (EC)           -> Studiepunten (EC)
  Proctoring             -> Surveillance
  Prior learning records -> EVC-dossiers
  Assessment             -> Toets
  Enrolment              -> Inschrijving
  Learner                -> Deelnemer
  Placement agreements   -> Praktijkovereenkomsten (POK)

Acronyms that ARE the Dutch term stay as they are: BPV, BSA, TLV, SWV, EC,
POK, WOR, OSO. Expanding them would make the labels longer and less
recognisable to the people who use them daily.

Strings already Dutch are left alone on both sides: Instructieniveau,
Praktijkovereenkomsten, Praktijkopleiders, Werkproces, Beoordeling,
Leerbedrijf.

Then `l10n:build`, because nl.json alone ships nothing: the frontend reads
OC.L10N from l10n/nl.js and a raw .json is not served from an app directory.

Verified: 0 manifest strings left without Dutch, keys DROPPED = 0 against the
pre-change catalogues, nl.js registers under "learniq" with 1,376 keys and
resolves "BPV placement" -> "BPV-plaats", check:l10n-js PASS.
…#637)

Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.67.0 to 8.68.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@nextcloud/router](https://github.com/nextcloud-libraries/nextcloud-router) from 2.2.1 to 3.1.0.
- [Release notes](https://github.com/nextcloud-libraries/nextcloud-router/releases)
- [Changelog](https://github.com/nextcloud-libraries/nextcloud-router/blob/main/CHANGELOG.md)
- [Commits](nextcloud-libraries/nextcloud-router@v2.2.1...v3.1.0)

---
updated-dependencies:
- dependency-name: "@nextcloud/router"
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruben van der Linde <rubenvdlinde@gmail.com>
#652)

The PHPUnit cell that runs the coverage ratchet fails with "scripts/coverage-guard.php predates merge-base comparison (it does not report the 'against' capability)". That message names a plausible cause rather than the measured one, but the underlying fact is simple: this copy is the 17,288-byte version and the workflow is written against the 35,733-byte canonical one, which additionally reports `deletion-neutral`. Without that capability, deleting well-tested dead code still reads as a coverage drop.

Measured across the 21-app fleet: 8 apps already carry the canonical copy and their ratchets run and report normally; 11 carry a stale variant. This is vendored-copy drift, the same shape as the six drifted app-manifest schemas.

Copied verbatim from ConductionNL/.github at quality-config/coverage-guard.php rather than patched locally. Reimplementing a gate has cost us before: a hand-rolled version found 1 finding where the real gate found 13.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 06c7cd3

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-27 10:05 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 102c816

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-27 11:17 UTC

Download the full PDF report from the workflow artifacts.

* feat(listeners): defer the Enrolment progress roll-up (ADR-078)

First leaf-app adoption of `ListenerDeferralService`. Until now only
openregister and openregister-authz used it.

`EnrolmentProgressRollupHandler` did its work INSIDE the write that triggered
it: creating a LessonCompletion ran a read for the active Enrolment, an
evaluation over the learner's lessons, and a second `saveObject()` — all before
the original write returned. gate-61 refuses that shape without a deferral or a
reasoned inline exception, and ADR-078 makes post-`*ed` work async by default.

A roll-up is the clearest case for deferring: nothing reads `progressPercent`
back in the same request, so inline bought only a slower LessonCompletion
write.

The listener now keeps its guards and defers; `EnrolmentProgressRollupJob`
does the read, the evaluation and the write.

DEDUPED per learner+course. A learner finishing ten lessons in one request owes
ONE roll-up, not ten — the roll-up recomputes from scratch, so repeating it
produces the same number nine times over. That is what makes deferring cheaper
rather than merely later.

## Stubs, and why their parameter names matter

learniq's tests map `OCA\OpenRegister\` at `tests/Stubs/`, so this needed
stubs for `ListenerDeferralService`, `DeferredListenerContext`,
`ActorForwardedJob` and `OrganisationService`.

Their signatures are copied from canonical openregister rather than invented,
and each says why in its docblock: these are called with PHP NAMED arguments,
which bind by name, so a stub with a plausible-but-wrong parameter name makes
an `Unknown named parameter` error invisible to psalm, phpstan and every
mock-based test. `ActorForwardedJob::$logger` is `protected` for the same
reason — a private stub would fail only at runtime.

That is not hypothetical: openregister#2921, opened today, fixes three TMLO
endpoints that answered 500 on every request for exactly this reason.

## Tests

The listener's test now asserts what the listener does — that it defers, with
the right entry and dedupe key, and writes NOTHING on the event path. The
"no active Enrolment" arm moved to `EnrolmentProgressRollupJobTest`, where that
decision now lives; leaving it behind would have asserted behaviour at a layer
that no longer has it.

The job's test adds an arm the inline version could not have: one failing entry
must not discard the rest of the chunk. Entries are batched now, so an
exception escaping the loop would drop every roll-up buffered after it —
silently, because the job still succeeds from the queue's point of view.

965 tests / 4,373 assertions pass. gate-61 reports 0 failures on the touched
registration. phpcs 0 errors, phpstan `[OK] No errors`.

Two more listeners carry the same defect — EngagementSignalHandler and
LearnerEngagementRollupHandler, both of which additionally run an UNBOUNDED
findAll() on the write path. They follow in this branch.

* feat(listeners): defer the LearnerEngagement roll-up and streak awards (ADR-078)

Second of the three. `LearnerEngagementRollupHandler` did all of this inside
the PointAward write that triggered it: a read, an evaluation, a
`saveObject()`, then an UNBOUNDED `findAll()` over every active
streak-milestone PointRule and a further `saveObject()` per crossed milestone.

gate-61 named that unbounded read on the write path specifically. It has not
become bounded — it has stopped being paid on every point a learner earns.
Reading every active rule once per queued chunk is a different proposition
from reading them on every award.

## The dedupe key carries `sourceKind`, and that is load-bearing

A milestone bonus award has a recursion guard: its own roll-up must not
re-check milestones. Deferring preserves that because `sourceKind` travels in
the entry — but only if the dedupe key distinguishes the two kinds.

Keyed on learner+tenant alone, a milestone award arriving FIRST in a request
would swallow an ordinary award arriving second, and the ordinary award's
milestone check — which the guard does not apply to — would be dropped
silently along with it. The key is `learnerId|tenantId|sourceKind`, and a test
pins it.

## Tests moved with the behaviour

The four behavioural arms — recompute, streak crossing awards exactly once,
the bonus award not re-triggering, no forward progress awarding nothing — are
now `LearnerEngagementRollupJobTest`, driving the job through `runDeferred()`
with the same fixtures. The listener keeps its schema guard and gains two arms
of its own: that it defers with the right entry, and the dedupe-key one above.

⚠️ Worth recording: after slimming the listener test, phpunit reported
"OK (1 test)" — green, and wrong. My two new tests had not been inserted
because the anchor I replaced on did not match. A passing run says nothing
about tests that are not in the file; the count does. Checked, fixed, now 3.

967 tests / 4,382 assertions. phpcs 0 errors, phpstan `[OK] No errors`.

* feat(listeners): defer the EngagementScore recompute and threshold checks (ADR-078)

Third and last. `EngagementSignalHandler` did the most of the three inside the
write that triggered it: recompute the EngagementScore, save it, then an
UNBOUNDED `findAll()` over every active EngagementRiskThreshold and, per
crossed threshold, further reads (cohort membership, open flags) and a flag
write — all on every xAPI statement.

xAPI statements arrive in volume. Paying a full threshold scan on each one is
precisely the shape gate-61 exists to catch.

The listener drops from 481 lines to 160: guards, then a defer. Nine private
methods move verbatim into `EngagementSignalJob` — verbatim on purpose, so this
change is "the work moved" and not "the work moved and quietly changed".

Deduped per learner+course: a burst of statements for one learner on one course
owes ONE recompute, since the score is recomputed from scratch.

## An invariant that had to follow the work

`testConstructorHasNoAiOrHermiqDependency` asserts this path takes no AI or
hermiq dependency. Left on the listener alone it would have kept passing while
the job — where the work now is — grew one. It now asserts against
`EngagementSignalJob`, and the listener keeps its own copy.

A test that guards a property of some work has to move when the work does, or
it guards an empty room.

## Tests

Five behavioural arms (recompute, first crossing, no duplicate while open,
relapse after resolution, cohort-scoped skip) move to `EngagementSignalJobTest`
with their fixtures. The listener keeps its schema guard and gains one arm: it
defers with the right entry and dedupe key, and writes nothing on the event
path.

969 tests / 4,401 assertions. gate-61 0 failures. phpcs 0 errors, phpstan
`[OK] No errors`.

All three listeners gate-61 flagged are now deferred. Both unbounded write-path
reads are off the write path.

* fix(tests): the DeferredListenerContext stub must carry canonical parameter names

learniq maps `OCA\OpenRegister\` at `tests/Stubs/`, so a unit run resolves the
deferral classes to stubs while CI installs the real app and resolves them to
openregister. The listeners call these with NAMED arguments, which bind by
name, so a stub whose parameter names differ from canonical passes every local
test and fails only in CI.

The stub declared `__construct(array $entries = [])`; canonical is
`__construct(?string $userId, ?string $orgUuid, array $entries)`. Eighteen job
tests passed locally and all six PHPUnit cells failed with

    Argument #1 ($userId) must be of type ?string, array given

Fixes, all measured:

- the stub now mirrors canonical, getters included;
- the three call sites pass named arguments, so the actor is visible at the
  point of use rather than implied;
- `StubSignatureParityTest` compares each stub's parameter NAMES against the
  real class. It reflects the loaded class when openregister is installed (the
  path CI always takes) and falls back to reading canonical off disk locally.
  It cannot silently verify nothing: resolving to the stub under CI is an
  explicit failure, and a local checkout with no openregister nearby skips
  loudly instead of passing. Mutation-checked by reintroducing the exact bug
  above - the test fails naming `userId`;
- psalm.xml allowlists the three ADR-078 deferral classes alongside the other
  cross-app OpenRegister types. Their absence, not the stub, was the psalm
  failure: psalm scans `lib` plus stubs and never sees the sibling app.

check:strict exits 0; 964 unit tests, 4406 assertions.

* test(deferral): cover the entry guards and per-entry catch the ratchet named

The coverage guard failed this PR: "coverage of the code this change KEEPS or
ADDS dropped by 7.32%" — 336/397 statements on the changed files against a base
of 80/87 (91.95%). Moving the roll-up bodies out of the listeners and into jobs
added 310 statements, and the branches that came with them were untested.

Measured the same 61 uncovered statements locally (pcov in a php:8.3-cli
container, since this machine has no coverage driver) and covered the ones that
carry a contract:

- **an entry missing an id is skipped** — the deferral buffer is fed by
  listeners, so a malformed entry is something that reaches the job, not
  something that cannot;
- **one failing entry does not lose the rest of the chunk** — the reason the
  per-entry catch exists at all: a throw out of the loop would silently drop
  every later entry. Mutation-checked by narrowing the catch to a class nothing
  throws; the test errors;
- **`isCrossed` for both metrics**, as data providers: strict comparison at the
  limit, and — the one that matters — a null/empty/unparseable `lastActivityAt`
  is NOT a crossing. "Infinitely stale" would have flagged every learner it
  touched;
- **an unknown metric never crosses**, rather than falling through to whichever
  branch is last;
- **`resolveMetricValue` picks its own metric's number**, days vs score;
- **milestone rules with no `milestoneDays` or no id award nothing** — a
  milestone at day zero is crossed by every streak, and a bonus award
  referencing no rule is worse than no award;
- **listener guards**: a non-ObjectCreatedEvent, and a payload with no
  learner/course, enqueue nothing.

Changed-file coverage 84.63% -> 96.22%, above the 91.95% base. 996 tests, 4444
assertions; check:strict exits 0.
…e endpoint (#659)

* feat(engagement): serve the learner's own points/level/streak from one endpoint

The points tile was the last bespoke KPI widget in the app. It could not be a
declared `type: 'stat'` tile because it needs a JOIN across two schemas —
`learner-engagement.levelId` resolved against `engagement-level.name` — which
no single aggregation config expresses.

It also could not tell the truth. Fetching twice from the client meant the two
halves failed independently: the level lookup's `catch { levelName = null }`
rendered "has points, no level", which is exactly what a learner who has not
reached a level looks like. A reader could not distinguish them.

GET /apps/learniq/api/engagement/me returns the joined record, so the whole
thing succeeds or fails as one:

    { totalPoints, levelName, currentStreakDays, summary }

and the tile becomes config — `endpointSource` + `valueField` + a caption —
with KpiPointsLevelWidget.vue deleted.

`summary` is composed server-side on purpose. CnStatWidget's caption resolves
a missing `{token}` to the empty string, so the obvious template
`'{levelName} · {currentStreakDays}-day streak'` would show a learner with no
level a leading orphan separator, and would say "0-day streak" where the old
tile showed nothing. `levelName` and `currentStreakDays` are still returned
separately — the summary is the display string, not a replacement for the
data. It reuses the existing `{days}-day streak` catalogue entry, so every
locale already translates it and no new source strings appear.

A failed read returns 500 rather than zeros. Serving 0 for "the store did not
answer" is the confident-zero bug this endpoint exists to remove: a mutation
reintroducing it fails testAFailedReadIsAnErrorNotAConfidentZero, and a
mutation letting a dangling levelId abort the request fails
testADanglingLevelIdDoesNotFailTheRequest. Both were checked, not assumed.

Also, in the files this touches: 66 `@spec`/`@e2e` tags pointed at
openspec/changes/engagement-gamification/, archived on 2026-07-16, so every
one of them dereferenced to nothing. Repointed to openspec/specs/engagement/.
All 16 distinct anchors were verified to resolve against the canonical spec,
with a control confirming the validator reports a fabricated anchor as dead —
the first slugifier I wrote silently "found" four dead tags that were fine,
because it dropped `/` and `.` instead of mapping them to `-`.

The e2e now asserts the tile's VALUE, not just its presence. That was
previously listed as an unmeasured gap and could not be closed while the tile
swallowed its own failure; bound to one endpoint, a placeholder dash means the
endpoint did not answer.

composer check:strict exits 0; 967 tests, 4386 assertions; npm run lint and
the webpack build are clean.

* fix(e2e): match a digit, not a numeric shape, in the points tile

The tile declares no `format`, so CnStatWidget formats through
`Intl.NumberFormat(undefined, ...)` and the RUNNER locale chooses the group
separator. Several locales use a non-breaking space, which the anchored
[0-9.,]+ pattern would have rejected for a perfectly correct number — a
locale-dependent red that says nothing about the endpoint.

/\\d/ plus the existing "no placeholder dash" assertion still separates the
three outcomes that matter: a value rendered, the loading placeholder, and the
error state.

* style(e2e): prettier

* style(dashboard): prettier — the whole-repo format check, not just the file I touched

CI runs `prettier --check "**/*.{js,ts,vue,css,scss}"`; I had only checked the
spec file. Verified with that exact glob rather than per-file this time.
…ADR-099 §3) (#655)

ADR-099 decision 3: "A directory under `lib/` holding exactly one class is not
a namespace. It collapses into `Service/`."

Eight concrete classes move, and every reference to them is rewritten (27
files):

    Analytics/EngagementScoreEvaluator      CourseEvaluation/CourseQualityScoreEvaluator
    Engagement/PointEngagementEvaluator     Payments/OrderTotalEvaluator
    PeerReview/PeerReviewAllocationService  Progress/EnrolmentProgressEvaluator
    Release/LessonReleaseEvaluator          StudyProgress/BsaProgressEvaluator

Safe to move because nothing names them as a STRING: no FQCN appears in
`info.xml`, the register JSON, or any config. They are DI-injected
collaborators referenced through `use` statements, so the rename is mechanical
and the autoloader fails loudly on a miss rather than silently.

## Two directories the rule should NOT have taken, and why

**The three `Provides*` interfaces stay put** — `Bpv`, `Plagiarism`,
`Proctoring`. Moving them broke a real test:

    SecureExamTestModeTest::testNoNewProctoringControllerServiceOrRouteExists
    "Learniq MUST NOT ship a Proctoring/TestMode service class"

That invariant guards ADR-022's "No new PHP" decision — proctoring session and
flag writes go through OpenRegister's generic object API, and the test globs
`lib/Service/*Proctoring*.php` to keep it that way. An INTERFACE is not a
service, but the glob is name-based and the ADR's rule is class-count-based, so
the two collide. The test encodes a deliberate architectural decision and wins;
the ADR needs an interface carve-out.

**`Support/FleetAppId` stays put.** FIVE fleet apps keep that same shared helper
at `lib/Support/FleetAppId.php` — openregister, openregister-authz, pipelinq,
learniq, shillinq. `Support` is absent from the ADR's vocabulary list, but
moving one app's copy while four keep theirs trades a tidiness gain for a
cross-app inconsistency in the same class. The vocabulary should gain `Support`.

`BackgroundJob`, `Portal`, `Sections` and `Settings` also hold one class each
and are all in the ADR's allowed vocabulary, so they are untouched.

## Verification

- Full suite 960 tests / 4,364 assertions, no failures (5 pre-existing skips).
  The proctoring invariant above is exactly why the suite was run before
  committing rather than after.
- phpcs 0 errors, phpstan `[OK] No errors`, psalm 0 errors, after
  `composer dump-autoload` — a stale autoloader would have hidden every missed
  reference behind "class not found".
- Grepped for surviving references to all eleven old namespaces: none.

No gate enforces decision 3 yet, so this does not unblock anything — it is the
decision applied by hand to one app. The other nineteen still drift.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ c485607

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-27 15:27 UTC

Download the full PDF report from the workflow artifacts.

dependabot Bot and others added 2 commits August 27, 2026 18:17
Bumps [vue-router](https://github.com/vuejs/router) from 4.6.4 to 5.2.0.
- [Release notes](https://github.com/vuejs/router/releases)
- [Commits](vuejs/router@v4.6.4...v5.2.0)

---
updated-dependencies:
- dependency-name: vue-router
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruben van der Linde <rubenvdlinde@gmail.com>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 45e1c8a

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-27 17:03 UTC

Download the full PDF report from the workflow artifacts.

The App Store keys everything on the app id, so renaming <id> to learniq made
it a brand new store entry starting from nothing. The release workflow
derives its version baseline from the git tags and info.xml of THIS repo,
neither of which knows anything about what shipped as scholiq -- so the
version line restarts below it. filinq was about to publish 0.0.40 while
docudesk sits at 0.1.0-beta.3 on the store.

The store has no version ordering rule (_check_permission validates
existence and ownership only), so that uploads with a 200 and is then
never offered to anyone already on the higher version.

previous-app-id folds the old entry's published versions into the
baseline, so the renamed app picks the line up instead of restarting it.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ c2dd286

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-27 18:21 UTC

Download the full PDF report from the workflow artifacts.

)

vue/new-line-between-multi-line-property, LearniqDashboards.vue:540. One
error, and it is the only thing standing between development and a green
Vue Quality job -- the other 19 findings in that run are warnings.

It reached development because every Code Quality run there since 09:54
was CANCELLED by the release loop's concurrency group rather than
completing, so nothing reported the regression.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ fc2b0e9

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 ✅ 648/648
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-27 20:45 UTC

Download the full PDF report from the workflow artifacts.

* feat(demo): generated demo data for every schema (ADR-111 rules 1-2)

This app declares schemas and shipped no demo data, so it opened on an empty
list: the person evaluating it had to author objects by hand against a schema
they did not know yet. Fleet-wide, 562 of 598 schemas were in that state.

🔴 GENERATED, NOT WRITTEN. Every value is derived from the schema that will
validate it — `enum` picks from the enum, `pattern` is satisfied, `format`
drives the shape, `minimum`/`maxLength` are honoured, `required` is always
populated. Hand-written demo data is wrong in a way nobody sees until the demo
(a status outside its own enum, a required field omitted) and it fails at
import, in front of whoever asked for the demo.

Produced and validated by the single copy that gate-99 also runs:
`vendor/conduction/hydra-gates/scripts/lib/generate_mock_register.py`.
Regenerate with that file; `--keep` preserves curated objects and tops up only
what is short.

🔴 IT DOES NOT INSTALL ITSELF (ADR-111 rule 3). `x-openregister.type: mock` is
imported ON DEMAND — sample data appearing on a production instance because
somebody upgraded is a data-integrity incident, not a convenience. Install it
with:

    occ openregister:descriptors:list --app=<app> --import=<register>

The setup-wizard step that offers this during first-run install (ADR-111 rule 4)
follows once OpenRegister's shared installer lands — deliberately not twenty-one
copies of the same logic.

Verified: `--check` re-validates every object against its own schema with
jsonschema and reports clean.

* fix(demo): emit real MDI icon names, so gate-60 stops rejecting the demo data

gate-60 (icon-vocabulary) failed the very PR that added demo data: the generator
emitted `Voorbeeld Icon 1` for properties named `icon`, and an icon outside the
canonical vocabulary "renders blank wherever it is not aliased locally"
(ADR-077).

🔴 THE FIX READS THE GATE'S OWN VOCABULARY FILE, never a copy of it. Carrying a
second list here is precisely how a producer and its judge drift apart, which
this package fixed once already today.

🔴 AND IT READS THE VALUES, NOT THE KEYS. The first attempt emitted the semantic
key — `documentation` — because that is what the map is keyed by. The map is
`"documentation": "BookOpenVariantOutline"`, and gate-60 accepts the MDI name:
it rejected `documentation` in the same breath as the invented name it replaced,
"a kebab-case or lowercase spelling of an MDI name resolves to nothing". Half
reading a vocabulary is not reading it.

Verified on portaliq: gate-60 PASS, zero gate failures. All seven apps
regenerate with zero validation findings.

* fix(demo-data): attribute the descriptor to the app id, not the directory

`x-openregister.app` is what the descriptor inventory resolves a register to
an app by, and the generator was writing the CHECKOUT DIRECTORY name into it.
Five of eight apps already ship an `<id>` that differs from their directory —
larpinq, learniq, humaniq, decidiq, buildiq — so those descriptors named an
app that does not exist, and the `occ openregister:descriptors:list --app=…`
command printed in their own description resolved nothing. A cross-app id is
a runtime lookup: it does not error, it silently finds nobody.

The id now comes from `<id>` in appinfo/info.xml, the stated authority,
falling back to the directory name only when there is no info.xml. The output
file is renamed to match and the directory-named one removed, so exactly one
mock descriptor remains per app — two would import demo data twice.

Register slugs are untouched: those are frozen per app.

* fix(lint): blank line between multi-line properties in LearniqDashboards

`vue/new-line-between-multi-line-property` was erroring on development
itself, not on anything this branch changed — the file here is byte-identical
to development's. Every branch cut from it inherits a red lint-check, which
is how the failure arrived on a PR that only touches a JSON descriptor.

eslint --fix, one blank line. `eslint src` now reports 0 errors (the 19
remaining warnings are pre-existing and do not gate).
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 5b1e9d3

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 ✅ 648/648
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-27 21:48 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ 900d8b3

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 ✅ 648/648
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-27 22:52 UTC

Download the full PDF report from the workflow artifacts.

The catch-all branch reported 'Nextcloud unreachable at $BASE' for every
seeder exit code that is not 0 or 2. Exit 1 is what the seeder returns
when it decides the instance is unreachable, and it is equally what node
returns for ANY uncaught exception -- so a crash was reported as an
outage.

Observed 2026-08-27 on #661: the seeder logged 'Nextcloud 34.0.3 at
http://localhost:8080 -- OK', imported 118 schemas and linked 118/118,
then died inside node's bundled HTTP client with
'AssertionError: assert(!this.paused)' at Parser.finish (undici, node
v24.19.0). CI reported 'Nextcloud unreachable' about a server that had
just answered a hundred and eighteen requests.

Probe status.php before naming a cause, and say which of the two it was.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ e9aa98a

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 ✅ 648/648
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-28 05:34 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde and others added 7 commits August 28, 2026 07:35
…661)

The App Store serves 0.2.0-beta.3 (scholiq) for this app, and the release workflow
was computing a beta BELOW it. The baseline comes from this repo's latest
stable tag and appinfo/info.xml, neither of which knows what was published
while the fleet released from Codeberg -- that line had walked up a minor
that only exists as prerelease tags, which the stable-tag filter cannot see.

The store has no version ordering rule (_check_permission validates
existence and ownership only), so a lower version uploads with a 200 and
is then never offered to anyone already on the higher one.

Setting the baseline to 0.2.0 makes the next beta 0.2.1-beta.<ts>,
which clears it. Minor bump only -- the major is unchanged.
#675)

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
.github#597 set cancel-in-progress on the shared quality.yml, but a
caller-level concurrency cancels the whole run before the called
workflows setting can apply -- so that fix reached only the apps that
declare no concurrency of their own.

Measured 2026-08-28 over push runs on development since #597 merged:

    caller silent          0 of 11 cancelled
    caller says true       7 of 13 cancelled  (54%)

This repo is in the second group. pull_request keeps cancelling, where
superseding really is correct.
* refactor(manifest): the flow pages are an index and a flow

`flows` and `flow-detail` are deprecated aliases. `flows` predates named index
sources: a flow lives in OpenRegister's native flow table rather than a
register/schema pair, so an object-backed index had nothing to bind to and the
list needed a page type of its own. `config.entitySource` closes that, so the
list is an ordinary `index` and only the EDITOR still needs its own type, now
named `flow`.

Behaviour is unchanged - both aliases resolve to the same components. The list
page's `_note` is updated in the same commit: it asserted the old rationale
(that an index "cannot address" a flow), which this change disproves.

`config.app` is untouched and is load-bearing: the editor stamps it on a flow
created there and the index filters on it.

Requires @conduction/nextcloud-vue 2.21, where a named source's columns and
create button are actually read.

* chore(deps): bump @conduction/nextcloud-vue to ^2.21.0

The flow pages need 2.21.0: earlier releases DECLARE a named index source's
columns, create button and row actions without reading them, so the migrated
page renders a columnless table with no working create action.

The lock is the part that matters. CI installs with `npm ci`, which honours
package-lock.json and ignores how permissive the caret is — bumping the range
alone would change nothing about what actually installs.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Adds `learniq-compose.yaml` and a setup page describing it.

The compose brings up Postgres and Nextcloud, installs openregister (required),
thematiq and integriq (optional) and learniq from release tarballs, and enables
them in dependency order. Nothing is bind-mounted: Nextcloud installs an app by
deleting its directory and extracting an archive over it, so pointing that at a
checkout deletes the working tree — measured on a development machine on
2026-08-27, where an app-store update fired on a container restart and removed
every top-level file including .git.

Release tarballs rather than a clone for a second reason: a tarball is a
complete app carrying vendor/ and the built js/, and an app with no vendor/
does not fail loudly — it warns once and keeps loading, so it looks installed
while every service needing a dependency is absent.

The openregister dependency is not declared in appinfo/info.xml — no app in the
fleet declares an <app> dependency — so the compose encodes what the manifest
does not.

Verified: docker compose config parses and interpolates; the same generated
file was booted end to end for portaliq, which produced 17 registers, 86
schemas and 13 magic tables for its own register, with the portal content API
returning a real site rather than an empty shell.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Two defects, both measured against a booted demo rather than inferred from
the code.

The verification step told you to run

  curl -s -o /dev/null -w '%{http_code}' http://localhost:PORT/apps/APP/

and described a pass. That request is unauthenticated, and a Nextcloud app
page requires a login, so it prints 401 on a perfectly healthy demo. A reader
following the page would conclude the demo was broken. The command now sends
the demo credentials and says in words that a bare 401 is expected.

Thematiq additionally has no app route at all — it declares
<admin-section>theming</admin-section> — so /apps/thematiq/ answers 404 even
authenticated. Its page now opens Settings -> Administration -> Theming.

Verified on two booted demos: the corrected commands return 200.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
learniq's 8 V3 findings, all four files. Not one was a carelessly
silenced test — every reason already existed and was good. None was
somewhere a tool could read it.

  peer-and-self-assessment  3  reason in the file header
  personal-timetable        3  reason in the file header
  adaptive-release          1  reason in a comment above the test
  report-card               1  reason in a comment above the test

The skip-discipline gate reads the Playwright REPORT, which is the only
place a run-time exclusion records a reason at all. A `describe.skip` and
a `test.fixme(name, fn)` both record NONE, so all eight classified as
"skip with no reason recorded" — indistinguishable from a test someone
silenced without saying why.

Nothing about what runs changes. Same skips, same conditions, same test
counts (3, 3, 3 and 9 listed before and after).

## Two placements, deliberately different

The file-level deferrals become a describe-level `test.skip(true, …)` —
that is the correct scope, because every test in those files is deferred
for the same reason.

The two single-test cases put the annotation INSIDE the test body. This
is not a style choice: report-card.spec.ts carries its own warning that

    Declared as `test.fixme(name, fn)` — NOT a bare `test.fixme(true, …)`
    statement, which applies to every test in the enclosing describe and
    would quietly take the other scenarios down with it.

That is exactly right about a DESCRIBE-level statement, and exactly the
hazard to avoid. In the test body it scopes to that test alone, and
unlike `test.fixme(name, fn)` it records the reason. So the author's
constraint is preserved and the reason still reaches the report.

Both remain `fixme` rather than `skip`, keeping the property the author
wanted: a KNOWN failure rather than a pass.

Verified: npm ci rc=0, npm run lint rc=0 (0 errors), prettier clean on all
four, and `playwright test --list` compiles each file with its original
test count.

Part of ConductionNL/.github#609.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde and others added 22 commits August 29, 2026 03:48
#686)

* feat(setup): a wizard that offers the demo data this app already ships

This app ships lib/Settings/*_mock_register.json - a dataset generated from its
own schemas, conformant by construction, validated by the generator's --check -
and had no way for an operator to reach it. There was no setup wizard at all.

welcome -> demo-data -> done. Nothing app-specific is invented: the only action
is the demo-data import the descriptor already supports. A wizard that asked
questions the app does not act on would be worse than none, which is why there
are no configuration steps here yet.

completed is TRUE and the demo-data step is optional, so setup never gates the
app. skip-demo-data records its outcome just as installing does: since
nextcloud-vue 2.21 an OUTSTANDING OPTIONAL step opens the wizard over every
page (nextcloud-vue#806), so a step that can never be marked done is a dialog
that never closes - the defect buildiq was failing 37 E2E specs on.

Verified: manifest validates against schema 2.26.0, gate-100 PASS, routes.php
and both PHP files parse. The template was checked on launchpad against phpcs,
phpstan, psalm and phpmd - all clean.

* fix(setup): declare the endpoints' auth, and translate the wizard's strings

Two gate findings on the previous push.

gate-5 route-auth — status() and runAction() carried no auth attribute. The
docblock said 'admin-only by Nextcloud's default for an un-attributed method',
which is true and is not a declaration: the gate exists because a missing
attribute silently makes an endpoint unreachable, and a comment cannot be
checked by middleware. Both now carry
#[AuthorizedAdminSetting(Application::APP_ID)], placed DIRECTLY above the
declaration - gate-5 walks upward from the method and a long docblock between
attribute and declaration costs the attribute its visibility, which the gate
documents as a false FAIL it had to repair.

gate-102 manifest-l10n-coverage — the wizard's title and body strings had no
l10n/nl.json key, so a Dutch user would read them in English. Added, and the
browser catalogue rebuilt where the app ships one: nl.json alone is not enough,
because the browser reads nl.js.

The catalogue edit is insertions only, proven against the same change applied
structurally - an earlier attempt on another app re-serialised the whole file
(410 lines) before being reverted.

* fix(setup): authorize against the admin settings class, and test what it guards

`AuthorizedAdminSetting` takes a `class-string<IDelegatedSettings>`, not an
app id, so `Application::APP_ID` — a plain string — was rejected by phpstan.
The apps where this shipped green (larpinq, shillinq) already pass their admin
settings class; match them.

gate-47 and the coverage ratchet were both right to fail this. The change adds
an admin-authorized endpoint pair and ~364 lines of PHP with nothing behind
them. Two assertions are worth naming:

- a FAILED install must leave the step UNDECIDED. Recording the decision in
  the catch block would close the step for an operator who asked for demo data
  and received none.
- the object count comes from the FILE, not the importer's reply, so the
  number reported is the number ASKED FOR.

Both verified by mutation on openregister: reversing each behaviour fails
exactly the test that claims to guard it. The e2e spec issues both calls from
inside the logged-in admin page, which is the only place that middleware can
be observed admitting a real session.

---------

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

The ADR-111 setup step is OPTIONAL, and CnAppRoot opens the non-gating wizard
as a full modal mask while any optional non-info step is reported not-done —
in every fresh browser context, so once per spec. Merging the setup wizard
therefore turned this app's whole E2E suite red without touching a single
spec: the call log reads "locator resolved to <button ...> - attempting click
action" with <ol class="cn-wizard-dialog__progress"> named as the interceptor.
The element was found; the click never landed.

SKIPPED rather than installed, because recording the DECISION is what closes
the wizard. Installing would push the app's demo dataset into every list the
suite asserts on, which changes what the other specs measure.
`demo-data-setup-step.spec.ts` exercises the install deliberately, in
isolation.

Uses the workflow's own exported credentials rather than this script's
internals, and is tolerant of a non-200: an app whose wizard has no demo-data
step answers 400, and that is not a seeding failure.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* fix(e2e): let the demo-data spec inherit this suite's auth

The spec declared `storageState: tests/e2e/.auth/admin.json`, which this app
does not write — `global-setup.ts` writes `test-results/.auth/admin.json`, and
the config already points `use.storageState` there for every project. The
override therefore replaced a working session with a path that does not
exist, and the whole describe failed in 2ms on ENOENT before a single
assertion ran.

That declaration was carried over from openregister, whose default project
authenticates with a Basic header instead and genuinely needs it. Everywhere
else in this fleet the spec inherits the suite's own session, which is what
the five apps it was proven on do.

* style(e2e): drop the blank line the removal left behind

---------

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

* feat(walkthrough): a first-visit tour built from this app's own menu

ADR-043. The app had no walkthrough, so a new user arrived on a shell with no
orientation at all.

Every step targets a route READ FROM THIS MANIFEST, never invented.
CnWalkthrough resolves target {kind: nav-item, ref} by querying
[data-cn-route=<ref>], which CnAppNav emits from the ROUTE - so a tour pointing
at a menu id that differs from its route still renders, as a centred anchorless
coachmark that points at nothing. Verified: every ref resolves to a page this
manifest declares.

Where a menu label is a translation KEY rather than a sentence, the copy uses
the key's last segment as a word. Printing launchpad.menu.dashboards at a user
is worse than useless, and falling back to the route gives 'Click
dashboard-detail in the menu' - an internal identifier read aloud.

Verified: manifest validates against schema 2.26.0, every target ref resolves,
and no raw translation key reaches the copy.

* fix(walkthrough): point the tour at flows, and translate its copy

gate-70 walkthrough-flows-stop - this app ships a flows page and no tour step
targeted it. The gate's own measurement is why it exists: across 20 manifests,
12 apps shipped a flows page and exactly ONE tour mentioned it, so the
automation surface was discoverable only by someone who already knew.

Detected the way the gate detects it - type:'flows' OR the migrated form, an
index over config.entitySource:'flows'. Matching on type alone would miss every
app the flow-page migration touched, which is all of them.

The step advances on route-match and sets allowManualNext. The same gate fails a
step advancing only on object-created: that turns 'here is where flows live'
into 'build an automation before you may continue', and a tour nobody can finish
is worse than one that never mentions flows.

gate-102 manifest-l10n-coverage - the tour's titles, bodies and tasks had no
l10n/nl.json key. Added, and the browser catalogue rebuilt where the app ships
one, because the browser reads nl.js rather than nl.json.

* style(walkthrough): drop the em-dash from the flows step

gate-96 (manifest-copy-style) fails this: voice.md section 8 is unambiguous —
no em-dashes, Conduction strips them fleet-wide.

The nl.json KEY is the English string, so it is renamed with it; leaving the
key behind would orphan the Dutch translation of a string that still ships.
The Dutch VALUE carried the same em-dash and gets the same treatment — the
gate only counts manifest strings, but it is the same user-facing sentence.

Note for a later sweep: this app's nl.json still holds 16 other em-dash
strings from Vue copy. They are outside this gate's scope (it reads manifest
strings) and outside this PR's.

* fix(l10n): refresh the browser catalogue after the em-dash rename

check:l10n-js reported 'Stale browser catalogue: l10n/nl.js'. Renaming the key
in nl.json without nl.js leaves the two halves of the same catalogue
disagreeing, and the runtime reads the .js one.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
2.22.1 carries the theme app-id fix (nextcloud-vue#840). CnAppRoot calls
useScopedTheme() with no slug, so this app resolved theme tokens, the
token-set catalogue and the contrast check through a hardcoded 'nldesign'
app id. thematiq is renaming to 'thematiq', and every path in that
composable degrades to default styling by design — so once a renamed build
is installed this app would render unthemed with nothing in any log.

The LOCK is what moves here. A caret range alone changes nothing, because
npm ci installs what package-lock.json pins.

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

@conduction/nextcloud-vue 2.22.x made the product walkthrough actually open.
A `placement: "center"` welcome step used to be parked in `_pendingAutoTour`
and never shown; the library now correctly starts it on any route. Its
`cn-walkthrough__dim--full` layer is a `role="dialog" aria-modal="true"`
overlay, so every spec that clicks behind it times out, and
`getByRole('dialog')` resolves to the dim layer rather than the modal under
test.

The marker is per USER, not per test, so leaving it unseeded also makes the
suite order-dependent: whichever spec runs first wears the tour.

Seeded before the single storageState save, which is where this suite
persists its authenticated state.

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

gate-103 (locale-duplication) fails this app: 28 locale pairs are byte-for-byte
copies of another language. They are not "untranslated" — they are WRONG, and a
user who picks one is shown a language they did not choose.

Identified by reading the content, not just the hashes. Six groups, one real
translation each:

  * bs, cs, mk, sk, sl, sr == hr    CROATIAN  ("Konfiguriranje postavki")
  * be, uk                 == ru    RUSSIAN   ("Настройки Learniq")
  * ca                     == es    SPANISH   ("Configurar los ajustes")
  * sv                     == da    DANISH    ("Konfigurer appindstillingerne")
  * lb                     == de    GERMAN    ("App-Einstellungen konfigurieren")
  * rm                     == it    ITALIAN   ("Configura le impostazioni")

Deleted rather than blanked, so those twelve fall back to English. English a
Slovak reader can recognise as not-their-language beats Croatian they may
mistake for it.

Verified after the deletion: zero duplicate groups in both the .json and .js
catalogues, 24 locales remain, and every kept bundle still parses.

Noted while reading, NOT fixed here: the kept `hr` bundle still says
"Postavke Scholiqa" — the pre-rename app name survives inside the translated
strings. That is a separate change.

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

The first-visit walkthrough opens as a modal with a full-page dim inside
role="dialog", and it does so in EVERY fresh browser context — once per spec,
not once per suite. Merging it therefore turned specs red without touching
them: the Playwright call log resolves the target button, attempts the click,
and names the interceptor as

    <div class="cn-walkthrough__dim cn-walkthrough__dim--full">

learniq lost 7 specs to this and portaliq 2, all on development, all after the
walkthrough landed.

Marks the tour SEEN rather than disabling it. The manifest declares
`walkthrough.version: 1` with `completionConfigKey:
walkthrough_completed_version`, so storing that version is exactly what a real
operator does by finishing or dismissing the tour once — the walkthrough
itself stays shipped and testable.

Same shape as the demo-data decision this seed already settles, and tolerant of
a non-200 for the same reason: an app without a walkthrough answers 400 there,
which is not a seeding failure.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
`prettier --check` failed on the walkthrough marker seeded earlier today.
Purely a line-width wrap; the marker and its behaviour are unchanged.

Length-dependent, which is why the identical insertion passed in
shorter-named apps: 'cn-walkthrough-seen:learniq' pushes the call past the
print width where a shorter app id does not.

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

* feat(demo): generated demo data for every schema (ADR-111 rules 1-2)

This app declares schemas and shipped no demo data, so it opened on an empty
list: the person evaluating it had to author objects by hand against a schema
they did not know yet. Fleet-wide, 562 of 598 schemas were in that state.

🔴 GENERATED, NOT WRITTEN. Every value is derived from the schema that will
validate it — `enum` picks from the enum, `pattern` is satisfied, `format`
drives the shape, `minimum`/`maxLength` are honoured, `required` is always
populated. Hand-written demo data is wrong in a way nobody sees until the demo
(a status outside its own enum, a required field omitted) and it fails at
import, in front of whoever asked for the demo.

Produced and validated by the single copy that gate-99 also runs:
`vendor/conduction/hydra-gates/scripts/lib/generate_mock_register.py`.
Regenerate with that file; `--keep` preserves curated objects and tops up only
what is short.

🔴 IT DOES NOT INSTALL ITSELF (ADR-111 rule 3). `x-openregister.type: mock` is
imported ON DEMAND — sample data appearing on a production instance because
somebody upgraded is a data-integrity incident, not a convenience. Install it
with:

    occ openregister:descriptors:list --app=<app> --import=<register>

The setup-wizard step that offers this during first-run install (ADR-111 rule 4)
follows once OpenRegister's shared installer lands — deliberately not twenty-one
copies of the same logic.

Verified: `--check` re-validates every object against its own schema with
jsonschema and reports clean.

* fix(demo): emit real MDI icon names, so gate-60 stops rejecting the demo data

gate-60 (icon-vocabulary) failed the very PR that added demo data: the generator
emitted `Voorbeeld Icon 1` for properties named `icon`, and an icon outside the
canonical vocabulary "renders blank wherever it is not aliased locally"
(ADR-077).

🔴 THE FIX READS THE GATE'S OWN VOCABULARY FILE, never a copy of it. Carrying a
second list here is precisely how a producer and its judge drift apart, which
this package fixed once already today.

🔴 AND IT READS THE VALUES, NOT THE KEYS. The first attempt emitted the semantic
key — `documentation` — because that is what the map is keyed by. The map is
`"documentation": "BookOpenVariantOutline"`, and gate-60 accepts the MDI name:
it rejected `documentation` in the same breath as the invented name it replaced,
"a kebab-case or lowercase spelling of an MDI name resolves to nothing". Half
reading a vocabulary is not reading it.

Verified on portaliq: gate-60 PASS, zero gate failures. All seven apps
regenerate with zero validation findings.

* fix(demo-data): attribute the descriptor to the app id, not the directory

`x-openregister.app` is what the descriptor inventory resolves a register to
an app by, and the generator was writing the CHECKOUT DIRECTORY name into it.
Five of eight apps already ship an `<id>` that differs from their directory —
larpinq, learniq, humaniq, decidiq, buildiq — so those descriptors named an
app that does not exist, and the `occ openregister:descriptors:list --app=…`
command printed in their own description resolved nothing. A cross-app id is
a runtime lookup: it does not error, it silently finds nobody.

The id now comes from `<id>` in appinfo/info.xml, the stated authority,
falling back to the directory name only when there is no info.xml. The output
file is renamed to match and the directory-named one removed, so exactly one
mock descriptor remains per app — two would import demo data twice.

Register slugs are untouched: those are frozen per app.

* fix(lint): blank line between multi-line properties in LearniqDashboards

`vue/new-line-between-multi-line-property` was erroring on development
itself, not on anything this branch changed — the file here is byte-identical
to development's. Every branch cut from it inherits a red lint-check, which
is how the failure arrived on a PR that only touches a JSON descriptor.

eslint --fix, one blank line. `eslint src` now reports 0 errors (the 19
remaining warnings are pre-existing and do not gate).
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Two independent faults, either of which alone stops the docs site updating.

TRIGGER. This listened on a branch called `documentation`. Nobody has pushed
to one since 2026-05-25, so every docs change merged to `development` passed
review and published nothing.

SECRETS. A reusable workflow receives no secrets by default. With none mapped,
the callee's publish step finds CF_API_TOKEN empty and skips itself on its own
guard, and the run finishes GREEN having changed nothing. Fixing only the
trigger would have produced exactly that.

The worker name is now pinned. Deriving it is the documented way to get a green
run that reaches nobody: wrangler creates the derived worker and publishes
there while the custom domains keep routing to the real one.

Where the app was renamed, `canonical-host` turns the retired hostname from a
second live copy of every page into a 301 to the same path on the current one.

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

* feat(demo): generated demo data for every schema (ADR-111 rules 1-2)

This app declares schemas and shipped no demo data, so it opened on an empty
list: the person evaluating it had to author objects by hand against a schema
they did not know yet. Fleet-wide, 562 of 598 schemas were in that state.

🔴 GENERATED, NOT WRITTEN. Every value is derived from the schema that will
validate it — `enum` picks from the enum, `pattern` is satisfied, `format`
drives the shape, `minimum`/`maxLength` are honoured, `required` is always
populated. Hand-written demo data is wrong in a way nobody sees until the demo
(a status outside its own enum, a required field omitted) and it fails at
import, in front of whoever asked for the demo.

Produced and validated by the single copy that gate-99 also runs:
`vendor/conduction/hydra-gates/scripts/lib/generate_mock_register.py`.
Regenerate with that file; `--keep` preserves curated objects and tops up only
what is short.

🔴 IT DOES NOT INSTALL ITSELF (ADR-111 rule 3). `x-openregister.type: mock` is
imported ON DEMAND — sample data appearing on a production instance because
somebody upgraded is a data-integrity incident, not a convenience. Install it
with:

    occ openregister:descriptors:list --app=<app> --import=<register>

The setup-wizard step that offers this during first-run install (ADR-111 rule 4)
follows once OpenRegister's shared installer lands — deliberately not twenty-one
copies of the same logic.

Verified: `--check` re-validates every object against its own schema with
jsonschema and reports clean.

* fix(demo): emit real MDI icon names, so gate-60 stops rejecting the demo data

gate-60 (icon-vocabulary) failed the very PR that added demo data: the generator
emitted `Voorbeeld Icon 1` for properties named `icon`, and an icon outside the
canonical vocabulary "renders blank wherever it is not aliased locally"
(ADR-077).

🔴 THE FIX READS THE GATE'S OWN VOCABULARY FILE, never a copy of it. Carrying a
second list here is precisely how a producer and its judge drift apart, which
this package fixed once already today.

🔴 AND IT READS THE VALUES, NOT THE KEYS. The first attempt emitted the semantic
key — `documentation` — because that is what the map is keyed by. The map is
`"documentation": "BookOpenVariantOutline"`, and gate-60 accepts the MDI name:
it rejected `documentation` in the same breath as the invented name it replaced,
"a kebab-case or lowercase spelling of an MDI name resolves to nothing". Half
reading a vocabulary is not reading it.

Verified on portaliq: gate-60 PASS, zero gate failures. All seven apps
regenerate with zero validation findings.

* fix(demo-data): attribute the descriptor to the app id, not the directory

`x-openregister.app` is what the descriptor inventory resolves a register to
an app by, and the generator was writing the CHECKOUT DIRECTORY name into it.
Five of eight apps already ship an `<id>` that differs from their directory —
larpinq, learniq, humaniq, decidiq, buildiq — so those descriptors named an
app that does not exist, and the `occ openregister:descriptors:list --app=…`
command printed in their own description resolved nothing. A cross-app id is
a runtime lookup: it does not error, it silently finds nobody.

The id now comes from `<id>` in appinfo/info.xml, the stated authority,
falling back to the directory name only when there is no info.xml. The output
file is renamed to match and the directory-named one removed, so exactly one
mock descriptor remains per app — two would import demo data twice.

Register slugs are untouched: those are frozen per app.

* feat(flows): give the flow-detail canvas its sidebar

The manifest _note claimed the controls rendered in the NC app sidebar,
but the sidebarComponent field it described did not exist. Every
#/flows/:id -- and #/flows/new, the same route with the literal id -- drew
a bare canvas: savable and runnable, but with no way to name, describe,
trigger or step-edit the flow, because those controls all live in
CnFlowSidebar. Mirrors pipelinq#1490. ADR-110 Decision 4.

* test(stubs): assert OpenRegister stubs carry canonical parameter names

learniq calls the stubbed OpenRegister classes with named arguments, which
bind by name, so a stub whose parameter names drift from canonical passes
every local unit run and fails only in CI -- where the real app is
installed -- with an error naming the stub rather than the caller.
DeferredListenerContext was stubbed __construct(array $entries) against a
canonical __construct(?string $userId, ?string $orgUuid, array $entries).

* docs(registry): name the flow page types development actually declares

The comment named `flows` / `flow-detail`; development renamed them to an
`index` with config.entitySource 'flows' and `flow" (#669), which the merge
resolution above adopts.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
beta held 23 commit(s) development did not. Merged with -s ours:
development's tree is kept BYTE FOR BYTE and only the ancestry is
recorded. That is the payload -- without it the merge base never moves
and the next development -> beta promotion conflicts on the version file
exactly as before. 13 of 19 promotion PRs were CONFLICTING for this
reason.

Nothing is silently imported. What beta holds and development does not,
and which this deliberately does NOT bring over:

  .forgejo/.beta-release-trigger .forgejo/issue_template/bug-report.yml .forgejo/issue_template/feature-request.yml .forgejo/issue_template/technical-task.yml .forgejo/issue_template/user-story.yml .forgejo/workflows/documentation.yml .forgejo/workflows/release-beta.yml .forgejo/workflows/release-stable.yml CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md SUPPORT.md

Those are dead Forgejo/Codeberg CI (removed from development on
2026-08-24/25 by 'chore(ci): remove dead Forgejo/Codeberg CI
configuration'), generated Docusaurus build output, and community-health
files that never existed on development. Each can be added deliberately
if wanted; resurrecting them as a side effect of a sync is how a merge
silently undoes a decision.
…260830084323

chore(sync): carry beta back into development
…0841

chore(sync): record beta's ancestry on development
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 @ 64e954d

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-30 09:07 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/learniq @ a7194be

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 ✅ 648/648
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-30 10:19 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 8b5e41f into beta Aug 30, 2026
92 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant