From dd0ac28eaff9cac5ad06b5ceabfa01ebb4d7c73f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 3 May 2026 17:14:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(openspec):=20procest-adopt-or-abstractions?= =?UTF-8?q?=20=E2=80=94=20heaviest=20spec=20rewrite=20(4=20specs=20?= =?UTF-8?q?=E2=86=92=20lifecycle=20annotation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the OR-abstraction audit (2026-05-03). Spec-only — no code changes. Drafts the per-app adoption openspec change so each app can run /opsx-apply against it when ready. References .claude/audit-2026-05-03/ research, Phase 2 OR/nc-vue/ hydra specs (#1420, #113, #218), and ADRs 022/024/025. --- .../procest-adopt-or-abstractions/design.md | 558 ++++++++++++++++++ .../procest-adopt-or-abstractions/proposal.md | 272 +++++++++ .../procest-adopt-or-abstractions/spec.md | 487 +++++++++++++++ .../procest-adopt-or-abstractions/tasks.md | 338 +++++++++++ 4 files changed, 1655 insertions(+) create mode 100644 openspec/changes/procest-adopt-or-abstractions/design.md create mode 100644 openspec/changes/procest-adopt-or-abstractions/proposal.md create mode 100644 openspec/changes/procest-adopt-or-abstractions/specs/procest-adopt-or-abstractions/spec.md create mode 100644 openspec/changes/procest-adopt-or-abstractions/tasks.md diff --git a/openspec/changes/procest-adopt-or-abstractions/design.md b/openspec/changes/procest-adopt-or-abstractions/design.md new file mode 100644 index 0000000..7feda84 --- /dev/null +++ b/openspec/changes/procest-adopt-or-abstractions/design.md @@ -0,0 +1,558 @@ +# Design — procest adopts OR abstractions + +This design document focuses on the **lifecycle annotation** that absorbs +four overlapping procest specs and ~200 LOC of state-machine PHP. It also +documents the notifications annotation, the bezwaar deadline calculation +annotation, and the boundary that keeps ZGW protocol code app-local. + +Audit references: +`.claude/audit-2026-05-03/01-code-cleanup.md`, +`02-spec-rewrite.md` (lines 60–73, 96–105, 113–132), +`04-hardcoded.md` (lines 82–99, 144–172). + +## Decisions + +### D1 — One lifecycle annotation, not four specs + +Procest currently describes its case + parafering state machine across +four specs (`case-management`, `parafering-actions`, +`parafering-audit-trail`, `parafeerroute-engine`). The audit identified +this as the heaviest spec rewrite debt of any audited app. + +**Decision:** Consolidate into a single `x-openregister-lifecycle` +annotation on the case schema. The annotation expresses states, +transitions, guards, role-based authorization, and audit-recording +requirements as data. `case-management/spec.md` becomes the canonical +home; the three sibling specs are retired with closing notes. + +**Why:** The four specs all describe one state machine from different +angles. OR provides this natively. Maintaining four specs encourages +implementation drift; consolidating to data lets OR own the engine and +procest own only the configuration. + +### D2 — Notifications as annotations, not a service + +The three transition notifications in +`ParaferingNotificationService.php:64,109,153` are textbook +`x-openregister-notifications` candidates. The PHP-built `setSubject()` +in `CaseEmailService.php:92` adds another instance of the same +anti-pattern. + +**Decision:** Express all three transition notifications as +`x-openregister-notifications` annotations. Delete +`ParaferingNotificationService.php` (in the follow-up code change). Move +`CaseEmailService` notification copy into the annotation. + +**Why:** Notifications are configuration, not code. Centralising them in +the schema makes i18n trivial (one source of truth per ADR-025), makes +the rules visible to schema readers, and aligns with pipelinq / +opencatalogi patterns. + +### D3 — Calculations as annotations (bezwaar deadlines) + +`bezwaar-lifecycle/spec.md:88,95` prescribes "system SHALL automatically +calculate legal deadlines based on AWB articles 6:7, 6:8, 7:10, 7:24." +This is the textbook case for `x-openregister-calculations`. + +**Decision:** Each AWB article becomes a calculation entry on the +bezwaar schema. `BezwaarDeadlineService` (if it exists or would have +been built) is not needed. + +**Why:** Legal deadline rules are declarative, not procedural. Citing +the AWB article as the calculation key makes traceability to the legal +source explicit. + +### D4 — ZGW protocol stays app-local + +`NotificatieService.php`, `ZrcController::ZGW_API`, +`VERTROUWELIJKHEID_LEVELS`, `ZgwZtcRulesService::AFLEIDINGSWIJZE_*`, +`ZgwDrcRulesService` are all bindings to VNG ZGW protocol. The audit +explicitly lists these as legitimate app-local +(`04-hardcoded.md:144–152`). + +**Decision:** Document the boundary. ZGW protocol code is NOT subsumed +into OR notification annotations. OR's `AnnotationNotificationDispatcher` +handles NC-internal notifications; `NotificatieService` handles outbound +ZGW Notificaties API messages. + +**Why:** Future agents looking at procest's state-machine cleanup +might mistakenly fold ZGW notifications into OR — that would break +compliance with the published Dutch government standard. + +### D5 — Manifest declares dependency on openconnector + +ADR-024 mandates a manifest. procest depends on openconnector for ZGW +protocol bindings (Notificaties API outbound; ZRC/BRC/ZTC inbound for +sync). + +**Decision:** `dependencies: ["openregister", "openconnector"]`. + +**Why:** Without openconnector, procest's ZGW integration cannot +function. The manifest must accurately reflect runtime needs so the +hydra orchestrator can validate compatibility. + +### D6 — Defer code changes to follow-up opsx changes + +This change is spec-only. The PHP refactor (replacing +`ParaferingService.php:43-353`, deleting +`ParaferingNotificationService.php`, moving constants to admin-config, +fixing `getUserFolder('admin')` fallback) is deferred to a follow-up +implementation change. + +**Why:** The audit task instruction is "spec-only — no code changes, no +PRs." Separating spec from code lets the spec be reviewed in isolation +and lets the code refactor reference the agreed annotation shape. + +## Lifecycle annotation — before/after sketch + +### Before (current — distributed across specs and PHP) + +`lib/Service/ParaferingService.php:43-68`: +```php +public const STATUS_CONCEPT = 'concept'; +public const STATUS_IN_PARAFERING = 'in_parafering'; +public const STATUS_TERUGGESTUURD = 'teruggestuurd'; +public const STATUS_GEPARAFEERD = 'geparafeerd'; +public const STATUS_AANGEBODEN = 'aangeboden'; +public const STATUS_BESLOTEN = 'besloten'; +``` + +`lib/Service/ParaferingService.php:158-353` (illustrative — actual +method bodies are longer): +```php +public function startParafering(Voorstel $voorstel): Voorstel +{ + if ($voorstel->getStatus() !== self::STATUS_CONCEPT + && $voorstel->getStatus() !== self::STATUS_TERUGGESTUURD) { + throw new \RuntimeException('Cannot start parafering from status ' . $voorstel->getStatus()); + } + $voorstel->setStatus(self::STATUS_IN_PARAFERING); + $voorstel->setStartedAt(new \DateTime()); + // ... ~50 lines persisting + audit + notify ... + return $voorstel; +} + +public function acceptVoorstel(Voorstel $voorstel, string $userId): Voorstel +{ + if ($voorstel->getStatus() !== self::STATUS_IN_PARAFERING) { + throw new \RuntimeException('Cannot accept from status ' . $voorstel->getStatus()); + } + if (!$this->userCanParaferen($voorstel, $userId)) { + throw new \AccessException('User may not parafer this case'); + } + $voorstel->setStatus(self::STATUS_GEPARAFEERD); + // ... record ParaFeeractie audit row ... + // ... call ParaferingNotificationService->onAccept() ... + return $voorstel; +} + +public function rejectVoorstel(Voorstel $voorstel, string $userId, string $reason): Voorstel +{ + if ($voorstel->getStatus() !== self::STATUS_IN_PARAFERING) { + throw new \RuntimeException('Cannot reject from status ' . $voorstel->getStatus()); + } + $voorstel->setStatus(self::STATUS_TERUGGESTUURD); + $voorstel->setRejectionReason($reason); + // ... ParaferingNotificationService->onReject() ... + return $voorstel; +} + +// ... offerVoorstel(), recordDecision(), addAdHocStep() etc ... +``` + +Spec-side, the same machine is described prose-style in: +- `case-management/spec.md` (status transitions) +- `parafering-actions/spec.md` (action definitions) +- `parafering-audit-trail/spec.md` (audit recording) +- `parafeerroute-engine/spec.md` (route modification) + +### After (target — single annotation) + +```jsonc +// case schema (excerpt) — proposed x-openregister-lifecycle annotation +{ + "$id": "https://schemas.conduction.nl/procest/case.json", + "type": "object", + "x-openregister-lifecycle": { + "field": "status", + "initial": "concept", + "states": { + "concept": { + "label": { "nl": "Concept", "en": "Draft" } + }, + "in_parafering": { + "label": { "nl": "In parafering", "en": "Pending review" } + }, + "teruggestuurd": { + "label": { "nl": "Teruggestuurd", "en": "Returned" } + }, + "geparafeerd": { + "label": { "nl": "Geparafeerd", "en": "Approved" } + }, + "aangeboden": { + "label": { "nl": "Aangeboden", "en": "Submitted" } + }, + "besloten": { + "label": { "nl": "Besloten", "en": "Decided" }, + "terminal": true + } + }, + "transitions": [ + { + "action": "start_parafering", + "label": { "nl": "Parafering starten", "en": "Start review" }, + "from": ["concept", "teruggestuurd"], + "to": "in_parafering", + "roles": ["zaakbehandelaar", "procest-admin"], + "requires": [ + { "field": "title", "operator": "not-empty" }, + { "field": "documents", "operator": "min-count", "value": 1 } + ], + "audit": { + "record": true, + "context": ["actorType", "onBehalfOf", "comment"] + } + }, + { + "action": "accept", + "label": { "nl": "Paraferen", "en": "Approve" }, + "from": "in_parafering", + "to": "geparafeerd", + "roles": ["parafeerder"], + "requires": [ + { "field": "currentStep.assignee", "operator": "equals", "value": "$user.id" } + ], + "audit": { + "record": true, + "context": ["actorType", "onBehalfOf", "comment"] + } + }, + { + "action": "reject", + "label": { "nl": "Terugsturen", "en": "Return" }, + "from": "in_parafering", + "to": "teruggestuurd", + "roles": ["parafeerder"], + "requires": [ + { "field": "rejectionReason", "operator": "not-empty" } + ], + "audit": { + "record": true, + "context": ["actorType", "onBehalfOf", "comment", "rejectionReason"] + } + }, + { + "action": "offer", + "label": { "nl": "Aanbieden", "en": "Submit" }, + "from": "geparafeerd", + "to": "aangeboden", + "roles": ["zaakbehandelaar"], + "audit": { "record": true } + }, + { + "action": "decide", + "label": { "nl": "Besluit nemen", "en": "Record decision" }, + "from": "aangeboden", + "to": "besloten", + "roles": ["bestuurder"], + "requires": [ + { "field": "decision", "operator": "not-empty" } + ], + "audit": { + "record": true, + "context": ["actorType", "onBehalfOf", "comment", "decision"] + } + }, + { + "action": "skip_step", + "label": { "nl": "Stap overslaan", "en": "Skip step" }, + "from": "in_parafering", + "to": "in_parafering", + "roles": ["procest-admin"], + "requires": [ + { "field": "route.allowSkip", "operator": "equals", "value": true } + ], + "audit": { "record": true, "context": ["skippedStep", "comment"] } + }, + { + "action": "add_adhoc_step", + "label": { "nl": "Stap toevoegen", "en": "Add step" }, + "from": "in_parafering", + "to": "in_parafering", + "roles": ["procest-admin", "zaakbehandelaar"], + "audit": { "record": true, "context": ["addedStep", "reason"] } + } + ] + } +} +``` + +### Code-side after (illustrative, deferred) + +```php +// ParaferingService becomes a thin facade that translates Dutch domain +// action names to OR lifecycle transition keys. No more STATUS_* constants, +// no more `if status !== ...` blocks. +public function acceptVoorstel(Voorstel $voorstel, string $userId, ?string $comment = null): Voorstel +{ + // Engine handles: guard check, role check, audit write, notification + // dispatch, persistence. ParaferingService is just the domain entry point. + return $this->lifecycleEngine->transition( + $voorstel, + action: 'accept', + context: ['actorType' => 'user', 'onBehalfOf' => $userId, 'comment' => $comment] + ); +} +``` + +### Mapping table — current spec sections → new home + +| Old spec | Old section | New home | +|----------|-------------|----------| +| `case-management/spec.md` | Status transitions | `case-management/spec.md` § Lifecycle annotation | +| `parafering-actions/spec.md` | Action definitions | annotation `transitions[].action` | +| `parafering-actions/spec.md` | Action authorization | annotation `transitions[].roles` | +| `parafering-audit-trail/spec.md` | Immutable audit rows | OR `audit-trail-immutable` capability + annotation `audit.record` | +| `parafering-audit-trail/spec.md` | Delegation tracking | annotation `audit.context: [actorType, onBehalfOf]` | +| `parafeerroute-engine/spec.md` | Skip-step rules | annotation `transitions[action=skip_step]` | +| `parafeerroute-engine/spec.md` | Ad-hoc step rules | annotation `transitions[action=add_adhoc_step]` | +| `parafeerroute-engine/spec.md` | Route modification audit | annotation `audit.context: [skippedStep, addedStep]` | + +## Notifications annotation — sketch + +### Before + +`lib/Service/ParaferingNotificationService.php` (illustrative — three +methods, ~200 LOC total): + +```php +// :64 +public function onParaferingStarted(Voorstel $voorstel): void +{ + foreach ($voorstel->getCurrentStep()->getAssignees() as $assignee) { + $n = $this->notificationManager->createNotification(); + $n->setApp('procest') + ->setUser($assignee) + ->setSubject('parafering_pending', ['title' => $voorstel->getTitle()]) + ->setObject('voorstel', (string)$voorstel->getId()) + ->setLink($this->urlFor($voorstel)); + $this->notificationManager->notify($n); + } +} + +// :109 — onAccept(); :153 — onReject() — same shape, different subjects. +``` + +`lib/Service/CaseEmailService.php:92` — same anti-pattern via PHP-built +email subject. + +### After + +```jsonc +// case schema — x-openregister-notifications +{ + "x-openregister-notifications": [ + { + "trigger": { "type": "lifecycle.transition", "action": "start_parafering" }, + "recipient": { "type": "role", "value": "currentStep.assignees" }, + "subject": { "i18nKey": "procest.notification.paraferingPending.subject" }, + "body": { "i18nKey": "procest.notification.paraferingPending.body" }, + "link": "/procest/case/{id}", + "channel": ["nc-notification", "email"] + }, + { + "trigger": { "type": "lifecycle.transition", "action": "accept" }, + "recipient": { "type": "field", "value": "createdBy" }, + "subject": { "i18nKey": "procest.notification.accepted.subject" }, + "body": { "i18nKey": "procest.notification.accepted.body" }, + "link": "/procest/case/{id}", + "channel": ["nc-notification", "email"] + }, + { + "trigger": { "type": "lifecycle.transition", "action": "reject" }, + "recipient": { "type": "field", "value": "createdBy" }, + "subject": { "i18nKey": "procest.notification.rejected.subject" }, + "body": { "i18nKey": "procest.notification.rejected.body" }, + "context": { "rejectionReason": "$.rejectionReason" }, + "link": "/procest/case/{id}", + "channel": ["nc-notification", "email"] + } + ] +} +``` + +The annotation completely replaces both `ParaferingNotificationService` +and the PHP-built `CaseEmailService::setSubject()` path. i18n keys flow +through OR's `register-i18n` per ADR-025. + +## Calculations annotation — bezwaar AWB deadlines + +### Before + +`openspec/specs/bezwaar-lifecycle/spec.md:88,95`: +> "The system SHALL automatically calculate legal deadlines based on AWB +> articles 6:7, 6:8, 7:10, 7:24." + +(Implementation implied to be a `BezwaarDeadlineService` PHP class.) + +### After + +```jsonc +// bezwaar schema — x-openregister-calculations +{ + "x-openregister-calculations": [ + { + "field": "termijnBezwaar", + "label": { "nl": "Termijn bezwaar (AWB 6:7)", "en": "Objection deadline (AWB 6:7)" }, + "expression": "addBusinessDays($.besluitDatum, 42)", + "legalSource": "AWB Art. 6:7" + }, + { + "field": "termijnVerschoonbaar", + "label": { "nl": "Verschoonbare termijn (AWB 6:8)", "en": "Excusable deadline (AWB 6:8)" }, + "expression": "$.termijnBezwaar", + "legalSource": "AWB Art. 6:8" + }, + { + "field": "termijnBeslissing", + "label": { "nl": "Beslistermijn (AWB 7:10)", "en": "Decision deadline (AWB 7:10)" }, + "expression": "addWeeks($.bezwaarOntvangenDatum, 6)", + "legalSource": "AWB Art. 7:10" + }, + { + "field": "termijnVerdaging", + "label": { "nl": "Verdagingstermijn (AWB 7:24)", "en": "Postponement deadline (AWB 7:24)" }, + "expression": "addWeeks($.bezwaarOntvangenDatum, 12)", + "legalSource": "AWB Art. 7:24" + } + ] +} +``` + +## Aggregations citations (case-dashboard, parafering-dashboard) + +```jsonc +// case schema — x-openregister-aggregations +{ + "x-openregister-aggregations": [ + { + "name": "casesByStatus", + "groupBy": "status", + "aggregate": "count" + }, + { + "name": "casesOverdue", + "filter": { "termijnBeslissing": { "$lt": "$now" }, "status": { "$ne": "besloten" } }, + "aggregate": "count" + } + ] +} +``` + +`parafering-dashboard/spec.md` and `case-dashboard-view/spec.md` cite +this annotation; the dashboard widgets bind directly to OR's aggregation +endpoint. + +## Boundary preserved — ZGW protocol stays app-local + +| Component | Stays app-local | Reason | +|-----------|-----------------|--------| +| `lib/Service/NotificatieService.php` | YES | Dispatches outbound ZGW Notificaties API messages per VNG protocol. Not OR objects. | +| `lib/Controller/ZrcController.php` | YES | ZGW Zaken API — VNG REST contract. | +| `lib/Controller/BrcController.php` | YES | ZGW Besluiten API — VNG REST contract. | +| `lib/Controller/ZtcController.php` | YES | ZGW Catalogi API — VNG REST contract. | +| `lib/Controller/NrcController.php` | YES | ZGW Notificaties API — VNG REST contract. | +| `ZrcController::ZGW_API` constants | YES | API path identifiers per VNG. | +| `VERTROUWELIJKHEID_LEVELS` | YES | VNG-defined enum. | +| `ZgwZtcRulesService::AFLEIDINGSWIJZE_*` | YES | VNG-defined ZTC business rules. | +| `ZgwDrcRulesService` | YES | VNG-defined DRC business rules. | + +Compare with what MOVES to OR annotations: + +| Component | Moves to OR | Replacement | +|-----------|-------------|-------------| +| `ParaferingService.php:43-68` (STATUS_*) | YES | `x-openregister-lifecycle.states` | +| `ParaferingService.php:158-353` (transitions) | YES | `x-openregister-lifecycle.transitions` | +| `ParaferingNotificationService.php` (3 methods) | YES | `x-openregister-notifications` | +| `CaseEmailService::setSubject()` | YES | `x-openregister-notifications.subject.i18nKey` | +| `BezwaarDeadlineService` (implied) | YES | `x-openregister-calculations` | +| Bespoke parafeeractie versioning | YES | OR `audit-trail-immutable` | +| Custom dashboard count query | YES | `x-openregister-aggregations` | + +## Manifest sketch + +```jsonc +// procest/manifest.json (sketched; created in follow-up code change) +{ + "$schema": "https://schemas.conduction.nl/hydra/app-manifest.json", + "id": "procest", + "name": { "nl": "Procest", "en": "Procest" }, + "tier": 2, + "version": "0.x", + "dependencies": ["openregister", "openconnector"], + "consumes": [ + "openregister.object-lifecycle", + "openregister.audit-trail-immutable", + "openregister.notificatie-engine", + "openregister.aggregations-backend-native", + "openregister.geo-metadata-kaart", + "openregister.register-i18n", + "openregister.computed-fields", + "openregister.register-resolver-service", + "nextcloud-vue.multi-tenancy-context", + "hydra.i18n-source-of-truth", + "hydra.i18n-api-language-negotiation" + ], + "provides": [ + "procest.case-schema", + "procest.parafering-lifecycle", + "procest.zgw-bindings" + ], + "routes": "appinfo/routes.php" +} +``` + +`tier: 2` reflects current state (some custom code remains for ZGW +protocol bindings). After the follow-up implementation change lands — +when `ParaferingService` is a thin facade over OR's lifecycle engine — +procest can be promoted to tier 3. + +## ADR alignment + +- **ADR-022 (apps consume OR abstractions)** — directly satisfied: + lifecycle, notifications, calculations, aggregations, audit, i18n, + resolver — all annotation-based. +- **ADR-023 (action authorization)** — satisfied via + `transitions[].roles` in the lifecycle annotation. +- **ADR-024 (app manifest)** — manifest sketched; follow-up change + creates the file. +- **ADR-025 (i18n source of truth)** — all user-facing strings in + annotations use `i18nKey` references. No PHP-built copy survives. + +## Risks (re-stated from proposal with mitigations) + +- **R1 OR lifecycle engine maturity** — annotation must support + guards, role auth, audit-recording listener. Tracked above; if a gap, + raise an OR-side change before code refactor. +- **R2 Information loss in retirement** — Phase 5 mapping table + ensures every requirement from the four old specs has a new home. +- **R3 Notification annotation gaps** — three call sites map cleanly; + if rich attachments / multi-channel needs surface, OR side gets a + feature request, this change is amended. +- **R4 NotificatieService confusion** — D4 + Phase 13 explicit + boundary documentation. +- **R5 Tenant-unsafe seed** — Phase 9.5 audits and flags; + multi-tenant fix is its own change. + +## Acceptance (design-side) + +- [ ] Lifecycle annotation example covers all 6 STATUS_* values, + all transitions in `ParaferingService.php:158-353`, and all + skip-step / ad-hoc rules from `parafeerroute-engine`. +- [ ] Notifications annotation example covers all 3 call sites in + `ParaferingNotificationService` + `CaseEmailService::setSubject()`. +- [ ] Calculations annotation example covers AWB 6:7, 6:8, 7:10, 7:24. +- [ ] Aggregations annotation cited by dashboards. +- [ ] Boundary table (D4 + § "Boundary preserved") is unambiguous. +- [ ] Manifest sketch lists openregister + openconnector dependencies. +- [ ] No code is modified. diff --git a/openspec/changes/procest-adopt-or-abstractions/proposal.md b/openspec/changes/procest-adopt-or-abstractions/proposal.md new file mode 100644 index 0000000..0ed875d --- /dev/null +++ b/openspec/changes/procest-adopt-or-abstractions/proposal.md @@ -0,0 +1,272 @@ +# Proposal — procest adopts OpenRegister abstractions + +## Why + +The 2026-05-03 platform audit identified **procest** as carrying the heaviest +spec rewrite debt of any Conduction app. Across streams 1, 2 and 4 the audit +documents that procest currently: + +- Maintains **four near-overlapping specs** describing a custom case / + parafering state machine (`case-management`, `parafering-actions`, + `parafering-audit-trail`, `parafeerroute-engine`) which all should + collapse into a single OR `x-openregister-lifecycle` annotation on the + case schema (audit ref: `02-spec-rewrite.md` lines 60–73, 96–105). +- Implements that state machine in PHP — six `STATUS_*` constants and ~200 + lines of guarded transition code in `lib/Service/ParaferingService.php` + (audit ref: `04-hardcoded.md:85–86`). +- Dispatches three per-transition notifications by hand in + `lib/Service/ParaferingNotificationService.php` (audit ref: + `04-hardcoded.md:87`) — a textbook `x-openregister-notifications` + candidate. +- Misses OR feature citations on `parafering-dashboard`, `case-location`, + `case-dashboard-view` (`02-spec-rewrite.md:68–70`). +- Carries hardcoded admin-tunable magic numbers + (`ShareMaintenanceJob::REMINDER_DAYS`, `MetricsController::CACHE_TTL_*`, + `CaseEmailService::setSubject()` PHP-built copy) that should be admin + configuration or schema-declared (audit ref: `04-hardcoded.md:91–93`). +- Has a security smell in `ZgwDocumentService::getUserFolder('admin')` + fallback (audit ref: `04-hardcoded.md:90`). + +The cross-cutting insight from the audit is unambiguous: *"The four OR +primitives that would absorb the most code already exist +(`x-openregister-lifecycle`, `-calculations`, `-notifications`, +`-archival`). The blocker is adoption, not OR feature work."* (`04-hardcoded.md:167–172`). + +This change is procest's adoption response. It is **spec-only** — all +artefacts are new spec deltas, retired-spec closing notes, ADR-022 / ADR-024 +/ ADR-025 alignment, and a manifest sketch. **No code changes** are made in +this change; the corresponding implementation work is referenced as future +opsx changes that depend on this proposal landing. + +## What Changes + +### Spec consolidation (the big one) + +Four specs collapse into one. `case-management` becomes the canonical home +for the case + parafering lifecycle, expressed as a single +`x-openregister-lifecycle` annotation on the case schema. Three sibling +specs are retired with closing notes pointing to the consolidated spec: + +- **KEEP and rewrite** — `openspec/specs/case-management/spec.md` + - Describes the case schema, lifecycle annotation, and the parafering + states + guarded transitions as data, not code. + - Cites OR's `object-lifecycle`, `audit-trail-immutable`, + `notificatie-engine`, and `register-i18n` capabilities. +- **RETIRE** — `openspec/specs/parafering-actions/spec.md` + - Folds into `case-management` as the action-specific guards and audit + recording on each transition. +- **RETIRE** — `openspec/specs/parafering-audit-trail/spec.md` + - Folds into `case-management` by citing OR's `audit-trail-immutable` + instead of describing custom parafeeractie versioning. +- **RETIRE** — `openspec/specs/parafeerroute-engine/spec.md` + - Folds into `case-management` as data-driven lifecycle transitions + with `requires` guards (skip-step / ad-hoc step semantics expressed as + transition pre-conditions). + +Three more specs gain OR-feature citations: + +- `openspec/specs/parafering-dashboard/spec.md` — cite OR + `x-openregister-aggregations` for count-by-status queries. +- `openspec/specs/case-location/spec.md` — cite OR `geo-metadata-kaart`. +- `openspec/specs/case-dashboard-view/spec.md` — cite OR aggregations. + +One forward-looking spec (`bezwaar-lifecycle/spec.md:88,95`) is updated so +its "AWB articles 6:7, 6:8, 7:10, 7:24 deadline calculation" is declared +as `x-openregister-calculations` rather than a `BezwaarDeadlineService` +class (audit ref: `04-hardcoded.md:97`). + +### Annotation design (lifecycle + notifications) + +This change defines the canonical annotation shape for the case schema: + +- **`x-openregister-lifecycle`** — six states + (`concept|in_parafering|teruggestuurd|geparafeerd|aangeboden|besloten`), + the guarded transitions between them, role-based action authorization + (ADR-023), and `requires` guards encoding the skip-step / ad-hoc-step + rules currently in `parafeerroute-engine`. +- **`x-openregister-notifications`** — three per-transition rules + replacing the three hand-rolled `notificationManager->createNotification()` + calls in `ParaferingNotificationService.php`. +- **`x-openregister-calculations`** — AWB legal deadlines for bezwaar + (`6:7`, `6:8`, `7:10`, `7:24`). +- **`x-openregister-aggregations`** — count-by-status for parafering / + case dashboards. + +`design.md` shows the explicit before/after sketch for each. + +### Code refactor (referenced, not executed here) + +This proposal **does not** modify code. It defines the target shape so +follow-up opsx changes can: + +1. Replace `ParaferingService.php:43-353` guarded transitions with calls + to OR's lifecycle engine. +2. Delete `ParaferingNotificationService.php` (subsumed by + `AnnotationNotificationDispatcher`). +3. Replace `ShareMaintenanceJob::REMINDER_DAYS`, + `MetricsController::CACHE_TTL_*`, `CaseEmailService::setSubject()` with + admin-config + schema-declared notification copy. +4. Fix the `ZgwDocumentService::getUserFolder('admin')` fallback so + non-admin contexts don't silently fall back to admin's home folder. + +### NotificatieService boundary clarification + +`lib/Service/NotificatieService.php` is **legitimately ZGW-specific** +(it dispatches Notificaties API messages per VNG protocol). It is **not** +duplicating OR's `AnnotationNotificationDispatcher` for OR objects. This +change documents the boundary in `design.md` so it is not refactored away +by mistake. ZGW protocol bindings stay app-local; OR-object lifecycle +notifications move to annotations. + +### Manifest adoption (ADR-024) + +A manifest is sketched declaring procest as **Tier 2-3** with +`dependencies: ["openregister", "openconnector"]` (procest uses connector +for ZGW protocol bindings — Notificaties API outbound, ZRC/BRC/ZTC +inbound). The manifest is generated from existing router config; no new +runtime behaviour. + +### i18n + multi-tenancy (ADR-025 + nextcloud-vue) + +procest already wires `createObjectStore('object', { plugins: [filesPlugin(), +auditTrailsPlugin(), relationsPlugin()] })` in `src/store/modules/object.js` +(audit ref: `01-code-cleanup.md` notes this is **GOOD**, no migration +needed). This change adds: + +- A reverse-citation of nextcloud-vue's `multi-tenancy-context` + composable so the integration is documented (currently implicit). +- Adoption of `i18n-source-of-truth` + `i18n-api-language-negotiation` + for case content (Dutch + English at minimum per + `feedback_i18n-requirement.md`). + +### Hardcoded cleanup (forward-looking) + +The following constants are flagged for the implementation change that +follows this proposal: + +| File:line | Constant | Disposition | +|-----------|----------|-------------| +| `ParaferingService.php:43-68` | 6× `STATUS_*` | move to `x-openregister-lifecycle` | +| `ParaferingService.php:158-353` | guarded transitions | move to lifecycle engine | +| `ParaferingNotificationService.php:64,109,153` | 3× `createNotification()` | move to `x-openregister-notifications` | +| `ShareMaintenanceJob.php:42` | `REMINDER_DAYS = 3` | admin-config | +| `MetricsController.php:43,48` | `CACHE_TTL_*` | admin-config | +| `CaseEmailService.php:92` | `setSubject()` | schema-declared notification copy | +| `ZgwDocumentService.php:312` | `getUserFolder('admin')` fallback | drop fallback, throw on missing user | +| `ZgwDocumentService.php:45` | `STORAGE_BASE = 'procest/documenten'` | admin-config (per-tenant override) | +| `LoadDefaultZgwMappings.php:1535-1550` | `procest-admin` group + `userId='admin'` | document, audit for tenant safety | + +Items NOT changed (per `04-hardcoded.md:144–152`): +- `ZGW_API` / `VERTROUWELIJKHEID_LEVELS` — VNG protocol, app-local. +- `ZgwZtcRulesService::AFLEIDINGSWIJZE_*` — VNG protocol, app-local. +- `ZgwDrcRulesService` constants — VNG protocol, app-local. +- `NotificatieService` — ZGW Notificaties API, app-local. + +## OR-side dependencies (must land first or in parallel) + +This change consumes the following capabilities. They are cited (not +re-specified) here: + +| Capability | Home | Status | +|------------|------|--------| +| `register-resolver-service` | `openregister/openspec/changes/` (forthcoming per `04-hardcoded.md:136–138`) | depended-on | +| `pluggable-integration-registry` | `openregister/openspec/specs/` (per ADR-019) | exists | +| `i18n-source-of-truth` | `openregister/openspec/specs/register-i18n/spec.md` + ADR-025 | exists | +| `i18n-api-language-negotiation` | `openregister/openspec/changes/` (per ADR-025) | depended-on | +| `multi-tenancy-context` | `nextcloud-vue/openspec/specs/composables/spec.md` (per `R2-nc-vue-multitenancy.md`) | exists | +| `adopt-app-manifest` | `hydra/openspec/architecture/adr-024-app-manifest.md` + `openregister/openspec/changes/openregister-adopt-app-manifest/` | exists | + +ADRs cited: +- ADR-022 — apps consume OR abstractions (`hydra/openspec/architecture/adr-022-apps-consume-or-abstractions.md`). +- ADR-024 — app manifest (`hydra/openspec/architecture/adr-024-app-manifest.md`). +- ADR-025 — i18n source of truth (`hydra/openspec/architecture/adr-025-i18n-source-of-truth.md`). + +## Impact + +### Specs + +- **1 spec rewritten** — `case-management/spec.md` becomes the canonical + parafering + case lifecycle home, consuming OR annotations. +- **3 specs retired with closing notes** — `parafering-actions`, + `parafering-audit-trail`, `parafeerroute-engine`. +- **3 specs gain OR-feature citations** — `parafering-dashboard`, + `case-location`, `case-dashboard-view`. +- **1 spec line updated** — `bezwaar-lifecycle/spec.md:88,95` switches to + `x-openregister-calculations`. +- **1 spec retro-documented** — `voorstel-management/spec.md` cites the + consolidated lifecycle annotation (audit ref: `04-hardcoded.md:99`). + +### Code (forward-looking) + +- ~200 LOC of guarded transition code in `ParaferingService.php` becomes + data (~50 lines of JSON in the case schema). +- `ParaferingNotificationService.php` (entire file, ~200 LOC) is deleted. +- 3 magic-number constants move to admin-config UI. +- 1 security smell (`getUserFolder('admin')` fallback) is fixed. + +### Cross-app + +- procest aligns with pipelinq (the `01-code-cleanup.md` "exemplar + pattern" — `02-spec-rewrite.md:78`) and removes its outlier status as + the heaviest spec-rewrite debt holder. +- Reduces the count of "custom lifecycle / state-machine" + implementations (`02-spec-rewrite.md:99–105`) from 4 to 1. + +## Out of scope + +- Code changes — this change is spec-only (per task instructions). +- ZGW protocol logic — `ZrcController`, `BrcController`, `ZtcController`, + `NrcController`, `ZgwZtcRulesService`, `ZgwDrcRulesService`, + `NotificatieService` stay app-local (audit ref: `04-hardcoded.md:144–152`). +- VNG ZGW Zaak mapping (`zgw-api-mapping/spec.md`) — domain-legitimate + per `02-spec-rewrite.md:71`. +- Case-type versioning (`zaaktype-versioning/spec.md`) — domain-specific + per `02-spec-rewrite.md:72`. +- Bezwaar advisory committee, hearing, decision specs — bezwaar-specific + workflow, not part of this consolidation. Only + `bezwaar-lifecycle/spec.md` is touched (calculations annotation). + +## Risks + +- **R1 — Lifecycle engine maturity.** OR's `object-lifecycle` capability + must support: action-bound transitions (advies, parafering, + accordering), `requires` guards, role-based authorization (ADR-023), + and an audit-recording event listener. If the implementation lags, the + follow-up code change blocks. **Mitigation:** this change documents the + full annotation shape so OR can verify coverage before code lands. +- **R2 — Three-spec retirement information loss.** Closing notes on the + retired specs must preserve any nuance (especially route-engine's + ad-hoc-step semantics). **Mitigation:** `tasks.md` Phase 5 explicitly + enumerates each retired spec's unique requirements and where they live + in the consolidated spec. +- **R3 — Notification annotation coverage.** OR's + `x-openregister-notifications` must support per-transition trigger, + recipient-by-role, and i18n subject/body. **Mitigation:** annotation + shape sketched in `design.md`; if a gap emerges this change is amended + rather than the lifecycle work being delayed. +- **R4 — `NotificatieService` confusion.** A future agent might + mistakenly fold ZGW Notificaties API dispatching into OR notifications. + **Mitigation:** `design.md` contains an explicit "boundary preserved" + section calling out that ZGW protocol notifications are NOT subsumed. +- **R5 — Tenant safety of `userId='admin'` seed.** The + `LoadDefaultZgwMappings` repair seeds objects with `userId='admin'` + ownership. In a multi-tenant deploy this could cross tenants. + **Mitigation:** Phase 9 audits the repair step and proposes a + tenant-aware seed. + +## Acceptance criteria + +- [ ] All 4 NEEDS-REWRITE procest specs from `02-spec-rewrite.md` are + either rewritten (1) or retired with closing notes (3). +- [ ] All 3 MISSING-OR-DEP procest specs cite the relevant OR feature. +- [ ] `case-management/spec.md` includes a complete + `x-openregister-lifecycle` annotation example covering all six current + STATUS_* values with their guarded transitions. +- [ ] `case-management/spec.md` includes a complete + `x-openregister-notifications` annotation example covering all three + current `ParaferingNotificationService` calls. +- [ ] `bezwaar-lifecycle/spec.md` AWB deadline calculation is declared + via `x-openregister-calculations`. +- [ ] Manifest sketch present and ADR-024 alignment documented. +- [ ] ZGW protocol boundary documented in `design.md`. +- [ ] No code files are modified. diff --git a/openspec/changes/procest-adopt-or-abstractions/specs/procest-adopt-or-abstractions/spec.md b/openspec/changes/procest-adopt-or-abstractions/specs/procest-adopt-or-abstractions/spec.md new file mode 100644 index 0000000..c72006c --- /dev/null +++ b/openspec/changes/procest-adopt-or-abstractions/specs/procest-adopt-or-abstractions/spec.md @@ -0,0 +1,487 @@ +# Spec — procest-adopt-or-abstractions + +This delta spec captures the new contract procest establishes with the +OpenRegister platform: a single lifecycle annotation replaces four +overlapping specs and ~200 LOC of state-machine PHP, notifications move +from PHP services to schema annotations, legal deadline calculations +become declarative, and a manifest declares dependencies on `openregister` +and `openconnector`. The spec retires three sibling specs and updates +three more with OR-feature citations. + +This change is **spec-only**. The implementation refactor is deferred to +a follow-up opsx change. + +## ADDED Requirements + +### Requirement: Procest case schema SHALL declare its lifecycle as an `x-openregister-lifecycle` annotation + +The procest case schema SHALL include a single +`x-openregister-lifecycle` annotation that fully describes: + +1. The set of lifecycle states (currently: + `concept`, `in_parafering`, `teruggestuurd`, `geparafeerd`, + `aangeboden`, `besloten`). +2. The transitions between states, including: `from` state(s), `to` + state, action key, role-based authorization, and `requires` guards. +3. Per-transition audit-recording requirements (which audit context + keys must be captured). +4. Skip-step and ad-hoc-step semantics expressed as transitions with + appropriate `requires` guards. +5. i18n labels for every state and every transition action (Dutch + + English minimum per ADR-025). + +**Rationale:** Replaces the textbook state machine in +`lib/Service/ParaferingService.php:43-353` with declarative +configuration. Audit ref: `04-hardcoded.md:85–86`, +`02-spec-rewrite.md:64,66,67`. + +#### Scenario: Annotation includes all six current STATUS_* values + +- **WHEN** the case schema is loaded +- **THEN** `x-openregister-lifecycle.states` SHALL contain at minimum: + `concept`, `in_parafering`, `teruggestuurd`, `geparafeerd`, + `aangeboden`, `besloten`. +- **AND** each state SHALL include an `i18nKey` or inline `label` map + with at minimum `nl` and `en` translations. + +#### Scenario: Each transition specifies role-based authorization + +- **WHEN** a transition is declared +- **THEN** the transition SHALL include a `roles` array listing which + roles may invoke it. +- **AND** the OR lifecycle engine SHALL reject a transition request + from a user lacking any of the listed roles (per ADR-023). + +#### Scenario: Each transition records audit context + +- **WHEN** a transition fires +- **THEN** the OR lifecycle engine SHALL record an + `audit-trail-immutable` entry containing at minimum: + `actorType`, `onBehalfOf`, `comment` (when applicable), + the transition action key, the from-state and to-state, and the + timestamp. + +#### Scenario: Skip-step semantics expressed as a transition + +- **GIVEN** a case in `in_parafering` +- **WHEN** an admin invokes the `skip_step` transition +- **THEN** the engine SHALL evaluate the `route.allowSkip` guard +- **AND** record an audit entry with `skippedStep` context +- **AND** keep the case in `in_parafering` (skip-step does not change + state, only step-pointer). + +#### Scenario: Ad-hoc step expressed as a transition + +- **GIVEN** a case in `in_parafering` +- **WHEN** an admin or zaakbehandelaar invokes the `add_adhoc_step` + transition +- **THEN** the engine SHALL record an audit entry with `addedStep` + and `reason` context. + +### Requirement: Procest case schema SHALL declare lifecycle notifications as `x-openregister-notifications` + +The procest case schema SHALL replace the three hand-rolled +notifications in `lib/Service/ParaferingNotificationService.php` and +the PHP-built subject in `lib/Service/CaseEmailService.php:92` with +schema-declared `x-openregister-notifications` rules. + +**Rationale:** Audit ref: `04-hardcoded.md:87,93`. The +`x-openregister-notifications` capability already exists in OR. + +#### Scenario: Three transition notifications declared + +- **THEN** the case schema SHALL include at least three + `x-openregister-notifications` rules triggered respectively by: + (a) the `start_parafering` transition, + (b) the `accept` transition, + (c) the `reject` transition. + +#### Scenario: All notification copy is i18n keys + +- **WHEN** a notification rule is declared +- **THEN** its `subject` and `body` SHALL be expressed as `i18nKey` + references resolved through OR's `register-i18n` capability. +- **AND** no PHP-built `setSubject()` call SHALL be retained for + these three notifications. + +#### Scenario: Recipient resolves through schema fields or roles + +- **WHEN** a notification rule is declared +- **THEN** its `recipient` SHALL be expressed as either a `field` + reference (e.g. `createdBy`) or a `role` reference (e.g. + `currentStep.assignees`), not as a hardcoded user list. + +### Requirement: Bezwaar legal deadlines SHALL be declared as `x-openregister-calculations` + +The bezwaar schema SHALL replace prose deadline calculation +requirements with declarative `x-openregister-calculations` entries, +one per AWB article cited in `bezwaar-lifecycle/spec.md:88,95`. + +**Rationale:** Audit ref: `04-hardcoded.md:97`. AWB articles 6:7, 6:8, +7:10, 7:24 are legal calculation rules; OR's calculations annotation +is the correct home. + +#### Scenario: AWB 6:7 deadline is declarative + +- **WHEN** the bezwaar schema is loaded +- **THEN** `x-openregister-calculations` SHALL include an entry + computing `termijnBezwaar` from `besluitDatum + 42 business days` +- **AND** the entry SHALL cite `AWB Art. 6:7` in a + `legalSource` field. + +#### Scenario: AWB 7:10 and 7:24 deadlines are declarative + +- **WHEN** the bezwaar schema is loaded +- **THEN** `x-openregister-calculations` SHALL include entries for + `termijnBeslissing` (6 weeks per AWB 7:10) and `termijnVerdaging` + (12 weeks per AWB 7:24). + +#### Scenario: No `BezwaarDeadlineService` PHP class is required + +- **THEN** the spec SHALL NOT prescribe a custom PHP service for AWB + deadline calculation; the calculations annotation is the contract. + +### Requirement: Procest dashboards SHALL cite `x-openregister-aggregations` + +`parafering-dashboard/spec.md` and `case-dashboard-view/spec.md` SHALL +cite OR's `aggregations-backend-native` capability and express their +count-by-status / overdue queries as `x-openregister-aggregations` +annotations on the case schema. + +**Rationale:** Audit ref: `02-spec-rewrite.md:68,70`. + +#### Scenario: Count-by-status aggregation declared + +- **WHEN** the case schema is loaded +- **THEN** `x-openregister-aggregations` SHALL include a `casesByStatus` + entry grouping by the lifecycle status field with a `count` aggregate. + +#### Scenario: Dashboard widgets bind to OR aggregations endpoint + +- **WHEN** a dashboard widget renders count-by-status +- **THEN** it SHALL consume the OR aggregations endpoint +- **AND** SHALL NOT issue per-status `findObjects` calls in a loop. + +### Requirement: Procest case-location SHALL cite `geo-metadata-kaart` + +`openspec/specs/case-location/spec.md` SHALL cite OR's +`geo-metadata-kaart` capability for storing and rendering geographic +metadata on cases. + +**Rationale:** Audit ref: `02-spec-rewrite.md:69`. + +#### Scenario: Case-location spec cites OR feature + +- **WHEN** the case-location spec is read +- **THEN** it SHALL include an explicit citation to + `openregister/openspec/changes/geo-metadata-kaart/`. +- **AND** SHALL describe geo metadata as annotation-driven, not as a + custom location service. + +### Requirement: Procest SHALL adopt the OR i18n source of truth + +All user-facing case strings — state labels, transition action labels, +notification subjects/bodies, dashboard widget titles — SHALL be +sourced through OR's `register-i18n` capability, with Dutch and English +as the minimum language set. + +**Rationale:** ADR-025 mandates a single i18n source of truth. +`feedback_i18n-requirement.md` requires nl + en for all apps. + +#### Scenario: All annotation labels use `i18nKey` or `{ nl, en }` map + +- **WHEN** a state, transition, notification, or aggregation declares a + user-facing string +- **THEN** the string SHALL be an `i18nKey` reference OR an inline + `{ nl, en }` translation map. +- **AND** SHALL NOT be a hardcoded single-language literal. + +#### Scenario: API requests honour Accept-Language + +- **WHEN** the case API receives a request with `Accept-Language: en` +- **THEN** the response SHALL render i18n keys in English per OR's + `i18n-api-language-negotiation` capability. + +### Requirement: Procest SHALL declare an app manifest + +Per ADR-024, procest SHALL ship a `manifest.json` declaring its tier, +dependencies, consumed capabilities, and provided capabilities. + +**Rationale:** Audit instruction; ADR-024. + +#### Scenario: Dependencies include openregister and openconnector + +- **WHEN** the manifest is read +- **THEN** `dependencies` SHALL include `openregister` and + `openconnector`. +- **AND** SHALL explain in `consumes` which OR capabilities are + required (lifecycle, audit, notifications, aggregations, geo, i18n, + resolver, computed fields). + +#### Scenario: Manifest tier reflects current adoption state + +- **WHEN** the manifest is read +- **THEN** `tier` SHALL be 2 until the follow-up implementation change + lands, after which procest may be promoted to tier 3. + +#### Scenario: Manifest is generated from `appinfo/routes.php` + +- **WHEN** the manifest is generated +- **THEN** the routes section SHALL be derived from + `appinfo/routes.php` (no hand-maintained route list). + +### Requirement: ZGW protocol bindings SHALL remain app-local + +`lib/Service/NotificatieService.php`, `lib/Controller/ZrcController.php`, +`lib/Controller/BrcController.php`, `lib/Controller/ZtcController.php`, +`lib/Controller/NrcController.php`, `lib/Service/ZgwZtcRulesService.php`, +`lib/Service/ZgwDrcRulesService.php`, and the `VERTROUWELIJKHEID_LEVELS` +/ `AFLEIDINGSWIJZE_*` constants SHALL remain app-local and SHALL NOT be +folded into OR notification annotations or OR lifecycle. + +**Rationale:** Audit ref: `04-hardcoded.md:144–152`. These are bindings +to VNG ZGW protocol — published Dutch government standards. + +#### Scenario: NotificatieService dispatches outbound ZGW Notificaties API + +- **WHEN** a ZGW event must be published +- **THEN** `NotificatieService` SHALL dispatch via the VNG ZGW + Notificaties API. +- **AND** OR's `AnnotationNotificationDispatcher` SHALL NOT be invoked + for ZGW protocol messages. + +#### Scenario: Internal NC notifications use OR annotations + +- **WHEN** a transition triggers an in-app NC notification +- **THEN** the notification SHALL flow through OR's annotation + dispatcher per the `x-openregister-notifications` rule. +- **AND** SHALL NOT use `NotificatieService`. + +### Requirement: Procest SHALL adopt OR's register-resolver service + +When OR's `register-resolver-service` capability lands (per +`04-hardcoded.md:136–138`), procest SHALL replace direct +`getValueString(APP_ID, 'foo_register', '')` calls with +`RegisterResolver::resolve('case')` (and equivalents for other +schemas). + +**Rationale:** Eliminate per-app slug-resolution logic; centralise in +OR with per-tenant override semantics (per ADR-022). + +#### Scenario: All register/schema slug lookups go through the resolver + +- **WHEN** a procest service needs the active case register slug +- **THEN** it SHALL call `RegisterResolver::resolve('case')` +- **AND** SHALL NOT call `IAppConfig::getValueString('procest', 'case_register', '')` directly. + +### Requirement: Procest SHALL consume nextcloud-vue multi-tenancy context + +Per `feedback_design-system-cd-first.md`-style alignment with the +nextcloud-vue contract, procest's case Vue components SHALL consume +the `multi-tenancy-context` composable so that store calls and +register-resolver calls are tenant-scoped. + +**Rationale:** Audit ref: `01-code-cleanup.md` notes +`createObjectStore('object', { plugins: [...] })` is already wired — +this requirement formalises it as the contract and adds the +`useTenantContext` citation. + +#### Scenario: Case store uses createObjectStore + +- **WHEN** the case store is instantiated +- **THEN** it SHALL use `createObjectStore('object', { plugins: [...] })` + exactly as currently in `src/store/modules/object.js`. +- **AND** SHALL include the `filesPlugin`, `auditTrailsPlugin`, and + `relationsPlugin`. + +#### Scenario: Case detail components honour tenant context + +- **WHEN** a case detail component renders +- **THEN** it SHALL resolve the active tenant via + `useTenantContext` +- **AND** SHALL pass the tenant identifier to register-resolver calls. + +## MODIFIED Requirements + +### Requirement: Case-management spec is the canonical lifecycle home + +The previously distributed lifecycle requirements (across +`case-management`, `parafering-actions`, `parafering-audit-trail`, +`parafeerroute-engine`) are consolidated into a single +`case-management/spec.md`. The other three specs are retired with +closing notes. + +**Rationale:** Audit ref: `02-spec-rewrite.md:96–105` — procest carries +~4 specs of state-machine duplication. + +#### Scenario: Three sibling specs are retired with closing notes + +- **WHEN** `parafering-actions/spec.md` is opened +- **THEN** it SHALL begin with a "**RETIRED — see + `case-management/spec.md`**" note pointing to the consolidated home. +- **AND** the same SHALL apply to `parafering-audit-trail/spec.md` and + `parafeerroute-engine/spec.md`. + +#### Scenario: Migration map preserves no-information-loss + +- **WHEN** `case-management/spec.md` is read +- **THEN** it SHALL include a "Migration map" appendix listing each + retired spec's section → new section, ensuring no requirement is + silently dropped. + +### Requirement: Bezwaar-lifecycle spec uses x-openregister-calculations + +`openspec/specs/bezwaar-lifecycle/spec.md:88,95` is updated so the AWB +deadline calculation requirement is declarative +(`x-openregister-calculations`) rather than implying a +`BezwaarDeadlineService` PHP class. + +**Rationale:** Audit ref: `04-hardcoded.md:97`. + +#### Scenario: AWB articles cited in calculation entries + +- **WHEN** the bezwaar schema is loaded +- **THEN** each AWB-derived calculation entry SHALL include a + `legalSource` field naming the AWB article (e.g. `AWB Art. 7:10`). + +### Requirement: Voorstel-management spec retro-documents the lifecycle + +`openspec/specs/voorstel-management/spec.md` is updated to retro- +document the consolidated lifecycle annotation rather than describing +a custom paraferingflow. + +**Rationale:** Audit ref: `04-hardcoded.md:99`. + +#### Scenario: Voorstel-management cites the lifecycle annotation + +- **WHEN** the voorstel-management spec is read +- **THEN** it SHALL cite `case-management/spec.md` § Lifecycle + annotation as the authoritative source for paraferingflow rules. + +### Requirement: Automatic-actions spec uses lifecycle annotation + +`openspec/specs/automatic-actions/spec.md` is updated so per-status +branching is expressed as lifecycle transitions rather than a +per-status PHP service. + +**Rationale:** Audit ref: `04-hardcoded.md:98`. + +## REMOVED Requirements + +### Requirement: Custom parafering state machine in PHP + +**Removed.** The previous requirement (implied by +`ParaferingService.php:43-353` and described across four specs) for a +hand-maintained PHP state machine with `STATUS_*` constants and +guarded transition methods is removed. Replaced by ADDED +`x-openregister-lifecycle` annotation requirement above. + +**Rationale:** Audit ref: `02-spec-rewrite.md:64,66,67; +04-hardcoded.md:85–86`. + +### Requirement: Custom parafering audit trail + +**Removed.** The previous requirement for a bespoke parafeeractie +versioning table is removed. Replaced by consumption of OR's +`audit-trail-immutable` capability with delegation tracking +(`actorType`, `onBehalfOf`) recorded as audit context. + +**Rationale:** Audit ref: `02-spec-rewrite.md:65; +01-code-cleanup.md`. + +### Requirement: Custom parafeerroute engine + +**Removed.** The previous requirement for a bespoke route-modification +engine is removed. Replaced by lifecycle transitions with `requires` +guards encoding skip-step / ad-hoc-step rules. + +**Rationale:** Audit ref: `02-spec-rewrite.md:67`. + +### Requirement: Custom case-status transition tables + +**Removed.** The previous requirement for `CaseStatus` and +`CaseTransition` database tables is removed. State and transitions +are declared as schema annotation; OR provides the engine. + +**Rationale:** Audit ref: `02-spec-rewrite.md:64`. + +### Requirement: Hand-rolled parafering notification service + +**Removed.** The previous requirement implied by +`ParaferingNotificationService.php` for three hand-rolled +`notificationManager->createNotification()` calls per transition is +removed. Replaced by `x-openregister-notifications` rules. + +**Rationale:** Audit ref: `04-hardcoded.md:87`. + +### Requirement: PHP-built case email subject + +**Removed.** The previous requirement implied by +`CaseEmailService.php:92` for a PHP-built `setSubject()` call is +removed. Replaced by schema-declared notification copy via i18n keys. + +**Rationale:** Audit ref: `04-hardcoded.md:93`. + +## Deferred to follow-up implementation change + +The following code-side moves are referenced by this spec but are NOT +performed here (this change is spec-only): + +1. Replace `lib/Service/ParaferingService.php:43-353` guarded + transitions with calls to OR's lifecycle engine. Convert + `ParaferingService` into a thin facade. +2. Delete `lib/Service/ParaferingNotificationService.php` (subsumed by + OR's `AnnotationNotificationDispatcher`). +3. Move `lib/Service/CaseEmailService.php:92` PHP-built subject to + schema-declared `x-openregister-notifications`. +4. Move `lib/BackgroundJob/ShareMaintenanceJob.php:42` + `REMINDER_DAYS = 3` to admin config. +5. Move `lib/Controller/MetricsController.php:43,48` `CACHE_TTL_*` to + admin config. +6. Move `lib/Service/ZgwDocumentService.php:45` `STORAGE_BASE` to + admin config (per-tenant override). +7. Fix the `lib/Service/ZgwDocumentService.php:312` + `getUserFolder('admin')` fallback — throw on missing user, never + silently fall back to admin's home folder. +8. Replace `IAppConfig::getValueString('procest', 'case_register', '')` + call sites (when present) with `RegisterResolver::resolve('case')` + once the OR resolver capability lands. +9. Audit `lib/Repair/LoadDefaultZgwMappings.php:1535-1550` for tenant + safety; propose a tenant-aware seed. +10. Add the manifest.json sketched in `design.md`. + +The follow-up change SHALL reference this spec and SHALL ensure the +annotation shapes here are honoured exactly. + +## Out of scope + +- VNG ZGW protocol code (`NotificatieService`, `ZrcController`, + `BrcController`, `ZtcController`, `NrcController`, + `ZgwZtcRulesService`, `ZgwDrcRulesService`, `VERTROUWELIJKHEID_LEVELS`, + `AFLEIDINGSWIJZE_*`) — KEEP app-local. +- ZGW Zaak mapping spec (`zgw-api-mapping/spec.md`) — domain-legitimate. +- Case-type versioning (`zaaktype-versioning/spec.md`) — domain-specific. +- Bezwaar advisory committee, hearing, decision specs — bezwaar-specific + workflow detail; only `bezwaar-lifecycle/spec.md` deadline + calculation is touched here. + +## Acceptance criteria + +- [ ] Case schema declares `x-openregister-lifecycle` covering all 6 + STATUS_* values and all transitions in + `ParaferingService.php:158-353`. +- [ ] Case schema declares `x-openregister-notifications` covering all + 3 `ParaferingNotificationService` call sites + the + `CaseEmailService::setSubject()` path. +- [ ] Bezwaar schema declares `x-openregister-calculations` for AWB + 6:7, 6:8, 7:10, 7:24. +- [ ] Case schema declares `x-openregister-aggregations` referenced + by parafering-dashboard and case-dashboard-view. +- [ ] All user-facing strings flow through OR i18n source of truth + (Dutch + English minimum). +- [ ] Manifest sketched with `dependencies: ["openregister", + "openconnector"]`. +- [ ] Three sibling specs retired with closing notes; migration map + embedded in consolidated spec. +- [ ] ZGW protocol boundary explicitly preserved. +- [ ] No code is modified by this change. diff --git a/openspec/changes/procest-adopt-or-abstractions/tasks.md b/openspec/changes/procest-adopt-or-abstractions/tasks.md new file mode 100644 index 0000000..1c865b9 --- /dev/null +++ b/openspec/changes/procest-adopt-or-abstractions/tasks.md @@ -0,0 +1,338 @@ +# Tasks — procest adopts OR abstractions + +> **Spec-only change.** All tasks below produce or modify spec files, +> closing notes, manifest sketches, or annotation designs. No code is +> modified by this change. Code refactor tasks are listed for traceability +> but are deferred to follow-up opsx changes. +> +> Audit references throughout: `.claude/audit-2026-05-03/01-code-cleanup.md`, +> `02-spec-rewrite.md`, `04-hardcoded.md`. + +## Phase 1 — Lifecycle annotation design + +Sketch the `x-openregister-lifecycle` annotation that consolidates the +parafering state machine currently spread across four specs and ~200 LOC +of `ParaferingService.php`. + +- [ ] 1.1 Enumerate the six STATUS_* values from + `lib/Service/ParaferingService.php:43-68` and map each to a lifecycle + state name (lowercase snake_case): + - [ ] STATUS_CONCEPT → `concept` + - [ ] STATUS_IN_PARAFERING → `in_parafering` + - [ ] STATUS_TERUGGESTUURD → `teruggestuurd` + - [ ] STATUS_GEPARAFEERD → `geparafeerd` + - [ ] STATUS_AANGEBODEN → `aangeboden` + - [ ] STATUS_BESLOTEN → `besloten` +- [ ] 1.2 Enumerate the guarded transitions from + `ParaferingService.php:158-353` and express each as a transition rule + with a `requires` guard (for the `if status !== ...` checks). +- [ ] 1.3 Map each parafering action (`advies`, `parafering`, + `accordering`) to a transition action name (cross-reference + `parafering-actions/spec.md`). +- [ ] 1.4 Map ad-hoc step / skip-step semantics from + `parafeerroute-engine/spec.md` to lifecycle `requires` guards. +- [ ] 1.5 Define role-based authorization per transition (ADR-023): + which roles may invoke each transition. +- [ ] 1.6 Define audit-recording requirements per transition (cite OR + `audit-trail-immutable`): actorType, onBehalfOf, comment, timestamp. +- [ ] 1.7 Capture delegation semantics (currently in + `parafering-audit-trail/spec.md`): `actorType`, `onBehalfOf` recorded + as audit context, not as separate fields on the case. +- [ ] 1.8 Write the full JSON annotation example into `design.md` § + "Lifecycle annotation" with before/after sketch. + +## Phase 2 — Notifications annotation design + +Sketch `x-openregister-notifications` rules replacing +`ParaferingNotificationService.php`. + +- [ ] 2.1 Enumerate the three `notificationManager->createNotification()` + call sites in `ParaferingNotificationService.php:64,109,153` and + identify the trigger transition for each. +- [ ] 2.2 Map each notification's: recipient (by role / user reference), + subject template, body template, link. +- [ ] 2.3 Express subject / body as i18n keys (per ADR-025); cite + `i18n-source-of-truth`. +- [ ] 2.4 Define notification triggering condition (always-on / + conditional / role-filtered). +- [ ] 2.5 Replace `CaseEmailService::setSubject()` PHP-built copy + (`lib/Service/CaseEmailService.php:92`) with schema-declared + notification copy in the annotation. +- [ ] 2.6 Write the full JSON notification annotation into `design.md` § + "Notifications annotation". +- [ ] 2.7 Confirm OR's `x-openregister-notifications` (already exists per + `04-hardcoded.md:127–129`) supports all three rules; if a gap, add a + note to the OR-side spec. + +## Phase 3 — ParaferingService refactor design (deferred to code change) + +Document target shape of `ParaferingService` after lifecycle adoption. +**No code changes here.** + +- [ ] 3.1 Identify which methods of `ParaferingService` survive: methods + that translate domain action names to lifecycle transitions (thin + facade), Dutch-business-rule methods that aren't pure state. +- [ ] 3.2 Identify which methods are deleted: all six `STATUS_*` + constants, all guarded transition `if`-blocks at lines 158–353. +- [ ] 3.3 Document the call shape: `paraferingEngine->transition(case, + 'advies_geven', $context)` calling OR lifecycle engine. +- [ ] 3.4 Note that the spec change does NOT include the refactor; + reference to follow-up implementation change `procest-implement-or-lifecycle`. +- [ ] 3.5 Add a "future code change" note to `case-management/spec.md` + pointing at the refactor task. + +## Phase 4 — ParaferingNotificationService refactor design (deferred) + +Document target shape: file is **deleted**. + +- [ ] 4.1 Confirm all three call sites map to annotation rules from + Phase 2. +- [ ] 4.2 Verify OR's `AnnotationNotificationDispatcher` is the + consuming component (cross-reference OR `notificatie-engine` spec). +- [ ] 4.3 Document deletion intent in `case-management/spec.md` and in + `tasks.md` for the follow-up code change. + +## Phase 5 — Spec retirements (the consolidation) + +The big move. Three specs fold into `case-management`. Each retired spec +gets a closing note pointing to the consolidated spec. + +- [ ] 5.1 Rewrite `openspec/specs/case-management/spec.md`: + - [ ] 5.1.1 Open with: "Procest case lifecycle is expressed as an OR + `x-openregister-lifecycle` annotation on the case schema. This spec + is the canonical home; previously sibling specs + (`parafering-actions`, `parafering-audit-trail`, + `parafeerroute-engine`) are retired and their requirements live + here." + - [ ] 5.1.2 Cite OR capabilities: `object-lifecycle`, + `audit-trail-immutable`, `notificatie-engine`, `register-i18n`. + - [ ] 5.1.3 Embed the full lifecycle annotation example from Phase 1. + - [ ] 5.1.4 Embed the notification annotation example from Phase 2. + - [ ] 5.1.5 Specify guard semantics (skip-step, ad-hoc step) inherited + from `parafeerroute-engine`. + - [ ] 5.1.6 Specify audit context (actorType, onBehalfOf, comment) + inherited from `parafering-audit-trail`. + - [ ] 5.1.7 Specify action-specific authorization inherited from + `parafering-actions`. + - [ ] 5.1.8 Add explicit cross-references to retired specs. +- [ ] 5.2 Add closing note to + `openspec/specs/parafering-actions/spec.md`: + - [ ] 5.2.1 Insert frontmatter or top-of-file: "**RETIRED — see + `case-management/spec.md`.** Action-specific guards and audit + recording moved to the consolidated lifecycle annotation." + - [ ] 5.2.2 Preserve the original requirements as historical + appendix; flag what unique requirement (if any) wasn't carried over + and address it. +- [ ] 5.3 Add closing note to + `openspec/specs/parafering-audit-trail/spec.md`: + - [ ] 5.3.1 Insert: "**RETIRED — consume OR + `audit-trail-immutable`.** Delegation tracking + (`actorType`, `onBehalfOf`) is recorded as audit context." + - [ ] 5.3.2 Preserve original requirements; ensure delegation tracking + is in the consolidated spec. +- [ ] 5.4 Add closing note to + `openspec/specs/parafeerroute-engine/spec.md`: + - [ ] 5.4.1 Insert: "**RETIRED — route modification expressed as + `requires` guards on lifecycle transitions.** Skip-step / ad-hoc + step semantics live in the consolidated lifecycle annotation." + - [ ] 5.4.2 Preserve original requirements; ensure skip / ad-hoc + semantics are in the consolidated spec. +- [ ] 5.5 Verify no other procest spec references the retired specs by + path; if found, update to point to `case-management`. +- [ ] 5.6 Add a "Migration map" appendix to + `case-management/spec.md` listing each retired spec's section → new + section in the consolidated spec. + +## Phase 6 — Register-resolver consumption + +procest must adopt OR's `register-resolver-service` (`04-hardcoded.md:136–138`) +when it lands. Spec-level adoption only here. + +- [ ] 6.1 Inventory current procest call sites that resolve register / + schema slugs from `IAppConfig` (procest is lighter on this than + pipelinq's 8 sites; document the actual count). +- [ ] 6.2 Cite the OR `register-resolver-service` capability in + `case-management/spec.md` and (forward-looking) in + `openregister-integration/spec.md`. +- [ ] 6.3 Note the follow-up code change will replace direct + `getValueString(APP_ID, 'foo_register', '')` calls with + `RegisterResolver::resolve('case')`. +- [ ] 6.4 Document the per-tenant override semantics (per ADR-022 + + multi-tenancy-context). + +## Phase 7 — Bezwaar deadline calculation → x-openregister-calculations + +- [ ] 7.1 Read current `openspec/specs/bezwaar-lifecycle/spec.md:88,95` + ("system SHALL automatically calculate legal deadlines based on AWB + articles 6:7, 6:8, 7:10, 7:24"). +- [ ] 7.2 Replace prose calculation with declarative + `x-openregister-calculations` annotation example for each AWB article. +- [ ] 7.3 Cite OR computed-fields capability (per `RenderObject.php:1418`, + audit ref: `04-hardcoded.md:130–132`). +- [ ] 7.4 Note that `BezwaarDeadlineService` is no longer needed and + flag for the follow-up code change. +- [ ] 7.5 Cross-reference Forum Standaardisatie / Algemene wet + bestuursrecht (AWB) so calc rules are traceable to the legal source. +- [ ] 7.6 Update `openspec/specs/automatic-actions/spec.md` (audit ref: + `04-hardcoded.md:98`) — per-status branching uses lifecycle + annotation, not a per-status PHP service. +- [ ] 7.7 Update `openspec/specs/voorstel-management/spec.md` (audit + ref: `04-hardcoded.md:99`) — retro-document the lifecycle annotation + rather than describing a custom paraferingflow. + +## Phase 8 — Citation-only spec updates + +Three specs need OR-feature citations only — no rewrites. + +- [ ] 8.1 `openspec/specs/case-location/spec.md`: + - [ ] 8.1.1 Add citation block referencing OR `geo-metadata-kaart` + (which is in `openregister/openspec/changes/geo-metadata-kaart/`). + - [ ] 8.1.2 Note geo metadata is annotation-driven, not a custom + location service. +- [ ] 8.2 `openspec/specs/parafering-dashboard/spec.md`: + - [ ] 8.2.1 Add citation block referencing OR + `aggregations-backend-native` (in + `openregister/openspec/changes/aggregations-backend-native/`). + - [ ] 8.2.2 Express count-by-status queries as + `x-openregister-aggregations` annotations on the case schema. +- [ ] 8.3 `openspec/specs/case-dashboard-view/spec.md`: + - [ ] 8.3.1 Same OR aggregations citation. + - [ ] 8.3.2 Note KPI cards bind directly to the aggregations + endpoint; no custom dashboard service. + +## Phase 9 — Manifest adoption (ADR-024) + +- [ ] 9.1 Sketch a `manifest.json` for procest at the proposal level + (the manifest itself is created in the follow-up code change): + - [ ] 9.1.1 `tier: 2` (ramps to 3 once full lifecycle adoption lands). + - [ ] 9.1.2 `dependencies: ["openregister", "openconnector"]` (per + audit instruction; openconnector is needed for ZGW protocol bindings). + - [ ] 9.1.3 Routes generated from `appinfo/routes.php`; document + generation process. + - [ ] 9.1.4 `provides` block lists the case schema + parafering + lifecycle entry point. + - [ ] 9.1.5 `consumes` block lists the OR capabilities cited in + Phases 1–8. +- [ ] 9.2 Cite hydra `adopt-app-manifest` change. +- [ ] 9.3 Cite ADR-024 (`hydra/openspec/architecture/adr-024-app-manifest.md`). +- [ ] 9.4 Document manifest in `case-management/spec.md` and link from + `proposal.md`. +- [ ] 9.5 Audit the seed in `lib/Repair/LoadDefaultZgwMappings.php:1535-1550` + for tenant safety: + - [ ] 9.5.1 Document `procest-admin` group seed. + - [ ] 9.5.2 Document `userId='admin'` ownership seed. + - [ ] 9.5.3 Flag risk: in multi-tenant deployment, ownership crosses + tenants. Defer fix to a follow-up change with explicit tenant + propagation. + +## Phase 10 — ZgwDocumentService security fix design + +The `getUserFolder('admin')` fallback in +`lib/Service/ZgwDocumentService.php:312` is a security smell (audit ref: +`04-hardcoded.md:90`). Spec the fix; defer the code change. + +- [ ] 10.1 Document current behaviour: when `getUser()` returns null, + fall back to `getUserFolder('admin')`. This silently writes documents + into admin's home folder, bypassing per-user authorization. +- [ ] 10.2 Specify target behaviour: throw `\RuntimeException` (or + return 401) when no authenticated user is available. **Never** fall + back to admin. +- [ ] 10.3 Identify legitimate "system" callers (background jobs, + repair steps): they must use a system-context authentication, not + admin's home folder. +- [ ] 10.4 Document `STORAGE_BASE = 'procest/documenten'` (audit ref: + `04-hardcoded.md:90`) — should be admin-config (per-tenant override). +- [ ] 10.5 Add the fix as a follow-up change scope item. + +## Phase 11 — Hardcoded magic-number cleanup design + +Spec the moves; defer the code changes. + +- [ ] 11.1 `lib/BackgroundJob/ShareMaintenanceJob.php:42` — + `REMINDER_DAYS = 3`: + - [ ] 11.1.1 Move to admin-config (`procest_reminder_days`). + - [ ] 11.1.2 Document default value (3) as fallback only. +- [ ] 11.2 `lib/Controller/MetricsController.php:43,48` — + `CACHE_TTL_DEFAULT = 30`, `CACHE_TTL_OVERDUE = 60`: + - [ ] 11.2.1 Move to admin-config (`procest_metrics_cache_default`, + `procest_metrics_cache_overdue`). + - [ ] 11.2.2 Document defaults as fallback only. +- [ ] 11.3 `lib/Service/CaseEmailService.php:92` — + PHP-built `setSubject()`: + - [ ] 11.3.1 Replace with schema-declared notification copy via + `x-openregister-notifications` (covered in Phase 2). + - [ ] 11.3.2 i18n via the OR i18n source-of-truth. +- [ ] 11.4 Confirm ZGW protocol constants stay app-local (audit ref: + `04-hardcoded.md:144–152`): + - [ ] 11.4.1 `ZrcController::ZGW_API` — KEEP. + - [ ] 11.4.2 `BrcController::ZGW_API` — KEEP. + - [ ] 11.4.3 `ZtcController::ZGW_API` — KEEP. + - [ ] 11.4.4 `NrcController::ZGW_API`, `VERTROUWELIJKHEID_LEVELS` — + KEEP. + - [ ] 11.4.5 `ZgwZtcRulesService::AFLEIDINGSWIJZE_*` — KEEP (VNG + standard). + - [ ] 11.4.6 `NotificatieService` — KEEP (ZGW Notificaties API). + +## Phase 12 — i18n + multi-tenancy adoption + +- [ ] 12.1 Confirm `src/store/modules/object.js` uses + `createObjectStore('object', { plugins: [filesPlugin(), + auditTrailsPlugin(), relationsPlugin()] })` (audit ref: + `01-code-cleanup.md` confirms this is GOOD). +- [ ] 12.2 Add `useTenantContext` composable citation + (nextcloud-vue `multi-tenancy-context`) to relevant Vue components in + the spec. +- [ ] 12.3 Document i18n adoption in `case-management/spec.md`: + - [ ] 12.3.1 Cite `i18n-source-of-truth`. + - [ ] 12.3.2 Cite `i18n-api-language-negotiation`. + - [ ] 12.3.3 Confirm Dutch + English minimum (per + `feedback_i18n-requirement.md`). + - [ ] 12.3.4 Note case status labels, transition action labels, + notification subjects/bodies all flow through the OR i18n source of + truth. +- [ ] 12.4 Note `register-i18n` (`openregister/openspec/specs/register-i18n/`) + is the implementation home. + +## Phase 13 — NotificatieService boundary documentation + +`lib/Service/NotificatieService.php` is **legitimately ZGW-specific** and +must not be confused with OR's `AnnotationNotificationDispatcher`. + +- [ ] 13.1 Document in `design.md` § "Boundary preserved": + - [ ] 13.1.1 What `NotificatieService` does: dispatches messages to + the VNG ZGW Notificaties API (NRC) per Dutch government protocol. + - [ ] 13.1.2 What it does NOT do: handle OR-object lifecycle + notifications. + - [ ] 13.1.3 Why it stays: ZGW Notificaties API is a published + Dutch government standard with strict outbound message format. +- [ ] 13.2 Add a note in `case-management/spec.md` explicitly: + "ZGW Notificaties API outbound messages are dispatched by + `NotificatieService` (legitimately app-local). Lifecycle notifications + to NC users are dispatched by OR's notification engine via the + schema annotation." + +## Phase 14 — Cross-reference verification + +- [ ] 14.1 Grep for remaining references to retired specs across + procest/openspec; update each. +- [ ] 14.2 Confirm no broken citations after retirement. +- [ ] 14.3 Add a CHANGELOG note to procest/openspec for the + consolidation (so future audits can trace). +- [ ] 14.4 Update `openspec/app-config.json` (if present) to reflect + retired specs and consolidated spec. + +## Phase 15 — Acceptance review + +- [ ] 15.1 Verify all 4 NEEDS-REWRITE specs from `02-spec-rewrite.md` + are addressed. +- [ ] 15.2 Verify all 3 MISSING-OR-DEP specs cite OR features. +- [ ] 15.3 Verify the lifecycle annotation example is complete (6 + states, all transitions, all guards, all roles, all audit context). +- [ ] 15.4 Verify the notifications annotation example covers all 3 + current call sites. +- [ ] 15.5 Verify the bezwaar deadline calculation is declarative. +- [ ] 15.6 Verify manifest sketch is present. +- [ ] 15.7 Verify ZGW protocol boundary is documented. +- [ ] 15.8 Verify no code is modified. +- [ ] 15.9 Run `openspec validate` (if tooling exists) on the new and + modified specs.