diff --git a/appinfo/info.xml b/appinfo/info.xml
index 456ff5c67..63b395d01 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -68,7 +68,7 @@ Vrij en open source onder de EUPL-1.2-licentie.
**Ondersteuning:** support@conduction.nl
]]>
- 0.3.9-beta.20261005130000
+ 0.3.9-beta.20261015200000
EUPL-1.2
Conduction
Learniq
diff --git a/l10n/en.js b/l10n/en.js
index 0df6864ff..28293ed76 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -3752,7 +3752,14 @@ OC.L10N.register(
"Level %s": "Level %s",
"Learning path": "Learning path",
"or": "or",
- "Training course": "Training course"
+ "Training course": "Training course",
+ "Wallet": "Wallet",
+ "Add to wallet": "Add to wallet",
+ "Wallet offer link": "Wallet offer link",
+ "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.",
+ "Learniq wallet offers": "Learniq wallet offers",
+ "Certificates can be offered to a wallet through integriq.": "Certificates can be offered to a wallet through integriq.",
+ "No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "No integriq API token is set (%s), so a certificate cannot be offered to a wallet."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index f3b781eae..e573e795d 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -3790,7 +3790,14 @@
"Level %s": "Level %s",
"Learning path": "Learning path",
"or": "or",
- "Training course": "Training course"
+ "Training course": "Training course",
+ "Wallet": "Wallet",
+ "Add to wallet": "Add to wallet",
+ "Wallet offer link": "Wallet offer link",
+ "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.",
+ "Learniq wallet offers": "Learniq wallet offers",
+ "Certificates can be offered to a wallet through integriq.": "Certificates can be offered to a wallet through integriq.",
+ "No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "No integriq API token is set (%s), so a certificate cannot be offered to a wallet."
},
"plurals": "",
"pluralForm": "nplurals=2; plural=(n != 1);"
diff --git a/l10n/nl.js b/l10n/nl.js
index 93349773a..4fe4da3be 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -4206,7 +4206,14 @@ OC.L10N.register(
"Level %s": "Niveau %s",
"Learning path": "Leerweg",
"or": "of",
- "Training course": "Cursus"
+ "Training course": "Cursus",
+ "Wallet": "Wallet",
+ "Add to wallet": "In wallet zetten",
+ "Wallet offer link": "Wallet-link",
+ "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "De link die een wallet-app opent om dit certificaat op te halen, zolang het aanbod openstaat. Wordt leeggemaakt zodra het is opgehaald, ingetrokken of het aanbod mislukt.",
+ "Learniq wallet offers": "Learniq-walletaanbiedingen",
+ "Certificates can be offered to a wallet through integriq.": "Certificaten kunnen via integriq in een wallet worden aangeboden.",
+ "No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "Er is geen integriq-API-token ingesteld (%s), dus een certificaat kan niet in een wallet worden aangeboden."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 60c174618..9ed37415c 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -4259,7 +4259,14 @@
"Level %s": "Niveau %s",
"Learning path": "Leerweg",
"or": "of",
- "Training course": "Cursus"
+ "Training course": "Cursus",
+ "Wallet": "Wallet",
+ "Add to wallet": "In wallet zetten",
+ "Wallet offer link": "Wallet-link",
+ "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails.": "De link die een wallet-app opent om dit certificaat op te halen, zolang het aanbod openstaat. Wordt leeggemaakt zodra het is opgehaald, ingetrokken of het aanbod mislukt.",
+ "Learniq wallet offers": "Learniq-walletaanbiedingen",
+ "Certificates can be offered to a wallet through integriq.": "Certificaten kunnen via integriq in een wallet worden aangeboden.",
+ "No integriq API token is set (%s), so a certificate cannot be offered to a wallet.": "Er is geen integriq-API-token ingesteld (%s), dus een certificaat kan niet in een wallet worden aangeboden."
},
"plurals": "",
"pluralForm": "nplurals=2; plural=(n != 1);"
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index de3fa3d52..905a2a7f3 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -26,6 +26,7 @@
use OCA\OpenRegister\AppHost\Bootstrap;
use OCA\Learniq\AppInfo\Registrar\EventListenerWiring;
use OCA\Learniq\Middleware\ActionForbiddenMiddleware;
+use OCA\Learniq\SetupCheck\WalletOfferTokenCheck;
use OCA\Learniq\AppInfo\Registrar\ServiceOverrideRegistrar;
use OCA\Learniq\Mcp\LearniqScannableServices;
use OCP\AppFramework\App;
@@ -90,6 +91,10 @@ public function register(IRegistrationContext $context): void {
// First, so nothing below that throws can leave it unregistered.
$context->registerMiddleware(ActionForbiddenMiddleware::class);
+ // The admin overview names a missing integriq token, which makes every
+ // wallet offer fail (credential-wallet-offer-for-the-learner).
+ $context->registerSetupCheck(WalletOfferTokenCheck::class);
+
// ADR-040: adopt the OpenRegister AppHost. One call wires the generic
// SPA/settings/preferences/health/metrics controllers, the settings +
// action-auth services, the install repair steps, the admin settings
diff --git a/lib/Portal/ParticipantSitePages.php b/lib/Portal/ParticipantSitePages.php
index c01f7deac..c51fc7c65 100644
--- a/lib/Portal/ParticipantSitePages.php
+++ b/lib/Portal/ParticipantSitePages.php
@@ -127,6 +127,7 @@ public function collections(): array {
'expiryLabel',
'renewalLine',
'verificationUrl',
+ 'walletOfferUri',
'lifecycle',
],
extra: [
@@ -140,6 +141,8 @@ public function collections(): array {
['field' => 'expiresAt', 'label' => 'Valid until', 'render' => 'date'],
['field' => 'expiryLabel', 'label' => 'Status'],
['field' => 'verificationUrl', 'label' => 'Check or download', 'render' => 'link'],
+ // The open wallet offer as a QR code with its link (portaliq link-field-qr-code).
+ ['field' => 'walletOfferUri', 'label' => 'Wallet', 'render' => 'qr', 'linkLabel' => 'Add to wallet'],
],
]
),
diff --git a/lib/Portal/PortalLabelTranslator.php b/lib/Portal/PortalLabelTranslator.php
index 7a80e16aa..c612f5b7a 100644
--- a/lib/Portal/PortalLabelTranslator.php
+++ b/lib/Portal/PortalLabelTranslator.php
@@ -74,6 +74,8 @@ class PortalLabelTranslator {
'composeHint',
// A task's title sentence; its `{field}` places stay as they are (portaliq lookup-by-row-field).
'titleTemplate',
+ // A link or QR column's words for its link (portaliq link-field-qr-code).
+ 'linkLabel',
];
/**
diff --git a/lib/Service/ConnectionReportService.php b/lib/Service/ConnectionReportService.php
index 1ccff6ea0..b985f195a 100644
--- a/lib/Service/ConnectionReportService.php
+++ b/lib/Service/ConnectionReportService.php
@@ -286,6 +286,20 @@ public function observeLti(): void {
$this->observe(key: self::LTI_KEY, status: 'unavailable', reason: 'LTI tools need integriq, which is not installed.');
}//end observeLti()
+ /**
+ * The status last recorded for a connection (`configured`, `unconfigured`,
+ * `error` or `unavailable`), or null when nothing was recorded.
+ *
+ * @param string $key The connection key.
+ *
+ * @return string|null
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
+ */
+ public function lastStatus(string $key): ?string {
+ return ($this->readObservation(key: $key)['status'] ?? null);
+ }//end lastStatus()
+
/**
* The stored observation for a connection, or null when none is usable.
*
diff --git a/lib/Service/WalletClaimSyncService.php b/lib/Service/WalletClaimSyncService.php
index 9ceba70a8..febf94164 100644
--- a/lib/Service/WalletClaimSyncService.php
+++ b/lib/Service/WalletClaimSyncService.php
@@ -81,13 +81,15 @@ public function check(array $object, string $action, string $userId): GuardResul
*
* @param array $credential The Credential data array.
*
- * @return array The Credential with `walletOfferStatus=claimed` and `walletClaimedAt=now`.
+ * @return array The Credential with `walletOfferStatus=claimed`, `walletClaimedAt=now` and no offer link.
*
* @spec openspec/specs/certification/spec.md#requirement-recordwalletclaim-transition-syncs-wallet-claim-status-back-onto-the-credential
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-credential-keeps-the-wallet-offer-link-while-the-offer-is-open
*/
public function claim(array $credential): array {
$credential['walletOfferStatus'] = 'claimed';
$credential['walletClaimedAt'] = gmdate('c');
+ $credential['walletOfferUri'] = null;
return $credential;
}//end claim()
diff --git a/lib/Service/WalletOfferDelegationService.php b/lib/Service/WalletOfferDelegationService.php
index ad89bb18c..b42dce156 100644
--- a/lib/Service/WalletOfferDelegationService.php
+++ b/lib/Service/WalletOfferDelegationService.php
@@ -115,6 +115,13 @@ class WalletOfferDelegationService implements LifecycleGuardInterface {
*/
private const OPENCONNECTOR_TOKEN_KEY = 'openconnector_api_token';
+ /**
+ * The scheme of the link a wallet app opens (OpenID4VCI credential offer).
+ *
+ * @var string
+ */
+ private const WALLET_SCHEME = 'openid-credential-offer://';
+
/**
* Refusal when the Credential carries nothing to offer.
*
@@ -180,18 +187,21 @@ public function check(array $object, string $action, string $userId): GuardResul
/**
* Push the Credential to the EUDI wallet through openconnector.
*
- * On success sets `walletOfferStatus=offered`, `walletOfferedAt` and
- * `walletAttestationRef` and clears `walletOfferError`. On any failure sets
- * `walletOfferError` and leaves the offer fields as they were, so a failed
- * offer is recorded rather than lost. Never throws.
+ * On success sets `walletOfferStatus=offered`, `walletOfferedAt`,
+ * `walletAttestationRef` and `walletOfferUri` (the link a wallet app opens)
+ * and clears `walletOfferError`. On any failure sets `walletOfferError`,
+ * clears `walletOfferUri` and leaves the other offer fields as they were,
+ * so a failed offer is recorded rather than lost. Never throws.
*
* @param array $credential The Credential data array.
*
* @return array The Credential with the offer outcome applied.
*
* @spec openspec/specs/certification/spec.md#requirement-offertowallet-transition-pushes-an-issued-credential-to-the-eudi-wallet
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-credential-keeps-the-wallet-offer-link-while-the-offer-is-open
*/
public function offer(array $credential): array {
+ $credential['walletOfferUri'] = null;
$requestBody = $this->buildOfferRequest(credential: $credential);
if ($requestBody === null) {
$credential['walletOfferError'] = self::NO_PAYLOAD;
@@ -216,6 +226,7 @@ public function offer(array $credential): array {
$credential['walletOfferStatus'] = 'offered';
$credential['walletOfferedAt'] = gmdate('c');
$credential['walletAttestationRef'] = $attestationRef;
+ $credential['walletOfferUri'] = $this->walletLink(response: $result);
$credential['walletOfferError'] = null;
$this->logger->info(
@@ -309,6 +320,24 @@ private function extractOfferUuid(array $response): ?string {
return $uuid;
}//end extractOfferUuid()
+ /**
+ * The link a wallet app opens to collect the offer: integriq's `offerUrl`
+ * (`openid-credential-offer://?credential_offer_uri=...`, the same value
+ * as `qrPayload`), else that link built from `credentialOfferUri`.
+ *
+ * @param array $response The decoded `createOffer` response body.
+ *
+ * @return string
+ */
+ private function walletLink(array $response): string {
+ $offerUrl = ($response['offerUrl'] ?? null);
+ if (is_string($offerUrl) === true && str_starts_with($offerUrl, self::WALLET_SCHEME) === true) {
+ return $offerUrl;
+ }
+
+ return self::WALLET_SCHEME . '?credential_offer_uri=' . rawurlencode((string)$response['credentialOfferUri']);
+ }//end walletLink()
+
/**
* Call openconnector's app-facing, consumer-gated offer-creation
* endpoint.
diff --git a/lib/Service/WalletRevocationPropagationService.php b/lib/Service/WalletRevocationPropagationService.php
index 9d38230cb..292a39fdf 100644
--- a/lib/Service/WalletRevocationPropagationService.php
+++ b/lib/Service/WalletRevocationPropagationService.php
@@ -154,18 +154,24 @@ public function check(array $object, string $action, string $userId): GuardResul
/**
* Propagate a revocation to any outstanding wallet offer, best-effort.
*
- * No-op (credential returned unchanged, no call made) unless there is an
- * outstanding offer with a correlation key. On a confirmed revocation sets
- * `walletOfferStatus=revoked`; on any failure records `walletOfferError`
- * and never throws.
+ * The offer link is cleared first, whatever follows: a revoked
+ * certificate is never offered. Otherwise no call is made unless there is
+ * an outstanding offer with a correlation key. On a confirmed revocation
+ * sets `walletOfferStatus=revoked`; on any failure records
+ * `walletOfferError` and never throws.
*
* @param array $credential The Credential data array.
*
* @return array The Credential with the propagation outcome applied.
*
* @spec openspec/specs/certification/spec.md#requirement-revoking-a-credential-propagates-to-any-outstanding-wallet-offer-fail-soft
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-credential-keeps-the-wallet-offer-link-while-the-offer-is-open
*/
public function propagate(array $credential): array {
+ if (isset($credential['walletOfferUri']) === true) {
+ $credential['walletOfferUri'] = null;
+ }
+
$walletOfferStatus = ($credential['walletOfferStatus'] ?? null);
if (in_array($walletOfferStatus, self::OUTSTANDING_STATUSES, true) === false) {
// Nothing outstanding to revoke — no-op.
diff --git a/lib/Settings/learniq_register.json b/lib/Settings/learniq_register.json
index f9d6da8b2..fa75a59c5 100644
--- a/lib/Settings/learniq_register.json
+++ b/lib/Settings/learniq_register.json
@@ -3,7 +3,7 @@
"info": {
"title": "Learniq Register",
"description": "Register containing all schemas for the Learniq application. v0.2.0 declares Learniq's AVG Art. 30 processing activities (learner administration, attendance & leerplicht reporting, grading & assessment, signed attestations, credentialing, data exchange to DUO/OSO/municipality/HR, and AI features) as x-openregister-processing catalogue annotations on LearnerProfile, AttendanceRecord, Assessment, Attestation, Credential, DataExchangeJob, and AiFeature. Each carries purpose (doelbinding), legal basis (rechtsgrond), data categories, backend identifier, retention reference, owner/review fields, and opts the schema into OpenRegister's per-access read-logging (logReads:true, attribution.default referencing the activity code). Seeds arrive as drafts; the school's privacy officer activates them in the OpenRegister lifecycle. v0.3.0 adds ADR-046 portal-identity scoping refs (learnerRef / learnerRefs / submittedByRef / guardianRefs) alongside the existing Nextcloud-user-id fields on GradeEntry, FinalGrade, AttendanceRecord, Enrolment, Submission, ExcuseRequest, LearnerProfile, and GradeNotification — additive, optional, fail-closed; see openspec/changes/archive/2026-09-28-portal-identity/. v0.3.1 strips the AiFeature schema to its AVG Art. 30 processing carrier only — EU AI Act high-risk feature governance (lifecycle, DPO acknowledgement, register pages) is delegated to the Hermiq app (ADR-005 amendment). Requires OpenRegister >= 0.2.14. See openspec/specs/avg-verwerkingsregister/spec.md. v0.4.0 adds beroepspraktijkvorming (BPV) — six new schemas (Praktijkopleider, BpvPlacement, Praktijkovereenkomst, PokSignature, WerkprocesAssessment, BpvVisitReport) implementing the MBO work-placement/POK/leerbedrijf-verification/werkproces-assessment domain (WEB art. 7.2.8/7.2.9). No existing schema is modified. See openspec/changes/archive/2026-07-13-bpv-praktijkovereenkomst/. v0.5.0 adds parent-conferences (oudergesprekken-cyclus) — five new schemas (ConferenceRound, TeacherAvailability, ConferenceSignup, ConferenceSlot, ConferenceReport) implementing conference-round invitations, guardian/self signup, ConferenceScheduleGenerator's greedy conflict-free slot generation, and an append-only gespreksverslag (ConferenceReport). No existing schema is modified. See openspec/changes/archive/2026-07-13-parent-evening-planner/. v0.6.0 adds exam-board (examencommissie) case handling — two new schemas (ExemptionCase, FraudCase) implementing the WHW art. 7.13 exemption (vrijstelling) and fraud/plagiarism dossier flows: ExemptionCase submitted→in-assessment→granted|rejected|withdrawn with ExemptionGrantHandler bridging a grant into a published GradeEntry (sourceKind=exemption); FraudCase reported→hearing-scheduled→heard→decided(+dismissed) with FraudCaseDecisionGuard stamping a 42-day appealDeadline and FraudCaseDecisionHandler invalidating a contested concept GradeEntry on a fraud-proven verdict. GradeEntry gains sourceKind=exemption, exemptionCaseId/fraudCaseId refs, conditional value requiredness, FraudCaseBlockGuard on publish/republish, and a new invalidated terminal state; GradeEntry/FinalGrade gain an examboard RBAC read role. See openspec/changes/archive/2026-07-13-exam-board-case-handling/. v0.6.1 is doc-only: DataExchangeJob.target's description gains the 'ooapi-catalog' example, naming the OpenConnector target that syncs published/archived Course, Programme, and Cohort objects to the OOAPI 5.0 catalog hosted by opencatalogi, per the course-management publication contract resolving the course-management/data-exchange OOAPI self-contradiction. No schema/type change — target was already a free-text string. See openspec/changes/archive/2026-07-13-delegate-ooapi-to-opencatalogi/. v0.7.0 adds LtiToolPlacement — places an external LTI 1.3 tool inside a Course/Lesson, naming the corresponding lti_deployment UUID in OpenConnector's own register plus an optional CurriculumPlan/component/GradeScale mapping for AGS grade passback; brings the previously-dead Lesson.contentType='lti' enum value to life (contentRef now names an LtiToolPlacement, doc-only correction). GradeEntry.sourceKind gains lti-ags, plus ltiToolPlacementId/ltiAgsResultId fields (same per-sourceKind-id-field shape as submissionId/assessmentResultId/sessionId), so an LTI Assignment & Grade Services score carries an honest, traceable, idempotent origin. See openspec/changes/archive/2026-07-13-lti-tool-placement/. v0.8.0 adds the competency capability — CompetencyFramework (a named taxonomy with its own proficiencyLevels scale), Competency (recursive via parentId, mirroring Course.parentCourseId — a leaf Competency IS the learning outcome, no separate LearningOutcome schema), and CompetencyAttainment (a FinalGrade-shaped, read-only, per-(learnerId, competencyId) roll-up recomputed by the new CompetencyAttainmentRollupHandler listener). Course, Lesson, Assignment, Assessment, Item, Credential, and Programme each gain an additive competencyIds/requiredCompetencyIds array. WerkprocesAssessment gains a nullable, server-resolved competencyId generalizing its existing kwalificatiedossierCode/kerntaakCode/werkprocesCode strings (unchanged in shape) into the shared taxonomy, and its confirm transition additionally fires CompetencyAttainmentRollupHandler alongside the existing WerkprocesGradeEmitHandler. See openspec/changes/archive/2026-07-16-competency-framework/. v0.9.0 adds learning-progress-and-analytics — LessonCompletion (one row per (learnerId, lessonId), source: xapi | manual, written by the new LessonProgressHandler sibling listener on the same XapiStatement ObjectCreatedEvent XapiCompletionHandler already consumes — no mandatoryTraining/last-lesson gate — plus a learner self-serve manual path); Enrolment gains completedLessonCount/totalPublishedLessonCount aggregate-refs and a progressPercent field (EnrolmentProgressEvaluator/EnrolmentProgressRollupHandler, mirroring FinalGrade.value/GradeFormulaEvaluator — no division operator exists in the calculation DSL); EngagementScore (per learnerId+courseId time-on-task/recency/score, EngagementScoreEvaluator), EngagementRiskThreshold + EngagementRiskFlag (deterministic rule-based at-risk detection generalised beyond the HE/MBO-only BsaTrajectory, mirroring AttendanceThreshold/AttendanceFlag and BsaTrajectory/BsaProgressFlag, via the new EngagementSignalHandler). GroupTrendHeatmap.vue renders a cohort x period grade-trend heat map over existing GradeEntry data — no new schema. See openspec/changes/archive/2026-07-16-learning-progress-and-analytics/. v0.10.0 adds adaptive-release-and-prerequisites — closes the prerequisite hole: Course gains prerequisiteCourseIds (array of $ref Course), enforced by the new EnrolmentPrerequisiteListener on OpenRegister's ObjectCreatingEvent for the enrolment schema (NOT a lifecycle `requires` guard — Enrolment has no transition into its initial pending state for one to attach to; mirrors decidesk's SubmissionDeadlineListener/larpingapp's CharacterRequirementListener creation-time-veto shape). Lesson and Assessment each gain releaseConditions (array of {kind: lesson-completed|assessment-min-score, lessonId?, assessmentId?, minScore?}, AND-combined) and availableAfterDays (nullable integer — drip release relative to each learner's OWN Enrolment.created), evaluated per-learner at request time by the new stateless LessonReleaseEvaluator/LessonReleaseController (no @self/@aggregate/@ref calc-DSL token carries a per-viewer identity, so this cannot be materialised). Assessment's existing availableFrom/availableUntil/isAvailable absolute window is unchanged; the new gates layer on top. See openspec/changes/archive/2026-07-16-adaptive-release-and-prerequisites/. v0.11.0 adds report-card (periodic rapport composition & rapportvergadering review) — three new schemas: ReportPeriod (the school-wide term a report is composed for: periodCode matching GradeEntry.period/CurriculumPlan.components[].period/FinalGrade.breakdown.periods keys, curriculumPlanIds[]/cohortIds[] scope, a nullable lockDate backing a materialised isLocked calculation — NOT an automatic lifecycle transition, mirroring ConferenceRound.isBookingClosed's documented 'scheduled triggers cannot drive a transition' precedent); ReportCard (one composed document per learner per ReportPeriod: subjectGrades[] snapshotting FinalGrade.breakdown.periods[periodCode] plus sourceGradeEntryIds[], an attendanceSummary, mentor/teacher comments, a forward-compatible nullable competencyAttainment field, and fail-soft docudesk PDF-render state fields; lifecycle draft → rapportvergadering-review → finalised → published-to-parents, with reopen back to review); ReportCardParentNotification (append-only fan-out record mirroring GradeNotification's shape). ReportCardComposer (Listener, ADR-031 cross-object write bridge mirroring DataExchangeRunHandler's composeLeerplichtDossier/composeSwvDossier assembly shape, NOT the DataExchangeJob queue) composes ReportCards on ReportPeriod's compose transition and on ReportCard's recompose self-loop. GradeEntry.publish/republish's requires guard is swapped from FraudCaseBlockGuard to ReportPeriodLockGuard, which composes FraudCaseBlockGuard's existing check internally then additionally blocks publishing into a locked ReportPeriod's scope unless the actor holds admin/mentor/principal — a single-guard composition, not a second `requires` array entry: verified at HEAD (LifecycleAnnotationValidator::validate + LifecycleGuardRegistry::resolve) that `requires` is strictly a single DI-tag string, never an array, so 'second requires entry' precedents named in this change's own design.md (Credential.revoke) do not actually exist as an array in this register — Credential.revoke has exactly one requires guard. ReportCard.publishToParents requires ReportCardVisibilityGuard, which re-resolves every contributing GradeEntry.visibleFrom at the moment of publication (not trusted from compose time) so grade-visibility-scheduling's promise holds end-to-end. PortalContributionProvider::parentContribution() gains a fourth parentReportCards collection, identical childJoin reverse-scope pattern to parentGrades/parentAttendance/parentExcuseRequests, filtered to lifecycle published-to-parents only. See openspec/changes/archive/2026-07-16-report-card-composer/. v0.12.0 adds pupil-dossier — the everyday pupil dossier layer underneath the formal LearningPlan/SupportRequest/DeliberationRecord care chain: three new schemas, DossierNote (appendOnly observation/conversation/phone-call-home/concern/positive note, x-property-rbac restricting every row to admin/mentor/coordinator/the note's own authorId — the tightest floor the dialect can express; the finer team-visible/care-team-only/private-to-author tiering beyond that floor is a named, unimplemented OpenRegister platform-capability gap, not a fabricated guarantee), BehaviourIncident (appendOnly what/severity/append-only followUpActions log/resolution, own lifecycle open → in-handling → resolved, escalatedSupportRequestId referencing — never duplicating — a SupportRequest), and WellbeingCheckIn (appendOnly learner-authored moodScale 1-5 + optional comment, no x-openregister-authorization restriction — any authenticated user may submit their own, a documented platform gap same as SupportRequest.raisedBy). All three carry x-openregister-processing catalogue entries (scholiq-pupil-dossier-notes / scholiq-behaviour-incidents / scholiq-wellbeing-checkins). No existing schema is modified. See openspec/changes/archive/2026-07-16-pupil-dossier-notes/. v0.13.0 adds course-evaluation (vakevaluatie) — five new schemas closing the gap between Nextcloud Forms (a real survey instrument this change deliberately does not rebuild) and course-quality reporting: EvaluationCampaign (which Course(s)/Cohort(s), academicYear/period, a minimal built-in Likert+free-text questions[] instrument or an external-form convenience link Learniq never ingests, a fixed anonymityPolicy, draft → open → closed → archived); EvaluationInvitation (system-provisioned per learner when a campaign opens, via EvaluationInvitationProvisioningHandler — the ONLY place a learner's identity and response status co-exist, reusing Enrolment.dueReminder's scheduled+filter reminder shape); CourseEvaluationResponse (appendOnly, anonymous BY SCHEMA SHAPE — no learnerId/submittedBy property exists at all, not RBAC-hidden — gated by CourseEvaluationEligibilityGuard, which mirrors ConferenceSignupGuardianGuard's session-resolved-caller-looked-up-against-a-different-schema shape and structurally prevents both uninvited and duplicate submission); CourseQualityScore (materialised per courseId/teacherId/academicYear/period roll-up, recomputed by CourseQualityScoreRollupHandler via CourseQualityScoreEvaluator — averaging exceeds this register's proven declarative count/count_distinct aggregation metrics, mirroring FinalGrade/GradeFormulaEvaluator's x-openregister-triggers.calculatedChange shape exactly, not an `engine`-keyed x-openregister-calculations entry, which is not a dialect key used anywhere in this register); ImprovementAction (declarative opleidingscommissie review/action tracker, no PHP guard). CourseEvaluationResponseSubmittedHandler re-resolves the same session identity after a successful submit and flips the caller's own EvaluationInvitation.hasResponded — a write to a different, unlinked object; the invitation never gains a field referencing which response satisfied it. No course-management schema is modified — Course/Cohort are referenced by $ref only. See openspec/changes/archive/2026-07-16-course-evaluation/. v0.14.0 adds payments — the `enrolment`/`course-management` specs both punted payment to 'a separate spec' that was never written; this closes the gap with five new schemas: FeeItem (the chargeable definition — kind, amount/currency/taxPosture, and a required voluntary flag: true is the Wet vrijwillige ouderbijdrage schoolkassa/trip/materials case, false is a genuine paid product like MBO contractonderwijs); Order/OrderLine (payer-facing request draft -> open -> partially-paid | paid -> refunded, with a materialised isOverdue calculation and dueSoon/overdue notifications mirroring Enrolment's exact idiom, and OrderTotalValidationGuard validating OrderLine.lineTotal sums against Order.totalAmount at finalize since no sum aggregation metric is precedented in this register); PaymentTransaction (appendOnly PSP round-trip per ADR-008, mirroring Attestation's evidentiary shape — learniq forwards OpenConnector's opaque status only, parsing nothing PSP-specific); Entitlement (what a paid Order grants — structurally cannot gate a voluntary fee: FeeItemVoluntaryEntitlementGuard blocks the pending -> active grant transition whenever the linked FeeItem.voluntary is true, composing EntitlementOrderPaidGuard which additionally requires the linked Order to have reached paid). PaymentTransactionController delegates payment initiation/callback to OpenConnector's (not-yet-built) mollie-stripe-payment-adapter, reusing the established IClientService/IURLGenerator/IAppConfig bearer-token shape LtiToolPlacementController/DataExchangeRunHandler already use. No invoicing/dunning/ERP — that territory belongs to pipelinq/financeq. See openspec/changes/archive/2026-07-16-school-payments/. v0.15.0 adds accessibility-conformance — the toegankelijkheidsverklaring (accessibility statement) obligation under the Tijdelijk besluit digitale toegankelijkheid overheid (BDTO, BWBR0040936): three new schemas, AccessibilityStatement (channel identity, a 3-value status collapsed from the government's five-level A–E scale with the mapping documented in the schema description, evaluation method/date, standard applied, feedback contact, escalation route, a materialised reviewOverdue calculation mirroring Enrolment.isOverdue's @now/dateDiff idiom plus a calculatedChange notification mirroring TlvApplication.tlvExpiringSoon; lifecycle draft → published → archived, with AccessibilityStatementPublishGuard refusing to publish without evaluation evidence or a fully-compliant status while an open/mitigated limitation exists); AccessibilityLimitation (the known-limitations register — WCAG criterion, severity, affected surface, justification, disproportionate-burden flag, workaround, planned-fix date; open → mitigated → fixed; create/update restricted to admin/compliance-officer, mirroring xapi-statement/BsaWarning); AccessibilityFeedback (a user-reported barrier, create open to any authenticated user — the opposite RBAC posture, deliberately — with an onSubmitted notification to compliance-officer/admin mirroring ItemRevisionFlag's created-trigger shape; submitted → acknowledged → resolved). No new PHP controller or route — only the one lifecycle guard, per ADR-022/ADR-031. See openspec/changes/archive/2026-07-16-accessibility-conformance-statement/. v0.16.0 adds course-authoring-ux — the one authoring surface course-management never got: Lesson gains blocks (an ordered array of typed content blocks — richText | media | quiz | assignment | ltiTool — each pointing at an existing Material/Assessment/Assignment/LtiToolPlacement UUID or carrying inline markdown for richText, structured JSON mirroring the Assessment.itemRefs precedent, not a new Block schema) and contentRef's top-level required entry is replaced by an allOf/if/then conditional (contentType='text' → contentRef not required; every other contentType unchanged), the second use of the exam-board-case-handling if/then/else construct in this register. Course gains order (nullable integer, additive — null sorts after every sibling with an explicit value) sequencing sibling modules sharing a parentCourseId. New schema CourseTemplate captures a reusable Course→Module→Lesson skeleton (+ optional CurriculumPlan skeleton) instantiated via frontend orchestration against OpenRegister's existing object-create endpoint (no new PHP controller, per ADR-022) — the concrete implementation of the 'Clone for next year' acceptance criterion the spec already promised but never backed with a requirement. Two new custom views (CourseBuilder.vue, LessonComposer.vue) provide drag-and-drop AND keyboard-operable reorder (WCAG 2.1 AA SC 2.1.1) for modules/lessons/blocks; LessonPlayer.vue gains a real contentType='text' rendering path over lesson.blocks, replacing the previously dead lesson.title/lesson.summary/lesson.content reads. See openspec/changes/archive/2026-07-16-course-authoring-ux/. v0.17.0 adds timetabling-and-substitution — the operational layer between 'an external optimiser (Zermelo/Untis/Xedule) produced a timetable' and 'a learner/teacher sees today's reality,' deliberately NOT a constraint-solving timetable generator (school-structure's 'Room-booking conflict resolution and timetabling optimisation' stays out of scope, permanently, by design): new Room (bookable resource — capacity/kind/facilities/buildingCode/floor, no lifecycle, the same resource-metadata shape as Material); Session gains additive roomId ($ref Room, location kept unchanged as a display-only fallback), externalRef (timetable-import idempotency key), substituteTeacherId/changeReasonKind/changeReason/affectedLearnerIds/affectedParentIds (mirroring ConferenceRound.invitedLearnerIds), and changedAt (server-stamped, backs personal-timetable's same-day dagrooster changes list), plus a substitute-teacher/substitute-teacher-in-progress self-loop pair (split per from-state — verified at HEAD that TransitionEngine resolves a single scalar 'to', so one from:[scheduled,in-progress] self-loop is not expressible as a single transition entry) and a SessionChangeGuard-gated cancel (now requiring changeReasonKind). New TimetableConflict (teacher/room/cohort/learner-double-booking, room-capacity-exceeded, exam-clash; open → acknowledged → resolved; admin/coordinator-only read; idempotent by (sessionIds, kind); created — never auto-resolved — by TimetableConflictDetector, an OR-event-driven cross-object write bridge mirroring ConferenceScheduleGenerator, invoked by SessionConflictListener on Session create/update and once in batch after a timetable-import DataExchangeJob succeeds). New ExamAccommodation (learnerId/submittedBy/accommodationKind/value/evidenceRef/approvedBy; requested → approved → active → expired|revoked; create open to learner/parent-portal roles, approve restricted to admin/compliance-officer/mentor via the new ExamAccommodationApprovalGuard — a 5th PHP class beyond the 4 named in the timetabling spec's 'Frontend is declarative' text, documented as a deviation since no x-openregister-authorization key expresses a per-transition role gate in this register). DataMappingProfile gains three timetable-import (direction:import) seed profiles (Zermelo/Untis/Xedule), reusing — not duplicating — the existing DataExchangeJob queue (target: timetable-import); the OpenConnector wire adapters themselves are a cross-repo openconnector follow-up, not built here. personal-timetable's TimetableController::mine() projects roomId/Room detail/substitution fields per Session and a same-day changes list — still read-only, no new write endpoint, per personal-timetable's unchanged invariant. See openspec/changes/archive/2026-07-16-timetabling-and-substitution/. v0.18.0 adds sovereign-ai-guarantee — the school-verifiable AI-processing locality guarantee, composing (not duplicating) avg-verwerkingsregister's existing scholiq-ai-features AVG Art. 30 activity: new SovereigntyPolicy (flat, un-lifecycled singleton — policy tier on-premises-only | eu-hosted-allowed | third-country-allowed, default eu-hosted-allowed, mirroring Hermiq's TenantControl/ModelPolicy/GuardrailPolicy precedent; create/update restricted to admin/compliance-officer, mirroring AccessibilityLimitation). No AiFeature schema change — EU AI Act governance stays delegated to Hermiq's agentaifeature register per ai-feature-delegate-to-hermiq. New AiLocalityClassifier service derives a {locality, verified, evidence} verdict from Hermiq's real hermiq.llm chat-provider configuration and, for the three OpenRegister-catalogued host-locked broker SaaS providers (openai/fireworks/anthropic), the referenced brokeredcredential's catalogue provider field — never from a hand-typed field; every other configuration (ollama, nextcloud TaskProcessing, an inject-only credential, Hermiq absent) classifies unverified, by construction, since no code path proves an on-premises/eu-hosted destination true today. AssessmentPublishGuard composes a new locality check (SovereigntyPolicyService::isCompliant()) inside its existing check() method after the DPO-enablement check — an unverified locality never satisfies the two stricter policy tiers. New read-only AiProcessingDisclosureController composes Hermiq's agentaifeature register, this AVG carrier, and the locality verdict for a DPO-facing disclosure page (LearniqAiProcessingDisclosure.vue) where no verdict renders compliant/green without verified: true. See openspec/changes/archive/2026-07-16-sovereign-ai-guarantee/. v0.19.0 adds portable-learning-record — inverts who holds a learner's trajectory: three new schemas, LearningRecordExport (a learner-initiated, signed, dual-shaped bundle — an ELM/Europass-shaped credential section carrying each in-scope Credential's edciPayload/openbadges3Payload verbatim, plus a lossless scholiqNative section — with a coverageReport[] naming every source object's included/summarized/omitted outcome, mirroring CoursePackageImportReport's honest fidelity/loss-report pattern applied to output instead of input; requested → generated, guarded by the new LearningRecordExportService, signed by LearningRecordExportSigningService reusing KeyManagementService's existing RS256 keypair — no new key material), LearningRecordShare (a time-boxed, revocable grant of one already-generated export snapshot to a named external recipient with no Learniq presence, expiresAt required — never nullable — draft → active → revoked, a materialised isExpired mirroring Credential.isExpired, resolved via the new public LearningRecordShareVerifyController mirroring CredentialVerifyController's pattern rather than reusing PortfolioShare, whose sharedWithKind enum has no shape for an arbitrary external party), and LearningRecordImport (a coordinator-facing, evidence-only report of an applicant-uploaded prior-institution bundle during Application intake, guarded by the new LearningRecordImportService, gated by ActionAuthService::requireAction('learning-record.import') — never auto-writes any other schema; a coordinator acts on it only through the existing ExemptionCase mechanism). No new materialized rollup schema — the learner-facing aggregate view is composed live by the new LearningRecordAggregationService, scoped by learnerRef, reading Enrolment/FinalGrade/CompetencyAttainment/Credential/Portfolio+PortfolioEntry/ExternalTrainingRecord(verified only)/BpvPlacement+WerkprocesAssessment/LessonCompletion(summarized per-course)/ReportCard(published-to-parents only) — explicitly excluding DossierNote/BehaviourIncident/WellbeingCheckIn (staff professional-judgment records, not learner-portable evidence) and AttendanceRecord (leerplicht/BRON compliance spine, not portable 'what I learned' evidence). ExternalTrainingRecord gains an additive, optional learnerRef (its existing learnerId was already a LearnerProfile UUID under a non-standard name; this gives the aggregation service a consistently-named scoping key across all nine consumed schemas) — the one schema this change touches besides the three new ones. Every underlying record keeps its existing hardDelete: false + selectielijst-onderwijs retentionReference posture unchanged: a learner can hold, export, and share a verifiable copy of their record; they cannot delete or force deletion of the school's statutory original. See openspec/changes/archive/2026-07-16-portable-learning-record/. v0.20.1 is doc-only: rename-to-learniq — info.title/description, x-openregister.description, the register's own title/description, and the operational role's description now read Learniq, not Learniq; corrects two stale SovereigntyPolicy.properties.setBy/setAt.description references to the already-renamed LearniqAiProcessingDisclosure.vue (was ScholiqAiProcessingDisclosure.vue). No schema, property, enum, x-openregister-processing code/backend/attribution, or scholiqField/scholiqNative/scholiq-json/scholiq-learning-record identifier is touched — those remain scholiq-prefixed by design (AVG read-log attribution and external wire-format compatibility). v0.22.0 is doc/notification-only: session-roster-notifications — Session gains an x-openregister-notifications.rosterChanged block (transition trigger on cancel/substitute-teacher/substitute-teacher-in-progress, recipients the existing SessionChangeNoticeHandler already materialises: affectedLearnerIds/affectedParentIds) so the roster-change notification the timetabling spec already documented actually fires; SessionChangeNoticeHandler itself is unchanged. See openspec/changes/archive/2026-09-29-session-roster-notifications/. credential-renewal-listener: closes the expiry half of the pre-existing 'Auto-enrol on renewal or content-version change' certification requirement. CredentialRenewalListener creates a renewal Enrolment (source: credential-renewal, mandatory: true) when a Credential's expire transition fires, and links it back via Credential.renewalEnrolmentId. Enrolment.source gains the credential-renewal enum value. The content-version-change half stays an open, named platform gap (Course has no content-version concept). See openspec/changes/archive/2026-09-29-credential-renewal-listener/. attendance-threshold-calculation: declares AttendanceThreshold.x-openregister-calculations (unexcusedLesuren, isThresholdCrossed) backed by a new x-openregister-aggregations per-cohort count of absent-unexcused AttendanceRecords, so the existing thresholdCrossed calculatedChange notification (now also notifying coordinator, not just mentor) fires for real. Adds a guarded manual check-threshold self-loop transition plus five transient checkedLearnerId/checkedMetricValue/checkedWindowStart/checkedWindowEnd/checkedBreachingRecordIds properties, so a caller can submit a genuinely per-learner crossing (the aggregation DSL cannot parameterise a per-learner figure on a shared threshold definition) and AttendanceFlagCreationHandler can create a real AttendanceFlag. See openspec/changes/archive/2026-09-29-attendance-threshold-calculation/. adds school-and-location-records: School (BRIN, name, pedagogicalConcept) and Location (schoolId, vestigingscode, onderwijslocatiecode, address) as plain resource-metadata schemas, same shape as Room. Adds an additive Cohort.locationId (nullable, single $ref, never an array) enforcing DUO's one-groep-per-location rule. No board/bestuur entity (decision D2). See openspec/changes/archive/2026-09-28-school-and-location-records/. adds school-and-location-records: School (BRIN, name, pedagogicalConcept) and Location (schoolId, vestigingscode, onderwijslocatiecode, address) as plain resource-metadata schemas, same shape as Room. Adds an additive Cohort.locationId (nullable, single $ref, never an array) enforcing DUO's one-groep-per-location rule. No board/bestuur entity (decision D2). See openspec/changes/archive/2026-09-28-school-and-location-records/. v0.23.0 adds enrolment-statutory-fields: Enrolment gains inschrijvingDate, volgnummer and its own locationId (the inschrijving's vestiging, independent of any Cohort.locationId), destinationSchoolId on withdrawal, and leerjaar per pupil (1 to 8) so a combination group is one Cohort with a leerjaar per Enrolment rather than parsed from the cohort name. Surfaced as a column on CohortDetail's roster. See openspec/changes/archive/2026-09-28-enrolment-statutory-fields/. adds subject-and-teacher-assignment: Staff (ncUserId, roles, qualifications, workingDays) and SubjectTeacherAssignment (cohortId, courseId, teacherId) as plain resource-metadata schemas, same shape as Room, plus an additive Cohort.teacherAssignments (duo-partner role and days per teacher, alongside the unchanged teacherIds). See openspec/changes/archive/2026-09-28-subject-and-teacher-assignment/. adds cohort-group-page-polish: an additive Cohort.notes (free text) plus two CohortDetail widgets, a notes card and a today-scoped session list using the manifest's existing @today filter-token grammar. The header-name gap the baseline capture named is already closed by CnDetailPage's objectDisplayName resolution (verified, not re-implemented). See openspec/changes/archive/2026-09-28-cohort-group-page-polish/. adds school-year-shape: additive ReportPeriod.holidays and .studyDays, plus two Session-scoped index pages (SessionsToday, SessionsThisWeek) using the manifest's existing @today/@today+Nd filter-token grammar. The TimetableConflictQueue per-teacher filter is explicitly deferred to a follow-up code change: that page is a registered custom Vue component, not a manifest page. See openspec/changes/archive/2026-09-28-school-year-shape/. adds segment-feature-flags: a new LearniqSettings singleton schema (segment: po/vo/mbo/he/corporate, default corporate) mirroring SovereigntyPolicy's flat singleton shape, with an admin-only index+detail page. Deliberately does not add any visibleIf menu gating on segment: manifest.runtime has no generic settings-to-runtime bridge, so declaring one now (before the PHP/JS wiring a follow-up code change must add) would hide the gated menus for every tenant via the library's undefined-runtime fail-safe. See openspec/changes/archive/2026-09-28-segment-feature-flags/. adds report-card-templates — closes the report-card template-richness gap named by ParnasSys/Easyrapport/MijnRapportfolio/IEP/gibbon research (findings 14.3/7.4/7.5/7.8/7.7/7.3): new ReportCardTemplate schema (an ordered, typed sections[] array — grades/lvs-results/attendance/social-emotional/narrative/pupil-voice/portfolio — each with a scale drawn from a shared library of steps/dots/smileys/grades-1-10/letters/cito-level/text, sections[] required non-empty via minItems:1, plus a testKindSectionMap[] mapping an imported methodetoets/LVS test kind to a section, declared now for the not-yet-built lvs-import-contract change to slot into). Cohort gains reportCardTemplateId (nullable $ref ReportCardTemplate — a standing per-group assignment, mirroring ParnasSys's per-leerjaar report setting); ReportCard gains templateId, stamped by ReportCardComposer from the learner's cohort at compose/recompose time. ReportCardComposer now populates only the sections a cohort's assigned template declares — an untemplated cohort composes the pre-existing fixed subjectGrades[]/attendanceSummary/mentorComment shape unchanged. ReportCardPdfDelegationService now sends the assigned ReportCardTemplate.slug as docudesk's templateSlug instead of the fixed 'report-card' literal, falling back to that literal when no template is assigned — the contract filinq-configurable-report-templates (a sibling filinq change) consumes. See openspec/changes/archive/2026-09-28-report-card-templates/. 1.0 adds privacy-governance-surfaces — two new schemas, Compliance (a flat singleton recording whether the Privacyconvenant verwerkersovereenkomst is signed and where it and the privacybijsluiter can be found, closing finding 15.5) and DataSubjectRequest (a staff-logged AVG correction/deletion request with a requested → in-review → completed | rejected lifecycle and an append-only auditTrail, mirroring BehaviourIncident's unguarded-transition shape, closing finding 15.3's request-workflow gap); and five additive DataExchangeJob properties (requiresPartnerApproval, partnerApprovalStatus, partnerApprovedBy, partnerApprovedAt, dataSharedFields) plus one new condition in DataExchangeRunGuard gating the run transition on partner approval, independent of the existing OSO/SWV parent-review gate. See openspec/changes/archive/2026-09-28-privacy-governance-surfaces/. adds rbac-scope-kinds-extension — two additive scope-kind grants on the REAL, enforced authorization[action] conditional-match grammar (never the x-property-rbac key, confirmed by direct grep of OpenRegister's lib/ to be unread by any code path, the same decoy class rbac-declare-groups already fixed for x-openregister-authorization): Cohort gains an explicit authorization block reproducing the register cascade plus one own-group read entry (any caller in the cohort's own ncGroupId Nextcloud group may read it, via the $in operator against $user.groups); DossierNote gains a careTeamUserIds property and one additive care-team read entry (via the $contains operator). See openspec/changes/archive/2026-09-28-rbac-scope-kinds-extension/. 3.0 adds payment-request-ux — two additive Order properties (paymentRequestSentAt, paymentRequestSentBy, both nullable) staff set through the generic object-edit form, and a presentational change to OrderPaymentPanel.vue: the PSP picker is skipped when exactly one provider is configured (one-tap pay), and a note renders when a request was recorded as sent. No PHP change; PaymentInitiationClient/PaymentTransactionController stay untouched. adds care-and-support-index — closes findings 8.11/6.9/L-new-10/8.3-residual/L-new-4 (kindkans/onderwijs-transparant/sera care-team overview evidence, IEP kleuter observatie-instrument, sera bovenschoolse-voorziening traject-status, ParnasSys/Somtoday's OPP six-week rule, ESIS's uitstroombestemming/Geplande groei): LearningPlan gains sixWeekDeadline plus materialised daysUntilSixWeekDeadline/isOverdueForActivation/sixWeekDeadlineApproaching calculations and a calculatedChange-triggered sixWeekDeadlineApproaching notification to coordinatorId (mirrors TlvApplication.tlvExpiringSoon's idiom exactly), plus nullable uitstroombestemming/outflowBandwidth/trackedGrowth[] fields. New ObservationInstrument schema (entries[] per leerlijn: taal/rekenen/sociaal-emotioneel/motoriek/spel/leren-leren, each scored on IEP's documented four-point kleuter scale). New Trajectory (referral -> preparation -> scheduled -> running -> stopped, nullable SupportRequest back-reference) plus append-only TrajectoryStatusUpdate. No existing schema besides LearningPlan is modified. See openspec/changes/archive/2026-09-28-care-and-support-index/. 1.0 adds funding-and-teldatum-checks — five additive DataExchangeJob properties (requiresTeldatumCheck, teldatumCheckStatus, teldatumCheckDate, teldatumCheckedBy, teldatumCheckedAt) and one more independent condition in DataExchangeRunGuard gating the run transition on a school-confirmed teldatum pre-flight check (P-new-12), plus a fundingWeightCode property on LearnerProfile for the NOAT/CUMI/NNCA funding-weging classification (P-new-13). Both additive to the existing 13.1/3.1 ROD contract, not new contracts. See openspec/changes/archive/2026-09-28-funding-and-teldatum-checks/. 1.0 adds portal-contribution-guardian-audiences — two additive LearnerProfile properties (beeldmateriaalConsent: a per-purpose object over website/socialMedia/schoolgids/classPhoto/video, each nullable-boolean; beeldmateriaalConsentReviewDueAt) closing finding 2.8/PA-new-2, and a PortalContributionProvider change: a new parentChildren directory collection (per-child, per-guardian-group, per-purpose consent state), groupByField on the four existing parent read collections, and a re-enabled parentActions() createExcuseRequest now that portaliq's writer cross-reference guard is merged (portaliq#607). See openspec/changes/archive/2026-09-28-portal-contribution-guardian-audiences/. adds trend-and-export-reporting — closes findings 12.6/14.7 (partial via platform mass export) and L-new-1: GradeScale.kind gains dle (didactische leeftijd) and leerrendement as declared scale kinds for the not-yet-built lvs-import-contract change to attach results to (vocabulary only, no calculation). No existing GradeScale row is affected. See openspec/changes/archive/2026-09-28-trend-and-export-reporting/. adds po-schooladvies-flow: closes findings 7.9 and 6.7 with a new SchoolAdvies schema for the PO (primary school) groep-8 schooladvies flow, distinct from the VO-intake Application.schoolAdviceLevel: voorlopigAdviesLevel (statutory 31 Jan window), doorstroomtoetsResultLevel, definitiefAdviesLevel (statutory 7 Apr deadline), coordinator-set voorlopigDeadline/definitiefDeadline with materialised daysUntil.../isVoorlopigOverdue/isDefinitiefOverdue calculations mirroring TlvApplication's own idiom, and heroverwegingMotivation. New SchoolAdviesFinalizeGuard blocks vaststellenDefinitief (voorlopig -> definitief) when doorstroomtoetsResultLevel outranks definitiefAdviesLevel on the shared ordinal unless raised, motivated, or the pro/vmbo-bb exemption applies, mirroring AdmissionsDecisionGuard's identical VO-side rule. New SchoolAdviesSendToRodHandler auto-queues a bron-rod DataExchangeJob on verzendenNaarRod (definitief -> verzonden-naar-rod), mirroring SupportRequestSubmitHandler's auto-queue pattern. No existing schema is modified. See openspec/changes/archive/2026-09-28-po-schooladvies-flow/. v0.25.0 adds competency-year-scope: Competency gains optional applicableYears (free year labels, e.g. groep 5 or 2026-2027, default []) and subjectId (nullable $ref Course), so a kerndoel or eindterm can be allocated to years and a subject; an empty value inherits from the nearest ancestor at read time. Additive, Competency 0.1.0 -> 0.2.0. See openspec/changes/archive/2026-09-28-competency-year-scope/. v0.26.0 adds goal-alignment-depth: Lesson, Course, Assignment and Assessment gain competencyAlignments [{competencyId, depth}], where depth is a levelId of the aligned goal's CompetencyFramework.proficiencyLevels (no fixed enum). competencyIds stays and is kept derived by the new CompetencyAlignmentListener (pre-save), which also refuses an unknown goal, a duplicate goal or a depth the framework does not know. Lesson/Course/Assignment 0.3.0 -> 0.4.0, Assessment 0.3.0. See openspec/changes/archive/2026-09-28-goal-alignment-depth/. v0.27.0 adds curriculum-coverage-rollup: a new read-only CurriculumCoverage schema per framework, year and subject counts leaf goals that are planned (a lesson or course aligns to them) and assessed (an assignment or assessment does) separately, with the uncovered and planned-but-not-assessed goal ids and per-goal depth. Kept current by CurriculumCoverageRollupHandler (post-save, never a TimedJob) and filled for existing data by occ learniq:curriculum-coverage:recompute. No existing schema is modified. See openspec/changes/archive/2026-09-28-curriculum-coverage-rollup/. adds submission-resubmission-action: Submission gains resubmissionDueAt, the deadline of a requested resubmission. The reopen transition (returned -> draft) now requires it as an input, is authorized for instructors, compliance-officers and team-leads only, and notifies the learners (resubmissionRequested). SubmissionWindowGuard judges the hand-in window against resubmissionDueAt when it is set, so work handed in again after the assignment deadline is on time. Submission 0.2.0 -> 0.3.0. See openspec/changes/archive/2026-09-28-submission-resubmission-action/. adds peer-review-projection-guard: a peer reviewer reads the work under review through GET /api/peer-review/{peerReviewId}/work, a server-side projection that withholds the authors for double-blind reviews and never includes the teacher's marking; the reviewer still cannot read the raw Submission. Assignment.peerReviewAnonymity's description now says so. Assignment 0.3.0 -> 0.4.0. See openspec/changes/archive/2026-09-28-peer-review-projection-guard/. adds bpv-coach-authorization: BpvPlacement and Praktijkopleider gain an enforced authorization block (they fell through to the register cascade). The cascade groups keep their grants, coordinators and administration-managers read, coordinators write, and on BpvPlacement the schoolCoachId and learnerId users read their own placement. See openspec/changes/archive/2026-09-28-bpv-coach-authorization/. adds confidential-counsellor-channel: one new declared scope, confidential-counsellors (D18), and a new ConfidentialNote schema readable only by its author while in that group and by the people the author names; no school role reads it. See openspec/changes/archive/2026-09-28-confidential-counsellor-channel/. adds course-content-metadata: Course and Lesson gain optional NL-LOM aligned sharing metadata: license (Creative Commons 4.0 family, CC0 or all-rights-reserved, no default), author, subject (OBK name) and educationalLevels (NL-LOM levels). See openspec/changes/archive/2026-09-28-course-content-metadata/./. adds course-content-metadata: Course and Lesson gain optional NL-LOM aligned sharing metadata: license (Creative Commons 4.0 family, CC0 or all-rights-reserved, no default), author, subject (OBK name) and educationalLevels (NL-LOM levels). See openspec/changes/archive/2026-09-28-course-content-metadata/. adds lesson-sharing-consent-gate: a new CourseShareConsent schema records who confirmed that a course may leave the school (no pupil data, rights cleared), when and under which licence; written by the share export behind the sharing gate. See openspec/changes/archive/2026-09-28-lesson-sharing-consent-gate/./. adds course-content-metadata: Course and Lesson gain optional NL-LOM aligned sharing metadata: license (Creative Commons 4.0 family, CC0 or all-rights-reserved, no default), author, subject (OBK name) and educationalLevels (NL-LOM levels). See openspec/changes/archive/2026-09-28-course-content-metadata/. adds lesson-sharing-consent-gate: a new CourseShareConsent schema records who confirmed that a course may leave the school (no pupil data, rights cleared), when and under which licence; written by the share export behind the sharing gate. See openspec/changes/archive/2026-09-28-lesson-sharing-consent-gate/. adds lesson-sharing-via-store-plane: a new SharedCoursePackage schema lets any learniq instance act as the course registry that OpenRegister's store plane searches and resolves; member schools publish gated packages to it and install copies from it. Free sharing only. See openspec/changes/archive/2026-09-28-lesson-sharing-via-store-plane/. adds differentiation-not-styles-copy: the group plan, instruction group, support request and exam accommodation forms get plain titles and help texts about support needs and differentiation by level, goal, time or material; the engineering rationale moves to x-notes. See openspec/changes/archive/2026-09-28-differentiation-not-styles-copy/. v0.25.0 (office-file-lesson-onboarding): new LessonOnboardingFile schema (a Word or PowerPoint file a teacher dropped in their lesson onboarding folder; lifecycle detected -> imported | dismissed, a created notification to the teacher with a link to the review page, self-only read and update) and a teacherNote block type on Lesson.blocks (Lesson 0.3.0 -> 0.4.0), shown in LessonComposer and never rendered by LessonPlayer. Additive. See openspec/changes/archive/2026-09-29-office-file-lesson-onboarding/. v0.27.5 (assignment-portal-wiring): Submission gains a scalar learnerRef, the portal scope key, always stamped by the server (SubmissionOwnerStamp). A portal hand-in gets learnerIds, learnerRefs and tenant_id from the pupil's LearnerProfile and the Assignment; learnerIds and tenant_id leave Submission.required because OpenRegister validates required before a listener can stamp them, and the listener enforces both on every write. See openspec/changes/archive/2026-09-28-assignment-portal-wiring/. v0.27.6 (assessment-portal-endpoints): AssessmentResult gains learnerRef (the portal scope key) and assessmentTitle, both readOnly and stamped by the attempt gate on every create, so a pupil's attempts can be listed in the portal. See openspec/changes/archive/2026-09-28-assessment-portal-endpoints/. v0.27.2 retires the DataSubjectRequest schema (D20, privacy-reuse-openregister-register): correction and deletion requests now live in OpenRegister's shared data-subject-requests register (dataSubjectRequest), and the MigrateDataSubjectRequestsToOpenRegister repair step copies existing rows there. v0.28.0 retires Order, OrderLine and PaymentTransaction (D19, payments-to-shillinq-migration): a school charges through shillinq and the pay screen is portaliq's. Entitlement drops orderLineId for paymentRequestRef, paymentSettledAt and paymentSettledVia; its grant waits for shillinq's settled PaymentRequest (contract extracurricular-fee-to-shillinq v1). ArchiveRetiredPaymentObjects exports existing rows to a JSON file in the app data folder. adds staff-role-vocabulary-extension: Staff.roles gains seven descriptive function tags (career-counsellor, study-adviser, remedial-teacher, care-coordinator, exam-secretary, placement-coordinator, confidential-counsellor) and an x-enum-labels map for all fourteen values. A tag grants no access (D18). See openspec/changes/archive/2026-09-28-staff-role-vocabulary-extension/. v0.29.0 (access-control-ratchet-compliance, D23): LvsResult and OsoImportDossier get enforced authorization blocks, read and written by coordinators and compliance-officers, and LvsResult adds the learner's own row. FirstAidIncident adds its reporter's own row. LvsResult and FirstAidIncident are no longer appendOnly, so their lifecycles run. See openspec/changes/archive/2026-09-28-access-control-ratchet-compliance/. v0.29.1 (round1-landing-repairs): AttendanceThreshold.thresholdCrossed notifies one recipient entry, mentor and coordinator, instead of notifying the mentor twice. DataExchangeJob.target and DataMappingProfile.target name every connection again (lvs-results, the inbound oso direction, uwlr, edu-v, basispoort, entree-content, migration-import), which the serial landing of round one had overwritten. See openspec/changes/archive/2026-09-28-round1-landing-repairs/. v0.29.0 (settings-and-excuse-authorization): LearniqSettings gets an authorization block, read by the staff groups and written by administration-managers only, so an instructor can no longer change the organisation's segment. ExcuseRequest.required keeps only what every caller sends (dateFrom, dateTo, reason, reasonKind); ExcuseRequestOwnerStamp fills learnerId, learnerRef, submittedBy, submittedAuthLevel and tenant_id for a portal absence report and refuses any write that still lacks the pupil, a submitter or the school. See openspec/changes/archive/2026-09-28-settings-and-excuse-authorization/. 0.29.2 adds teacher-notes-protection: teacher notes leave Lesson.blocks (the teacherNote block type is gone) for the staff-only LessonTeacherNote schema, which learners and guardians cannot read on any path; MoveTeacherNotesOutOfLessons moves existing notes on upgrade. See openspec/changes/archive/2026-09-28-teacher-notes-protection/././. v0.30.0 moves data exchange to integriq (openspec/changes/data-exchange-to-integriq, decision D7): DataExchangeJob, DataMappingProfile, ExchangeRejection and ExchangeErrorCode leave the register (their jobs, mappings, rejections and codes are integriq job, mapping and sync_item_dead_letter rows); ExchangePartnerApproval, TeldatumCheck and DossierReview hold the exchange gate's own state; AttendanceFlag carries the municipality's feedback; the five dataExchangeJobId properties name an integriq job and lose their $ref. governance-four-eyes-on-approved-data: a new DataCorrectionRequest schema (the grade entry, the proposed value, the reason, the value before, the requester, the approver and when it was applied; requested, approved, rejected, applied). A grade in a locked report period is published again only on a request approved by a second person, never by the requester or the publisher; admin, team leads and administration managers lose their lone override and approve instead. GradeEntry gains correctionRequestId, set when a correction is applied. See openspec/changes/governance-four-eyes-on-approved-data/. lvs-result-learner-ref: LvsResult gains learnerRef, the LearnerProfile UUID of the pupil, stamped by the server from learnerId on every write and back-filled once on existing rows. See openspec/changes/lvs-result-learner-ref/. direct-conference-booking: a ConferenceRound gains bookingMode (direct or preference; a round without one keeps the preference flow, and a new round in a primary school gets direct) and maxBookingsPerChild, and the transition create-free-slots. ConferenceSlot gains the states free, booked, acknowledged and declined, the transitions book, acknowledge and decline, and teacherName, slotLabel, eligibleLearnerRefs, guardianRef, declineNote, bookedAt and acknowledgedAt; learnerId is no longer required, because a free slot has no learner. ConferenceSignup no longer requires conferenceRoundId (a portal booking names the time, and the server stamps the round from it), and gains the states booked, acknowledged and declined and slotId, teacherName, startsAt, endsAt, slotLabel and declineNote. See openspec/changes/direct-conference-booking/. internship-hours: a new BpvHourWeek records one week of realised BPV hours for one placement, keeping the hours the student submitted and the hours the trainer approved as two values so a correction stays readable, with submittedBy, submittedAt, approvedBy, approvedByName, approvedAt, assuranceLevel, a note and the states submitted, approved, corrected and rejected. tenant_id is stamped from the placement, not required, because OpenRegister validates required before any listener runs. BpvPlacement gains agreedHours and hoursApprovedTotal, so the portal shows hours done against the hours the agreement states and shows the hours alone when none were agreed. See openspec/changes/internship-hours/.",
- "version": "0.41.0"
+ "version": "0.41.1"
},
"x-openregister": {
"type": "application",
@@ -18,7 +18,7 @@
"slug": "learniq",
"title": "Learniq Register",
"description": "Learniq — open-source LVS + LMS for Nextcloud compliance training, certification, and learning management. Holds every learner, enrolment, assessment, grade, attendance, portfolio, credential and exchange object the app reads and writes.",
- "version": "0.24.0",
+ "version": "0.24.1",
"schemas": [
"AiFeature",
"accessibility-feedback",
@@ -215,7 +215,7 @@
"slug": "credential",
"appendOnly": false,
"icon": "CertificateOutline",
- "version": "0.3.4",
+ "version": "0.3.5",
"title": "Credential",
"description": "Verifiable Credential (W3C VC + Open Badges 3.0). Append-only compliance evidence; issued on enrolment completion; supports expiry detection with tiered reminders.",
"type": "object",
@@ -387,6 +387,13 @@
"description": "Last wallet-offer push or revocation-propagation failure message. Cleared on the next successful `offerToWallet`.",
"title": "Wallet Offer Error"
},
+ "walletOfferUri": {
+ "type": "string",
+ "nullable": true,
+ "format": "uri",
+ "title": "Wallet offer link",
+ "description": "The link a wallet app opens to collect this certificate, while the offer is open. Cleared once it is collected, revoked or the offer fails."
+ },
"competencyIds": {
"type": "array",
"default": [],
diff --git a/lib/SetupCheck/WalletOfferTokenCheck.php b/lib/SetupCheck/WalletOfferTokenCheck.php
new file mode 100644
index 000000000..e3400e3b6
--- /dev/null
+++ b/lib/SetupCheck/WalletOfferTokenCheck.php
@@ -0,0 +1,115 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://conduction.nl
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Learniq\SetupCheck;
+
+use OCA\Learniq\AppInfo\Application;
+use OCA\Learniq\Service\ConnectionReportService;
+use OCP\IAppConfig;
+use OCP\IL10N;
+use OCP\SetupCheck\ISetupCheck;
+use OCP\SetupCheck\SetupResult;
+
+/**
+ * Names `learniq.openconnector_api_token` when it is empty: a warning once a
+ * wallet offer has failed on it, a note while no offer was tried.
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
+ */
+class WalletOfferTokenCheck implements ISetupCheck {
+
+ /**
+ * The app config key of the integriq bearer token.
+ *
+ * @var string
+ */
+ public const TOKEN_KEY = 'openconnector_api_token';
+
+ /**
+ * Constructor.
+ *
+ * @param IAppConfig $appConfig App config, for the token.
+ * @param ConnectionReportService $connectionReports What the last wallet offer met.
+ * @param IL10N $l10n Translations.
+ *
+ * @return void
+ */
+ public function __construct(
+ private readonly IAppConfig $appConfig,
+ private readonly ConnectionReportService $connectionReports,
+ private readonly IL10N $l10n,
+ ) {
+ }//end __construct()
+
+ /**
+ * The admin overview section this check is listed under.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
+ */
+ public function getCategory(): string {
+ return 'system';
+ }//end getCategory()
+
+ /**
+ * The check's name in the admin overview.
+ *
+ * @return string
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
+ */
+ public function getName(): string {
+ return $this->l10n->t('Learniq wallet offers');
+ }//end getName()
+
+ /**
+ * Success with the token set; without it, a warning once an offer failed
+ * for it, else a note naming the key.
+ *
+ * @return SetupResult
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess) SetupResult is built only through its static constructors.
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
+ */
+ public function run(): SetupResult {
+ $token = $this->appConfig->getValueString(app: Application::APP_ID, key: self::TOKEN_KEY, default: '');
+ if ($token !== '') {
+ return SetupResult::success($this->l10n->t('Certificates can be offered to a wallet through integriq.'));
+ }
+
+ $message = $this->l10n->t(
+ 'No integriq API token is set (%s), so a certificate cannot be offered to a wallet.',
+ [Application::APP_ID . '.' . self::TOKEN_KEY]
+ );
+ if ($this->connectionReports->lastStatus(key: ConnectionReportService::WALLET_KEY) === 'unconfigured') {
+ return SetupResult::warning($message);
+ }
+
+ return SetupResult::info($message);
+ }//end run()
+}//end class
diff --git a/openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md b/openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md
index 81e76c156..b7349fc16 100644
--- a/openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md
+++ b/openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md
@@ -2,25 +2,39 @@
### Requirement: The credential keeps the wallet offer link while the offer is open
-When `offerToWallet` succeeds, `Credential.walletOfferUri` MUST hold the `credentialOfferUri` integriq returned. When the offer is claimed, revoked or fails, `walletOfferUri` MUST be cleared in the same save.
+When `offerToWallet` succeeds, `Credential.walletOfferUri` MUST hold the link a wallet app opens: the `offerUrl` integriq returned (`openid-credential-offer://?credential_offer_uri=...`, the same value as its `qrPayload`), or that link built from `credentialOfferUri` when `offerUrl` is missing. When the offer is claimed, revoked or fails, `walletOfferUri` MUST be cleared in the same save. On a revocation it MUST be cleared even when integriq could not be told.
#### Scenario: An offer is created
-- **GIVEN** an issued credential and integriq answering with a `credentialOfferUri`
+- **GIVEN** an issued credential and integriq answering with `{offerUrl, credentialOfferUri, qrPayload}`
- **WHEN** a user in `hr` runs `offerToWallet`
-- **THEN** `walletOfferStatus` is `offered` and `walletOfferUri` is that URI
+- **THEN** `walletOfferStatus` is `offered` and `walletOfferUri` is the `offerUrl`
+- @e2e exclude service outcome; covered by tests/Unit/Service/WalletOfferDelegationServiceTest.php (testHandledResponseRecordsOfferFields) and tests/Unit/Settings/CredentialWalletOfferUriRegisterTest.php
#### Scenario: The wallet claims it
- **GIVEN** a credential with an open offer
- **WHEN** `recordWalletClaim` records the claim
- **THEN** `walletOfferUri` is empty and `walletClaimedAt` is set
+- @e2e exclude service outcome; covered by tests/Unit/Service/WalletClaimSyncServiceTest.php
#### Scenario: The credential is revoked
- **GIVEN** a credential with an open offer
- **WHEN** it is revoked
- **THEN** `walletOfferUri` is empty
+- @e2e exclude service outcome; covered by tests/Unit/Service/WalletRevocationPropagationServiceTest.php
+
+### Requirement: The setup check names the missing wallet token
+
+The admin overview MUST name `learniq.openconnector_api_token` while it is empty: as a warning once a wallet offer has failed on it, as a note before any offer was tried. With the token set it MUST report that certificates can be offered.
+
+#### Scenario: An offer failed for want of the token
+
+- **GIVEN** no `learniq.openconnector_api_token` and a wallet offer that failed on it
+- **WHEN** the admin opens the overview
+- **THEN** a warning names `learniq.openconnector_api_token`
+- @e2e exclude admin setup check; covered by tests/Unit/SetupCheck/WalletOfferTokenCheckTest.php
### Requirement: The holder sees the offer on their own certificate
@@ -46,10 +60,11 @@ On `CredentialDetail`, a section MUST show the holder (the user whose id is `lea
### Requirement: The course participant finds the offer in the portal
-The participant portal's "My certificates" collection MUST include `walletOfferUri` and MUST show an "Add to wallet" link on a certificate with an open offer.
+The participant portal's "My certificates" collection MUST include `walletOfferUri` and MUST show it as a `qr` column (portaliq `link-field-qr-code`): a QR code with an "Add to wallet" link on a certificate with an open offer. A `link` column cannot carry it: portaliq follows only http(s) and site links there.
#### Scenario: A participant with an open offer
- **GIVEN** a course participant signed in to the portal and an issued certificate of theirs with an open offer
- **WHEN** they open "My certificates"
-- **THEN** that certificate shows an "Add to wallet" link to `walletOfferUri`
+- **THEN** that certificate shows an "Add to wallet" link to `walletOfferUri` and a QR code for it
+- @e2e exclude manifest shape; covered by tests/Unit/Portal/ParticipantSitePagesTest.php (testTheOverviewShowsTheNextCourseDayFirst)
diff --git a/openspec/changes/credential-wallet-offer-for-the-learner/tasks.md b/openspec/changes/credential-wallet-offer-for-the-learner/tasks.md
index ed4f45e4b..94d3e238c 100644
--- a/openspec/changes/credential-wallet-offer-for-the-learner/tasks.md
+++ b/openspec/changes/credential-wallet-offer-for-the-learner/tasks.md
@@ -2,20 +2,20 @@
## 1. Register and services
-- [ ] 1.1 `lib/Settings/learniq_register.json` `credential`: add `walletOfferUri` (string, nullable, format uri, title "Wallet offer link"); bump the register version. Verify: validate a real credential payload against the fragment (Opis).
-- [ ] 1.2 `WalletOfferDelegationService::offer()`: store `credentialOfferUri` in `walletOfferUri` on success, clear it on failure. Verify: PHPUnit with a recorded integriq response.
-- [ ] 1.3 `WalletClaimSyncService` and `WalletRevocationPropagationService`: clear `walletOfferUri`. Verify: PHPUnit, constructing the real transition event.
-- [ ] 1.4 The setup check names `learniq.openconnector_api_token` when it is empty. Verify: PHPUnit on the check.
+- [x] 1.1 `lib/Settings/learniq_register.json` `credential`: add `walletOfferUri` (string, nullable, format uri, title "Wallet offer link"); bump the register version. Verify: validate a real credential payload against the fragment (Opis).
+- [x] 1.2 `WalletOfferDelegationService::offer()`: store integriq's `offerUrl` (the `openid-credential-offer://` link; built from `credentialOfferUri` when missing) in `walletOfferUri` on success, clear it on failure. Verify: PHPUnit with a recorded integriq response.
+- [x] 1.3 `WalletClaimSyncService` and `WalletRevocationPropagationService`: clear `walletOfferUri`. Verify: PHPUnit, constructing the real transition event.
+- [x] 1.4 The setup check (`WalletOfferTokenCheck`, a warning once an offer failed on it, else a note) names `learniq.openconnector_api_token` when it is empty. Verify: PHPUnit on the check.
## 2. Learner section
-- [ ] 2.1 `src/components/sections/CredentialWalletOffer.vue`: renders only when `getCurrentUser().uid === object.learnerUserId`; QR from `walletOfferUri` with a small QR library (add to package.json, licence checked), the link, the claimed date, the empty sentence. Register in `src/registry.js` as `kind: 'section'`.
+- [ ] 2.1 `src/components/sections/CredentialWalletOffer.vue`: renders only when `getCurrentUser().uid === object.learnerUserId`; QR from `walletOfferUri` with a small QR library (add to package.json, licence checked), the link, the claimed date, the empty sentence. Register in `src/registry.js` as `kind: 'section'`. (UI waits: board LqLlCertificaat draws the learner starting a new code, "Naar wallet" and "Nieuwe code maken", against design D2; QUESTIONS)
- [ ] 2.2 `src/manifest.d/people.json` CredentialDetail: `bodyWidgets` entry `credential-wallet-offer`. Verify: `npm run check:manifest`.
- [ ] 2.3 Strings in English and Dutch (design). Verify: `npm run check:l10n`.
## 3. Portal
-- [ ] 3.1 `lib/Portal/ParticipantSitePages.php` `participantCertificates`: add `walletOfferUri` to `fields` and a column `{field: walletOfferUri, label: "Add to wallet", render: link}`. Verify: PHPUnit on the provider output.
+- [x] 3.1 `lib/Portal/ParticipantSitePages.php` `participantCertificates`: add `walletOfferUri` to `fields` and a column `{field: walletOfferUri, label: "Wallet", render: "qr", linkLabel: "Add to wallet"}` (portaliq link-field-qr-code T10; a `link` render drops a non-http link). `linkLabel` is translated. Verify: PHPUnit on the provider output.
## 4. Tests and close out
diff --git a/tests/Unit/Portal/ParticipantSitePagesTest.php b/tests/Unit/Portal/ParticipantSitePagesTest.php
index 7f4e3eb27..6620cc18f 100644
--- a/tests/Unit/Portal/ParticipantSitePagesTest.php
+++ b/tests/Unit/Portal/ParticipantSitePagesTest.php
@@ -76,6 +76,12 @@ public function testTheOverviewShowsTheNextCourseDayFirst(): void {
self::assertSame(['greeting', 'tasks', 'collection', 'collection'], array_column($pages['participantOverview']['blocks'], 'type'));
self::assertSame(['upcoming' => true], $collections['participantComingDays']['filter']);
self::assertSame(['lifecycle' => 'issued', 'kind' => 'certificate'], $collections['participantCertificates']['filter']);
+ // credential-wallet-offer-for-the-learner: the open wallet offer as a QR code with its link.
+ self::assertContains('walletOfferUri', $collections['participantCertificates']['fields']);
+ self::assertContains(
+ ['field' => 'walletOfferUri', 'label' => 'Wallet', 'render' => 'qr', 'linkLabel' => 'Add to wallet'],
+ $collections['participantCertificates']['columns']
+ );
foreach ($manifest['pages'] as $page) {
foreach ($page['blocks'] as $block) {
if (isset($block['collection']) === false) {
diff --git a/tests/Unit/Service/WalletClaimSyncServiceTest.php b/tests/Unit/Service/WalletClaimSyncServiceTest.php
index 263ef335f..4be63c968 100644
--- a/tests/Unit/Service/WalletClaimSyncServiceTest.php
+++ b/tests/Unit/Service/WalletClaimSyncServiceTest.php
@@ -62,11 +62,13 @@ public function testClaimWritesStatusAndTimestamp(): void {
'lifecycle' => 'issued',
'walletOfferStatus' => 'offered',
'walletClaimedAt' => null,
+ 'walletOfferUri' => 'openid-credential-offer://?credential_offer_uri=x',
];
$saved = (new WalletClaimSyncService())->claim(credential: $credential);
self::assertSame('claimed', $saved['walletOfferStatus']);
+ self::assertNull($saved['walletOfferUri']);
self::assertNotEmpty($saved['walletClaimedAt']);
self::assertSame('issued', $saved['lifecycle']);
}//end testClaimWritesStatusAndTimestamp()
diff --git a/tests/Unit/Service/WalletOfferDelegationServiceTest.php b/tests/Unit/Service/WalletOfferDelegationServiceTest.php
index 384030691..075556d06 100644
--- a/tests/Unit/Service/WalletOfferDelegationServiceTest.php
+++ b/tests/Unit/Service/WalletOfferDelegationServiceTest.php
@@ -107,15 +107,12 @@ private function service(): WalletOfferDelegationService {
public function testHandledResponseRecordsOfferFields(): void {
$this->appConfig->method('getValueString')->willReturn('token-abc');
+ // The shape integriq's EudiWalletController::createOffer answers with.
+ $offerUri = 'https://openconnector.example/index.php/apps/openconnector/api/eudi/credential-offers/offer-uuid-1';
+ $offerUrl = 'openid-credential-offer://?credential_offer_uri=' . rawurlencode($offerUri);
$response = $this->createMock(IResponse::class);
$response->method('getBody')->willReturn(
- json_encode(
- [
- 'offerUrl' => 'openid-credential-offer://?credential_offer_uri=...',
- 'credentialOfferUri' => 'https://openconnector.example/index.php/apps/openconnector/api/eudi/credential-offers/offer-uuid-1',
- 'qrPayload' => 'openid-credential-offer://?credential_offer_uri=...',
- ]
- )
+ json_encode(['offerUrl' => $offerUrl, 'credentialOfferUri' => $offerUri, 'qrPayload' => $offerUrl])
);
$capturedUrl = null;
@@ -150,6 +147,7 @@ function (string $url, array $options) use (&$capturedUrl, &$capturedOptions, $r
self::assertSame('offer-uuid-1', $saved['walletAttestationRef']);
self::assertNotEmpty($saved['walletOfferedAt']);
self::assertNull($saved['walletOfferError']);
+ self::assertSame($offerUrl, $saved['walletOfferUri']);
// The app SEGMENT is resolved at call time — the target answers to
// `integriq` on development and `openconnector` on beta/main — so
@@ -197,8 +195,13 @@ function (string $url, array $options) use (&$capturedOptions, $response): IResp
'lifecycle' => 'issued',
];
- $this->service()->offer(credential: $object);
+ $saved = $this->service()->offer(credential: $object);
self::assertSame('open-badges-3', $capturedOptions['json']['credentialConfigurationId']);
+ // Without `offerUrl` the wallet link is built from `credentialOfferUri`.
+ self::assertSame(
+ 'openid-credential-offer://?credential_offer_uri=' . rawurlencode('https://openconnector.example/.../credential-offers/offer-uuid-2'),
+ $saved['walletOfferUri']
+ );
}//end testBadgeUsesOpenBadges3ConfigurationId()
/**
@@ -272,6 +275,7 @@ public function testOpenConnectorUnreachableFailsClosed(): void {
'kind' => 'diploma',
'learnerId' => 'learner-5',
'walletOfferStatus' => null,
+ 'walletOfferUri' => 'openid-credential-offer://?credential_offer_uri=old',
'openbadges3Payload' => ['credentialSubject' => ['id' => 'urn:learniq:learner:learner-5']],
'lifecycle' => 'issued',
];
@@ -280,6 +284,7 @@ public function testOpenConnectorUnreachableFailsClosed(): void {
self::assertNull($saved['walletOfferStatus']);
self::assertNotEmpty($saved['walletOfferError']);
+ self::assertNull($saved['walletOfferUri']);
}//end testOpenConnectorUnreachableFailsClosed()
/**
diff --git a/tests/Unit/Service/WalletRevocationPropagationServiceTest.php b/tests/Unit/Service/WalletRevocationPropagationServiceTest.php
index e66100b92..ee2d68af2 100644
--- a/tests/Unit/Service/WalletRevocationPropagationServiceTest.php
+++ b/tests/Unit/Service/WalletRevocationPropagationServiceTest.php
@@ -145,11 +145,13 @@ function (string $url) use (&$capturedUrl, $response): IResponse {
'id' => 'credential-2',
'walletOfferStatus' => 'offered',
'walletAttestationRef' => 'offer-uuid-1',
+ 'walletOfferUri' => 'openid-credential-offer://?credential_offer_uri=x',
'lifecycle' => 'revoked',
];
$saved = $this->service()->propagate(credential: $object);
self::assertSame('revoked', $saved['walletOfferStatus']);
+ self::assertNull($saved['walletOfferUri']);
self::assertStringContainsString('offer-uuid-1', (string)$capturedUrl);
self::assertStringContainsString('/revoke', (string)$capturedUrl);
}//end testPropagatesAndSetsRevokedOnSuccess()
@@ -224,12 +226,15 @@ public function testMissingTokenStillProceedsFailSoft(): void {
'id' => 'credential-5',
'walletOfferStatus' => 'offered',
'walletAttestationRef' => 'offer-uuid-4',
+ 'walletOfferUri' => 'openid-credential-offer://?credential_offer_uri=x',
'lifecycle' => 'revoked',
];
$saved = $this->service()->propagate(credential: $object);
self::assertSame('offered', $saved['walletOfferStatus']);
+ // The link goes even when integriq could not be told.
+ self::assertNull($saved['walletOfferUri']);
}//end testMissingTokenStillProceedsFailSoft()
/**
* The revoke guard never blocks: revoking is the compliance action of record.
diff --git a/tests/Unit/Settings/CredentialWalletOfferUriRegisterTest.php b/tests/Unit/Settings/CredentialWalletOfferUriRegisterTest.php
new file mode 100644
index 000000000..e48aca319
--- /dev/null
+++ b/tests/Unit/Settings/CredentialWalletOfferUriRegisterTest.php
@@ -0,0 +1,87 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://conduction.nl
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-credential-keeps-the-wallet-offer-link-while-the-offer-is-open
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Learniq\Tests\Unit\Settings;
+
+use OCA\Learniq\Tests\Support\RegisterSchemaPayloads;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Validates real credential payloads against the shipped fragment with Opis.
+ *
+ * @coversNothing
+ */
+class CredentialWalletOfferUriRegisterTest extends TestCase {
+
+ use RegisterSchemaPayloads;
+
+ /**
+ * A credential from the training example set, with the link integriq's
+ * createOffer answers with, fits the schema; so does one without a link.
+ *
+ * @return void
+ */
+ public function testTheWalletLinkFitsTheCredentialSchema(): void {
+ $offerUri = 'https://learniq.example/index.php/apps/integriq/api/eudi/credential-offers/0b6e3c1e-1f7a-4a52-9a43-3c1d2b5e8f10';
+ $credential = self::credential(walletOfferUri: 'openid-credential-offer://?credential_offer_uri=' . rawurlencode($offerUri));
+ self::assertNull(self::schemaError(slug: 'credential', payload: $credential));
+ self::assertNull(self::schemaError(slug: 'credential', payload: self::credential(walletOfferUri: null)));
+ }//end testTheWalletLinkFitsTheCredentialSchema()
+
+ /**
+ * Text that is no link is refused.
+ *
+ * @return void
+ */
+ public function testTextThatIsNoLinkIsRefused(): void {
+ self::assertStringContainsString('walletOfferUri', (string)self::schemaError(slug: 'credential', payload: self::credential(walletOfferUri: 'scan me')));
+ }//end testTextThatIsNoLinkIsRefused()
+
+ /**
+ * A credential as the training example set ships it, with an offer link.
+ *
+ * @param string|null $walletOfferUri The offer link.
+ *
+ * @return array
+ */
+ private static function credential(?string $walletOfferUri): array {
+ return [
+ 'learnerId' => 'ee06000c-0000-4000-8000-000000000042',
+ 'learnerUserId' => 'training-deelnemer-036',
+ 'courseId' => 'ee060004-0000-4000-8000-000000000002',
+ 'kind' => 'certificate',
+ 'issuedAt' => '2024-08-04T10:00:00+02:00',
+ 'issuerDid' => 'did:web:warmtepompacademie.example',
+ 'signature' => 'voorbeeld.training-credential-001.niet-geldig',
+ 'openbadges3Payload' => ['credentialSubject' => ['id' => 'urn:learniq:learner:ee06000c-0000-4000-8000-000000000042']],
+ 'issuedBy' => 'Warmtepompacademie',
+ 'expiresAt' => '2025-08-04T10:00:00+02:00',
+ 'lifecycle' => 'issued',
+ 'tenant_id' => '00000000-0000-4000-8000-000000000000',
+ 'walletOfferStatus' => 'offered',
+ 'walletOfferedAt' => '2026-10-07T14:18:00+02:00',
+ 'walletAttestationRef' => '0b6e3c1e-1f7a-4a52-9a43-3c1d2b5e8f10',
+ 'walletOfferUri' => $walletOfferUri,
+ ];
+ }//end credential()
+}//end class
diff --git a/tests/Unit/SetupCheck/WalletOfferTokenCheckTest.php b/tests/Unit/SetupCheck/WalletOfferTokenCheckTest.php
new file mode 100644
index 000000000..e6603daa6
--- /dev/null
+++ b/tests/Unit/SetupCheck/WalletOfferTokenCheckTest.php
@@ -0,0 +1,108 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @version GIT:
+ *
+ * @link https://conduction.nl
+ *
+ * @spec openspec/changes/credential-wallet-offer-for-the-learner/specs/certification/spec.md#requirement-the-setup-check-names-the-missing-wallet-token
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Learniq\Tests\Unit\SetupCheck;
+
+use OCA\Learniq\Service\ConnectionReportService;
+use OCA\Learniq\SetupCheck\WalletOfferTokenCheck;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IL10N;
+use OCP\SetupCheck\SetupResult;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+
+/**
+ * @covers \OCA\Learniq\SetupCheck\WalletOfferTokenCheck
+ * @uses \OCA\Learniq\Service\ConnectionReportService
+ */
+class WalletOfferTokenCheckTest extends TestCase {
+
+ /**
+ * With the token set, offers can go out.
+ *
+ * @return void
+ */
+ public function testATokenIsASuccess(): void {
+ $result = $this->check(token: 'token-abc', observation: '')->run();
+
+ self::assertSame(SetupResult::SUCCESS, $result->getSeverity());
+ }//end testATokenIsASuccess()
+
+ /**
+ * Without the token, after an offer failed on it, the check warns and names the key.
+ *
+ * @return void
+ */
+ public function testAFailedOfferWithoutATokenWarnsAndNamesTheKey(): void {
+ $observation = json_encode(['status' => 'unconfigured', 'reason' => 'No integriq API token is set.', 'since' => '2026-10-15 12:00']);
+ $result = $this->check(token: '', observation: (string)$observation)->run();
+
+ self::assertSame(SetupResult::WARNING, $result->getSeverity());
+ self::assertStringContainsString('learniq.openconnector_api_token', (string)$result->getDescription());
+ }//end testAFailedOfferWithoutATokenWarnsAndNamesTheKey()
+
+ /**
+ * Without the token and before any offer, the check only notes the key.
+ *
+ * @return void
+ */
+ public function testNoTokenBeforeAnyOfferIsANote(): void {
+ $result = $this->check(token: '', observation: '')->run();
+
+ self::assertSame(SetupResult::INFO, $result->getSeverity());
+ self::assertStringContainsString('learniq.openconnector_api_token', (string)$result->getDescription());
+ }//end testNoTokenBeforeAnyOfferIsANote()
+
+ /**
+ * The check over an app config holding the token and the wallet observation.
+ *
+ * @param string $token The stored token.
+ * @param string $observation The stored wallet observation, JSON or ''.
+ *
+ * @return WalletOfferTokenCheck
+ */
+ private function check(string $token, string $observation): WalletOfferTokenCheck {
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueString')->willReturnCallback(
+ static fn (string $app, string $key): string => match ($key) {
+ WalletOfferTokenCheck::TOKEN_KEY => $token,
+ ConnectionReportService::OBSERVATION_KEY_PREFIX . ConnectionReportService::WALLET_KEY => $observation,
+ default => '',
+ }
+ );
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(static fn (string $text, array $parameters=[]): string => vsprintf($text, $parameters));
+ $reports = new ConnectionReportService(
+ $appConfig,
+ $this->createMock(IEventDispatcher::class),
+ $this->createMock(ITimeFactory::class),
+ new NullLogger(),
+ $this->createMock(IAppManager::class)
+ );
+
+ return new WalletOfferTokenCheck(appConfig: $appConfig, connectionReports: $reports, l10n: $l10n);
+ }//end check()
+}//end class