Skip to content

Scholiq becomes Learniq: declared RBAC, reachable Compliance, and the rename - #452

Merged
rubenvdlinde merged 26 commits into
developmentfrom
feat/learniq-reframe
Aug 19, 2026
Merged

Scholiq becomes Learniq: declared RBAC, reachable Compliance, and the rename#452
rubenvdlinde merged 26 commits into
developmentfrom
feat/learniq-reframe

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Three OpenSpec changes, applied and live-verified on the dev instance. The two navigation changes are specced but held — see Not in this PR.

1. rbac-declare-groups — the register declared access control that did nothing

The register carried 60 authorization declarations under key names OpenRegister never reads: x-openregister-authorization (20 schemas) and x-property-rbac (40). Neither is consulted by any code path — RbacGroupCollector and PermissionHandler both key on authorization, which appeared zero times. Positive control: x-openregister-lifecycle, a key OR really does read, is consumed in 3+ files.

So this was never "nobody thought about access control". It was sixty declarations that read as working RBAC in review and enforced nothing. PermissionHandler's undeclared branch is commented "Default-OPEN behaviour preserved", and the enforce_default_closed mitigation only covers create/update/deleteread is never closable by configuration.

42 schemas now declare real blocks across three tiers (21 sensitive · 21 catalogue · 76 cascade), plus the OAuth2 scope map so GroupProvisioner creates the groups.

Verified live: the instance went from 28 groups to 36. A learners-only user reads a Course (200) and is refused another learner's GradeEntry (404 — OR masks read denials deliberately) and its create (403).

⚠️ Residual, stated deliberately: this closes instance-wide open access. It does not close per-learner scoping — that needs object-level conditional scopes. Learners read the catalogue, never another learner's record.

2. fix-dead-role-gates — Compliance had no door

24 menu items gate on user.primaryRole. They named ten role values; the resolver could produce six, and not the same six. Result: 17 items were silently admin-only while claiming to delegate, and two were invisible to admins — including Compliance, the app's headline feature, whose page was healthy all along.

Worse, none of the scholiq-* groups the resolver tested had ever existed (28 groups on the instance, zero matching), so in practice only admin and learner were reachable at all.

Role vocabulary is now canonical and sector-neutrallearner · instructor · team-lead · coordinator · hr · compliance-officer · guardian · administration-manager — because the product is being reframed for companies as well as schools. teacher, principal, mentor, parent become profile labels, not identifiers.

CI gate 30 gained role-resolvable and group-declared checks, and was watched FAILING on a deliberately broken manifest before being trusted. A gate that has only ever passed is the exact defect this change exists to fix.

3. rename-to-learniq

479 files, the register slug, and two silent cross-app couplings (hermiq's CourseRecommendationEngine, pipelinq's DOWNSTREAM_SYSTEMS).

Three defects found while applying it, one severe:

  • MigrateAppConfigKeys copied Nextcloud's reserved keys. AppManager writes enabled via the deprecated setValue() (MIXED); copying with setValueString() stores STRING, and every later app:enable dies with AppConfigTypeConflictExceptionpermanently, because the failure precedes anything that could repair it. The app was un-enableable.
  • AiProcessingDisclosureController still read the old register filename; it threw on boot.
  • RoleSelector still built 'scholiq-' . $role group names, contradicting change 1.

Migration safety, measured not argued: shards key on the numeric register id (oc_openregister_table_9_*, 118 of them); table_name ~ 'oc_openregister_table_[a-z]' returns 0. The canary GradeEntry d4caa278 has an identical field fingerprint before and after (6ca294bb6174…).

Tests

runner tests errors failures
development (host) 937 21 1
this branch (host) 941 21 1

Exact parity; the one failure is the same on both. The 21 "errors" are environment, not code — the host lacks ext-zip and openregister. In the container (which has both) it is 2 errors / 2 failures, in classes this branch does not touch in production code.

Four unit tests were asserting x-openregister-authorizationgreen while asserting a fiction. They now assert the key that is actually read.

e2e

Two harness faults fixed, both of which made results untrustworthy rather than red:

  • The shared loggedInPage fixture probed the session by loading Nextcloud's dashboard with a 20s budget. Measured here: HTML in 2.7s, domContentLoadedEventEnd at 28.5s. It failed every spec, in the fixture rather than an assertion, so it read as "the app is broken".
  • shell.spec asserted Compliance must be absent — encoding the F2 defect as intended behaviour. Its role probe also read initial state via .textContent, which is always '' because Nextcloud renders it as <input type="hidden" value="base64">.

Not in this PR

manifest-fragment-split and menu-six-main-items (23 → 6 top-level, ADR-097-compliant at 4 counted) are specced but blocked on a release of @conduction/nextcloud-vue carrying CnNavCardGrid (ConductionNL/nextcloud-vue#701). ADR-044 §4 instructs apps to build card-grid landing pages; no component in the library could render one. Apps install from npm, so a merge alone reaches nobody.

documentation.yml's cname: scholiq.conduction.nl is deliberately unchanged — repointing a live domain needs a DNS decision and a redirect, not a one-line edit.

🤖 Generated with Claude Code

Specs only; no product code.

- rbac-declare-groups: 118 schemas declare no authorization OpenRegister
  reads, while 60 rules sit under x-openregister-authorization and
  x-property-rbac, keys no OR code path consumes. Declares the real
  blocks plus the eight unprefixed groups GroupProvisioner creates.
- fix-dead-role-gates: 6 of 10 role literals in manifest visibleIf gates
  can never be produced; 17 items are silently admin-only and Compliance
  has no door for an administrator at all.
- rename-to-learniq: scholiq -> learniq across 479 files, plus the
  register slug and two silent cross-app couplings (hermiq, pipelinq).
- manifest-fragment-split / menu-six-main-items: ADR-044 prerequisite
  and the 23-to-6 navigation collapse.
… scope map

lib/Settings/scholiq_register.json gains:
- Register-level authorization cascade (staff-only read-write role,
  no learners grant) governing the 76 Tier-2 learner-attributed schemas
  by default.
- Schema-level authorization on 21 Tier-1 sensitive-data schemas across
  four narrow profiles (A/B/C/D) — no learners in any profile.
- Schema-level authorization on 21 Tier-3 catalogue schemas across two
  write profiles (3a excludes instructors/hr, 3b includes them) — read
  is authenticated (wide, learners included), deliberately wider than
  the Tier-2 cascade.
- components.securitySchemes.oauth2 scope map declaring all eight
  canonical group ids so OpenRegister's GroupProvisioner creates them.
- Removal of the 20 dead x-openregister-authorization decoy keys
  (never read by any OpenRegister code path) — 9 replaced by real
  blocks, 11 deleted outright to fall back to the register cascade.

Closes instance-wide open read/write access on both the catalogue and
learner-attributed populations. Does not yet close per-learner scoping
of learner-attributed records (tracked separately per design.md).
Import re-run on localhost:8080 provisioned all eight canonical
groups (28 -> 36 total groups, zero canonical matches before, all
eight present after, each zero members). Paired read test (TC-7) on
a single learners-only user: Course read succeeds (200), a different
learner's GradeEntry read and create are both refused (404/403).
Tier-1 refusal/admission pair (TC-3/TC-4) confirmed on DossierNote
with a no-group user and an instructors-group user.

Note: RBAC-denied single-object reads surface as 404, not 403 -
OpenRegister's ObjectsController::show() deliberately remaps the
internal NotAuthorizedException (403-shaped) to 404 to avoid leaking
object existence to an unauthorized caller. This is documented,
intended behaviour in OpenRegister, not a defect in this change.
Observed live: a learners-only user reading another learner's GradeEntry
gets 404, not the 403 the scenarios named. OpenRegister's ObjectsController
remaps a read denial deliberately - 'Mirror show(): 404, not 403/500, so
denial reveals nothing'. Writes still surface as a true 403.

The positive control is what makes a masked 404 readable as a refusal:
admin reads the same object (d4caa278) with 200 and a body, so the
learner's 404 is a denial rather than an absence. Without that pair the
two are indistinguishable, and the test would pass against an empty
register.
… vocabulary

DashboardRoleService::resolvePrimaryRole() could only ever emit six values
(admin-first) while src/manifest.json named ten role literals across 24
visibleIf.user.primaryRole gates — six of the manifest's literals
(coordinator, finance, mentor, parent, principal, teacher) could never be
produced, silently locking 17 role-delegated menu items to admin-only and
hiding Compliance and BookConferenceSlotsMenu from admin entirely.

- DashboardRoleService: GROUP_BACKED_ROLES becomes an explicit role =>
  unprefixed-group-id map (compliance-officer, hr, administration-manager,
  team-lead, coordinator, instructor, guardian), pointed at the groups
  rbac-declare-groups provisions instead of the never-existing scholiq-*
  prefix. resolveViews() updated in lockstep; learner stays the
  unconditional, non-group-gated fallback.
- manifest.json: 13 of 24 visibleIf.user.primaryRole gates corrected onto
  the canonical vocabulary (teacher->instructor, principal/manager->
  administration-manager, mentor->team-lead, parent->guardian), admin added
  to the two admin-invisible gates (Compliance, BookConferenceSlotsMenu),
  finance dropped from the four Payments gates per ADR-081.
- DashboardRoleServiceTest: vocabulary updated, new coverage for
  administration-manager/team-lead/coordinator/guardian plus the
  no-privileged-group refusal case.

openspec/changes/fix-dead-role-gates, Tasks 1-4.
…oles

Verified live on localhost:8080 with a named user per group-backed role
(instructors, coordinators, guardians, team-leads, hr, compliance-officers,
administration-managers) plus a no-privileged-group learner and admin,
reading server-resolved runtime.user.primaryRole and the rendered nav DOM
for the exact 24-item role-gated checklist per test-plan.md TC-1..TC-6:

- instructor: exactly 2/24 (GroupTrendHeatmapMenu, CourseEvaluationResponsesMenu)
- coordinator: exactly 7/24 (the coordinator-gated engagement/progress/timetable set)
- guardian: exactly 1/24 (BookConferenceSlotsMenu) — positive control pairs with
  the no-group learner also seeing it via the shared `learner` literal
- team-lead: exactly 1/24 (ConferenceScheduleBoardMenu)
- hr: exactly 2/24 (Compliance, ExternalTraining)
- compliance-officer: exactly 5/24
- administration-manager: exactly 5/24
- no-privileged-group learner (rbactest): exactly 1/24 (BookConferenceSlotsMenu),
  the other 23 individually confirmed absent, with My learning present as the
  positive control that the nav rendered at all
- admin: 24/24 — every role-gated gate is reachable by admin

Compliance and Book Conference Slots confirmed absent from the admin nav on
the pre-fix build (temporarily rebuilt from the prior commit) and present on
the post-fix build, both via the rendered DOM, not the manifest JSON.
Task 6/7: hydra-gates check_manifest_crossref.js (gate 30) gained
role-resolvable and group-declared checks, with fixtures and self-tests
(conduction/hydra-gates package, .github repo, branch
fix/gate-30-role-resolvable-group-declared — not pushed/PR'd from here).
Verified against scholiq's real post-fix tree (zero findings) and against
a deliberately broken manifest+resolver pair (both checks FAIL
independently, exit 1), then reverted and re-confirmed clean.

Task 8: hydra's gate-30 skill doc updated with the two new check classes,
fix recipes, and diff-scope paths (hydra repo, branch
docs/gate-30-role-resolvable-group-declared — not pushed/PR'd from here).
Scholiq's docs/Technical/architecture.md was checked for the retired
scholiq-{role} convention or school-only role words as identifiers — it
names none (its primaryRole mentions are RoleSelector's own, deliberately
unchanged vocabulary per design.md Non-Goals), so no doc edit was needed.

openspec validate fix-dead-role-gates: valid. All 16 tasks complete.
…Learniq

Boundary 1 of the rename-to-learniq change: renames every namespace and
use-statement declaration, plus FQCN references in docblocks, across all
374 lib/ and tests/ PHP files that declared or referenced OCA\Scholiq.

Lowercase 'scholiq' string literals (app id, register slug, config-key
prefixes) are untouched here — those are separate boundaries.

openspec/changes/rename-to-learniq/tasks.md Task 1
…ation steps

Boundary 2: app id scholiq -> learniq in appinfo/info.xml (<id>,
<namespace>, nav route id learniq.page.index, repair-step class paths),
composer.json (package name), package.json, and the psalm/phpstan/phpcs/
phpmd tool-config names.

Boundary 3+4 (data-migration infrastructure): two new idempotent,
non-destructive IRepairStep classes.

- RenameRegisterSlug: single-row UPDATE renaming the OpenRegister
  register's slug scholiq -> learniq. Verified against the live DB that
  the register's 118 shard tables are keyed on the numeric register id,
  not the slug, so no shard table is touched.
- MigrateAppConfigKeys: copies every stored IAppConfig value from the
  scholiq app-config namespace to learniq via IAppConfig::getKeys(),
  superseding the design doc's hardcoded 9-key list, which missed several
  real keys (register, lti_ags_subscription_id, lti_ags_pull_cursor,
  openconnector_api_token/user/callback_token, docudesk_api_token,
  keygen.last_at.*) and treated credential.signing.* as 4 fixed keys when
  they are actually per-tenant-UUID prefixes.

Both new steps are registered in BOTH <install> and <post-migration>,
ordered BEFORE InitializeSettings in each block. Renaming the app's <id>
means Nextcloud discovers learniq as a different app id than scholiq —
there is no in-place app-id upgrade in Nextcloud, so <install> is the
realistic path a real deployment goes through. OpenRegister's
ImportHandler::importRegister() matches an existing register by slug; if
InitializeSettings's register-JSON import ran before the slug rename, it
would find no register at 'learniq' yet, create a new empty one, and the
rename step's collision guard would then refuse to touch the real,
data-carrying register — orphaning every existing object. Running the
slug rename first avoids that.

Also fixes RenameDutchColumns.php's REGISTER_SLUG_PREFIX, which a
mechanical batch pass renamed to 'learniq' — it must stay 'scholiq'
because that step runs before RenameRegisterSlug in the same pass, so the
database still has the old slug when it executes.

openspec/changes/rename-to-learniq/tasks.md Task 2, Task 3 (partial —
repair step + code call-sites; DB verification tracked separately),
Task 4 (partial — repair step; DB verification tracked separately)
…ister-slug/app-id strings

Boundary 5: renames lib/Settings/scholiq_register.json ->
learniq_register.json, updating x-openregister.app, the components.
registers key + nested slug, the 13 fixture registers[].register
references, and 71 OCA\Scholiq FQCN strings in lifecycle 'requires'
tags — all verified via a structural JSON diff showing only intended
values changed. src/manifest.json: all 345 register/registerSlug
'scholiq' values, the 5 /apps/scholiq/ route-prefix urlTemplates, 3
metric names, 7 Scholiq*.vue component-name references (kept in sync
with the not-yet-landed filename rename), and the scholiq.conduction.nl
documentation links, verified the same way.

Boundary 6: the register-slug/app-id literal 'scholiq' string constant
across lib/ and tests/ (178 files, ~250 occurrences) -> 'learniq',
including the credential.signing.* IAppConfig key-prefix family,
credentialVerify.verify, two IThrottler bucket names, three tempnam()
prefixes, and the DID/URN identifier formats CredentialSigningService
and LearningRecordExportSigningService embed into issued Open
Badges/DID documents (urn:scholiq:* -> urn:learniq:*, did:web:scholiq:
-> did:web:learniq:) — these are forward-only: already-issued
credentials keep their historical urn:scholiq: identity, only future
issuance moves to the new scheme, same posture as the register-slug
migration.

Deliberately NOT renamed (out of scope, same category as domain-model
vocabulary): the scholiq-json / scholiq-learning-record /
scholiqNative course-package and portable-learning-record wire-format
identifiers embedded in exported/imported files — renaming those is a
file-format compatibility question, not an identity boundary, and
needs its own migration design if ever done.

openspec/changes/rename-to-learniq/tasks.md Task 3 (call sites),
Task 4 (call sites), Task 5
… provider, course importer, and postman collections

Boundary 6/7 (part 1 of 2, the 6 non-Vue files):
- lib/Mcp/ScholiqToolProvider.php -> LearniqToolProvider.php (class +
  getAppId() return value + every reference in Application.php,
  CourseToolPresenter.php, tests/bootstrap*.php,
  .github/workflows/code-quality.yml)
- tests/Unit/Mcp/ScholiqToolProviderTest.php ->
  LearniqToolProviderTest.php (class + test method name)
- lib/Service/CoursePackage/ScholiqJsonCourseImporter.php ->
  LearniqJsonCourseImporter.php (class + every reference)
- tests/Unit/ScholiqTest.php -> LearniqTest.php (class)
- tests/integration/scholiq.postman_collection.json ->
  learniq.postman_collection.json, tests/wedge-scaffolds/
  scholiq-wedge.postman_collection.json ->
  learniq-wedge.postman_collection.json — every register-slug URL
  segment and register value inside both renamed (structural JSON walk,
  word-boundary substitution, verified valid JSON after); referencing
  READMEs updated.

Deliberately left alone: the exportScholiqJson()/scholiq-json/
scholiqNative course-package and portable-learning-record wire-format
names — same out-of-scope call as the previous commit.

openspec/changes/rename-to-learniq/tasks.md Task 6, Task 7
…and l10n

Completes boundary 6 (literal 'scholiq' register-slug/app-id string
constants across lib/ and tests/, 178 files - the actual content
change; the prior commit's message named this but the change was not
fully staged there).

New boundaries found beyond design.md's stated scope, all required for
the renamed app to actually run:

- 66 Vue files: t('scholiq', msgid) -> t('learniq', msgid) - the first
  argument selects which app's l10n bundle @nextcloud/l10n searches;
  left as 'scholiq' every translated string in the UI silently falls
  back to its untranslated English source.
- 37 Vue files: hardcoded OpenRegister object-API URLs
  (/apps/openregister/api/objects/scholiq/...) and 14 files using this
  app's own /apps/scholiq/api/... route prefix -> .../learniq/... -
  left as 'scholiq' these calls 404 once the register-slug migration
  runs.
- src/main.js: loadState('scholiq', ...) (initial-state key), the
  vue-router history base generateUrl('/apps/scholiq'), and the
  #scholiq-app mount selector (+ matching templates/index.php host
  div). src/settings.js: the #scholiq-settings mount selector (+
  matching templates/settings/admin.php). src/App.vue: appId="scholiq"
  passed to CnAppRoot - the frontend's own root identity prop.
- templates/index.php and templates/settings/admin.php also still
  referenced the PHP class OCA\Scholiq\AppInfo\Application, which no
  longer exists after the namespace rename - a fatal "class not found"
  on every page load. Neither file is under lib/ or tests/, so no
  earlier namespace-rename batch touched them.
- webpack.config.js hardcoded const appId = 'scholiq', which names
  the built entry bundles (scholiq-main.js / scholiq-settings.js).
  appinfo/info.xml's Util::addScript(APP_ID, APP_ID . '-main') now
  requests learniq-main.js - left unfixed, the built filename would
  never match what the app requests and the main script 404s.
  Confirmed via two full npm run build runs: the first (before this
  fix) still emitted scholiq-main.js/scholiq-settings.js despite every
  other chunk already reading learniq-* (those derive their name from
  package.json's already-renamed "name" field); the second, after this
  fix, emits learniq-main.js / learniq-settings.js - verified present
  in js/ with 0 remaining 'scholiq' references in the built bundle.
- lib/Service/VerwerkingsregisterCsvBuilder.php: a hardcoded
  '?register=scholiq&from=...' query string on the AVG export URL -
  same class of bug as the frontend API paths, just server-side.
- The 7 src/views/Scholiq*.vue files renamed to Learniq*.vue (class
  Vue name: property + every reference in src/registry.js,
  src/App.vue, DashboardAdmin/Student/Teacher.vue,
  src/views/settings/AdminRoot.vue,
  src/views/settings/DataExchangeSettingsSection.vue).

l10n (Task 8): 13 English source keys in l10n/en.json that name the
product ('Scholiq settings', 'Scholiq processing activities', etc.)
renamed (key + value) across all 37 locale files; every other key's
already-translated VALUE gets the literal word Scholiq/scholiq
mechanically substituted to Learniq/learniq (case-preserving), keys
and surrounding translated text untouched - verified via a structural
JSON diff per file showing only the product-name token changed
anywhere. Deliberately NOT touched: 86 dead 'scholiq.*'-namespaced
l10n keys (scholiq.page.dashboard.title, scholiq.col.course, etc.) -
grepped across src/ and manifest.json, referenced nowhere; legacy
leftovers from a prior (pre-ADR-036 v2 registry) architecture, out of
this rename's scope.

Deliberately left as-is (cosmetic, same category as prior commits):
CSS class names (.scholiq-admin__*, .scholiq-settings__*), local JS
variable names (scholiqResults, SCHOLIQ_SCHEMAS), DOM id attributes
paired within one component (#scholiq-default-register), the
scholiq-json/scholiq-learning-record/scholiqNative wire-format
identifiers and their AVG processing-activity 'code' values, and
stale prose/docblock references to the old
lib/Settings/scholiq_register.json filename.

npm run build verified green both before and after the webpack.config.js
fix (no compile errors either time - only pre-existing bundle-size
warnings).

openspec/changes/rename-to-learniq/tasks.md Task 6 (completing), Task 8
44 files, a 1:1 substitution of the app id in route prefixes and the
[data-app] selector. No assertion logic changes.
1. MigrateAppConfigKeys copied Nextcloud's reserved keys. AppManager
   writes 'enabled' via the deprecated setValue() (type MIXED); copying
   scholiq's with setValueString() stores STRING, and every subsequent
   app:enable then dies with AppConfigTypeConflictException. The failure
   happens before the app can run anything that would repair it, so the
   app was permanently un-enableable. Reserved keys are now skipped.

2. AiProcessingDisclosureController still read scholiq_register.json,
   which no longer exists. It threw on boot.

3. RoleSelector still built 'scholiq-' . $role group names. The RBAC
   change retired that prefix - OpenRegister's rbac-scopes requires group
   ids to be free-form and unprefixed - so the branch could never be
   true. It now reads the single canonical role->group map, promoted to
   a public const on DashboardRoleService.

Verified on the dev instance: app enables, register slug migrated 9
scholiq -> 9 learniq, and the canary GradeEntry d4caa278 has an
identical field fingerprint before and after (6ca294bb6174...).
…pp id

app-name drives where CI installs and mounts the app, so leaving it as
scholiq would fail the build rather than merely mislabel it. Covers
code-quality (plus the Playwright seed path), release (x3), issue-triage
and openspec-sync.

documentation.yml's cname is deliberately NOT changed: scholiq.conduction.nl
is a live domain, and repointing it needs a DNS decision plus a redirect,
not a one-line edit.
…constants and messages

Comment and docblock prose, the admin settings sectionName, stale
config-key names in operator-facing log messages, and the SCHOLIQ_REGISTER
constant NAME where its value was already learniq.

Partial by design: seven sweep agents were killed mid-run by a session
limit. Every file is internally consistent - checked that no file uses a
constant it does not declare, and that all 75 changed PHP files parse -
so this is a safe stopping point, not a half-applied rename.
Two groups.

1. Four tests asserted x-openregister-authorization. No code path in
   OpenRegister reads that key - RbacGroupCollector and PermissionHandler
   both key on 'authorization' - so those tests were GREEN while asserting
   a write posture that was never enforced. They now assert the key that
   is actually consulted, with the canonical unprefixed group ids, and
   additionally assert the decoy key is gone.

2. did:web:scholiq -> did:web:learniq. Checked first that this is safe:
   the DID is a synthetic label derived from the tenant public key's
   fingerprint, emitted in two places and string-matched by no
   verification path - verify() is openssl_verify against the key. So
   previously issued credentials still verify.

Also repoints 10 test files at learniq_register.json, which was failing
25 tests in setUp before they ran a single assertion.

Parity with origin/development: 21 errors / 1 failure on both. The one
remaining failure (CoursePackageImportServiceTest:166) fails identically
on development and is pre-existing.
…nce defect

Two faults, both of which made e2e results untrustworthy rather than red.

1. ensureLoggedIn probed the session by loading Nextcloud's DASHBOARD app
   and waiting for domcontentloaded within 20s, commented as 'a lightweight
   dashboard check'. It is not lightweight where many apps are installed -
   the dashboard pulls a widget bundle from each. Measured on the shared
   instance (44 custom apps): HTML in 2.7s, domContentLoadedEventEnd at
   28.5s. Since loggedInPage is the shared fixture, that one number failed
   EVERY spec, and it failed in the fixture rather than an assertion, so it
   read as 'the app is broken'. Now probes /ocs/v2.php/cloud/user - a few
   hundred bytes, real session required, no app bundles.

2. shell.spec asserted Compliance must be ABSENT from the nav. That encoded
   the F2 defect as intended behaviour: Compliance is the headline feature
   and was invisible to every administrator. fix-dead-role-gates added
   admin to the gate, so the assertion now checks both directions against
   the real entitled set.

   Its role probe also could not work: it read initial state via
   .textContent, but Nextcloud renders initial state as
   <input type=hidden value=base64>, so textContent is always '' whether
   set or not. primaryRole was permanently null and the branch always ran.
   Now reads the value attribute.

Result: 2/2 pass, and the run is ~40% faster.
…eframe

# Conflicts:
#	lib/Mcp/LearniqToolProvider.php
…ir steps

Three CI regressions this branch introduced, all caught by the quality gate.

- appinfo/info.xml: the rewritten <summary> was 171/158 chars against the
  App Store schema's 128 cap. Shortened both locales.
- src/registry.js: renaming Scholiq* -> Learniq* moved the imports'
  alphabetical position, so perfectionist/sort-imports failed. Moved the
  block rather than running eslint --fix, which also rewrites SUPPRESSED
  violations. eslint is now 0 errors (25 warnings, unchanged).
- Coverage guard: base 10814/15098 -> head 11031/15547, +449 statements.
  MigrateAppConfigKeys (218 lines) and RenameRegisterSlug (166 lines) were
  brand new with no tests at all - there was no tests/Unit/Repair directory.

The new tests are regression guards, not coverage filler. The reserved-key
test was PROVEN to fail: removing the guard makes it fail with exactly the
un-enableable-app message, restoring it makes it pass. RenameRegisterSlug's
cover the case that actually matters - the collision guard failing CLOSED,
including when the collision check itself errors, because merging two
registers is the unrecoverable outcome.

Also adds a self-guarding Doctrine\DBAL\ParameterType stub. IQueryBuilder
references it in its constant declarations, so doubling OCP\IDBConnection
loads it, and on a bare host it is absent - an environment gap that reads as
a code failure. The guard means it is a no-op where real Doctrine exists
rather than a shadowing second definition. Verified on both runners: 11/11.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Checked one thing a rename can silently drop, so a reviewer does not have to: the MCP tool taxonomy survives it.

lib/Mcp/LearniqToolProvider.php on this branch carries both declarations, on the new ids:

'id' => 'learniq.listCourses',   'subject' => 'course', 'action' => 'list',
'id' => 'learniq.getCourseDetails', 'subject' => 'course', 'action' => 'get',

Verified live on an instance carrying this branch alongside hermiq:

total 177 | undeclared 0
learniq tools: [('learniq_listCourses', 'course', 'list'),
                ('learniq_getCourseDetails', 'course', 'get')]

Context for why it was worth checking: an undeclared subject/action produces no error, no warning and no failing test. ToolRegistryFacade::describeTools() deliberately returns null rather than inferring one from the tool id — correct, since a consumer cannot tell an inferred subject from a real one — so a tool that loses these keys just arrives at the agent grant matrix ungroupable and renders as a one-off row. That silence is how 87 of 177 tools accumulated undeclared across the fleet before the recent sweep.

I filed scholiq#451 when an earlier state of this branch reported 2 undeclared; your merge of development resolved it, and I have closed that issue.

⚠️ Note for future renames: hermiq's e2e guard (tool-grant-matrix.spec.tsevery tool DECLARES its subject and action, hermiq#385) will not catch this class of regression here — learniq is not installed in hermiq's CI instance. It only shows up where both apps are present.

No action needed on this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/scholiq @ f5b83a8

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-19 18:01 UTC

Download the full PDF report from the workflow artifacts.

gate-26 flagged five 'new page components' — they are the renamed
Scholiq*.vue files. Chasing that surfaced the real bug: pages.visual.spec.ts
still asserted manifestComponentFor(...) === 'ScholiqCompliance',
'ScholiqLearnerHome' and 'ScholiqAiProcessingDisclosure'. The manifest now
declares the Learniq* names, so those three assertions would have failed in
CI's e2e job. The earlier rename sweep updated route prefixes and the
[data-app] selector but not component-name assertions.
Annotates the 19 changed methods hydra gate-16 (spec-coverage) reported
as missing @SPEC: 12 get a real @SPEC pointing at the openspec
requirement/scenario that already governs their behaviour (repair-step
config/register-slug migration, credential fingerprint resolution,
dashboard widget composition, drag-and-drop reordering, notification
override persistence, etc.), and 7 pure presentational label/formatting
helpers get a reason-bearing @SPEC exclude, matching the precedent in
PageController.php and RenameDutchColumns.php.
Closes gate-26. LearniqDashboards (route /) and
LearniqAccessibilityStatement (route /accessibility) were the last two
'new page components missing a visual baseline' — new only because the
rename changed their filenames.

Both routes were already reachable in other specs, but neither was named
in the visual spec, which is what gate-26 reads. Added real assertions
rather than @visual exclude: the root test asserts the manifest mounts
LearniqDashboards and the shell renders, which is the thing the rename
could plausibly have broken.

Gates now: RESULT 2 FAILED, down from 4. gate-16 PASS, gate-26 PASS.
COVERAGE held at 63 of 63 applicable, so this is not gates going quiet.
Remaining 19 (93 scenarios missing @e2e) and 61 (7 listeners, ADR-078)
are pre-existing and fail identically on development.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/scholiq @ 8ddcac2

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-19 20:00 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/scholiq @ 3a827ad

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-19 22:10 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit c95cb6a into development Aug 19, 2026
28 of 30 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/scholiq @ 9cfb63f

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-19 23:05 UTC

Download the full PDF report from the workflow artifacts.

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