Learniq, Architecture
Learniq is an open-source leerlingvolgsysteem (LVS) + leeromgeving (LMS) for Nextcloud. The Wave-2 compliance-audit wedge (Path A MVP) is the shipped baseline documented here.
| Slug | learniq |
| License | EUPL-1.2 |
| Status | Wave 2 applied (compliance-audit wedge) |
| Predecessors | learniq (deprecated), edudesk (deprecated) |
| Repository | https://github.com/ConductionNL/learniq |
1. Architectural overview
Learniq is a thin Nextcloud client that owns no database tables of its own and writes no PHP service classes for behaviour that can be expressed declaratively.
- All persistent state, courses, lessons, enrolments, credentials, regulations, attestations, xAPI statements, learner profiles, AI feature flags, lives in OpenRegister as schemas declared in
lib/Settings/scholiq_register.json. - All entity behaviour that fits an
x-openregister-*extension, state machines, aggregations, derived fields, notifications, relations, dashboard widgets, is declared in the schema register, not in a PHP service class. (ADR-031.) - All UI shell, sidebar, page dispatch, dependency check, routing, is consumed from the
CnAppRootcomponent in@conduction/nextcloud-vue, configured bysrc/manifest.json. (ADR-024.) - All cross-cutting capabilities, audit trail, RBAC, archival/retention, relations, are consumed from OpenRegister. Learniq never reimplements an OR abstraction. (ADR-022.)
+----------------------------------------------------------------------+
| Browser |
| CnAppRoot (from @conduction/nextcloud-vue) driven by |
| src/manifest.json (Tier 4), declares menu, pages, dependencies |
+---------------------------+------------------------------------------+
|
v
+----------------------------------------------------------------------+
| Nextcloud (Hub 28+) |
| Auth :: IUserSession Groups :: IGroupManager |
| Crypto :: ICrypto (keys) i18n :: IL10N (nl+en) |
+-----+----------------------------+----------------------------------+
| |
v v
+-----------+ +-------------------------------------------+
| Learniq | | OpenRegister (foundation) |
| thin +-->+ 9 Schemas + REST API |
| client | | Audit trail (immutable, append-only) |
| (PHP + | | RBAC (role + state) |
| Vue + | | Lifecycle / aggregations / |
| manifest)| | calculations / notifications / widgets |
+-----------+ +-------------------------------------------+
Governing ADR chain:
- ADR-022, apps consume OR abstractions; no parallel implementations.
- ADR-024, every app ships
src/manifest.json; Tier-4 CnAppRoot. - ADR-031, business logic that fits
x-openregister-*extensions is declared in the schema, not written as PHP. - ADR-002, cmi5 + xAPI as primary content runtime.
- ADR-005, EU AI Act high-risk feature gate via schema lifecycle.
- ADR-008, immutable audit trail consumed from OR; append-only schemas for evidence objects.
2. The 9 schemas
All schemas live in lib/Settings/scholiq_register.json. Every schema carries an implicit @self envelope (tenant_id, created_at, updated_at) provided by OR.
2.1 Course (slug course)
Schema.org: schema:Course
Lifecycle: draft → published → archived (plus unarchive back to draft).
The publish transition is guarded by CoursePublishGuard, at least one published Lesson must exist.
Calculations:
lessonCount, count of Lesson objects with matchingcourseIdisPublished, boolean derived fromlifecycle == "published"
Aggregations:
enrolledLearners, count_distinct of learner UUIDs across active Enrolments for this coursecompletedLearners, count_distinct of learner UUIDs across completed Enrolments
Key fields: code, name, name_nl, level (po/vo/mbo/hbo/wo/corporate), language, mandatoryTraining, regulationSlug, renewalCourseSlug, certificateTemplate, tenant_id
2.2 Lesson (slug lesson)
Schema.org: schema:LearningResource
Lifecycle: draft → published → retired
Relations: course (many-to-one via courseId)
Key fields: courseId, name, order, contentType (text/video/scorm12/scorm2004/cmi5/lti/quiz), contentRef, durationMinutes, learningObjectives, mandatoryTraining, regulationSlug
2.3 XapiStatement (slug xapi-statement)
Append-only: true, records are never mutated after creation per ADR-008.
Every save emits an xapi.statement.received audit entry via OR's audit-trail abstraction.
Key fields: actor, verb, object, result, context, timestamp, stored, authority, version (const "1.0.3"), courseId, lessonId (Learniq denormalisations for filtering), tenant_id
2.4 Enrolment (slug enrolment)
Schema.org: schema:EnrollmentRequest
Lifecycle: pending → active → completed | withdrawn | failed. Withdraw is allowed from both pending and active.
Calculations:
isOverdue,lifecycle == "active"ANDdueDate < @nowdaysRemaining, date diff betweendueDateand@now(null when no due date)ragStatus,completed/red(overdue) /amber(≤7 days) /green
Relations: learner (LearnerProfile via learnerId), course (Course via courseId)
Notifications (all idempotency-keyed where relevant):
welcomeOnActivate, on enteringactivecompletionOnComplete, on enteringcompletedreminderT30,reminderT7,reminderT1, whendaysRemainingequals 30/7/1 ANDmandatory == truemanagerAlertOnOverdue, whenisOverdueis true; recipient ismanagerIdwith HR-group fallback
Widget: myMandatoryTraining, task-list widget showing the learner's own open mandatory enrolments, sorted by due date.
Key fields: learnerId, courseId, mandatory, dueDate, source (self/manager/hr/bulk/migrated/system), managerId, bulkJobId, reason, regulationSlug, tenant_id
2.5 Regulation (slug regulation)
Lifecycle: draft → published → archived
Aggregations (cross-schema, joining Enrolment/Attestation/Credential via regulationSlug):
mandatoryEnrolledCount, count of mandatory Enrolments with matchingregulationSlugin active/completed/failed statesmandatoryCompletedCount, count of mandatory completed EnrolmentsattestationCount, count of signed Attestations for this regulationvalidCredentialCount, count_distinct of learner IDs with an issued Credential for this regulation
Calculations:
coveragePercent,(mandatoryCompletedCount / mandatoryEnrolledCount) * 100; returns 0 when no enrolmentsragStatus,green(≥ragAmberThreshold),amber(≥ragRedThreshold),red(<ragRedThreshold); thresholds are per-Regulation fields (defaults: amber=90, red=70)
Notifications:
officerAlertOnCoverageDrop,calculatedChangetrigger whenragStatustransitions tored; recipient is thecompliance-officertenant roleonPublished, on thepublishtransition
Widgets: coverageGrid (regulation-coverage-grid with coverage/enrolled/completed/attestation metrics + campaign/export actions), boardProof (stats-block for board-scoped regulations), attestationCount (KPI tile)
Key fields: slug, name, audienceScope (all-employees/board/role-specific/department), requiresAnnualRenewal, renewalCycleMonths, ragRedThreshold, ragAmberThreshold, tenant_id
2.6 Attestation (slug attestation)
Append-only: true, evidence records are immutable per ADR-008.
Lifecycle: drafted → signed → revoked. The sign transition is guarded by AttestationSigningGuard, which:
- Verifies a matching
cmi5.completedXapiStatement exists for the learner + lesson. - Computes an HMAC-SHA256 signature using the tenant's OR signing key.
- Writes
signatureandsigningKeyIdonto the object.
Relations: learner (LearnerProfile), course (Course), lesson (Lesson)
Key fields: learnerId, lessonId, courseId, regulationSlug, actorIp, employeeId, score, xapiStatementId, signature, signingKeyId, tenant_id
2.7 Credential (slug credential)
Append-only: true, issued credentials are immutable; revocation is a lifecycle transition, not a delete.
Lifecycle: issue (null → issued, guarded by CredentialSigningService), revoke (issued → revoked), expire (issued → expired, dispatched automatically by the expiredAlert notification).
CredentialSigningService builds an Open Badges 3.0 JSON-LD assertion and RS256-signs it using the tenant's RSA key from KeyManagementService.
Calculations:
daysUntilExpiry, date diff toexpiresAt(null when no expiry)expiryStatus,none/expired/expiring-soon(≤30d) /expiring(≤90d) /validisOpenBadgesV3Signed, boolean checkingsignatureandopenbadges3Payloadare presentisExpiringIn90Days,isExpiringIn30Days,isExpired, boolean flags driving notifications
Notifications:
issuedToLearner, onissuetransitionexpiringSoonAlert(idempotency keyexpiryT30), whenisExpiringIn30Daysis trueexpiryT90(idempotency keyexpiryT90), whenisExpiringIn90Daysis trueexpiredAlert(idempotency keyexpired), whenisExpiredis true; also dispatches theexpirelifecycle transitionrevoked, onrevoketransition
Relations: learner (LearnerProfile via learnerId), course (Course via courseId)
Key fields: learnerId, courseId, kind (diploma/certificate/badge/microcredential), issuedAt, expiresAt, issuerDid, signature, openbadges3Payload, edciPayload (Phase 3), revocationReason, source (auto/manual/migrated), regulationSlug, renewalEnrolmentId, verificationUrl, tenant_id
2.8 LearnerProfile (slug learner-profile)
Lifecycle: active → merged | deleted. Merged profiles are retained for audit and back-reference resolution. Deleted profiles are soft-deleted and retained per AVG retention windows.
Calculation:
primaryRole, resolved byRoleSelector(PHP exception, see section 3); Nextcloud-admin override takes precedence, then static priority map: compliance-officer > hr > admin/manager > instructor > learner
Key fields: ncUserId, givenName, familyName, birthDate, bsnEncrypted (encrypted; K-12/government tenants only), schoolId, eckId, eduPersonAffiliation, roles, parentIds, managerId, department, tenant_id
2.9 AiFeature (slug AiFeature)
EU AI Act high-risk feature registry. Each AI capability that could be classified as high-risk under EU AI Act Annex III is registered as an AiFeature object.
Lifecycle: disabled → enabled. The enable transition is guarded by AiFeatureDpoAckGuard, which checks for a stored DPO acknowledgement in IAppConfig before allowing the transition.
No high-risk features ship in v0.1, the seed array is empty.
Key fields: slug, name, description, riskCategory (minimal/limited/high/unacceptable), lifecycle
3. Cross-schema aggregation pattern
The Regulation schema demonstrates the cross-schema aggregation pattern where one schema's computed metrics join data across multiple other schemas:
Regulation (regulationSlug = "NIS2")
mandatoryEnrolledCount <-- count(Enrolment where mandatory=true AND regulationSlug="NIS2" AND lifecycle IN [active,completed,failed])
mandatoryCompletedCount <-- count(Enrolment where mandatory=true AND regulationSlug="NIS2" AND lifecycle=completed)
attestationCount <-- count(Attestation where regulationSlug="NIS2" AND lifecycle=signed)
validCredentialCount <-- count_distinct(Credential.learnerId where regulationSlug="NIS2" AND lifecycle=issued)
coveragePercent = (mandatoryCompletedCount / mandatoryEnrolledCount) * 100
ragStatus = green | amber | red (thresholds configurable per Regulation)
This pattern means a compliance officer's coverage dashboard is always live, no batch job computes coverage, OR resolves it from the aggregation declarations on each schema read.
4. ADR-031 PHP exceptions, what ships in lib/
ADR-031 prohibits writing PHP service classes for behaviour that fits x-openregister-* extensions. The following are the legitimate exceptions for the Wave-2 wedge, each justified by ADR-031's permitted categories.
4.1 Lifecycle guards (lib/Lifecycle/)
| File | ADR-031 category | Justification |
|---|---|---|
CoursePublishGuard.php | Lifecycle guard, "PHP guards remain a legitimate seam" | Called by OR's lifecycle engine on the Course publish transition. Verifies at least one published Lesson exists before allowing publish. |
AttestationSigningGuard.php | Lifecycle guard + cryptographic operation | Called on Attestation drafted → signed. Validates a matching cmi5.completed XapiStatement exists, then computes HMAC-SHA256 using OR's tenant key. |
AiFeatureDpoAckGuard.php | Lifecycle guard | Called on AiFeature disabled → enabled. Verifies a DPO acknowledgement is stored in IAppConfig for the feature slug before allowing activation. |
RoleSelector.php | Domain rule selector, "picks which template applies" | Resolves primaryRole on LearnerProfile. Applies Nextcloud-admin group override, then a static priority map. One focused method; not a state machine. |
XapiCompletionHandler.php | External-system contract bridge | Bridges an xapi.statement.received audit event (from a cmi5 content AU reporting cmi5:Completed) to the Enrolment complete lifecycle transition. Translates between two standardised protocols. |
4.2 Listeners (lib/Listener/)
| File | ADR-031 category | Justification |
|---|---|---|
CredentialIssuanceHandler.php | External-system contract bridge | Listens for the Enrolment completed event and triggers the Credential issue lifecycle transition via OR's API. Acts as a bridge between two OR lifecycle events; contains no business logic of its own. |
DeepLinkRegistrationListener.php | NC framework requirement | Registers deep-link routes with Nextcloud's navigation framework on app boot. Required by NC's app framework contract; no equivalent declarative mechanism exists. |
4.3 Services (lib/Service/)
| File | ADR-031 category | Justification |
|---|---|---|
CredentialSigningService.php | Cryptographic operation | Builds the Open Badges 3.0 JSON-LD assertion and RS256-signs it using the tenant's RSA private key from KeyManagementService. Cryptographic signing is explicitly listed as a legitimate PHP seam in ADR-031. |
Cmi5LaunchTokenService.php | Cryptographic operation | Signs a JWT launch token for cmi5 Assignable Unit (AU) launch. Required by the cmi5 specification's launch protocol. |
KeyManagementService.php | Cryptographic operation | Generates RSA key pairs and stores them via NC's ICrypto interface. Required for tenant signing key rotation and initial setup. |
SettingsService.php | NC framework requirement | Reads and writes app configuration via IAppConfig. Used by the settings panel and KeyAdminController. |
4.4 Controllers (lib/Controller/)
| File | ADR-031 category | Justification |
|---|---|---|
PageController.php | NC framework requirement | Delivers the SPA shell TemplateResponse. Required by Nextcloud's routing and template system; no declarative alternative. |
CredentialVerifyController.php | External-system contract + document generation | Public (unauthenticated) endpoint for verifying a credential by ID. Returns credential validity, OB3 payload, and revocation status. Required to make credentials verifiable outside the Nextcloud session. |
KeyAdminController.php | NC framework requirement (admin API) | Admin-only REST endpoints for generating, rotating, and inspecting tenant signing keys. Required for the key management UI; no equivalent in OR's schema API. |
AuditPackExportController.php | Document generation, ADR-008 section 6 | Queries OR's audit trail + Regulation + Attestation objects for a regulation and date range; packages results into a ZIP (audit-trail.ndjson, audit-trail.csv, manifest.json, signature-verification.txt). No business logic, pure query and packaging. |
SettingsController.php | NC framework requirement | REST endpoints backing the ScholiqSettings custom component. Reads/writes user and admin preferences. Kept bespoke (not aliased to the AppHost generic) because its register-import path calls OpenRegister's ConfigurationService::importFromApp(appId, data, version, force) — a signature the generic settings service does not yet drive. |
AppHost adoption (ADR-040): the former bespoke
HealthControllerwas deleted. Health is now served by the OpenRegister AppHostGenericHealthController(publicGET /api/health, ADR-006 declarative checks), and a new admin-onlyGenericMetricsControllerservesGET /api/metrics, both driven by theobservabilityblock insrc/manifest.jsonand aliased onto Learniq's controller namespace by\OCA\OpenRegister\AppHost\Bootstrap::register()inApplication.php.PreferencesController,InitializeSettings/InitializeActions,AdminSettings, andSettingsSectionare now one-line subclass stubs extending the AppHost generics (the class names must exist in Learniq's namespace becauseinfo.xmland#[AuthorizedAdminSetting]references load them by name).ActionAuthServiceis a stub subclass ofGenericActionAuthServicefor the same reason. The SPAPageControllerstays bespoke: it provides role-aware dashboard initial state (primaryRole/dashboardRole/dashboardRoles) the generic dashboard controller does not.
4.5 Anti-patterns that were excluded
Per ADR-031, the following classes were deliberately not written: AttestationService, CoverageComputationService, EnrolmentService, EnrolmentNotificationService, EnrolmentDueReminderJob, ExpiryDetectionService, CredentialExpiryJob, CourseService, LessonService, AiFeatureRegistry, AuditTrail, AuditedController, ComplianceDashboardService, BulkEnrolmentService, RoleDetectionService. Every one of these would have been either a state machine, an aggregation, a calculation, a notification dispatcher, or a parallel audit-trail substrate, all categories ADR-031 prohibits for net-new code.
5. The manifest, src/manifest.json
Learniq adopts CnAppRoot Tier 4 from @conduction/nextcloud-vue. src/manifest.json is the single source of truth for menu, pages, and cross-app dependencies.
21 pages declared:
| Page ID | Route | Type | Notes |
|---|---|---|---|
| Dashboard | / | dashboard | Empty widgets in v0.1 (planned role-aware widgets, see nc-vue#200) |
| Courses | /courses | index | schema: Course |
| CourseDetail | /courses/:id | detail | schema: Course |
| LessonIndex | /courses/:courseId/lessons | index | schema: Lesson |
| LessonDetail | /courses/:courseId/lessons/:id | detail | schema: Lesson |
| LessonPlayer | /courses/:courseId/lessons/:lessonId/play | custom | component: LessonPlayer |
| Enrolments | /enrolments | index | schema: Enrolment |
| EnrolmentDetail | /enrolments/:id | detail | schema: Enrolment |
| BulkEnrol | /enrolments/bulk | custom | component: BulkEnrolModal |
| Credentials | /credentials | index | schema: Credential |
| CredentialDetail | /credentials/:id | detail | schema: Credential |
| CredentialVerify | /credentials/:id/verify | custom | component: CredentialVerify |
| Compliance | /compliance | dashboard | widgets: coverage-grid, attestation-count |
| Regulations | /compliance/regulations | index | schema: Regulation |
| RegulationDetail | /compliance/regulations/:slug | detail | schema: Regulation; tabs: details, auditTrail |
| Attestations | /compliance/attestations | index | schema: Attestation; readOnly |
| AttestationDetail | /compliance/attestations/:id | detail | schema: Attestation; readOnly |
| AuditPackExport | /compliance/export | custom | component: AuditPackExportModal |
| LearnerHome | /learner | dashboard | widget: my-mandatory-training |
| AdminHealth | /admin/health | dashboard | widget: health-stats |
| Settings | /settings | custom | component: ScholiqSettings |
Custom components registered via customComponents: BulkEnrolModal, AuditPackExportModal, CredentialVerify, ScholiqSettings, LessonPlayer.
AppHost blocks (ADR-040): src/manifest.json also carries an
observability block (health checks = database + or/orAvailable +
launchpad/appEnabled, statusCodePolicy: adr006; three objectCount
metrics over course/enrolment/learner-profile) consumed by the AppHost
GenericHealth/GenericMetricsController, and a deepLinks block (course /
enrolment / learner-profile / credential URL templates) consumed by the
AppHost deep-link listener — replacing the deleted bespoke
DeepLinkRegistrationListener PHP patterns.
Important note on planned features: The visibleIf role-aware page visibility and the widget-ref page-content shape (linking manifest widgets directly to schema widget declarations) were designed but are not yet supported by nc-vue's manifest schema v1.4.0. Dashboard pages currently declare conformant-but-minimal {id, title, type:custom} widgets. Role-gating across pages is a follow-up tracked in nc-vue umbrella #200. The intended design is documented above in the schema widget sections (section 2.4, 2.5); mark these as planned when implementing.
6. Directory structure
scholiq/
├── appinfo/ # Nextcloud app manifest, routes, navigation
├── lib/
│ ├── AppInfo/Application.php # Service registration, listener wiring
│ ├── Controller/ # PageController, CredentialVerifyController,
│ │ # KeyAdminController, AuditPackExportController,
│ │ # SettingsController (health/metrics/preferences
│ │ # are AppHost generics, ADR-040)
│ ├── Lifecycle/ # CoursePublishGuard, AttestationSigningGuard,
│ │ # AiFeatureDpoAckGuard, RoleSelector,
│ │ # XapiCompletionHandler
│ ├── Listener/ # CredentialIssuanceHandler (deep-link patterns
│ │ # now declared in manifest `deepLinks`, ADR-040)
│ ├── Repair/ # InitializeSettings, InitializeActions
│ │ # (one-line AppHost subclass stubs)
│ ├── Service/ # CredentialSigningService, Cmi5LaunchTokenService,
│ │ # KeyManagementService, SettingsService,
│ │ # ActionAuthService (AppHost stub)
│ └── Settings/ # AdminSettings, scholiq_register.json
├── src/
│ ├── manifest.json # Canonical page/menu/dependency declaration
│ └── main.js # CnAppRoot bootstrap
├── openspec/
│ ├── architecture/ # ADR-002, ADR-005, ADR-008
│ └── changes/ # 6 applied spec changes
├── templates/ # SPA shell (main.php)
├── tests/ # PHPUnit unit + integration tests
└── l10n/ # nl, en translations
7. OOAPI 5.0 catalog-publication contract (cross-repo)
Learniq does not serve /ooapi/v5/* itself — course-management's "Publish course catalog via OOAPI
5.0" requirement and data-exchange's "Delegate wire protocols to OpenConnector" requirement both name
OOAPI as a protocol Learniq must not implement (see openspec/changes/archive/2026-07-13-delegate-ooapi-to-opencatalogi/,
which resolved a prior self-contradiction between those two specs). Learniq's obligation stops at the
publication contract: which objects are eligible, how they map to OOAPI 5.0 resources, and the
DataExchangeJob that carries the sync request. The public endpoint and the wire-format adapter are owned
by other apps in the fleet.
Who owns what:
| Concern | Owner |
|---|---|
Eligible objects (Course/Programme with lifecycle: published; Cohort as a course "run") | Learniq (this contract) |
| Field mapping (below) | Learniq (this contract) |
Publish/archive → DataExchangeJob queuing (direction: sync, target: ooapi-catalog) | Learniq's existing lifecycle + DataExchangeJob machinery |
Field-mapping adapter / Synchronization target | OpenConnector (ooapi-catalog-publication, tracked as a filed issue — not built in this repo) |
Public /ooapi/v5/* HTTP surface, faceting | OpenCatalogi — already shipped, see opencatalogi/openspec/changes/ooapi-catalog-publication/ |
Field mapping (OOAPI 5.0 ↔ Learniq ↔ RIO):
| OOAPI 5.0 resource | Learniq object | Key Learniq fields | RIO model (keyed when present) |
|---|---|---|---|
course | Course | code, name, name_nl, description, level, language | opleidingseenheid |
program | Programme | name, code, level, description, courseIds | aangeboden opleiding |
offering | Cohort | programmeId/courseId, period, academicYear, teacherIds, learnerIds | aangeboden opleiding (per-run instance) |
Neither Course, Programme, nor Cohort carries a RIO identifier field today — RIO keying is described as
"when the institution has recorded one" because most PO/VO/MBO-corporate tenants have no RIO registration
(RIO is HBO/WO-centric). Adding an optional rioId-style field is deferred to whichever change actually
implements the OpenConnector adapter.
This item was previously an undifferentiated "OOAPI 5.0 catalog publication" line in the course-management
roadmap; it is now split three ways per the table above, with OpenCatalogi's third of the split already
merged (ooapi-catalog-publication) and OpenConnector's ooapi-catalog Synchronization target
outstanding.
8. LTI 1.3 tool placement (cross-repo)
Learniq does not implement any LTI protocol code — OIDC third-party-initiated login, id_token
signing/verification, JWKS, or Assignment & Grade Services (AGS)/NRPS wire handling all live in
OpenConnector's lti-13-platform adapter (openconnector/openspec/changes/lti-13-platform/).
Learniq's obligation is the consuming-app contract the adapter defines (REQ-LTI-010): model a
placement inside a Course/Lesson, delegate the launch, and translate an AGS score CloudEvent into a
GradeEntry. See openspec/changes/archive/2026-07-13-lti-tool-placement/.
Who owns what:
| Concern | Owner |
|---|---|
LtiToolPlacement (which Lesson/Course, which grading component) | Learniq (this contract) |
Launch delegation (LtiToolPlacementController::launch) | Learniq — thin, opaque REST proxy, no LTI claim parsing |
OIDC login/launch, id_token signing/verification, JWKS, AGS/NRPS protocol | OpenConnector — already shipped for the Tool-role/inbound surface; the Platform-role launch-initiation REST wrapper this contract assumes is not yet exposed (see the "known gap" note below) |
AGS score → GradeEntry (via LtiAgsScorePollJob) | Learniq (this contract) |
Grade destination mapping (curriculumPlanId/gradeEntryComponentId/gradeScaleId) | Learniq, configured once per placement, never auto-derived from the LTI payload |
Admin bootstrap (once per tool placement), per REQ-LTI-010:
- On the OpenConnector side, create an
lti_deploymentnaming this Learniq instance's launch-resolve endpoint aslaunchTargetUrl.gradeSink/rosterSourceare informational only — OpenConnector never writes to Learniq's register directly (REQ-LTI-007); grade passback flows through the CloudEvent + poll job below instead. Note the returnedlti_deploymentUUID — it is theLtiToolPlacement.openconnectorDeploymentIdvalue. - Still on OpenConnector, create an
event_subscriptionfiltered totype = 'nl.conduction.lti.ags.score.received',style = 'pull'. Set the resulting subscription UUID as Learniq'sscholiq.lti_ags_subscription_idapp-config value (occ config:app:set scholiq lti_ags_subscription_id --value=<uuid>) —LtiAgsScorePollJobno-ops until this is set. - Set
scholiq.openconnector_api_token(already required forDataExchangeRunHandler) and the newscholiq.openconnector_api_user— an NC username, in a group authorized for OpenConnector'sevent.pullaction — as the app-password pairLtiAgsScorePollJobuses to authenticate its pull call (EventsController::pull()requires an authenticated NC session + group authorization, not a bearer token — see the class docblock onLtiAgsScorePollJobfor the full auth-shape note). - In Learniq, create the
LtiToolPlacementobject (via the generic OpenRegister object-save path — no dedicated create UI ships in this change) naming thelessonId/courseId, theopenconnectorDeploymentIdfrom step 1, and, when grade passback is wanted, thecurriculumPlanId/gradeEntryComponentId/gradeScaleIdtriple. SetLesson.contentType = 'lti'andLesson.contentRefto the new placement's UUID.
NRPS (roster) is explicitly out of scope for this change (design.md Non-goals) — lti_deployment .rosterSource exists on the OpenConnector contract but Learniq does not yet configure or consume it.
Exposing Learniq's Enrolment/Cohort membership through OpenConnector's ADR-008 register/schema read
path is a real, separate follow-up.
Known gap (documented, not silently dropped): LtiToolPlacementController::launch() calls an
assumed OpenConnector REST endpoint for Platform-role launch initiation (REQ-LTI-006). Verified against
OpenConnector HEAD at the time this change was built: the merged adapter exposes
LtiLaunchService::initiatePlatformLaunch() only as an in-process PHP service method — no HTTP route
wraps it (appinfo/routes.php in the OpenConnector repo covers only the Tool-role inbound surface).
Until OpenConnector adds a thin REST wrapper around that method, the launch call in this contract 404s.
The assumed request/response shape is documented on LtiToolPlacementController::OPENCONNECTOR_LAUNCH_PATH.
8a. Timetabling operational layer (timetabling-and-substitution, cross-repo import seam)
Learniq does not generate a timetable — that stays permanently Zermelo's/Untis's job. This change adds the operational layer between "an external optimiser produced a timetable" and "a learner/teacher sees today's reality":
Room(lib/Settings/scholiq_register.json) — a plain bookable resource (capacity,kind,facilities,buildingCode/floor), no lifecycle, the same resource-metadata shape asMaterial.Sessiongains an additiveroomId($refRoom, kept alongside the existing free-textlocation),externalRef(timetable-import idempotency key), and substitution fields (substituteTeacherId,changeReasonKind,changeReason,affectedLearnerIds,affectedParentIds,changedAt).- Import seam — a generated timetable is pulled via the existing
DataExchangeJob/DataMappingProfilemechanism (target: timetable-import,direction: import), never a parallel job schema. Three seededDataMappingProfilerows (Zermelo/Untis/Xedule) declare the field mapping;OCA\Learniq\Timetabling\TimetableImportHandler(an ADR-031 external-system bridge, the same shape asDataExchangeRunHandler) resolves the profile in reverse and idempotently upsertsSessionobjects keyed byexternalRef.DataExchangeRunHandlerbails out for this target so exactly one handler owns the job. - Conflict detection, not resolution —
OCA\Learniq\Timetabling\TimetableConflictDetector(an ADR-031 cross-object write bridge, the same class asConferenceScheduleGenerator) pairwise-scansSessions in the affected date window for teacher/room/cohort/learner double-booking, room-capacity-exceeded, and exam-clash, writing idempotentTimetableConflictrows — it never edits aSession.OCA\Learniq\Listener\SessionConflictListenertriggers it onSessioncreate/update (ObjectCreatedEvent/ObjectUpdatedEvent— the fleet's first use ofObjectUpdatedEvent); the import handler triggers it once in batch after a successful import. - Substitution / lesuitval-vervanging —
Session.cancel(now guarded) and two new self-loop transitions,substitute-teacher(scheduled→scheduled) andsubstitute-teacher-in-progress(in-progress→in-progress), both requiringOCA\Learniq\Lifecycle\SessionChangeGuard(caller must be aCohort.teacherIdsmember or admin/coordinator;changeReasonKindalways required,substituteTeacherIdrequired for the substitute transitions). Split into two action names becauseTransitionEngine::transition()resolves a single scalartovalue — an arrayfromconverging on onetois only correct for a state-changing transition (cancel), not a true multi-state self-loop.OCA\Learniq\Listener\SessionChangeNoticeHandlermaterialisesaffectedLearnerIds/affectedParentIds(mirroringConferenceRound.invitedLearnerIds) and a server-stampedchangedAtat both transitions, so the declaredx-openregister-notificationsrules can resolve recipients without a runtime join. ExamAccommodation— a learner's approved, evidence-backed exam entitlement (extra time, separate room, reader, etc.).createis open to learner/parent-portal roles;approveis restricted to admin/compliance-officer/mentor viaOCA\Learniq\Lifecycle\ExamAccommodationApprovalGuard(a PHP guard, since nox-openregister-authorizationkey expresses a per-transition role gate in this register). Wiring the effective time limit intoTakeAssessmentView/AssessmentResultis an explicitassessment-capability follow-up, not built here.- Dagrooster —
TimetableController::mine()(personal-timetable, still read-only, no new write endpoint) projectsroomId/resolvedRoomdetail,lifecycle, and substitution fields perSession, plus a same-daychangeslist (Sessions whosechangedAtfalls today, regardless of the requested window). - Frontend — declarative
Room/TimetableConflict/ExamAccommodationindex/detail pages insrc/manifest.json; two named custom views,src/dialogs/SubstitutionModal.vue(cancel/substitute, opened fromMyTimetable.vue) andsrc/views/TimetableConflictQueue.vue(the coordinator's acknowledge/resolve review queue). - Out of scope, tracked as cross-repo follow-ups: the OpenConnector Zermelo/Untis/Xedule wire adapters
themselves (
ConductionNL/openconnector), and wiringExamAccommodationinto theassessmentcapability.
See openspec/changes/archive/2026-07-16-timetabling-and-substitution/ for the full proposal/design/specs.
9. References
- Hydra ADR-022:
hydra/openspec/architecture/adr-022-apps-consume-or-abstractions.md - Hydra ADR-024:
hydra/openspec/architecture/adr-024-app-manifest.md - Hydra ADR-031:
hydra/openspec/architecture/adr-031-schema-declarative-business-logic.md - App ADR-002:
openspec/architecture/ADR-002-content-runtime-cmi5-xapi.md - App ADR-005:
openspec/architecture/ADR-005-eu-ai-act-gating.md - App ADR-008:
openspec/architecture/ADR-008-immutable-audit-trail.md - Schema source:
lib/Settings/scholiq_register.json - Manifest source:
src/manifest.json - Applied specs:
openspec/changes/(6 directories) - OOAPI 5.0 catalog-publication contract:
openspec/changes/archive/2026-07-13-delegate-ooapi-to-opencatalogi/(this repo, spec-consistency only);opencatalogi/openspec/changes/ooapi-catalog-publication/(merged, opencatalogi side) - Specs summary:
docs/SPECS.md