From 42e76899fd4d2c5c24faf5cdbb1812a4ddc88196 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 9 Sep 2026 10:19:31 +0200 Subject: [PATCH] chore(openspec): archive 44 completed changes and repair the deltas that blocked them openspec/changes/ held 67 entries. 44 of them describe work that is in `development` already, so they move to the archive and their deltas land in openspec/specs/. 22 remain: 12 partly implemented, 4 superseded, 5 never started, 1 finished but missing the artefacts to archive with. Every change was verified against the code, not against its tasks file. Six of the 44 carried zero ticked boxes and had shipped anyway. Delta repairs made so the archives apply cleanly: - Five requirements written as MODIFIED against a spec that did not have them become ADDED (the flow-engine trio, contacts-you-can-find). - Two are genuine renames and get a RENAMED block (task-management's resume requirement, dmn-decision-tables' hit policies). - Four requirements were rewritten wholesale by their change, dropping scenarios that are now false. Each is a REMOVED plus an ADDED with the reason recorded, rather than a silent rewrite. - Five scenarios that were merely omitted are copied back into their MODIFIED block unchanged. - Two legacy `### REQ-00n:` headers in workflow-definition-model become `### Requirement:` so the delta can address them. - The two kanban scenarios collided with existing 006d/006e ids and are renumbered to 006f/006g. Pre-existing spec debt fixed on the way: brp-register, kvk-register, initiator-display and initiator-selection had no `## Purpose`, which made the archiver refuse requester-on-the-case. The 11 capability specs the archive created got a written Purpose instead of the TBD placeholder. `openspec validate --strict --all` reports no change and no spec made invalid by this commit, and four specs that were invalid before are valid now. --- .../2026-09-09-add-work-queue}/proposal.md | 0 .../specs/add-work-queue/spec.md | 0 .../2026-09-09-add-work-queue}/tasks.md | 0 .../proposal.md | 0 .../specs/case-flow-human-steps/spec.md | 2 +- .../specs/task-management/spec.md | 19 +- .../tasks.md | 0 .../proposal.md | 0 .../specs/realtime-updates-ui/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/case-flow-human-steps/spec.md | 4 +- .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-09-09-case-actions-menu}/design.md | 0 .../2026-09-09-case-actions-menu}/proposal.md | 0 .../specs/case-management/spec.md | 0 .../specs/workflow-definition-engine/spec.md | 0 .../2026-09-09-case-actions-menu}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/case-flow-human-steps/spec.md | 0 .../specs/status-transition-engine/spec.md | 0 .../specs/task-management/spec.md | 0 .../tasks.md | 0 .../2026-09-09-case-header}/design.md | 0 .../2026-09-09-case-header}/proposal.md | 0 .../specs/case-dashboard-view/spec.md | 0 .../2026-09-09-case-header}/tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/case-dashboard-view/spec.md | 0 .../specs/status-transition-engine/spec.md | 0 .../tasks.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/case-status-machinery/spec.md | 0 .../tasks.md | 0 .../2026-09-09-case-timeline}/design.md | 0 .../2026-09-09-case-timeline}/proposal.md | 0 .../specs/case-dashboard-view/spec.md | 0 .../2026-09-09-case-timeline}/tasks.md | 0 .../proposal.md | 0 .../specs/case-types/spec.md | 0 .../specs/zaaktype-versioning/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/case-type-navigation/spec.md | 0 .../2026-09-09-case-type-navigation}/tasks.md | 0 .../proposal.md | 0 .../specs/besluitvorming-leaf/spec.md | 0 .../tasks.md | 0 .../design.md | 0 .../proposal.md | 0 .../case-search-via-or-unified-search/spec.md | 25 ++ .../specs/initiator-display/spec.md | 4 +- .../tasks.md | 0 .../proposal.md | 0 .../specs/dmn-decision-tables/spec.md | 93 +++-- .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../contract-decision-delegation/spec.md | 44 +- .../tasks.md | 0 .../proposal.md | 0 .../specs/dossiq-store-surface/spec.md | 0 .../2026-09-09-dossiq-store-surface}/tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/financial-integration/spec.md | 13 + .../tasks.md | 0 .../.openspec.yaml | 0 .../2026-09-09-first-time-setup}/design.md | 0 .../2026-09-09-first-time-setup}/proposal.md | 0 .../specs/first-time-setup/spec.md | 0 .../2026-09-09-first-time-setup}/tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/friendly-case-create-form/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/dashboard/spec.md | 15 +- .../tasks.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/kcc-routing/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/lhs-decision-table/spec.md | 0 .../tasks.md | 0 .../2026-09-09-lists-that-answer}/proposal.md | 0 .../specs/case-management/spec.md | 0 .../specs/task-management/spec.md | 0 .../2026-09-09-lists-that-answer}/tasks.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/portal-contribution/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/supplier-portal/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/case-management/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../frontend-locale-completeness/spec.md | 0 .../tasks.md | 0 .../2026-09-09-one-case-list}/design.md | 0 .../2026-09-09-one-case-list}/proposal.md | 0 .../specs/case-bulk-status-transition/spec.md | 0 .../specs/case-management/spec.md | 0 .../specs/my-work/spec.md | 0 .../specs/signalering-widgets/spec.md | 0 .../specs/task-management/spec.md | 0 .../2026-09-09-one-case-list}/tasks.md | 0 .../2026-09-09-ori-removal}/design.md | 0 .../2026-09-09-ori-removal}/proposal.md | 0 .../specs/ori-removal/spec.md | 0 .../2026-09-09-ori-removal}/tasks.md | 0 .../.openspec.yaml | 0 .../2026-09-09-parties-on-the-case}/design.md | 0 .../proposal.md | 0 .../specs/role-routing-via-or-rbac/spec.md | 0 .../specs/roles-decisions/spec.md | 0 .../2026-09-09-parties-on-the-case}/tasks.md | 0 .../proposal.md | 0 .../specs/partner-organisations/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/performance-hardening/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 3 + .../proposal.md | 0 .../spec-changes-already-applied.md} | 10 + .../2026-09-09-proposals-are-cases}/tasks.md | 0 .../proposal.md | 0 .../specs/reassignment-bulk-action/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/frontend-build-hygiene/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/case-flow-human-steps/spec.md | 2 - .../tasks.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/brp-register/spec.md | 0 .../specs/initiator-display/spec.md | 0 .../specs/initiator-selection/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/case-history-surface/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/zgw-brc/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/case-dashboard-view/spec.md | 20 +- .../tasks.md | 0 .../proposal.md | 0 .../woo-publication-via-opencatalogi/spec.md | 0 .../tasks.md | 0 .../design.md | 0 .../proposal.md | 0 .../workflow-definitions-to-flow/spec.md | 0 .../tasks.md | 0 .../2026-09-09-workflow-variants}/design.md | 0 .../2026-09-09-workflow-variants}/proposal.md | 0 .../specs/vth-workflow-templates/spec.md | 19 + .../specs/workflow-definition-model/spec.md | 4 +- .../specs/workflow-variants/spec.md | 0 .../2026-09-09-workflow-variants}/tasks.md | 0 openspec/specs/add-work-queue/spec.md | 60 +++ openspec/specs/besluitvorming-leaf/spec.md | 64 +++ openspec/specs/brp-register/spec.md | 42 ++ .../specs/case-bulk-status-transition/spec.md | 71 ++++ openspec/specs/case-dashboard-view/spec.md | 270 +++++++++++- openspec/specs/case-flow-human-steps/spec.md | 383 ++++++++++++++++++ openspec/specs/case-history-surface/spec.md | 25 ++ openspec/specs/case-management/spec.md | 153 +++++++ .../case-search-via-or-unified-search/spec.md | 81 +++- openspec/specs/case-status-machinery/spec.md | 89 ++++ openspec/specs/case-type-navigation/spec.md | 60 +++ openspec/specs/case-types/spec.md | 44 ++ .../contract-decision-delegation/spec.md | 143 ++++--- openspec/specs/dashboard/spec.md | 29 +- openspec/specs/dmn-decision-tables/spec.md | 75 +++- openspec/specs/dossiq-store-surface/spec.md | 177 ++++++++ openspec/specs/financial-integration/spec.md | 41 +- openspec/specs/first-time-setup/spec.md | 53 +++ .../specs/friendly-case-create-form/spec.md | 117 ++++++ openspec/specs/frontend-build-hygiene/spec.md | 34 ++ .../frontend-locale-completeness/spec.md | 47 +++ openspec/specs/initiator-display/spec.md | 165 +++++++- openspec/specs/initiator-selection/spec.md | 90 +++- openspec/specs/kcc-routing/spec.md | 95 +++++ openspec/specs/kvk-register/spec.md | 6 + openspec/specs/lhs-decision-table/spec.md | 133 ++++++ openspec/specs/my-work/spec.md | 57 +++ openspec/specs/ori-removal/spec.md | 112 +++++ openspec/specs/partner-organisations/spec.md | 79 ++++ openspec/specs/performance-hardening/spec.md | 61 +++ openspec/specs/portal-contribution/spec.md | 91 +++++ openspec/specs/realtime-updates-ui/spec.md | 51 +++ .../specs/reassignment-bulk-action/spec.md | 81 ++++ .../specs/role-routing-via-or-rbac/spec.md | 39 ++ openspec/specs/roles-decisions/spec.md | 50 +++ openspec/specs/signalering-widgets/spec.md | 65 +++ .../specs/status-transition-engine/spec.md | 129 +++++- openspec/specs/supplier-portal/spec.md | 33 ++ openspec/specs/task-management/spec.md | 153 +++++++ openspec/specs/vth-workflow-templates/spec.md | 67 ++- .../woo-publication-via-opencatalogi/spec.md | 153 +++++++ .../specs/workflow-definition-engine/spec.md | 61 +++ .../specs/workflow-definition-model/spec.md | 17 +- .../workflow-definitions-to-flow/spec.md | 122 ++++++ openspec/specs/zaaktype-versioning/spec.md | 66 +++ openspec/specs/zgw-brc/spec.md | 126 ++++++ 229 files changed, 4209 insertions(+), 228 deletions(-) rename openspec/changes/{add-work-queue => archive/2026-09-09-add-work-queue}/proposal.md (100%) rename openspec/changes/{add-work-queue => archive/2026-09-09-add-work-queue}/specs/add-work-queue/spec.md (100%) rename openspec/changes/{add-work-queue => archive/2026-09-09-add-work-queue}/tasks.md (100%) rename openspec/changes/{adopt-flow-engine-consumer-seams => archive/2026-09-09-adopt-flow-engine-consumer-seams}/proposal.md (100%) rename openspec/changes/{adopt-flow-engine-consumer-seams => archive/2026-09-09-adopt-flow-engine-consumer-seams}/specs/case-flow-human-steps/spec.md (98%) rename openspec/changes/{adopt-flow-engine-consumer-seams => archive/2026-09-09-adopt-flow-engine-consumer-seams}/specs/task-management/spec.md (72%) rename openspec/changes/{adopt-flow-engine-consumer-seams => archive/2026-09-09-adopt-flow-engine-consumer-seams}/tasks.md (100%) rename openspec/changes/{adopt-live-updates-ui => archive/2026-09-09-adopt-live-updates-ui}/proposal.md (100%) rename openspec/changes/{adopt-live-updates-ui => archive/2026-09-09-adopt-live-updates-ui}/specs/realtime-updates-ui/spec.md (100%) rename openspec/changes/{adopt-live-updates-ui => archive/2026-09-09-adopt-live-updates-ui}/tasks.md (100%) rename openspec/changes/{askperson-recovers-a-missed-answer => archive/2026-09-09-askperson-recovers-a-missed-answer}/proposal.md (100%) rename openspec/changes/{askperson-recovers-a-missed-answer => archive/2026-09-09-askperson-recovers-a-missed-answer}/specs/case-flow-human-steps/spec.md (99%) rename openspec/changes/{askperson-recovers-a-missed-answer => archive/2026-09-09-askperson-recovers-a-missed-answer}/tasks.md (100%) rename openspec/changes/{case-actions-menu => archive/2026-09-09-case-actions-menu}/.openspec.yaml (100%) rename openspec/changes/{case-actions-menu => archive/2026-09-09-case-actions-menu}/design.md (100%) rename openspec/changes/{case-actions-menu => archive/2026-09-09-case-actions-menu}/proposal.md (100%) rename openspec/changes/{case-actions-menu => archive/2026-09-09-case-actions-menu}/specs/case-management/spec.md (100%) rename openspec/changes/{case-actions-menu => archive/2026-09-09-case-actions-menu}/specs/workflow-definition-engine/spec.md (100%) rename openspec/changes/{case-actions-menu => archive/2026-09-09-case-actions-menu}/tasks.md (100%) rename openspec/changes/{case-flow-human-steps => archive/2026-09-09-case-flow-human-steps}/.openspec.yaml (100%) rename openspec/changes/{case-flow-human-steps => archive/2026-09-09-case-flow-human-steps}/design.md (100%) rename openspec/changes/{case-flow-human-steps => archive/2026-09-09-case-flow-human-steps}/proposal.md (100%) rename openspec/changes/{case-flow-human-steps => archive/2026-09-09-case-flow-human-steps}/specs/case-flow-human-steps/spec.md (100%) rename openspec/changes/{case-flow-human-steps => archive/2026-09-09-case-flow-human-steps}/specs/status-transition-engine/spec.md (100%) rename openspec/changes/{case-flow-human-steps => archive/2026-09-09-case-flow-human-steps}/specs/task-management/spec.md (100%) rename openspec/changes/{case-flow-human-steps => archive/2026-09-09-case-flow-human-steps}/tasks.md (100%) rename openspec/changes/{case-header => archive/2026-09-09-case-header}/design.md (100%) rename openspec/changes/{case-header => archive/2026-09-09-case-header}/proposal.md (100%) rename openspec/changes/{case-header => archive/2026-09-09-case-header}/specs/case-dashboard-view/spec.md (100%) rename openspec/changes/{case-header => archive/2026-09-09-case-header}/tasks.md (100%) rename openspec/changes/{case-lifecycle-on-the-page => archive/2026-09-09-case-lifecycle-on-the-page}/.openspec.yaml (100%) rename openspec/changes/{case-lifecycle-on-the-page => archive/2026-09-09-case-lifecycle-on-the-page}/design.md (100%) rename openspec/changes/{case-lifecycle-on-the-page => archive/2026-09-09-case-lifecycle-on-the-page}/proposal.md (100%) rename openspec/changes/{case-lifecycle-on-the-page => archive/2026-09-09-case-lifecycle-on-the-page}/specs/case-dashboard-view/spec.md (100%) rename openspec/changes/{case-lifecycle-on-the-page => archive/2026-09-09-case-lifecycle-on-the-page}/specs/status-transition-engine/spec.md (100%) rename openspec/changes/{case-lifecycle-on-the-page => archive/2026-09-09-case-lifecycle-on-the-page}/tasks.md (100%) rename openspec/changes/{case-status-onto-engine-lifecycle => archive/2026-09-09-case-status-onto-engine-lifecycle}/design.md (100%) rename openspec/changes/{case-status-onto-engine-lifecycle => archive/2026-09-09-case-status-onto-engine-lifecycle}/proposal.md (100%) rename openspec/changes/{case-status-onto-engine-lifecycle => archive/2026-09-09-case-status-onto-engine-lifecycle}/specs/case-status-machinery/spec.md (100%) rename openspec/changes/{case-status-onto-engine-lifecycle => archive/2026-09-09-case-status-onto-engine-lifecycle}/tasks.md (100%) rename openspec/changes/{case-timeline => archive/2026-09-09-case-timeline}/design.md (100%) rename openspec/changes/{case-timeline => archive/2026-09-09-case-timeline}/proposal.md (100%) rename openspec/changes/{case-timeline => archive/2026-09-09-case-timeline}/specs/case-dashboard-view/spec.md (100%) rename openspec/changes/{case-timeline => archive/2026-09-09-case-timeline}/tasks.md (100%) rename openspec/changes/{case-type-authored-not-edited => archive/2026-09-09-case-type-authored-not-edited}/proposal.md (100%) rename openspec/changes/{case-type-authored-not-edited => archive/2026-09-09-case-type-authored-not-edited}/specs/case-types/spec.md (100%) rename openspec/changes/{case-type-authored-not-edited => archive/2026-09-09-case-type-authored-not-edited}/specs/zaaktype-versioning/spec.md (100%) rename openspec/changes/{case-type-authored-not-edited => archive/2026-09-09-case-type-authored-not-edited}/tasks.md (100%) rename openspec/changes/{case-type-navigation => archive/2026-09-09-case-type-navigation}/.openspec.yaml (100%) rename openspec/changes/{case-type-navigation => archive/2026-09-09-case-type-navigation}/design.md (100%) rename openspec/changes/{case-type-navigation => archive/2026-09-09-case-type-navigation}/proposal.md (100%) rename openspec/changes/{case-type-navigation => archive/2026-09-09-case-type-navigation}/specs/case-type-navigation/spec.md (100%) rename openspec/changes/{case-type-navigation => archive/2026-09-09-case-type-navigation}/tasks.md (100%) rename openspec/changes/{consume-decidesk-besluitvorming-leaf => archive/2026-09-09-consume-decidesk-besluitvorming-leaf}/proposal.md (100%) rename openspec/changes/{consume-decidesk-besluitvorming-leaf => archive/2026-09-09-consume-decidesk-besluitvorming-leaf}/specs/besluitvorming-leaf/spec.md (100%) rename openspec/changes/{consume-decidesk-besluitvorming-leaf => archive/2026-09-09-consume-decidesk-besluitvorming-leaf}/tasks.md (100%) rename openspec/changes/{contacts-you-can-find => archive/2026-09-09-contacts-you-can-find}/design.md (100%) rename openspec/changes/{contacts-you-can-find => archive/2026-09-09-contacts-you-can-find}/proposal.md (100%) rename openspec/changes/{contacts-you-can-find => archive/2026-09-09-contacts-you-can-find}/specs/case-search-via-or-unified-search/spec.md (79%) rename openspec/changes/{contacts-you-can-find => archive/2026-09-09-contacts-you-can-find}/specs/initiator-display/spec.md (99%) rename openspec/changes/{contacts-you-can-find => archive/2026-09-09-contacts-you-can-find}/tasks.md (100%) rename openspec/changes/{dossiq-consumes-shared-dmn => archive/2026-09-09-dossiq-consumes-shared-dmn}/proposal.md (100%) rename openspec/changes/{dossiq-consumes-shared-dmn => archive/2026-09-09-dossiq-consumes-shared-dmn}/specs/dmn-decision-tables/spec.md (69%) rename openspec/changes/{dossiq-consumes-shared-dmn => archive/2026-09-09-dossiq-consumes-shared-dmn}/tasks.md (100%) rename openspec/changes/{dossiq-delegation-via-events => archive/2026-09-09-dossiq-delegation-via-events}/.openspec.yaml (100%) rename openspec/changes/{dossiq-delegation-via-events => archive/2026-09-09-dossiq-delegation-via-events}/design.md (100%) rename openspec/changes/{dossiq-delegation-via-events => archive/2026-09-09-dossiq-delegation-via-events}/proposal.md (100%) rename openspec/changes/{dossiq-delegation-via-events => archive/2026-09-09-dossiq-delegation-via-events}/specs/contract-decision-delegation/spec.md (73%) rename openspec/changes/{dossiq-delegation-via-events => archive/2026-09-09-dossiq-delegation-via-events}/tasks.md (100%) rename openspec/changes/{dossiq-store-surface => archive/2026-09-09-dossiq-store-surface}/proposal.md (100%) rename openspec/changes/{dossiq-store-surface => archive/2026-09-09-dossiq-store-surface}/specs/dossiq-store-surface/spec.md (100%) rename openspec/changes/{dossiq-store-surface => archive/2026-09-09-dossiq-store-surface}/tasks.md (100%) rename openspec/changes/{enforce-dwangsom-callback-signature => archive/2026-09-09-enforce-dwangsom-callback-signature}/.openspec.yaml (100%) rename openspec/changes/{enforce-dwangsom-callback-signature => archive/2026-09-09-enforce-dwangsom-callback-signature}/proposal.md (100%) rename openspec/changes/{enforce-dwangsom-callback-signature => archive/2026-09-09-enforce-dwangsom-callback-signature}/specs/financial-integration/spec.md (77%) rename openspec/changes/{enforce-dwangsom-callback-signature => archive/2026-09-09-enforce-dwangsom-callback-signature}/tasks.md (100%) rename openspec/changes/{first-time-setup => archive/2026-09-09-first-time-setup}/.openspec.yaml (100%) rename openspec/changes/{first-time-setup => archive/2026-09-09-first-time-setup}/design.md (100%) rename openspec/changes/{first-time-setup => archive/2026-09-09-first-time-setup}/proposal.md (100%) rename openspec/changes/{first-time-setup => archive/2026-09-09-first-time-setup}/specs/first-time-setup/spec.md (100%) rename openspec/changes/{first-time-setup => archive/2026-09-09-first-time-setup}/tasks.md (100%) rename openspec/changes/{friendly-case-create-form => archive/2026-09-09-friendly-case-create-form}/.openspec.yaml (100%) rename openspec/changes/{friendly-case-create-form => archive/2026-09-09-friendly-case-create-form}/proposal.md (100%) rename openspec/changes/{friendly-case-create-form => archive/2026-09-09-friendly-case-create-form}/specs/friendly-case-create-form/spec.md (100%) rename openspec/changes/{friendly-case-create-form => archive/2026-09-09-friendly-case-create-form}/tasks.md (100%) rename openspec/changes/{kanban-board-keyboard-status-transition => archive/2026-09-09-kanban-board-keyboard-status-transition}/.openspec.yaml (100%) rename openspec/changes/{kanban-board-keyboard-status-transition => archive/2026-09-09-kanban-board-keyboard-status-transition}/proposal.md (100%) rename openspec/changes/{kanban-board-keyboard-status-transition => archive/2026-09-09-kanban-board-keyboard-status-transition}/specs/dashboard/spec.md (80%) rename openspec/changes/{kanban-board-keyboard-status-transition => archive/2026-09-09-kanban-board-keyboard-status-transition}/tasks.md (100%) rename openspec/changes/{kcc-routing-onto-or-decision-tables => archive/2026-09-09-kcc-routing-onto-or-decision-tables}/design.md (100%) rename openspec/changes/{kcc-routing-onto-or-decision-tables => archive/2026-09-09-kcc-routing-onto-or-decision-tables}/proposal.md (100%) rename openspec/changes/{kcc-routing-onto-or-decision-tables => archive/2026-09-09-kcc-routing-onto-or-decision-tables}/specs/kcc-routing/spec.md (100%) rename openspec/changes/{kcc-routing-onto-or-decision-tables => archive/2026-09-09-kcc-routing-onto-or-decision-tables}/tasks.md (100%) rename openspec/changes/{lhs-matrix-is-a-decision-table => archive/2026-09-09-lhs-matrix-is-a-decision-table}/proposal.md (100%) rename openspec/changes/{lhs-matrix-is-a-decision-table => archive/2026-09-09-lhs-matrix-is-a-decision-table}/specs/lhs-decision-table/spec.md (100%) rename openspec/changes/{lhs-matrix-is-a-decision-table => archive/2026-09-09-lhs-matrix-is-a-decision-table}/tasks.md (100%) rename openspec/changes/{lists-that-answer => archive/2026-09-09-lists-that-answer}/proposal.md (100%) rename openspec/changes/{lists-that-answer => archive/2026-09-09-lists-that-answer}/specs/case-management/spec.md (100%) rename openspec/changes/{lists-that-answer => archive/2026-09-09-lists-that-answer}/specs/task-management/spec.md (100%) rename openspec/changes/{lists-that-answer => archive/2026-09-09-lists-that-answer}/tasks.md (100%) rename openspec/changes/{move-portals-to-portaliq => archive/2026-09-09-move-portals-to-portaliq}/design.md (100%) rename openspec/changes/{move-portals-to-portaliq => archive/2026-09-09-move-portals-to-portaliq}/proposal.md (100%) rename openspec/changes/{move-portals-to-portaliq => archive/2026-09-09-move-portals-to-portaliq}/specs/portal-contribution/spec.md (100%) rename openspec/changes/{move-portals-to-portaliq => archive/2026-09-09-move-portals-to-portaliq}/tasks.md (100%) rename openspec/changes/{namespace-the-case-supplier-invoice => archive/2026-09-09-namespace-the-case-supplier-invoice}/proposal.md (100%) rename openspec/changes/{namespace-the-case-supplier-invoice => archive/2026-09-09-namespace-the-case-supplier-invoice}/specs/supplier-portal/spec.md (100%) rename openspec/changes/{namespace-the-case-supplier-invoice => archive/2026-09-09-namespace-the-case-supplier-invoice}/tasks.md (100%) rename openspec/changes/{namespace-the-case-task => archive/2026-09-09-namespace-the-case-task}/proposal.md (100%) rename openspec/changes/{namespace-the-case-task => archive/2026-09-09-namespace-the-case-task}/specs/case-management/spec.md (100%) rename openspec/changes/{namespace-the-case-task => archive/2026-09-09-namespace-the-case-task}/tasks.md (100%) rename openspec/changes/{nl-locale-coverage-gap-and-dutch-keys => archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys}/.openspec.yaml (100%) rename openspec/changes/{nl-locale-coverage-gap-and-dutch-keys => archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys}/proposal.md (100%) rename openspec/changes/{nl-locale-coverage-gap-and-dutch-keys => archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys}/specs/frontend-locale-completeness/spec.md (100%) rename openspec/changes/{nl-locale-coverage-gap-and-dutch-keys => archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys}/tasks.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/design.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/proposal.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/specs/case-bulk-status-transition/spec.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/specs/case-management/spec.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/specs/my-work/spec.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/specs/signalering-widgets/spec.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/specs/task-management/spec.md (100%) rename openspec/changes/{one-case-list => archive/2026-09-09-one-case-list}/tasks.md (100%) rename openspec/changes/{ori-removal => archive/2026-09-09-ori-removal}/design.md (100%) rename openspec/changes/{ori-removal => archive/2026-09-09-ori-removal}/proposal.md (100%) rename openspec/changes/{ori-removal => archive/2026-09-09-ori-removal}/specs/ori-removal/spec.md (100%) rename openspec/changes/{ori-removal => archive/2026-09-09-ori-removal}/tasks.md (100%) rename openspec/changes/{parties-on-the-case => archive/2026-09-09-parties-on-the-case}/.openspec.yaml (100%) rename openspec/changes/{parties-on-the-case => archive/2026-09-09-parties-on-the-case}/design.md (100%) rename openspec/changes/{parties-on-the-case => archive/2026-09-09-parties-on-the-case}/proposal.md (100%) rename openspec/changes/{parties-on-the-case => archive/2026-09-09-parties-on-the-case}/specs/role-routing-via-or-rbac/spec.md (100%) rename openspec/changes/{parties-on-the-case => archive/2026-09-09-parties-on-the-case}/specs/roles-decisions/spec.md (100%) rename openspec/changes/{parties-on-the-case => archive/2026-09-09-parties-on-the-case}/tasks.md (100%) rename openspec/changes/{partners-are-organisations => archive/2026-09-09-partners-are-organisations}/proposal.md (100%) rename openspec/changes/{partners-are-organisations => archive/2026-09-09-partners-are-organisations}/specs/partner-organisations/spec.md (100%) rename openspec/changes/{partners-are-organisations => archive/2026-09-09-partners-are-organisations}/tasks.md (100%) rename openspec/changes/{performance-hardening-audit-log-and-boot => archive/2026-09-09-performance-hardening-audit-log-and-boot}/.openspec.yaml (100%) rename openspec/changes/{performance-hardening-audit-log-and-boot => archive/2026-09-09-performance-hardening-audit-log-and-boot}/proposal.md (100%) rename openspec/changes/{performance-hardening-audit-log-and-boot => archive/2026-09-09-performance-hardening-audit-log-and-boot}/specs/performance-hardening/spec.md (100%) rename openspec/changes/{performance-hardening-audit-log-and-boot => archive/2026-09-09-performance-hardening-audit-log-and-boot}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-09-09-proposals-are-cases/.openspec.yaml rename openspec/changes/{proposals-are-cases => archive/2026-09-09-proposals-are-cases}/proposal.md (100%) rename openspec/changes/{proposals-are-cases/specs/besluitvorming-workflow/spec.md => archive/2026-09-09-proposals-are-cases/spec-changes-already-applied.md} (86%) rename openspec/changes/{proposals-are-cases => archive/2026-09-09-proposals-are-cases}/tasks.md (100%) rename openspec/changes/{reassignment-is-a-bulk-action => archive/2026-09-09-reassignment-is-a-bulk-action}/proposal.md (100%) rename openspec/changes/{reassignment-is-a-bulk-action => archive/2026-09-09-reassignment-is-a-bulk-action}/specs/reassignment-bulk-action/spec.md (100%) rename openspec/changes/{reassignment-is-a-bulk-action => archive/2026-09-09-reassignment-is-a-bulk-action}/tasks.md (100%) rename openspec/changes/{remove-unused-map-clustering-dependency => archive/2026-09-09-remove-unused-map-clustering-dependency}/.openspec.yaml (100%) rename openspec/changes/{remove-unused-map-clustering-dependency => archive/2026-09-09-remove-unused-map-clustering-dependency}/proposal.md (100%) rename openspec/changes/{remove-unused-map-clustering-dependency => archive/2026-09-09-remove-unused-map-clustering-dependency}/specs/frontend-build-hygiene/spec.md (100%) rename openspec/changes/{remove-unused-map-clustering-dependency => archive/2026-09-09-remove-unused-map-clustering-dependency}/tasks.md (100%) rename openspec/changes/{requestdecision-recovers-a-missed-conclusion => archive/2026-09-09-requestdecision-recovers-a-missed-conclusion}/proposal.md (100%) rename openspec/changes/{requestdecision-recovers-a-missed-conclusion => archive/2026-09-09-requestdecision-recovers-a-missed-conclusion}/specs/case-flow-human-steps/spec.md (99%) rename openspec/changes/{requestdecision-recovers-a-missed-conclusion => archive/2026-09-09-requestdecision-recovers-a-missed-conclusion}/tasks.md (100%) rename openspec/changes/{requester-on-the-case => archive/2026-09-09-requester-on-the-case}/design.md (100%) rename openspec/changes/{requester-on-the-case => archive/2026-09-09-requester-on-the-case}/proposal.md (100%) rename openspec/changes/{requester-on-the-case => archive/2026-09-09-requester-on-the-case}/specs/brp-register/spec.md (100%) rename openspec/changes/{requester-on-the-case => archive/2026-09-09-requester-on-the-case}/specs/initiator-display/spec.md (100%) rename openspec/changes/{requester-on-the-case => archive/2026-09-09-requester-on-the-case}/specs/initiator-selection/spec.md (100%) rename openspec/changes/{requester-on-the-case => archive/2026-09-09-requester-on-the-case}/tasks.md (100%) rename openspec/changes/{retire-status-history-page => archive/2026-09-09-retire-status-history-page}/.openspec.yaml (100%) rename openspec/changes/{retire-status-history-page => archive/2026-09-09-retire-status-history-page}/design.md (100%) rename openspec/changes/{retire-status-history-page => archive/2026-09-09-retire-status-history-page}/proposal.md (100%) rename openspec/changes/{retire-status-history-page => archive/2026-09-09-retire-status-history-page}/specs/case-history-surface/spec.md (100%) rename openspec/changes/{retire-status-history-page => archive/2026-09-09-retire-status-history-page}/tasks.md (100%) rename openspec/changes/{the-besluit-resolves-to-decidiqs-decision => archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision}/proposal.md (100%) rename openspec/changes/{the-besluit-resolves-to-decidiqs-decision => archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision}/specs/zgw-brc/spec.md (100%) rename openspec/changes/{the-besluit-resolves-to-decidiqs-decision => archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision}/tasks.md (100%) rename openspec/changes/{the-case-page-finished => archive/2026-09-09-the-case-page-finished}/proposal.md (100%) rename openspec/changes/{the-case-page-finished => archive/2026-09-09-the-case-page-finished}/specs/case-dashboard-view/spec.md (78%) rename openspec/changes/{the-case-page-finished => archive/2026-09-09-the-case-page-finished}/tasks.md (100%) rename openspec/changes/{woo-publication-in-process-object-writes => archive/2026-09-09-woo-publication-in-process-object-writes}/proposal.md (100%) rename openspec/changes/{woo-publication-in-process-object-writes => archive/2026-09-09-woo-publication-in-process-object-writes}/specs/woo-publication-via-opencatalogi/spec.md (100%) rename openspec/changes/{woo-publication-in-process-object-writes => archive/2026-09-09-woo-publication-in-process-object-writes}/tasks.md (100%) rename openspec/changes/{workflow-definitions-to-flow => archive/2026-09-09-workflow-definitions-to-flow}/design.md (100%) rename openspec/changes/{workflow-definitions-to-flow => archive/2026-09-09-workflow-definitions-to-flow}/proposal.md (100%) rename openspec/changes/{workflow-definitions-to-flow => archive/2026-09-09-workflow-definitions-to-flow}/specs/workflow-definitions-to-flow/spec.md (100%) rename openspec/changes/{workflow-definitions-to-flow => archive/2026-09-09-workflow-definitions-to-flow}/tasks.md (100%) rename openspec/changes/{workflow-variants => archive/2026-09-09-workflow-variants}/design.md (100%) rename openspec/changes/{workflow-variants => archive/2026-09-09-workflow-variants}/proposal.md (100%) rename openspec/changes/{workflow-variants => archive/2026-09-09-workflow-variants}/specs/vth-workflow-templates/spec.md (83%) rename openspec/changes/{workflow-variants => archive/2026-09-09-workflow-variants}/specs/workflow-definition-model/spec.md (94%) rename openspec/changes/{workflow-variants => archive/2026-09-09-workflow-variants}/specs/workflow-variants/spec.md (100%) rename openspec/changes/{workflow-variants => archive/2026-09-09-workflow-variants}/tasks.md (100%) create mode 100644 openspec/specs/add-work-queue/spec.md create mode 100644 openspec/specs/besluitvorming-leaf/spec.md create mode 100644 openspec/specs/case-flow-human-steps/spec.md create mode 100644 openspec/specs/case-history-surface/spec.md create mode 100644 openspec/specs/case-status-machinery/spec.md create mode 100644 openspec/specs/case-type-navigation/spec.md create mode 100644 openspec/specs/dossiq-store-surface/spec.md create mode 100644 openspec/specs/first-time-setup/spec.md create mode 100644 openspec/specs/friendly-case-create-form/spec.md create mode 100644 openspec/specs/frontend-build-hygiene/spec.md create mode 100644 openspec/specs/frontend-locale-completeness/spec.md create mode 100644 openspec/specs/kcc-routing/spec.md create mode 100644 openspec/specs/lhs-decision-table/spec.md create mode 100644 openspec/specs/ori-removal/spec.md create mode 100644 openspec/specs/partner-organisations/spec.md create mode 100644 openspec/specs/performance-hardening/spec.md create mode 100644 openspec/specs/portal-contribution/spec.md create mode 100644 openspec/specs/realtime-updates-ui/spec.md create mode 100644 openspec/specs/reassignment-bulk-action/spec.md create mode 100644 openspec/specs/workflow-definitions-to-flow/spec.md create mode 100644 openspec/specs/zgw-brc/spec.md diff --git a/openspec/changes/add-work-queue/proposal.md b/openspec/changes/archive/2026-09-09-add-work-queue/proposal.md similarity index 100% rename from openspec/changes/add-work-queue/proposal.md rename to openspec/changes/archive/2026-09-09-add-work-queue/proposal.md diff --git a/openspec/changes/add-work-queue/specs/add-work-queue/spec.md b/openspec/changes/archive/2026-09-09-add-work-queue/specs/add-work-queue/spec.md similarity index 100% rename from openspec/changes/add-work-queue/specs/add-work-queue/spec.md rename to openspec/changes/archive/2026-09-09-add-work-queue/specs/add-work-queue/spec.md diff --git a/openspec/changes/add-work-queue/tasks.md b/openspec/changes/archive/2026-09-09-add-work-queue/tasks.md similarity index 100% rename from openspec/changes/add-work-queue/tasks.md rename to openspec/changes/archive/2026-09-09-add-work-queue/tasks.md diff --git a/openspec/changes/adopt-flow-engine-consumer-seams/proposal.md b/openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/proposal.md similarity index 100% rename from openspec/changes/adopt-flow-engine-consumer-seams/proposal.md rename to openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/proposal.md diff --git a/openspec/changes/adopt-flow-engine-consumer-seams/specs/case-flow-human-steps/spec.md b/openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/specs/case-flow-human-steps/spec.md similarity index 98% rename from openspec/changes/adopt-flow-engine-consumer-seams/specs/case-flow-human-steps/spec.md rename to openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/specs/case-flow-human-steps/spec.md index 4cc4b88ce..c801133a7 100644 --- a/openspec/changes/adopt-flow-engine-consumer-seams/specs/case-flow-human-steps/spec.md +++ b/openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/specs/case-flow-human-steps/spec.md @@ -8,7 +8,7 @@ Flow-facing storage work relies on the engine's native runAs scoping; the local wrapper retires. -## MODIFIED Requirements +## ADDED Requirements ### Requirement: Flow storage work runs under the engine's native scoping diff --git a/openspec/changes/adopt-flow-engine-consumer-seams/specs/task-management/spec.md b/openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/specs/task-management/spec.md similarity index 72% rename from openspec/changes/adopt-flow-engine-consumer-seams/specs/task-management/spec.md rename to openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/specs/task-management/spec.md index 256d26e5c..1f43456f9 100644 --- a/openspec/changes/adopt-flow-engine-consumer-seams/specs/task-management/spec.md +++ b/openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/specs/task-management/spec.md @@ -7,6 +7,11 @@ The task-completion resume travels through the engine's guarded signal seam. +## RENAMED Requirements + +- FROM: `### Requirement: A task may hold a suspended flow run, and completing it resumes that run @e2e exclude the resume mechanism is asserted by service tests; the user-visible half is covered by the case-flow e2e journey` +- TO: `### Requirement: A completed task resumes its run through the guarded seam` + ## MODIFIED Requirements ### Requirement: A completed task resumes its run through the guarded seam @@ -41,7 +46,7 @@ the seam call shape is pinned by TaskCompletionResumeListenerTest. `@e2e exclude` the guard itself is OpenRegister's, mutation-tested there; the listener's obedience is unit-pinned (TaskCompletionResumeListenerTest). -#### Scenario: A task whose run has gone still completes quietly +#### Scenario: A task whose run has gone is still completable - **GIVEN** a completed task naming a run uuid the engine cannot resolve - **WHEN** the seam refuses with RUN_NOT_FOUND @@ -49,3 +54,15 @@ listener's obedience is unit-pinned (TaskCompletionResumeListenerTest). `@e2e exclude` requires deleting a run out from under a task mid-journey; unit-pinned (TaskCompletionResumeListenerTest). + +#### Scenario: A task without a run resumes nothing + +- **WHEN** a task recording no run is completed +- **THEN** the task is completed normally +- **AND** no run is resumed and no error is raised + +#### Scenario: Completing a task twice resumes once + +- **WHEN** an already-completed task is completed again +- **THEN** the run is not resumed a second time +- **AND** the run does not advance past the step twice diff --git a/openspec/changes/adopt-flow-engine-consumer-seams/tasks.md b/openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/tasks.md similarity index 100% rename from openspec/changes/adopt-flow-engine-consumer-seams/tasks.md rename to openspec/changes/archive/2026-09-09-adopt-flow-engine-consumer-seams/tasks.md diff --git a/openspec/changes/adopt-live-updates-ui/proposal.md b/openspec/changes/archive/2026-09-09-adopt-live-updates-ui/proposal.md similarity index 100% rename from openspec/changes/adopt-live-updates-ui/proposal.md rename to openspec/changes/archive/2026-09-09-adopt-live-updates-ui/proposal.md diff --git a/openspec/changes/adopt-live-updates-ui/specs/realtime-updates-ui/spec.md b/openspec/changes/archive/2026-09-09-adopt-live-updates-ui/specs/realtime-updates-ui/spec.md similarity index 100% rename from openspec/changes/adopt-live-updates-ui/specs/realtime-updates-ui/spec.md rename to openspec/changes/archive/2026-09-09-adopt-live-updates-ui/specs/realtime-updates-ui/spec.md diff --git a/openspec/changes/adopt-live-updates-ui/tasks.md b/openspec/changes/archive/2026-09-09-adopt-live-updates-ui/tasks.md similarity index 100% rename from openspec/changes/adopt-live-updates-ui/tasks.md rename to openspec/changes/archive/2026-09-09-adopt-live-updates-ui/tasks.md diff --git a/openspec/changes/askperson-recovers-a-missed-answer/proposal.md b/openspec/changes/archive/2026-09-09-askperson-recovers-a-missed-answer/proposal.md similarity index 100% rename from openspec/changes/askperson-recovers-a-missed-answer/proposal.md rename to openspec/changes/archive/2026-09-09-askperson-recovers-a-missed-answer/proposal.md diff --git a/openspec/changes/askperson-recovers-a-missed-answer/specs/case-flow-human-steps/spec.md b/openspec/changes/archive/2026-09-09-askperson-recovers-a-missed-answer/specs/case-flow-human-steps/spec.md similarity index 99% rename from openspec/changes/askperson-recovers-a-missed-answer/specs/case-flow-human-steps/spec.md rename to openspec/changes/archive/2026-09-09-askperson-recovers-a-missed-answer/specs/case-flow-human-steps/spec.md index 570f8c57d..42b3ea115 100644 --- a/openspec/changes/askperson-recovers-a-missed-answer/specs/case-flow-human-steps/spec.md +++ b/openspec/changes/archive/2026-09-09-askperson-recovers-a-missed-answer/specs/case-flow-human-steps/spec.md @@ -9,7 +9,7 @@ A human step advances on the state of the task it created, so a completion whose wake was refused or lost is delivered by the next heartbeat instead of wedging the run forever. -## MODIFIED Requirements +## ADDED Requirements ### Requirement: An ask advances on its task, not on a signal @@ -90,8 +90,6 @@ read the answer given to the first. `@e2e exclude` the shape of a value passed between flow steps; pinned by AskPersonHeartbeatRecoveryTest and DossiqAskPersonNodeTest. -## ADDED Requirements - ### Requirement: A flow-engine test may drive the real engine The unit suite SHALL be able to run a test against OpenRegister's real flow diff --git a/openspec/changes/askperson-recovers-a-missed-answer/tasks.md b/openspec/changes/archive/2026-09-09-askperson-recovers-a-missed-answer/tasks.md similarity index 100% rename from openspec/changes/askperson-recovers-a-missed-answer/tasks.md rename to openspec/changes/archive/2026-09-09-askperson-recovers-a-missed-answer/tasks.md diff --git a/openspec/changes/case-actions-menu/.openspec.yaml b/openspec/changes/archive/2026-09-09-case-actions-menu/.openspec.yaml similarity index 100% rename from openspec/changes/case-actions-menu/.openspec.yaml rename to openspec/changes/archive/2026-09-09-case-actions-menu/.openspec.yaml diff --git a/openspec/changes/case-actions-menu/design.md b/openspec/changes/archive/2026-09-09-case-actions-menu/design.md similarity index 100% rename from openspec/changes/case-actions-menu/design.md rename to openspec/changes/archive/2026-09-09-case-actions-menu/design.md diff --git a/openspec/changes/case-actions-menu/proposal.md b/openspec/changes/archive/2026-09-09-case-actions-menu/proposal.md similarity index 100% rename from openspec/changes/case-actions-menu/proposal.md rename to openspec/changes/archive/2026-09-09-case-actions-menu/proposal.md diff --git a/openspec/changes/case-actions-menu/specs/case-management/spec.md b/openspec/changes/archive/2026-09-09-case-actions-menu/specs/case-management/spec.md similarity index 100% rename from openspec/changes/case-actions-menu/specs/case-management/spec.md rename to openspec/changes/archive/2026-09-09-case-actions-menu/specs/case-management/spec.md diff --git a/openspec/changes/case-actions-menu/specs/workflow-definition-engine/spec.md b/openspec/changes/archive/2026-09-09-case-actions-menu/specs/workflow-definition-engine/spec.md similarity index 100% rename from openspec/changes/case-actions-menu/specs/workflow-definition-engine/spec.md rename to openspec/changes/archive/2026-09-09-case-actions-menu/specs/workflow-definition-engine/spec.md diff --git a/openspec/changes/case-actions-menu/tasks.md b/openspec/changes/archive/2026-09-09-case-actions-menu/tasks.md similarity index 100% rename from openspec/changes/case-actions-menu/tasks.md rename to openspec/changes/archive/2026-09-09-case-actions-menu/tasks.md diff --git a/openspec/changes/case-flow-human-steps/.openspec.yaml b/openspec/changes/archive/2026-09-09-case-flow-human-steps/.openspec.yaml similarity index 100% rename from openspec/changes/case-flow-human-steps/.openspec.yaml rename to openspec/changes/archive/2026-09-09-case-flow-human-steps/.openspec.yaml diff --git a/openspec/changes/case-flow-human-steps/design.md b/openspec/changes/archive/2026-09-09-case-flow-human-steps/design.md similarity index 100% rename from openspec/changes/case-flow-human-steps/design.md rename to openspec/changes/archive/2026-09-09-case-flow-human-steps/design.md diff --git a/openspec/changes/case-flow-human-steps/proposal.md b/openspec/changes/archive/2026-09-09-case-flow-human-steps/proposal.md similarity index 100% rename from openspec/changes/case-flow-human-steps/proposal.md rename to openspec/changes/archive/2026-09-09-case-flow-human-steps/proposal.md diff --git a/openspec/changes/case-flow-human-steps/specs/case-flow-human-steps/spec.md b/openspec/changes/archive/2026-09-09-case-flow-human-steps/specs/case-flow-human-steps/spec.md similarity index 100% rename from openspec/changes/case-flow-human-steps/specs/case-flow-human-steps/spec.md rename to openspec/changes/archive/2026-09-09-case-flow-human-steps/specs/case-flow-human-steps/spec.md diff --git a/openspec/changes/case-flow-human-steps/specs/status-transition-engine/spec.md b/openspec/changes/archive/2026-09-09-case-flow-human-steps/specs/status-transition-engine/spec.md similarity index 100% rename from openspec/changes/case-flow-human-steps/specs/status-transition-engine/spec.md rename to openspec/changes/archive/2026-09-09-case-flow-human-steps/specs/status-transition-engine/spec.md diff --git a/openspec/changes/case-flow-human-steps/specs/task-management/spec.md b/openspec/changes/archive/2026-09-09-case-flow-human-steps/specs/task-management/spec.md similarity index 100% rename from openspec/changes/case-flow-human-steps/specs/task-management/spec.md rename to openspec/changes/archive/2026-09-09-case-flow-human-steps/specs/task-management/spec.md diff --git a/openspec/changes/case-flow-human-steps/tasks.md b/openspec/changes/archive/2026-09-09-case-flow-human-steps/tasks.md similarity index 100% rename from openspec/changes/case-flow-human-steps/tasks.md rename to openspec/changes/archive/2026-09-09-case-flow-human-steps/tasks.md diff --git a/openspec/changes/case-header/design.md b/openspec/changes/archive/2026-09-09-case-header/design.md similarity index 100% rename from openspec/changes/case-header/design.md rename to openspec/changes/archive/2026-09-09-case-header/design.md diff --git a/openspec/changes/case-header/proposal.md b/openspec/changes/archive/2026-09-09-case-header/proposal.md similarity index 100% rename from openspec/changes/case-header/proposal.md rename to openspec/changes/archive/2026-09-09-case-header/proposal.md diff --git a/openspec/changes/case-header/specs/case-dashboard-view/spec.md b/openspec/changes/archive/2026-09-09-case-header/specs/case-dashboard-view/spec.md similarity index 100% rename from openspec/changes/case-header/specs/case-dashboard-view/spec.md rename to openspec/changes/archive/2026-09-09-case-header/specs/case-dashboard-view/spec.md diff --git a/openspec/changes/case-header/tasks.md b/openspec/changes/archive/2026-09-09-case-header/tasks.md similarity index 100% rename from openspec/changes/case-header/tasks.md rename to openspec/changes/archive/2026-09-09-case-header/tasks.md diff --git a/openspec/changes/case-lifecycle-on-the-page/.openspec.yaml b/openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/.openspec.yaml similarity index 100% rename from openspec/changes/case-lifecycle-on-the-page/.openspec.yaml rename to openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/.openspec.yaml diff --git a/openspec/changes/case-lifecycle-on-the-page/design.md b/openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/design.md similarity index 100% rename from openspec/changes/case-lifecycle-on-the-page/design.md rename to openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/design.md diff --git a/openspec/changes/case-lifecycle-on-the-page/proposal.md b/openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/proposal.md similarity index 100% rename from openspec/changes/case-lifecycle-on-the-page/proposal.md rename to openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/proposal.md diff --git a/openspec/changes/case-lifecycle-on-the-page/specs/case-dashboard-view/spec.md b/openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/specs/case-dashboard-view/spec.md similarity index 100% rename from openspec/changes/case-lifecycle-on-the-page/specs/case-dashboard-view/spec.md rename to openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/specs/case-dashboard-view/spec.md diff --git a/openspec/changes/case-lifecycle-on-the-page/specs/status-transition-engine/spec.md b/openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/specs/status-transition-engine/spec.md similarity index 100% rename from openspec/changes/case-lifecycle-on-the-page/specs/status-transition-engine/spec.md rename to openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/specs/status-transition-engine/spec.md diff --git a/openspec/changes/case-lifecycle-on-the-page/tasks.md b/openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/tasks.md similarity index 100% rename from openspec/changes/case-lifecycle-on-the-page/tasks.md rename to openspec/changes/archive/2026-09-09-case-lifecycle-on-the-page/tasks.md diff --git a/openspec/changes/case-status-onto-engine-lifecycle/design.md b/openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/design.md similarity index 100% rename from openspec/changes/case-status-onto-engine-lifecycle/design.md rename to openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/design.md diff --git a/openspec/changes/case-status-onto-engine-lifecycle/proposal.md b/openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/proposal.md similarity index 100% rename from openspec/changes/case-status-onto-engine-lifecycle/proposal.md rename to openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/proposal.md diff --git a/openspec/changes/case-status-onto-engine-lifecycle/specs/case-status-machinery/spec.md b/openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/specs/case-status-machinery/spec.md similarity index 100% rename from openspec/changes/case-status-onto-engine-lifecycle/specs/case-status-machinery/spec.md rename to openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/specs/case-status-machinery/spec.md diff --git a/openspec/changes/case-status-onto-engine-lifecycle/tasks.md b/openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/tasks.md similarity index 100% rename from openspec/changes/case-status-onto-engine-lifecycle/tasks.md rename to openspec/changes/archive/2026-09-09-case-status-onto-engine-lifecycle/tasks.md diff --git a/openspec/changes/case-timeline/design.md b/openspec/changes/archive/2026-09-09-case-timeline/design.md similarity index 100% rename from openspec/changes/case-timeline/design.md rename to openspec/changes/archive/2026-09-09-case-timeline/design.md diff --git a/openspec/changes/case-timeline/proposal.md b/openspec/changes/archive/2026-09-09-case-timeline/proposal.md similarity index 100% rename from openspec/changes/case-timeline/proposal.md rename to openspec/changes/archive/2026-09-09-case-timeline/proposal.md diff --git a/openspec/changes/case-timeline/specs/case-dashboard-view/spec.md b/openspec/changes/archive/2026-09-09-case-timeline/specs/case-dashboard-view/spec.md similarity index 100% rename from openspec/changes/case-timeline/specs/case-dashboard-view/spec.md rename to openspec/changes/archive/2026-09-09-case-timeline/specs/case-dashboard-view/spec.md diff --git a/openspec/changes/case-timeline/tasks.md b/openspec/changes/archive/2026-09-09-case-timeline/tasks.md similarity index 100% rename from openspec/changes/case-timeline/tasks.md rename to openspec/changes/archive/2026-09-09-case-timeline/tasks.md diff --git a/openspec/changes/case-type-authored-not-edited/proposal.md b/openspec/changes/archive/2026-09-09-case-type-authored-not-edited/proposal.md similarity index 100% rename from openspec/changes/case-type-authored-not-edited/proposal.md rename to openspec/changes/archive/2026-09-09-case-type-authored-not-edited/proposal.md diff --git a/openspec/changes/case-type-authored-not-edited/specs/case-types/spec.md b/openspec/changes/archive/2026-09-09-case-type-authored-not-edited/specs/case-types/spec.md similarity index 100% rename from openspec/changes/case-type-authored-not-edited/specs/case-types/spec.md rename to openspec/changes/archive/2026-09-09-case-type-authored-not-edited/specs/case-types/spec.md diff --git a/openspec/changes/case-type-authored-not-edited/specs/zaaktype-versioning/spec.md b/openspec/changes/archive/2026-09-09-case-type-authored-not-edited/specs/zaaktype-versioning/spec.md similarity index 100% rename from openspec/changes/case-type-authored-not-edited/specs/zaaktype-versioning/spec.md rename to openspec/changes/archive/2026-09-09-case-type-authored-not-edited/specs/zaaktype-versioning/spec.md diff --git a/openspec/changes/case-type-authored-not-edited/tasks.md b/openspec/changes/archive/2026-09-09-case-type-authored-not-edited/tasks.md similarity index 100% rename from openspec/changes/case-type-authored-not-edited/tasks.md rename to openspec/changes/archive/2026-09-09-case-type-authored-not-edited/tasks.md diff --git a/openspec/changes/case-type-navigation/.openspec.yaml b/openspec/changes/archive/2026-09-09-case-type-navigation/.openspec.yaml similarity index 100% rename from openspec/changes/case-type-navigation/.openspec.yaml rename to openspec/changes/archive/2026-09-09-case-type-navigation/.openspec.yaml diff --git a/openspec/changes/case-type-navigation/design.md b/openspec/changes/archive/2026-09-09-case-type-navigation/design.md similarity index 100% rename from openspec/changes/case-type-navigation/design.md rename to openspec/changes/archive/2026-09-09-case-type-navigation/design.md diff --git a/openspec/changes/case-type-navigation/proposal.md b/openspec/changes/archive/2026-09-09-case-type-navigation/proposal.md similarity index 100% rename from openspec/changes/case-type-navigation/proposal.md rename to openspec/changes/archive/2026-09-09-case-type-navigation/proposal.md diff --git a/openspec/changes/case-type-navigation/specs/case-type-navigation/spec.md b/openspec/changes/archive/2026-09-09-case-type-navigation/specs/case-type-navigation/spec.md similarity index 100% rename from openspec/changes/case-type-navigation/specs/case-type-navigation/spec.md rename to openspec/changes/archive/2026-09-09-case-type-navigation/specs/case-type-navigation/spec.md diff --git a/openspec/changes/case-type-navigation/tasks.md b/openspec/changes/archive/2026-09-09-case-type-navigation/tasks.md similarity index 100% rename from openspec/changes/case-type-navigation/tasks.md rename to openspec/changes/archive/2026-09-09-case-type-navigation/tasks.md diff --git a/openspec/changes/consume-decidesk-besluitvorming-leaf/proposal.md b/openspec/changes/archive/2026-09-09-consume-decidesk-besluitvorming-leaf/proposal.md similarity index 100% rename from openspec/changes/consume-decidesk-besluitvorming-leaf/proposal.md rename to openspec/changes/archive/2026-09-09-consume-decidesk-besluitvorming-leaf/proposal.md diff --git a/openspec/changes/consume-decidesk-besluitvorming-leaf/specs/besluitvorming-leaf/spec.md b/openspec/changes/archive/2026-09-09-consume-decidesk-besluitvorming-leaf/specs/besluitvorming-leaf/spec.md similarity index 100% rename from openspec/changes/consume-decidesk-besluitvorming-leaf/specs/besluitvorming-leaf/spec.md rename to openspec/changes/archive/2026-09-09-consume-decidesk-besluitvorming-leaf/specs/besluitvorming-leaf/spec.md diff --git a/openspec/changes/consume-decidesk-besluitvorming-leaf/tasks.md b/openspec/changes/archive/2026-09-09-consume-decidesk-besluitvorming-leaf/tasks.md similarity index 100% rename from openspec/changes/consume-decidesk-besluitvorming-leaf/tasks.md rename to openspec/changes/archive/2026-09-09-consume-decidesk-besluitvorming-leaf/tasks.md diff --git a/openspec/changes/contacts-you-can-find/design.md b/openspec/changes/archive/2026-09-09-contacts-you-can-find/design.md similarity index 100% rename from openspec/changes/contacts-you-can-find/design.md rename to openspec/changes/archive/2026-09-09-contacts-you-can-find/design.md diff --git a/openspec/changes/contacts-you-can-find/proposal.md b/openspec/changes/archive/2026-09-09-contacts-you-can-find/proposal.md similarity index 100% rename from openspec/changes/contacts-you-can-find/proposal.md rename to openspec/changes/archive/2026-09-09-contacts-you-can-find/proposal.md diff --git a/openspec/changes/contacts-you-can-find/specs/case-search-via-or-unified-search/spec.md b/openspec/changes/archive/2026-09-09-contacts-you-can-find/specs/case-search-via-or-unified-search/spec.md similarity index 79% rename from openspec/changes/contacts-you-can-find/specs/case-search-via-or-unified-search/spec.md rename to openspec/changes/archive/2026-09-09-contacts-you-can-find/specs/case-search-via-or-unified-search/spec.md index 9e7ca70e8..11c34d558 100644 --- a/openspec/changes/contacts-you-can-find/specs/case-search-via-or-unified-search/spec.md +++ b/openspec/changes/archive/2026-09-09-contacts-you-can-find/specs/case-search-via-or-unified-search/spec.md @@ -30,6 +30,14 @@ the next requirement. @e2e exclude Requires the OR ObjectsProvider pipeline and NC search UI; provider behaviour is covered by openregister's own unified-search-provider e2e suite. +#### Scenario: Non-flagged schema absent from search + +- **GIVEN** a `decisionType` config object exists +- **WHEN** a user searches for its title +- **THEN** it does not appear in unified search results (schema not flagged searchable) + +@e2e exclude Same rationale — declarative flag asserted by unit test on the register JSON. + #### Scenario: A schema flagged out stays out - **GIVEN** a schema flagged `searchable: false` in the register definition @@ -46,8 +54,25 @@ the next requirement. @e2e exclude Enforced and tested in openregister (provider security contract); dossiq adds no code path. +## REMOVED Requirements + ### Requirement: Deep links for searchable schemas +**Reason:** its only scenario pins the deep-link table to five schemas by name, +including `voorstel` and the route `/apps/dossiq/voorstellen/{uuid}`. That page +was deleted when a voorstel became a case, so the scenario asserts a route the +manifest no longer declares, and a list of schema slugs goes stale every time +the table grows: this change adds two more. The replacement checks the property +that matters instead, that every deep link names a route that exists. Written +as a removal plus an addition rather than a rewrite in place so the dropped +scenario is dropped on the record. + +**Migration:** none. The requirement below covers the same deep-link table. + +## ADDED Requirements + +### Requirement: Deep links resolve to real pages + Every dossiq schema that has a standalone detail page SHALL have a `deepLinks` entry in `src/manifest.json` mapping `(dossiq, )` to that route, and the entry's `urlTemplate` SHALL name a route the manifest carries. diff --git a/openspec/changes/contacts-you-can-find/specs/initiator-display/spec.md b/openspec/changes/archive/2026-09-09-contacts-you-can-find/specs/initiator-display/spec.md similarity index 99% rename from openspec/changes/contacts-you-can-find/specs/initiator-display/spec.md rename to openspec/changes/archive/2026-09-09-contacts-you-can-find/specs/initiator-display/spec.md index 303dd9559..706ff1a23 100644 --- a/openspec/changes/contacts-you-can-find/specs/initiator-display/spec.md +++ b/openspec/changes/archive/2026-09-09-contacts-you-can-find/specs/initiator-display/spec.md @@ -1,4 +1,4 @@ -## MODIFIED Requirements +## ADDED Requirements ### Requirement: The Contacts index lists the people dossiq knows (REQ-ID-4) @@ -32,8 +32,6 @@ carry the measurement as a note on the page. - **THEN** it SHALL render no folder pane - **AND** the navigation SHALL offer the Organisations index without expanding anything -## ADDED Requirements - ### Requirement: The Organisations index lists the organisations dossiq knows (REQ-ID-6) You find an organisation without already holding a case that names it. The diff --git a/openspec/changes/contacts-you-can-find/tasks.md b/openspec/changes/archive/2026-09-09-contacts-you-can-find/tasks.md similarity index 100% rename from openspec/changes/contacts-you-can-find/tasks.md rename to openspec/changes/archive/2026-09-09-contacts-you-can-find/tasks.md diff --git a/openspec/changes/dossiq-consumes-shared-dmn/proposal.md b/openspec/changes/archive/2026-09-09-dossiq-consumes-shared-dmn/proposal.md similarity index 100% rename from openspec/changes/dossiq-consumes-shared-dmn/proposal.md rename to openspec/changes/archive/2026-09-09-dossiq-consumes-shared-dmn/proposal.md diff --git a/openspec/changes/dossiq-consumes-shared-dmn/specs/dmn-decision-tables/spec.md b/openspec/changes/archive/2026-09-09-dossiq-consumes-shared-dmn/specs/dmn-decision-tables/spec.md similarity index 69% rename from openspec/changes/dossiq-consumes-shared-dmn/specs/dmn-decision-tables/spec.md rename to openspec/changes/archive/2026-09-09-dossiq-consumes-shared-dmn/specs/dmn-decision-tables/spec.md index f5441159b..0b9e07c18 100644 --- a/openspec/changes/dossiq-consumes-shared-dmn/specs/dmn-decision-tables/spec.md +++ b/openspec/changes/archive/2026-09-09-dossiq-consumes-shared-dmn/specs/dmn-decision-tables/spec.md @@ -11,37 +11,12 @@ evaluate. Renamed from "PRIORITY and ANY hit policies are explicitly unsupported" and "Expression grammar is a closed, safe subset" respectively. -## MODIFIED Requirements - -### Requirement: PRIORITY and ANY hit policies are evaluated by OpenRegister -The system MUST evaluate a decision table declaring `PRIORITY` or `ANY` rather -than refusing it. `PRIORITY` MUST return the matching rule with the highest -priority, breaking ties by declaration order. `ANY` MUST return the shared -output of the matching rules and MUST raise `hit_policy_violation` when they -disagree, because a table declaring `ANY` asserts that its overlapping rules -agree. +## RENAMED Requirements -This replaces the previous requirement that both were rejected with -`hit_policy_not_implemented`. That requirement described a limitation of -dossiq's own engine, not a decision about DMN. The schema has offered all five -policies in its enum throughout, so the refusal was visible to users as a form -that offered a choice the engine would not honour. +- FROM: `### Requirement: Expression grammar is a closed, safe subset` +- TO: `### Requirement: Expression grammar is a closed, safe subset, owned by OpenRegister` -#### Scenario: PRIORITY returns the highest-priority match -- **GIVEN** a decision table with `hitPolicy: PRIORITY` and matching rules of priority 1, 10 and 5 -- **WHEN** it is evaluated -- **THEN** the rule with priority 10 MUST win - -#### Scenario: ANY refuses rules that disagree -- **GIVEN** a table with `hitPolicy: ANY` and two matching rules with different outputs -- **WHEN** it is evaluated -- **THEN** the system MUST raise `hit_policy_violation` and MUST NOT pick one - -#### Scenario: A PRIORITY table is no longer refused for its policy -- **GIVEN** any decision table declaring `hitPolicy: PRIORITY` -- **WHEN** it is evaluated -- **THEN** the system MUST NOT return `hit_policy_not_implemented`, and MUST - fail only on the table's own contents if those are invalid +## MODIFIED Requirements ### Requirement: Expression grammar is a closed, safe subset, owned by OpenRegister The system MUST evaluate rule input-entry expressions using only a fixed, @@ -57,6 +32,16 @@ nobody is reading. The behaviour is unchanged: the grammar matrix that proves it moved to openregister with the class, unaltered. +#### Scenario: Exclusive range boundary does not match +- **GIVEN** an input entry `(25000..40000]` +- **WHEN** the input value is exactly `25000` +- **THEN** the entry MUST NOT match + +#### Scenario: Set membership matches one of the listed values +- **GIVEN** an input entry `in (gold, silver, bronze)` on a `string` input +- **WHEN** the input value is `silver` +- **THEN** the entry MUST match + #### Scenario: Range expression matches inclusively - **GIVEN** an input entry `[0..25000]` on a `number`-typed input - **WHEN** the input value is exactly `25000` @@ -71,3 +56,53 @@ openregister with the class, unaltered. - **GIVEN** dossiq's source tree - **THEN** it MUST contain no class implementing the unary-test grammar or the hit policies, and MUST resolve both from OpenRegister + +## REMOVED Requirements + +### Requirement: PRIORITY and ANY hit policies are explicitly unsupported + +**Reason:** the requirement said dossiq's engine refuses `PRIORITY` and `ANY` +with `hit_policy_not_implemented`, and its scenario "PRIORITY hit policy is +rejected" asserts that refusal. dossiq no longer has an engine to refuse with: +`lib/Service/Dmn/DecisionEngine.php` is deleted and evaluation runs in +OpenRegister, which implements both policies. The schema has offered all five in +its enum throughout, so the refusal was a limitation of one implementation +rather than a decision about DMN. Written as a removal plus an addition rather +than a rewrite in place so the dropped scenario is dropped on the record. + +**Migration:** a table declaring `PRIORITY` or `ANY` that used to come back +`hit_policy_not_implemented` now evaluates. `PRIORITY` returns the +highest-priority match, `ANY` raises `hit_policy_violation` when the matching +rules disagree. + +## ADDED Requirements + +### Requirement: PRIORITY and ANY hit policies are evaluated by OpenRegister +The system MUST evaluate a decision table declaring `PRIORITY` or `ANY` rather +than refusing it. `PRIORITY` MUST return the matching rule with the highest +priority, breaking ties by declaration order. `ANY` MUST return the shared +output of the matching rules and MUST raise `hit_policy_violation` when they +disagree, because a table declaring `ANY` asserts that its overlapping rules +agree. + +This replaces the previous requirement that both were rejected with +`hit_policy_not_implemented`. That requirement described a limitation of +dossiq's own engine, not a decision about DMN. The schema has offered all five +policies in its enum throughout, so the refusal was visible to users as a form +that offered a choice the engine would not honour. + +#### Scenario: PRIORITY returns the highest-priority match +- **GIVEN** a decision table with `hitPolicy: PRIORITY` and matching rules of priority 1, 10 and 5 +- **WHEN** it is evaluated +- **THEN** the rule with priority 10 MUST win + +#### Scenario: ANY refuses rules that disagree +- **GIVEN** a table with `hitPolicy: ANY` and two matching rules with different outputs +- **WHEN** it is evaluated +- **THEN** the system MUST raise `hit_policy_violation` and MUST NOT pick one + +#### Scenario: A PRIORITY table is no longer refused for its policy +- **GIVEN** any decision table declaring `hitPolicy: PRIORITY` +- **WHEN** it is evaluated +- **THEN** the system MUST NOT return `hit_policy_not_implemented`, and MUST + fail only on the table's own contents if those are invalid diff --git a/openspec/changes/dossiq-consumes-shared-dmn/tasks.md b/openspec/changes/archive/2026-09-09-dossiq-consumes-shared-dmn/tasks.md similarity index 100% rename from openspec/changes/dossiq-consumes-shared-dmn/tasks.md rename to openspec/changes/archive/2026-09-09-dossiq-consumes-shared-dmn/tasks.md diff --git a/openspec/changes/dossiq-delegation-via-events/.openspec.yaml b/openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/.openspec.yaml similarity index 100% rename from openspec/changes/dossiq-delegation-via-events/.openspec.yaml rename to openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/.openspec.yaml diff --git a/openspec/changes/dossiq-delegation-via-events/design.md b/openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/design.md similarity index 100% rename from openspec/changes/dossiq-delegation-via-events/design.md rename to openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/design.md diff --git a/openspec/changes/dossiq-delegation-via-events/proposal.md b/openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/proposal.md similarity index 100% rename from openspec/changes/dossiq-delegation-via-events/proposal.md rename to openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/proposal.md diff --git a/openspec/changes/dossiq-delegation-via-events/specs/contract-decision-delegation/spec.md b/openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/specs/contract-decision-delegation/spec.md similarity index 73% rename from openspec/changes/dossiq-delegation-via-events/specs/contract-decision-delegation/spec.md rename to openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/specs/contract-decision-delegation/spec.md index ab53dc602..537a3422b 100644 --- a/openspec/changes/dossiq-delegation-via-events/specs/contract-decision-delegation/spec.md +++ b/openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/specs/contract-decision-delegation/spec.md @@ -17,7 +17,47 @@ unchanged. Only the mechanism changes: the non-existent is replaced by an `IEventDispatcher` dispatch of `DecisionRequestedEvent` plus a `DecisionConcludedEvent` listener that materialises the ZGW `Besluit`. -## MODIFIED Requirements +## REMOVED Requirements + +**Reason (all three):** the three requirements were written against the decidesk +integration leaf, and this change retires that route: delegation now travels as +a typed `DecisionRequestedEvent` and the outcome comes back as +`DecisionConcludedEvent`. Every scenario under them names the leaf as the +mechanism ("raises a decidesk Decision" through `IntegrationService::getLeaf`, +"the leaf is not registered or returns an error"), a call dossiq no longer +makes. Carrying those scenarios forward would leave the spec asserting a +mechanism the code does not have, so each requirement is removed and re-added +under the same REQ id with the event contract in place of the leaf. + +**Migration (all three):** none for a reader of the spec. The three requirements +below keep the REQ-PDCD-001, REQ-PDCD-002 and REQ-PDCD-003 ids and state the +same three rules: decisions are raised in decidesk, delegation fails closed when +decidesk does not answer, and the ZGW Besluit is materialised from the outcome. + +### Requirement: REQ-PDCD-001 — Contract Decisions Are Raised As decidesk Decisions + +**Reason:** replaced by the event-contract version below. + +**Migration:** the leaf call `IntegrationService::getLeaf('decidesk')` becomes a +`dispatchTyped(DecisionRequestedEvent)`; the decision id is read from the +handled event rather than the leaf's return value. + +### Requirement: REQ-PDCD-002 — Delegation Fails Closed When decidesk Is Unavailable + +**Reason:** replaced by the event-contract version below. + +**Migration:** the unavailability test moves from "the leaf is not registered or +returns an error" to "the event class does not exist, the event came back +unhandled, or it carries no decision id". + +### Requirement: REQ-PDCD-003 — The ZGW Besluit Is Materialised From The decidesk Outcome + +**Reason:** replaced by the event-contract version below. + +**Migration:** the outcome arrives as a `DecisionConcludedEvent` on dossiq's +listener rather than being polled from the leaf. + +## ADDED Requirements ### Requirement: REQ-PDCD-001 — Contract Decisions Are Raised As decidesk Decisions Via Events @@ -46,7 +86,7 @@ state machine for the decision. --- -### Requirement: REQ-PDCD-002 — Delegation Fails Closed When decidesk Is Unavailable +### Requirement: REQ-PDCD-002 — Delegation fails closed when decidesk does not answer the event dossiq SHALL fail closed when decidesk cannot handle the decision: if `class_exists(\OCA\Decidesk\Event\DecisionRequestedEvent::class)` is false (decidesk not installed), OR diff --git a/openspec/changes/dossiq-delegation-via-events/tasks.md b/openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/tasks.md similarity index 100% rename from openspec/changes/dossiq-delegation-via-events/tasks.md rename to openspec/changes/archive/2026-09-09-dossiq-delegation-via-events/tasks.md diff --git a/openspec/changes/dossiq-store-surface/proposal.md b/openspec/changes/archive/2026-09-09-dossiq-store-surface/proposal.md similarity index 100% rename from openspec/changes/dossiq-store-surface/proposal.md rename to openspec/changes/archive/2026-09-09-dossiq-store-surface/proposal.md diff --git a/openspec/changes/dossiq-store-surface/specs/dossiq-store-surface/spec.md b/openspec/changes/archive/2026-09-09-dossiq-store-surface/specs/dossiq-store-surface/spec.md similarity index 100% rename from openspec/changes/dossiq-store-surface/specs/dossiq-store-surface/spec.md rename to openspec/changes/archive/2026-09-09-dossiq-store-surface/specs/dossiq-store-surface/spec.md diff --git a/openspec/changes/dossiq-store-surface/tasks.md b/openspec/changes/archive/2026-09-09-dossiq-store-surface/tasks.md similarity index 100% rename from openspec/changes/dossiq-store-surface/tasks.md rename to openspec/changes/archive/2026-09-09-dossiq-store-surface/tasks.md diff --git a/openspec/changes/enforce-dwangsom-callback-signature/.openspec.yaml b/openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/.openspec.yaml similarity index 100% rename from openspec/changes/enforce-dwangsom-callback-signature/.openspec.yaml rename to openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/.openspec.yaml diff --git a/openspec/changes/enforce-dwangsom-callback-signature/proposal.md b/openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/proposal.md similarity index 100% rename from openspec/changes/enforce-dwangsom-callback-signature/proposal.md rename to openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/proposal.md diff --git a/openspec/changes/enforce-dwangsom-callback-signature/specs/financial-integration/spec.md b/openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/specs/financial-integration/spec.md similarity index 77% rename from openspec/changes/enforce-dwangsom-callback-signature/specs/financial-integration/spec.md rename to openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/specs/financial-integration/spec.md index fb651fbdc..f38d3e9e3 100644 --- a/openspec/changes/enforce-dwangsom-callback-signature/specs/financial-integration/spec.md +++ b/openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/specs/financial-integration/spec.md @@ -15,6 +15,19 @@ MUST be configurable via the dossiq admin settings UI. **Feature tier**: MVP +#### Scenario: Payment signal generation + +- **GIVEN** a `DwangsomBerekening` closes with a locked `definitievBedrag` and the burger's IBAN is known +- **WHEN** the payment signal is generated +- **THEN** a `DwangsomUitbetaling` SHALL be created with `bedrag`, `rekeninghouderNaam`, `iban`, `referentie` (zaakId + ingebrekestelling-date), `wettelijkeGrondslag` = "AWB 4:17 lid 2", `betaaldatumUiterlijk` = ingebrekestelling-date + 28 days, and `status` = `voorbereid` +- **AND** a `dwangsom-payment-signal` event SHALL be emitted to openconnector with the full metadata payload + +#### Scenario: Missing or invalid IBAN blocks the signal + +- **GIVEN** the burger's IBAN is missing or malformed +- **WHEN** `prepareBetaling` runs +- **THEN** the system SHALL raise an error and SHALL NOT emit a payment signal + #### Scenario: Payment confirmation callback updates status and notifies burger - **GIVEN** the ERP sends a payment-confirmation callback via openconnector diff --git a/openspec/changes/enforce-dwangsom-callback-signature/tasks.md b/openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/tasks.md similarity index 100% rename from openspec/changes/enforce-dwangsom-callback-signature/tasks.md rename to openspec/changes/archive/2026-09-09-enforce-dwangsom-callback-signature/tasks.md diff --git a/openspec/changes/first-time-setup/.openspec.yaml b/openspec/changes/archive/2026-09-09-first-time-setup/.openspec.yaml similarity index 100% rename from openspec/changes/first-time-setup/.openspec.yaml rename to openspec/changes/archive/2026-09-09-first-time-setup/.openspec.yaml diff --git a/openspec/changes/first-time-setup/design.md b/openspec/changes/archive/2026-09-09-first-time-setup/design.md similarity index 100% rename from openspec/changes/first-time-setup/design.md rename to openspec/changes/archive/2026-09-09-first-time-setup/design.md diff --git a/openspec/changes/first-time-setup/proposal.md b/openspec/changes/archive/2026-09-09-first-time-setup/proposal.md similarity index 100% rename from openspec/changes/first-time-setup/proposal.md rename to openspec/changes/archive/2026-09-09-first-time-setup/proposal.md diff --git a/openspec/changes/first-time-setup/specs/first-time-setup/spec.md b/openspec/changes/archive/2026-09-09-first-time-setup/specs/first-time-setup/spec.md similarity index 100% rename from openspec/changes/first-time-setup/specs/first-time-setup/spec.md rename to openspec/changes/archive/2026-09-09-first-time-setup/specs/first-time-setup/spec.md diff --git a/openspec/changes/first-time-setup/tasks.md b/openspec/changes/archive/2026-09-09-first-time-setup/tasks.md similarity index 100% rename from openspec/changes/first-time-setup/tasks.md rename to openspec/changes/archive/2026-09-09-first-time-setup/tasks.md diff --git a/openspec/changes/friendly-case-create-form/.openspec.yaml b/openspec/changes/archive/2026-09-09-friendly-case-create-form/.openspec.yaml similarity index 100% rename from openspec/changes/friendly-case-create-form/.openspec.yaml rename to openspec/changes/archive/2026-09-09-friendly-case-create-form/.openspec.yaml diff --git a/openspec/changes/friendly-case-create-form/proposal.md b/openspec/changes/archive/2026-09-09-friendly-case-create-form/proposal.md similarity index 100% rename from openspec/changes/friendly-case-create-form/proposal.md rename to openspec/changes/archive/2026-09-09-friendly-case-create-form/proposal.md diff --git a/openspec/changes/friendly-case-create-form/specs/friendly-case-create-form/spec.md b/openspec/changes/archive/2026-09-09-friendly-case-create-form/specs/friendly-case-create-form/spec.md similarity index 100% rename from openspec/changes/friendly-case-create-form/specs/friendly-case-create-form/spec.md rename to openspec/changes/archive/2026-09-09-friendly-case-create-form/specs/friendly-case-create-form/spec.md diff --git a/openspec/changes/friendly-case-create-form/tasks.md b/openspec/changes/archive/2026-09-09-friendly-case-create-form/tasks.md similarity index 100% rename from openspec/changes/friendly-case-create-form/tasks.md rename to openspec/changes/archive/2026-09-09-friendly-case-create-form/tasks.md diff --git a/openspec/changes/kanban-board-keyboard-status-transition/.openspec.yaml b/openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/.openspec.yaml similarity index 100% rename from openspec/changes/kanban-board-keyboard-status-transition/.openspec.yaml rename to openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/.openspec.yaml diff --git a/openspec/changes/kanban-board-keyboard-status-transition/proposal.md b/openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/proposal.md similarity index 100% rename from openspec/changes/kanban-board-keyboard-status-transition/proposal.md rename to openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/proposal.md diff --git a/openspec/changes/kanban-board-keyboard-status-transition/specs/dashboard/spec.md b/openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/specs/dashboard/spec.md similarity index 80% rename from openspec/changes/kanban-board-keyboard-status-transition/specs/dashboard/spec.md rename to openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/specs/dashboard/spec.md index b082ee6dd..3116747b9 100644 --- a/openspec/changes/kanban-board-keyboard-status-transition/specs/dashboard/spec.md +++ b/openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/specs/dashboard/spec.md @@ -33,7 +33,18 @@ advance a case's status. - AND the card MUST move to the "In behandeling" column - AND if the update fails (e.g., permission denied), the card MUST return to its original column -#### Scenario DASH-V1-006d: Keyboard-only status transition (NEW) +#### Scenario DASH-V1-006d: Click on case card navigates to detail +- GIVEN a case card is visible on the board +- WHEN the user clicks the card (not drags it) +- THEN the system MUST navigate to the case detail view for that case + +#### Scenario DASH-V1-006e: Empty column +- GIVEN no cases are currently in the "Besluitvorming" status +- WHEN the user views the Workflow Board +- THEN the "Besluitvorming" column MUST still be displayed with count "0" +- AND the column body MUST show an empty state placeholder + +#### Scenario DASH-V1-006f: Keyboard-only status transition (NEW) - GIVEN case "2026-0042" is in the "Ontvangen" column and the user is navigating with only a keyboard (no mouse/touch) @@ -46,7 +57,7 @@ advance a case's status. - AND the card's existing "open case detail" keyboard activation (Enter/Space on the card body) MUST remain unaffected by the new control -#### Scenario DASH-V1-006e: Drag path unchanged (NEW) +#### Scenario DASH-V1-006g: Drag path unchanged (NEW) - GIVEN a mouse/touch user - WHEN they drag a card between columns as in Scenario DASH-V1-006c diff --git a/openspec/changes/kanban-board-keyboard-status-transition/tasks.md b/openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/tasks.md similarity index 100% rename from openspec/changes/kanban-board-keyboard-status-transition/tasks.md rename to openspec/changes/archive/2026-09-09-kanban-board-keyboard-status-transition/tasks.md diff --git a/openspec/changes/kcc-routing-onto-or-decision-tables/design.md b/openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/design.md similarity index 100% rename from openspec/changes/kcc-routing-onto-or-decision-tables/design.md rename to openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/design.md diff --git a/openspec/changes/kcc-routing-onto-or-decision-tables/proposal.md b/openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/proposal.md similarity index 100% rename from openspec/changes/kcc-routing-onto-or-decision-tables/proposal.md rename to openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/proposal.md diff --git a/openspec/changes/kcc-routing-onto-or-decision-tables/specs/kcc-routing/spec.md b/openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/specs/kcc-routing/spec.md similarity index 100% rename from openspec/changes/kcc-routing-onto-or-decision-tables/specs/kcc-routing/spec.md rename to openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/specs/kcc-routing/spec.md diff --git a/openspec/changes/kcc-routing-onto-or-decision-tables/tasks.md b/openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/tasks.md similarity index 100% rename from openspec/changes/kcc-routing-onto-or-decision-tables/tasks.md rename to openspec/changes/archive/2026-09-09-kcc-routing-onto-or-decision-tables/tasks.md diff --git a/openspec/changes/lhs-matrix-is-a-decision-table/proposal.md b/openspec/changes/archive/2026-09-09-lhs-matrix-is-a-decision-table/proposal.md similarity index 100% rename from openspec/changes/lhs-matrix-is-a-decision-table/proposal.md rename to openspec/changes/archive/2026-09-09-lhs-matrix-is-a-decision-table/proposal.md diff --git a/openspec/changes/lhs-matrix-is-a-decision-table/specs/lhs-decision-table/spec.md b/openspec/changes/archive/2026-09-09-lhs-matrix-is-a-decision-table/specs/lhs-decision-table/spec.md similarity index 100% rename from openspec/changes/lhs-matrix-is-a-decision-table/specs/lhs-decision-table/spec.md rename to openspec/changes/archive/2026-09-09-lhs-matrix-is-a-decision-table/specs/lhs-decision-table/spec.md diff --git a/openspec/changes/lhs-matrix-is-a-decision-table/tasks.md b/openspec/changes/archive/2026-09-09-lhs-matrix-is-a-decision-table/tasks.md similarity index 100% rename from openspec/changes/lhs-matrix-is-a-decision-table/tasks.md rename to openspec/changes/archive/2026-09-09-lhs-matrix-is-a-decision-table/tasks.md diff --git a/openspec/changes/lists-that-answer/proposal.md b/openspec/changes/archive/2026-09-09-lists-that-answer/proposal.md similarity index 100% rename from openspec/changes/lists-that-answer/proposal.md rename to openspec/changes/archive/2026-09-09-lists-that-answer/proposal.md diff --git a/openspec/changes/lists-that-answer/specs/case-management/spec.md b/openspec/changes/archive/2026-09-09-lists-that-answer/specs/case-management/spec.md similarity index 100% rename from openspec/changes/lists-that-answer/specs/case-management/spec.md rename to openspec/changes/archive/2026-09-09-lists-that-answer/specs/case-management/spec.md diff --git a/openspec/changes/lists-that-answer/specs/task-management/spec.md b/openspec/changes/archive/2026-09-09-lists-that-answer/specs/task-management/spec.md similarity index 100% rename from openspec/changes/lists-that-answer/specs/task-management/spec.md rename to openspec/changes/archive/2026-09-09-lists-that-answer/specs/task-management/spec.md diff --git a/openspec/changes/lists-that-answer/tasks.md b/openspec/changes/archive/2026-09-09-lists-that-answer/tasks.md similarity index 100% rename from openspec/changes/lists-that-answer/tasks.md rename to openspec/changes/archive/2026-09-09-lists-that-answer/tasks.md diff --git a/openspec/changes/move-portals-to-portaliq/design.md b/openspec/changes/archive/2026-09-09-move-portals-to-portaliq/design.md similarity index 100% rename from openspec/changes/move-portals-to-portaliq/design.md rename to openspec/changes/archive/2026-09-09-move-portals-to-portaliq/design.md diff --git a/openspec/changes/move-portals-to-portaliq/proposal.md b/openspec/changes/archive/2026-09-09-move-portals-to-portaliq/proposal.md similarity index 100% rename from openspec/changes/move-portals-to-portaliq/proposal.md rename to openspec/changes/archive/2026-09-09-move-portals-to-portaliq/proposal.md diff --git a/openspec/changes/move-portals-to-portaliq/specs/portal-contribution/spec.md b/openspec/changes/archive/2026-09-09-move-portals-to-portaliq/specs/portal-contribution/spec.md similarity index 100% rename from openspec/changes/move-portals-to-portaliq/specs/portal-contribution/spec.md rename to openspec/changes/archive/2026-09-09-move-portals-to-portaliq/specs/portal-contribution/spec.md diff --git a/openspec/changes/move-portals-to-portaliq/tasks.md b/openspec/changes/archive/2026-09-09-move-portals-to-portaliq/tasks.md similarity index 100% rename from openspec/changes/move-portals-to-portaliq/tasks.md rename to openspec/changes/archive/2026-09-09-move-portals-to-portaliq/tasks.md diff --git a/openspec/changes/namespace-the-case-supplier-invoice/proposal.md b/openspec/changes/archive/2026-09-09-namespace-the-case-supplier-invoice/proposal.md similarity index 100% rename from openspec/changes/namespace-the-case-supplier-invoice/proposal.md rename to openspec/changes/archive/2026-09-09-namespace-the-case-supplier-invoice/proposal.md diff --git a/openspec/changes/namespace-the-case-supplier-invoice/specs/supplier-portal/spec.md b/openspec/changes/archive/2026-09-09-namespace-the-case-supplier-invoice/specs/supplier-portal/spec.md similarity index 100% rename from openspec/changes/namespace-the-case-supplier-invoice/specs/supplier-portal/spec.md rename to openspec/changes/archive/2026-09-09-namespace-the-case-supplier-invoice/specs/supplier-portal/spec.md diff --git a/openspec/changes/namespace-the-case-supplier-invoice/tasks.md b/openspec/changes/archive/2026-09-09-namespace-the-case-supplier-invoice/tasks.md similarity index 100% rename from openspec/changes/namespace-the-case-supplier-invoice/tasks.md rename to openspec/changes/archive/2026-09-09-namespace-the-case-supplier-invoice/tasks.md diff --git a/openspec/changes/namespace-the-case-task/proposal.md b/openspec/changes/archive/2026-09-09-namespace-the-case-task/proposal.md similarity index 100% rename from openspec/changes/namespace-the-case-task/proposal.md rename to openspec/changes/archive/2026-09-09-namespace-the-case-task/proposal.md diff --git a/openspec/changes/namespace-the-case-task/specs/case-management/spec.md b/openspec/changes/archive/2026-09-09-namespace-the-case-task/specs/case-management/spec.md similarity index 100% rename from openspec/changes/namespace-the-case-task/specs/case-management/spec.md rename to openspec/changes/archive/2026-09-09-namespace-the-case-task/specs/case-management/spec.md diff --git a/openspec/changes/namespace-the-case-task/tasks.md b/openspec/changes/archive/2026-09-09-namespace-the-case-task/tasks.md similarity index 100% rename from openspec/changes/namespace-the-case-task/tasks.md rename to openspec/changes/archive/2026-09-09-namespace-the-case-task/tasks.md diff --git a/openspec/changes/nl-locale-coverage-gap-and-dutch-keys/.openspec.yaml b/openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/.openspec.yaml similarity index 100% rename from openspec/changes/nl-locale-coverage-gap-and-dutch-keys/.openspec.yaml rename to openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/.openspec.yaml diff --git a/openspec/changes/nl-locale-coverage-gap-and-dutch-keys/proposal.md b/openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/proposal.md similarity index 100% rename from openspec/changes/nl-locale-coverage-gap-and-dutch-keys/proposal.md rename to openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/proposal.md diff --git a/openspec/changes/nl-locale-coverage-gap-and-dutch-keys/specs/frontend-locale-completeness/spec.md b/openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/specs/frontend-locale-completeness/spec.md similarity index 100% rename from openspec/changes/nl-locale-coverage-gap-and-dutch-keys/specs/frontend-locale-completeness/spec.md rename to openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/specs/frontend-locale-completeness/spec.md diff --git a/openspec/changes/nl-locale-coverage-gap-and-dutch-keys/tasks.md b/openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/tasks.md similarity index 100% rename from openspec/changes/nl-locale-coverage-gap-and-dutch-keys/tasks.md rename to openspec/changes/archive/2026-09-09-nl-locale-coverage-gap-and-dutch-keys/tasks.md diff --git a/openspec/changes/one-case-list/design.md b/openspec/changes/archive/2026-09-09-one-case-list/design.md similarity index 100% rename from openspec/changes/one-case-list/design.md rename to openspec/changes/archive/2026-09-09-one-case-list/design.md diff --git a/openspec/changes/one-case-list/proposal.md b/openspec/changes/archive/2026-09-09-one-case-list/proposal.md similarity index 100% rename from openspec/changes/one-case-list/proposal.md rename to openspec/changes/archive/2026-09-09-one-case-list/proposal.md diff --git a/openspec/changes/one-case-list/specs/case-bulk-status-transition/spec.md b/openspec/changes/archive/2026-09-09-one-case-list/specs/case-bulk-status-transition/spec.md similarity index 100% rename from openspec/changes/one-case-list/specs/case-bulk-status-transition/spec.md rename to openspec/changes/archive/2026-09-09-one-case-list/specs/case-bulk-status-transition/spec.md diff --git a/openspec/changes/one-case-list/specs/case-management/spec.md b/openspec/changes/archive/2026-09-09-one-case-list/specs/case-management/spec.md similarity index 100% rename from openspec/changes/one-case-list/specs/case-management/spec.md rename to openspec/changes/archive/2026-09-09-one-case-list/specs/case-management/spec.md diff --git a/openspec/changes/one-case-list/specs/my-work/spec.md b/openspec/changes/archive/2026-09-09-one-case-list/specs/my-work/spec.md similarity index 100% rename from openspec/changes/one-case-list/specs/my-work/spec.md rename to openspec/changes/archive/2026-09-09-one-case-list/specs/my-work/spec.md diff --git a/openspec/changes/one-case-list/specs/signalering-widgets/spec.md b/openspec/changes/archive/2026-09-09-one-case-list/specs/signalering-widgets/spec.md similarity index 100% rename from openspec/changes/one-case-list/specs/signalering-widgets/spec.md rename to openspec/changes/archive/2026-09-09-one-case-list/specs/signalering-widgets/spec.md diff --git a/openspec/changes/one-case-list/specs/task-management/spec.md b/openspec/changes/archive/2026-09-09-one-case-list/specs/task-management/spec.md similarity index 100% rename from openspec/changes/one-case-list/specs/task-management/spec.md rename to openspec/changes/archive/2026-09-09-one-case-list/specs/task-management/spec.md diff --git a/openspec/changes/one-case-list/tasks.md b/openspec/changes/archive/2026-09-09-one-case-list/tasks.md similarity index 100% rename from openspec/changes/one-case-list/tasks.md rename to openspec/changes/archive/2026-09-09-one-case-list/tasks.md diff --git a/openspec/changes/ori-removal/design.md b/openspec/changes/archive/2026-09-09-ori-removal/design.md similarity index 100% rename from openspec/changes/ori-removal/design.md rename to openspec/changes/archive/2026-09-09-ori-removal/design.md diff --git a/openspec/changes/ori-removal/proposal.md b/openspec/changes/archive/2026-09-09-ori-removal/proposal.md similarity index 100% rename from openspec/changes/ori-removal/proposal.md rename to openspec/changes/archive/2026-09-09-ori-removal/proposal.md diff --git a/openspec/changes/ori-removal/specs/ori-removal/spec.md b/openspec/changes/archive/2026-09-09-ori-removal/specs/ori-removal/spec.md similarity index 100% rename from openspec/changes/ori-removal/specs/ori-removal/spec.md rename to openspec/changes/archive/2026-09-09-ori-removal/specs/ori-removal/spec.md diff --git a/openspec/changes/ori-removal/tasks.md b/openspec/changes/archive/2026-09-09-ori-removal/tasks.md similarity index 100% rename from openspec/changes/ori-removal/tasks.md rename to openspec/changes/archive/2026-09-09-ori-removal/tasks.md diff --git a/openspec/changes/parties-on-the-case/.openspec.yaml b/openspec/changes/archive/2026-09-09-parties-on-the-case/.openspec.yaml similarity index 100% rename from openspec/changes/parties-on-the-case/.openspec.yaml rename to openspec/changes/archive/2026-09-09-parties-on-the-case/.openspec.yaml diff --git a/openspec/changes/parties-on-the-case/design.md b/openspec/changes/archive/2026-09-09-parties-on-the-case/design.md similarity index 100% rename from openspec/changes/parties-on-the-case/design.md rename to openspec/changes/archive/2026-09-09-parties-on-the-case/design.md diff --git a/openspec/changes/parties-on-the-case/proposal.md b/openspec/changes/archive/2026-09-09-parties-on-the-case/proposal.md similarity index 100% rename from openspec/changes/parties-on-the-case/proposal.md rename to openspec/changes/archive/2026-09-09-parties-on-the-case/proposal.md diff --git a/openspec/changes/parties-on-the-case/specs/role-routing-via-or-rbac/spec.md b/openspec/changes/archive/2026-09-09-parties-on-the-case/specs/role-routing-via-or-rbac/spec.md similarity index 100% rename from openspec/changes/parties-on-the-case/specs/role-routing-via-or-rbac/spec.md rename to openspec/changes/archive/2026-09-09-parties-on-the-case/specs/role-routing-via-or-rbac/spec.md diff --git a/openspec/changes/parties-on-the-case/specs/roles-decisions/spec.md b/openspec/changes/archive/2026-09-09-parties-on-the-case/specs/roles-decisions/spec.md similarity index 100% rename from openspec/changes/parties-on-the-case/specs/roles-decisions/spec.md rename to openspec/changes/archive/2026-09-09-parties-on-the-case/specs/roles-decisions/spec.md diff --git a/openspec/changes/parties-on-the-case/tasks.md b/openspec/changes/archive/2026-09-09-parties-on-the-case/tasks.md similarity index 100% rename from openspec/changes/parties-on-the-case/tasks.md rename to openspec/changes/archive/2026-09-09-parties-on-the-case/tasks.md diff --git a/openspec/changes/partners-are-organisations/proposal.md b/openspec/changes/archive/2026-09-09-partners-are-organisations/proposal.md similarity index 100% rename from openspec/changes/partners-are-organisations/proposal.md rename to openspec/changes/archive/2026-09-09-partners-are-organisations/proposal.md diff --git a/openspec/changes/partners-are-organisations/specs/partner-organisations/spec.md b/openspec/changes/archive/2026-09-09-partners-are-organisations/specs/partner-organisations/spec.md similarity index 100% rename from openspec/changes/partners-are-organisations/specs/partner-organisations/spec.md rename to openspec/changes/archive/2026-09-09-partners-are-organisations/specs/partner-organisations/spec.md diff --git a/openspec/changes/partners-are-organisations/tasks.md b/openspec/changes/archive/2026-09-09-partners-are-organisations/tasks.md similarity index 100% rename from openspec/changes/partners-are-organisations/tasks.md rename to openspec/changes/archive/2026-09-09-partners-are-organisations/tasks.md diff --git a/openspec/changes/performance-hardening-audit-log-and-boot/.openspec.yaml b/openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/.openspec.yaml similarity index 100% rename from openspec/changes/performance-hardening-audit-log-and-boot/.openspec.yaml rename to openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/.openspec.yaml diff --git a/openspec/changes/performance-hardening-audit-log-and-boot/proposal.md b/openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/proposal.md similarity index 100% rename from openspec/changes/performance-hardening-audit-log-and-boot/proposal.md rename to openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/proposal.md diff --git a/openspec/changes/performance-hardening-audit-log-and-boot/specs/performance-hardening/spec.md b/openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/specs/performance-hardening/spec.md similarity index 100% rename from openspec/changes/performance-hardening-audit-log-and-boot/specs/performance-hardening/spec.md rename to openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/specs/performance-hardening/spec.md diff --git a/openspec/changes/performance-hardening-audit-log-and-boot/tasks.md b/openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/tasks.md similarity index 100% rename from openspec/changes/performance-hardening-audit-log-and-boot/tasks.md rename to openspec/changes/archive/2026-09-09-performance-hardening-audit-log-and-boot/tasks.md diff --git a/openspec/changes/archive/2026-09-09-proposals-are-cases/.openspec.yaml b/openspec/changes/archive/2026-09-09-proposals-are-cases/.openspec.yaml new file mode 100644 index 000000000..3c7e76032 --- /dev/null +++ b/openspec/changes/archive/2026-09-09-proposals-are-cases/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-03 +skip_specs: true diff --git a/openspec/changes/proposals-are-cases/proposal.md b/openspec/changes/archive/2026-09-09-proposals-are-cases/proposal.md similarity index 100% rename from openspec/changes/proposals-are-cases/proposal.md rename to openspec/changes/archive/2026-09-09-proposals-are-cases/proposal.md diff --git a/openspec/changes/proposals-are-cases/specs/besluitvorming-workflow/spec.md b/openspec/changes/archive/2026-09-09-proposals-are-cases/spec-changes-already-applied.md similarity index 86% rename from openspec/changes/proposals-are-cases/specs/besluitvorming-workflow/spec.md rename to openspec/changes/archive/2026-09-09-proposals-are-cases/spec-changes-already-applied.md index e9c635649..d4fca22a0 100644 --- a/openspec/changes/proposals-are-cases/specs/besluitvorming-workflow/spec.md +++ b/openspec/changes/archive/2026-09-09-proposals-are-cases/spec-changes-already-applied.md @@ -1,6 +1,16 @@ # besluitvorming-workflow delta +**Already applied.** These four edits were written into +`openspec/specs/besluitvorming-workflow/spec.md` by the implementation commit +itself, af1ff19a: REQ-BVW-002 and REQ-BVW-003 are gone from the main spec, and +scenarios REQ-BVW-001-B and REQ-BVW-008-B no longer name a `parafeerroute` or a +`parafeeractie`. This file is the reasoning behind those edits, kept as the +record. It is written as prose rather than as replacement requirement blocks, so +the change archives with `--skip-specs`: re-applying it would rewrite four +requirements with their own rationale. + + ## REMOVED Requirements ### Removed Requirement: REQ-BVW-002 Parafering chain MUST activate automatically when a voorstel is submitted diff --git a/openspec/changes/proposals-are-cases/tasks.md b/openspec/changes/archive/2026-09-09-proposals-are-cases/tasks.md similarity index 100% rename from openspec/changes/proposals-are-cases/tasks.md rename to openspec/changes/archive/2026-09-09-proposals-are-cases/tasks.md diff --git a/openspec/changes/reassignment-is-a-bulk-action/proposal.md b/openspec/changes/archive/2026-09-09-reassignment-is-a-bulk-action/proposal.md similarity index 100% rename from openspec/changes/reassignment-is-a-bulk-action/proposal.md rename to openspec/changes/archive/2026-09-09-reassignment-is-a-bulk-action/proposal.md diff --git a/openspec/changes/reassignment-is-a-bulk-action/specs/reassignment-bulk-action/spec.md b/openspec/changes/archive/2026-09-09-reassignment-is-a-bulk-action/specs/reassignment-bulk-action/spec.md similarity index 100% rename from openspec/changes/reassignment-is-a-bulk-action/specs/reassignment-bulk-action/spec.md rename to openspec/changes/archive/2026-09-09-reassignment-is-a-bulk-action/specs/reassignment-bulk-action/spec.md diff --git a/openspec/changes/reassignment-is-a-bulk-action/tasks.md b/openspec/changes/archive/2026-09-09-reassignment-is-a-bulk-action/tasks.md similarity index 100% rename from openspec/changes/reassignment-is-a-bulk-action/tasks.md rename to openspec/changes/archive/2026-09-09-reassignment-is-a-bulk-action/tasks.md diff --git a/openspec/changes/remove-unused-map-clustering-dependency/.openspec.yaml b/openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/.openspec.yaml similarity index 100% rename from openspec/changes/remove-unused-map-clustering-dependency/.openspec.yaml rename to openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/.openspec.yaml diff --git a/openspec/changes/remove-unused-map-clustering-dependency/proposal.md b/openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/proposal.md similarity index 100% rename from openspec/changes/remove-unused-map-clustering-dependency/proposal.md rename to openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/proposal.md diff --git a/openspec/changes/remove-unused-map-clustering-dependency/specs/frontend-build-hygiene/spec.md b/openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/specs/frontend-build-hygiene/spec.md similarity index 100% rename from openspec/changes/remove-unused-map-clustering-dependency/specs/frontend-build-hygiene/spec.md rename to openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/specs/frontend-build-hygiene/spec.md diff --git a/openspec/changes/remove-unused-map-clustering-dependency/tasks.md b/openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/tasks.md similarity index 100% rename from openspec/changes/remove-unused-map-clustering-dependency/tasks.md rename to openspec/changes/archive/2026-09-09-remove-unused-map-clustering-dependency/tasks.md diff --git a/openspec/changes/requestdecision-recovers-a-missed-conclusion/proposal.md b/openspec/changes/archive/2026-09-09-requestdecision-recovers-a-missed-conclusion/proposal.md similarity index 100% rename from openspec/changes/requestdecision-recovers-a-missed-conclusion/proposal.md rename to openspec/changes/archive/2026-09-09-requestdecision-recovers-a-missed-conclusion/proposal.md diff --git a/openspec/changes/requestdecision-recovers-a-missed-conclusion/specs/case-flow-human-steps/spec.md b/openspec/changes/archive/2026-09-09-requestdecision-recovers-a-missed-conclusion/specs/case-flow-human-steps/spec.md similarity index 99% rename from openspec/changes/requestdecision-recovers-a-missed-conclusion/specs/case-flow-human-steps/spec.md rename to openspec/changes/archive/2026-09-09-requestdecision-recovers-a-missed-conclusion/specs/case-flow-human-steps/spec.md index 088b84fba..e6b908a3c 100644 --- a/openspec/changes/requestdecision-recovers-a-missed-conclusion/specs/case-flow-human-steps/spec.md +++ b/openspec/changes/archive/2026-09-09-requestdecision-recovers-a-missed-conclusion/specs/case-flow-human-steps/spec.md @@ -53,8 +53,6 @@ conclusion arrives; this is what a consumer consults when it did not. surface of its own; pinned by ContractDecisionDelegationReadTest and end to end through the real engine by tests/Unit/Flow/RequestDecisionHeartbeatRecoveryTest.php. -## MODIFIED Requirements - ### Requirement: A decision step advances on its decision, not on a signal `dossiq.requestDecision` SHALL raise exactly one decision on its first pass, diff --git a/openspec/changes/requestdecision-recovers-a-missed-conclusion/tasks.md b/openspec/changes/archive/2026-09-09-requestdecision-recovers-a-missed-conclusion/tasks.md similarity index 100% rename from openspec/changes/requestdecision-recovers-a-missed-conclusion/tasks.md rename to openspec/changes/archive/2026-09-09-requestdecision-recovers-a-missed-conclusion/tasks.md diff --git a/openspec/changes/requester-on-the-case/design.md b/openspec/changes/archive/2026-09-09-requester-on-the-case/design.md similarity index 100% rename from openspec/changes/requester-on-the-case/design.md rename to openspec/changes/archive/2026-09-09-requester-on-the-case/design.md diff --git a/openspec/changes/requester-on-the-case/proposal.md b/openspec/changes/archive/2026-09-09-requester-on-the-case/proposal.md similarity index 100% rename from openspec/changes/requester-on-the-case/proposal.md rename to openspec/changes/archive/2026-09-09-requester-on-the-case/proposal.md diff --git a/openspec/changes/requester-on-the-case/specs/brp-register/spec.md b/openspec/changes/archive/2026-09-09-requester-on-the-case/specs/brp-register/spec.md similarity index 100% rename from openspec/changes/requester-on-the-case/specs/brp-register/spec.md rename to openspec/changes/archive/2026-09-09-requester-on-the-case/specs/brp-register/spec.md diff --git a/openspec/changes/requester-on-the-case/specs/initiator-display/spec.md b/openspec/changes/archive/2026-09-09-requester-on-the-case/specs/initiator-display/spec.md similarity index 100% rename from openspec/changes/requester-on-the-case/specs/initiator-display/spec.md rename to openspec/changes/archive/2026-09-09-requester-on-the-case/specs/initiator-display/spec.md diff --git a/openspec/changes/requester-on-the-case/specs/initiator-selection/spec.md b/openspec/changes/archive/2026-09-09-requester-on-the-case/specs/initiator-selection/spec.md similarity index 100% rename from openspec/changes/requester-on-the-case/specs/initiator-selection/spec.md rename to openspec/changes/archive/2026-09-09-requester-on-the-case/specs/initiator-selection/spec.md diff --git a/openspec/changes/requester-on-the-case/tasks.md b/openspec/changes/archive/2026-09-09-requester-on-the-case/tasks.md similarity index 100% rename from openspec/changes/requester-on-the-case/tasks.md rename to openspec/changes/archive/2026-09-09-requester-on-the-case/tasks.md diff --git a/openspec/changes/retire-status-history-page/.openspec.yaml b/openspec/changes/archive/2026-09-09-retire-status-history-page/.openspec.yaml similarity index 100% rename from openspec/changes/retire-status-history-page/.openspec.yaml rename to openspec/changes/archive/2026-09-09-retire-status-history-page/.openspec.yaml diff --git a/openspec/changes/retire-status-history-page/design.md b/openspec/changes/archive/2026-09-09-retire-status-history-page/design.md similarity index 100% rename from openspec/changes/retire-status-history-page/design.md rename to openspec/changes/archive/2026-09-09-retire-status-history-page/design.md diff --git a/openspec/changes/retire-status-history-page/proposal.md b/openspec/changes/archive/2026-09-09-retire-status-history-page/proposal.md similarity index 100% rename from openspec/changes/retire-status-history-page/proposal.md rename to openspec/changes/archive/2026-09-09-retire-status-history-page/proposal.md diff --git a/openspec/changes/retire-status-history-page/specs/case-history-surface/spec.md b/openspec/changes/archive/2026-09-09-retire-status-history-page/specs/case-history-surface/spec.md similarity index 100% rename from openspec/changes/retire-status-history-page/specs/case-history-surface/spec.md rename to openspec/changes/archive/2026-09-09-retire-status-history-page/specs/case-history-surface/spec.md diff --git a/openspec/changes/retire-status-history-page/tasks.md b/openspec/changes/archive/2026-09-09-retire-status-history-page/tasks.md similarity index 100% rename from openspec/changes/retire-status-history-page/tasks.md rename to openspec/changes/archive/2026-09-09-retire-status-history-page/tasks.md diff --git a/openspec/changes/the-besluit-resolves-to-decidiqs-decision/proposal.md b/openspec/changes/archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision/proposal.md similarity index 100% rename from openspec/changes/the-besluit-resolves-to-decidiqs-decision/proposal.md rename to openspec/changes/archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision/proposal.md diff --git a/openspec/changes/the-besluit-resolves-to-decidiqs-decision/specs/zgw-brc/spec.md b/openspec/changes/archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision/specs/zgw-brc/spec.md similarity index 100% rename from openspec/changes/the-besluit-resolves-to-decidiqs-decision/specs/zgw-brc/spec.md rename to openspec/changes/archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision/specs/zgw-brc/spec.md diff --git a/openspec/changes/the-besluit-resolves-to-decidiqs-decision/tasks.md b/openspec/changes/archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision/tasks.md similarity index 100% rename from openspec/changes/the-besluit-resolves-to-decidiqs-decision/tasks.md rename to openspec/changes/archive/2026-09-09-the-besluit-resolves-to-decidiqs-decision/tasks.md diff --git a/openspec/changes/the-case-page-finished/proposal.md b/openspec/changes/archive/2026-09-09-the-case-page-finished/proposal.md similarity index 100% rename from openspec/changes/the-case-page-finished/proposal.md rename to openspec/changes/archive/2026-09-09-the-case-page-finished/proposal.md diff --git a/openspec/changes/the-case-page-finished/specs/case-dashboard-view/spec.md b/openspec/changes/archive/2026-09-09-the-case-page-finished/specs/case-dashboard-view/spec.md similarity index 78% rename from openspec/changes/the-case-page-finished/specs/case-dashboard-view/spec.md rename to openspec/changes/archive/2026-09-09-the-case-page-finished/specs/case-dashboard-view/spec.md index 4d15e18b8..eff2fd8d2 100644 --- a/openspec/changes/the-case-page-finished/specs/case-dashboard-view/spec.md +++ b/openspec/changes/archive/2026-09-09-the-case-page-finished/specs/case-dashboard-view/spec.md @@ -1,7 +1,25 @@ -## MODIFIED Requirements +## REMOVED Requirements ### Requirement: The tab strip fits one row and reads in work order (REQ-CDV-16) +**Reason:** the case page went from nine tabs to six, so the requirement this +replaces no longer describes the strip. Its three scenarios assert a five-tab +work order with Sub-cases, Locations, Appointments and Decisions trailing it, +and a tab hidden when its collection is empty. All three are false after the +consolidation: those four panels are now sections inside Related and Objects +and locations, and a section holding nothing renders a line of text inside a +tab the handler opened on purpose, which is why `visibleIf` is no longer +wanted. Carrying the scenarios forward would leave the spec asserting a strip +that is not on the page. Written as a removal plus an addition rather than a +rewrite in place so the three dropped scenarios are dropped on the record. + +**Migration:** none. The requirement below carries the same REQ-CDV-16 id and +covers the same ground for the six-tab strip. + +## ADDED Requirements + +### Requirement: Six tabs hold every panel of the case (REQ-CDV-16) + You reach every panel of the case from one row of six tabs. The `case-panels` widget on `CaseDetail` SHALL list exactly six tabs, in the order Data (`case-core`), Documents (`case-documents-panel`), People diff --git a/openspec/changes/the-case-page-finished/tasks.md b/openspec/changes/archive/2026-09-09-the-case-page-finished/tasks.md similarity index 100% rename from openspec/changes/the-case-page-finished/tasks.md rename to openspec/changes/archive/2026-09-09-the-case-page-finished/tasks.md diff --git a/openspec/changes/woo-publication-in-process-object-writes/proposal.md b/openspec/changes/archive/2026-09-09-woo-publication-in-process-object-writes/proposal.md similarity index 100% rename from openspec/changes/woo-publication-in-process-object-writes/proposal.md rename to openspec/changes/archive/2026-09-09-woo-publication-in-process-object-writes/proposal.md diff --git a/openspec/changes/woo-publication-in-process-object-writes/specs/woo-publication-via-opencatalogi/spec.md b/openspec/changes/archive/2026-09-09-woo-publication-in-process-object-writes/specs/woo-publication-via-opencatalogi/spec.md similarity index 100% rename from openspec/changes/woo-publication-in-process-object-writes/specs/woo-publication-via-opencatalogi/spec.md rename to openspec/changes/archive/2026-09-09-woo-publication-in-process-object-writes/specs/woo-publication-via-opencatalogi/spec.md diff --git a/openspec/changes/woo-publication-in-process-object-writes/tasks.md b/openspec/changes/archive/2026-09-09-woo-publication-in-process-object-writes/tasks.md similarity index 100% rename from openspec/changes/woo-publication-in-process-object-writes/tasks.md rename to openspec/changes/archive/2026-09-09-woo-publication-in-process-object-writes/tasks.md diff --git a/openspec/changes/workflow-definitions-to-flow/design.md b/openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/design.md similarity index 100% rename from openspec/changes/workflow-definitions-to-flow/design.md rename to openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/design.md diff --git a/openspec/changes/workflow-definitions-to-flow/proposal.md b/openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/proposal.md similarity index 100% rename from openspec/changes/workflow-definitions-to-flow/proposal.md rename to openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/proposal.md diff --git a/openspec/changes/workflow-definitions-to-flow/specs/workflow-definitions-to-flow/spec.md b/openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/specs/workflow-definitions-to-flow/spec.md similarity index 100% rename from openspec/changes/workflow-definitions-to-flow/specs/workflow-definitions-to-flow/spec.md rename to openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/specs/workflow-definitions-to-flow/spec.md diff --git a/openspec/changes/workflow-definitions-to-flow/tasks.md b/openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/tasks.md similarity index 100% rename from openspec/changes/workflow-definitions-to-flow/tasks.md rename to openspec/changes/archive/2026-09-09-workflow-definitions-to-flow/tasks.md diff --git a/openspec/changes/workflow-variants/design.md b/openspec/changes/archive/2026-09-09-workflow-variants/design.md similarity index 100% rename from openspec/changes/workflow-variants/design.md rename to openspec/changes/archive/2026-09-09-workflow-variants/design.md diff --git a/openspec/changes/workflow-variants/proposal.md b/openspec/changes/archive/2026-09-09-workflow-variants/proposal.md similarity index 100% rename from openspec/changes/workflow-variants/proposal.md rename to openspec/changes/archive/2026-09-09-workflow-variants/proposal.md diff --git a/openspec/changes/workflow-variants/specs/vth-workflow-templates/spec.md b/openspec/changes/archive/2026-09-09-workflow-variants/specs/vth-workflow-templates/spec.md similarity index 83% rename from openspec/changes/workflow-variants/specs/vth-workflow-templates/spec.md rename to openspec/changes/archive/2026-09-09-workflow-variants/specs/vth-workflow-templates/spec.md index 0a93efba5..38b4b57f4 100644 --- a/openspec/changes/workflow-variants/specs/vth-workflow-templates/spec.md +++ b/openspec/changes/archive/2026-09-09-workflow-variants/specs/vth-workflow-templates/spec.md @@ -61,6 +61,25 @@ A catalogue entry sharing a case type with another SHALL declare a `variant`, an - **THEN** the workflow SHALL support escalation transitions: last onder dwangsom, verbeuring, bestuursdwang - **THEN** each escalation step SHALL require a new handhavingsactie record with updated ernst/gedrag classification +#### Notes: how the two enforcement routes stopped deprecating each other + +The catalogue has always shipped two templates against `handhavingszaak`: +`handhavingstraject`, the ordinary enforcement route, and `spoedig-herstel`, the +Awb 5:31 route where the authority acts first and issues the decision +afterwards. Both are real, a municipality runs both, and which one a case +follows is decided at constatering rather than at configuration time. + +Until 2026-09-05 the model could not say that. One published definition per case +type meant whichever the seeder reached last deprecated the other, silently. +Two ways out were rejected: minting a sixth VTH case type changes what a +municipality registers as a zaaktype, and leaving the deprecation ships an +enforcement route that is dark on every install. + +The rule is now one published definition per `(case type, route)`, and these two +entries declare different routes. `workflowTemplate.parentWorkflow` is still not +that mechanism: it is Enterprise tier, unimplemented, and describes a hierarchy +BETWEEN case types. See `openspec/specs/workflow-variants/spec.md`. + ### REQ-001: SeedVthWorkflowTemplates repair step SHALL idempotently seed the VTH workflow catalog from bundled JSON files `OCA\Dossiq\Repair\SeedVthWorkflowTemplates` SHALL implement `IRepairStep` and SHALL run on every app enable / upgrade, with the behaviour the existing requirement describes: graceful no-ops when OpenRegister or the catalog directory is missing, per-file error containment, a per-entry summary, and idempotency keyed on case type plus title. diff --git a/openspec/changes/workflow-variants/specs/workflow-definition-model/spec.md b/openspec/changes/archive/2026-09-09-workflow-variants/specs/workflow-definition-model/spec.md similarity index 94% rename from openspec/changes/workflow-variants/specs/workflow-definition-model/spec.md rename to openspec/changes/archive/2026-09-09-workflow-variants/specs/workflow-definition-model/spec.md index f82624f70..d945b489a 100644 --- a/openspec/changes/workflow-variants/specs/workflow-definition-model/spec.md +++ b/openspec/changes/archive/2026-09-09-workflow-variants/specs/workflow-definition-model/spec.md @@ -4,7 +4,7 @@ ## MODIFIED Requirements -### REQ-001: WorkflowDefinitionController SHALL expose lifecycle + lookup endpoints +### Requirement: REQ-001 WorkflowDefinitionController SHALL expose lifecycle + lookup endpoints `OCA\Dossiq\Controller\WorkflowDefinitionController` SHALL provide HTTP endpoints for: `publish($id)` (move draft → active), `deprecate($id)` (active → deprecated), `cloneDefinition($id)` (create new draft from existing), `active($caseTypeId)` (lookup currently-active version for a case type), and `forCase($caseId)` (lookup version bound to a specific case). Each endpoint SHALL delegate to `WorkflowDefinitionService` and SHALL reject lifecycle transitions that violate the draft → active → deprecated state machine. @@ -19,7 +19,7 @@ - **WHEN** `POST /api/workflow-definitions/{id}/clone` is called on a definition of the `spoedeisend` route - **THEN** the resulting draft SHALL be on the `spoedeisend` route -### REQ-002: WorkflowDefinitionService SHALL implement the full lifecycle + version selection +### Requirement: REQ-002 WorkflowDefinitionService SHALL implement the full lifecycle + version selection `OCA\Dossiq\Service\WorkflowDefinitionService` SHALL provide the canonical version-selection logic (`getActiveDefinitionFor($caseTypeId, $variant)`, `getDefinitionForCase($caseId)`, `listVersions($caseTypeId)`) and the full lifecycle (`createDraft`, `publish`, `deprecate`, `cloneDefinition`, `getDefinition`). diff --git a/openspec/changes/workflow-variants/specs/workflow-variants/spec.md b/openspec/changes/archive/2026-09-09-workflow-variants/specs/workflow-variants/spec.md similarity index 100% rename from openspec/changes/workflow-variants/specs/workflow-variants/spec.md rename to openspec/changes/archive/2026-09-09-workflow-variants/specs/workflow-variants/spec.md diff --git a/openspec/changes/workflow-variants/tasks.md b/openspec/changes/archive/2026-09-09-workflow-variants/tasks.md similarity index 100% rename from openspec/changes/workflow-variants/tasks.md rename to openspec/changes/archive/2026-09-09-workflow-variants/tasks.md diff --git a/openspec/specs/add-work-queue/spec.md b/openspec/specs/add-work-queue/spec.md new file mode 100644 index 000000000..420424e8a --- /dev/null +++ b/openspec/specs/add-work-queue/spec.md @@ -0,0 +1,60 @@ +# add-work-queue Specification + +## Purpose + +Unclaimed work gets a page of its own. Assigned to me shows what a handler +owns, and All cases shows everything. Neither shows what is open and waiting for +somebody to pick it up. The Queue answers that third question, and the three +surfaces follow one rule. + +## Requirements + +### Requirement: The queue holds the work nobody has picked up + +dossiq SHALL provide a Queue page at `/queue`, an index over the `case` schema +filtered to cases with no `assignee` and `isFinalStatus` false. It SHALL sit first in +the My work group, above Assigned to me. + +The base filter SHALL be `assignee: "IS NULL"` and `isFinalStatus: false`. +`assignee: "IS NULL"` is the literal sentinel every OpenRegister condition builder +matches by value, and it SHALL be preferred over the `assignee_isnull=true` suffix, +which was unimplemented when this page shipped and works only on instances carrying +openregister `isnull-filter-operator`. + +#### Scenario: The queue holds unassigned open cases + +- **WHEN** a handler opens `/queue` +- **THEN** every row is a case with no assignee whose `isFinalStatus` is false +- **AND** the page holds strictly fewer rows than `/cases` + +#### Scenario: The queue narrows by case type + +- **WHEN** a handler selects a case type in the folder sidebar on `/queue` +- **THEN** the rows are the unassigned open cases of that case type + +#### Scenario: An empty queue says so + +- **WHEN** no case is both unassigned and open +- **THEN** `/queue` renders its empty state rather than an empty table + +### Requirement: Three surfaces, one rule + +The My work group SHALL offer Queue, Assigned to me and All cases, and a case SHALL +be reachable from at least one of them at all times. Assigning a case SHALL move it +from the Queue to the assignee's Assigned to me; All cases SHALL show it in both +states. + +#### Scenario: The group offers all five surfaces + +- **WHEN** a handler opens the navigation +- **THEN** the My work group holds Queue, Assigned to me, All cases, Tasks and + Workflow board + +#### Scenario: Assigning a case moves it +@e2e exclude mutates a shared instance — assigning a case rewrites demo data the other suites read; the filter itself is asserted by queue.spec.ts, which proves the queue is a strict subset of the case index + +- **GIVEN** an unassigned open case on `/queue` +- **WHEN** a handler is set as its assignee +- **THEN** the case no longer appears on `/queue` +- **AND** it appears on that handler's `/my-work` +- **AND** it appears on `/cases` in both states diff --git a/openspec/specs/besluitvorming-leaf/spec.md b/openspec/specs/besluitvorming-leaf/spec.md new file mode 100644 index 000000000..646e59c50 --- /dev/null +++ b/openspec/specs/besluitvorming-leaf/spec.md @@ -0,0 +1,64 @@ +# besluitvorming-leaf Specification + +## Purpose + +dossiq shows decidesk's Besluitvorming on the case page instead of building a +second one. The surface arrives as an OpenRegister integration leaf. The +standalone Besluitvorming navigation retires, and its pages stay routable so no +link breaks. + +## Requirements + +### Requirement: REQ-BVL-001 — The case-detail page MUST surface decidesk's Besluitvorming as an OR integration leaf + +The dossiq `CaseDetail` page MUST surface decidesk's decision-making via the shared OpenRegister integration registry leaf `decidesk-decisions` as a sidebar tab labelled "Besluitvorming". dossiq MUST resolve the registered provider's tab component from `window.OCA.OpenRegister.integrations` at render time and forward the case's `{ register, schema, objectId }` context. dossiq MUST NOT re-implement the decision list, create, or open-in-decidesk behaviour — it only references the registry id (ADR-019 / ADR-022). + +#### Scenario: Besluitvorming tab appears on the case detail + +- GIVEN the decidesk app is enabled and its decisions leaf is registered on the OR integration registry +- WHEN the user opens a dossiq case detail page +- THEN a "Besluitvorming" tab is shown in the case sidebar tab strip + +#### Scenario: The leaf reads decisions linked to the case + +- GIVEN the "Besluitvorming" tab is open on a case detail +- WHEN the leaf loads +- THEN it lists the decidesk proposals/advice/decisions whose `subjectId` matches the case uuid (read path), using the `{ register, schema, objectId }` context forwarded by dossiq + +#### Scenario: Graceful fallback when decidesk's leaf is absent + +- GIVEN the decidesk decisions leaf is NOT registered on the OR integration registry +- WHEN the user opens the "Besluitvorming" tab on a case detail +- THEN a quiet "Besluitvorming unavailable" notice is shown instead of a broken tab + +### Requirement: REQ-BVL-002 — The standalone Besluitvorming nav MUST be retired while its pages stay routable + +The `Voorstellen`, `Advies` (`Advice`) and `Agenda` (`BesluitvormingAgenda`) top-level navigation entries and the `BesluitvormingGroup` group MUST NOT appear in the dossiq left navigation. Their underlying pages (`/voorstellen`, `/voorstellen/:id`, `/advice`, `/advice/:id`, `/besluitvorming/agenda`, `/besluitvorming/vergaderingen/:id`) and the `voorstel` / `adviesAanvraag` schemas and data MUST remain intact and reachable as deep links (ADR-044). + +#### Scenario: Besluitvorming nav entries are gone + +- GIVEN the user opens the dossiq app +- WHEN the sidebar navigation renders +- THEN no "Voorstellen", "Advies", "Agenda" or "Besluitvorming" group entries appear in the navigation + +#### Scenario: Former pages stay reachable by deep link + +- GIVEN the Besluitvorming nav has been retired +- WHEN the user navigates directly to `/voorstellen` (or `/advice`, `/besluitvorming/agenda`) +- THEN the corresponding page still renders (the route is registered) + +### Requirement: REQ-BVL-003 — The voorstel → decidesk migration MUST reuse the existing delegation repair step + +The migration of dossiq `voorstel` records into decidesk's decision model MUST be performed by the existing `dossiq-delegate-remaining-decisions-to-decidesk` mechanism (`AdviceDelegationService::raiseVoorstelBesluit` invoked from `LinkInFlightRemainingDecisionsRepair`), which raises a decidesk Decision with `subjectId = voorstel.uuid`, `externalReference = case`, `subjectLabel = onderwerp`, and records the `decisionRef` back on the voorstel. This change MUST NOT introduce a second migration mechanism, MUST NOT drop data (terminal/already-linked records are kept), and MUST be idempotent. + +#### Scenario: Migration delegates to the existing repair step + +- GIVEN dossiq `voorstel` records exist that are not yet linked to a decidesk Decision and are not in a terminal status +- WHEN `LinkInFlightRemainingDecisionsRepair` runs (on `occ upgrade`) +- THEN each is linked forward to a decidesk Decision via `raiseVoorstelBesluit` and its `decisionRef` is persisted, without dropping any voorstel field + +#### Scenario: Migration warns and skips when the decidesk write path is unavailable + +- GIVEN the decidesk decision write path is unavailable (e.g. the `decisionType: format: uuid` 422 caveat) +- WHEN the repair step attempts to link a voorstel +- THEN it warns and skips that record rather than failing the migration, leaving the voorstel data intact for a later re-run diff --git a/openspec/specs/brp-register/spec.md b/openspec/specs/brp-register/spec.md index ff702b7ef..4574eb0b4 100644 --- a/openspec/specs/brp-register/spec.md +++ b/openspec/specs/brp-register/spec.md @@ -7,6 +7,12 @@ **Standards:** Haal Centraal BRP Personen bevragen (field naming), GEMMA Zaakafhandel (initiator betrokkene), ZGW ZRC Rol `natuurlijk_persoon`, Schema.org `schema:Person` **Feature tier:** MVP +## Purpose + +The BRP person register set in OpenRegister: the `brpPerson` schema and its +fictitious seed rows, named after the Haal Centraal BRP Personen bevragen API so +the live adapter and the seed data describe the same person the same way. + ## Requirements ### Requirement: BRP person register schema exists in OpenRegister @@ -58,3 +64,39 @@ Each row's description SHALL identify it as official fictitious test data. - **WHEN** the register configuration is imported on a fresh instance - **THEN** 10 `brpPerson` rows MUST exist and be searchable through the OpenRegister objects API + +### Requirement: A BRP person carries the secrecy indication (REQ-BRP-003) + +You can tell which persons asked for their data to be protected. The +`brpPerson` schema SHALL carry `indicatieGeheim` (boolean, default false), +mapped from Haal Centraal `geheimhoudingPersoonsgegevens`, where any value +other than 0 is true. `brpPerson` SHALL set `logReads: true`, so every read of +a person row is logged by OpenRegister. The seed SHALL flag one of the ten +personas, taken from a personen-mock row that carries the indication, never +invented. The property is additive; existing rows stay valid without it. + +#### Scenario: The schema carries the flag after import + +@e2e exclude Repair-step import with no browser surface: PHPUnit (BrpKvkRegisterSetsTest::testBrpPersonCarriesIndicatieGeheim) asserts the property, its default and logReads on the fragment; the masked card it drives is asserted in tests/e2e/case-requester.spec.ts under initiator-display. + +- **WHEN** the register configuration is imported +- **THEN** `brpPerson` MUST have `indicatieGeheim` as an optional boolean defaulting to false +- **AND** `brpPerson` MUST set `logReads: true` +- **AND** rows without the property MUST remain valid + +#### Scenario: One seeded persona is protected + +@e2e exclude Seed-data presence at the API layer: PHPUnit (BrpKvkRegisterSetsTest::testExactlyOneSeedPersonaIsProtected) counts the flagged rows in the fragment; the rendered effect is asserted in tests/e2e/case-requester.spec.ts. + +- **WHEN** the register configuration is imported on a fresh instance +- **THEN** exactly one of the ten `brpPerson` rows MUST carry `indicatieGeheim: true` +- **AND** that row's BSN MUST match a personen-mock persona with `geheimhoudingPersoonsgegevens` set + +#### Scenario: The live adapter maps the indication + +@e2e exclude Adapter mapping over a mocked HTTP client: PHPUnit (HaalCentraalBrpAdapterTest::testGeheimhoudingMapsToIndicatieGeheim) covers 0, 1 and absent; no browser surface. + +- **GIVEN** a Haal Centraal response with `geheimhoudingPersoonsgegevens: 1` +- **WHEN** `HaalCentraalBrpAdapter` maps the person +- **THEN** the mapped row MUST carry `indicatieGeheim: true` +- **AND** a response with 0 or without the field MUST map to false diff --git a/openspec/specs/case-bulk-status-transition/spec.md b/openspec/specs/case-bulk-status-transition/spec.md index 69690019c..3099cb170 100644 --- a/openspec/specs/case-bulk-status-transition/spec.md +++ b/openspec/specs/case-bulk-status-transition/spec.md @@ -2,7 +2,9 @@ ## Purpose TBD - created by archiving change case-bulk-status-transition. Update Purpose after archive. + ## Requirements + ### Requirement: Bulk transitions go through the engine Bulk execution SHALL call `StatusTransitionService::execute()` once per case — evaluating guards and dispatching side effects per case — and SHALL NOT write status by any other path. A request SHALL be rejected with 400 when it contains more than 100 case ids, zero case ids, or no transition id. @@ -57,3 +59,72 @@ The workflow board SHALL offer a selection mode where cases can be multi-selecte @e2e exclude Dialog request/response orchestration extracted to a helper covered by vitest; endpoint behaviour covered by PHPUnit. +### Requirement: Bulk actions on the case index + +You select cases and move, suspend, resume or extend them in one go. The +`Cases` page `bulkActions` SHALL hold, next to Reassign: Transition +(handler `transitionSelection`), Suspend (`suspendSelection`), Resume +(`resumeSelection`) and Extend term (`extendTermSelection`). Each handler +SHALL open `BulkTransitionDialog` for the selected case ids in the matching +mode with a reason field, and the dialog SHALL refuse to execute while the +reason is empty. Transition SHALL list the transitions available to the +selection and post through the bulk-transition endpoints the workflow board +uses, so the engine, its guards and its automatic actions run for every +case. Suspend SHALL register a pause on each case's running term with the reason; +Resume SHALL end that pause; Extend term SHALL take a new end date and write +it with the reason. All three SHALL go through `CaseLifecycleService`'s +single-case `suspend()` / `resume()` / `extend()`, which own the case type's +`suspensionAllowed` and `extensionAllowed` rules, the already-suspended and +not-suspended checks, the journal entry on the case and the term-instance +write — a bulk gesture SHALL NOT reach past them to `DeadlinePauseService`, +which would mean either reimplementing every guard or shipping without them. + +**Extend term does NOT move the Deadline COLUMN.** `case.deadline` is +`readOnly` and materialised by OpenRegister from +`startDate + caseType.processingDeadline`, recomputed on every save, so +nothing outside that calculation can write it. An extension writes +`plannedEndDate` and the term instance's `endDateCurrent`, which is what the +statutory clock, the daily scan and the dwangsom engine read. Making the +column follow an extension is a schema change (teaching the calculation to +prefer `plannedEndDate`), which this change rules out. Every mode SHALL preview per case before executing and SHALL +report per case which succeeded and which did not, the way the board's +dialog does today; a partial failure SHALL never read as success. + +#### Scenario: Transition moves the selection with a reason +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** two open cases of one case type selected on the Cases page +- **WHEN** you choose Transition, pick a transition, enter a reason and execute +- **THEN** the dialog SHALL report 2 of 2 cases transitioned +- **AND** each case SHALL show the new status on its page + +#### Scenario: No reason, no execute +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** one open case selected and the Suspend dialog open +- **WHEN** the reason field is empty +- **THEN** the Execute button SHALL be disabled + +#### Scenario: Suspend and resume through the term +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** one open case with a running term selected on the Cases page +- **WHEN** you choose Suspend, enter a reason and execute +- **THEN** the case page SHALL show the term as paused with that reason +- **AND** choosing Resume for the same case and executing SHALL show the term as running again + +#### Scenario: Extend term writes the new end date +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** one open case due in 5 days selected on the Cases page +- **WHEN** you choose Extend term, set the new deadline to 20 days from today, enter a reason and execute +- **THEN** the dialog SHALL report 1 of 1 cases extended +- **AND** the case page SHALL show the extended term with that reason +- **AND** the Deadline column SHALL be unchanged, because `case.deadline` is a read-only calculation off the case type's processing deadline + +#### Scenario: A case the engine refuses is reported per case +@e2e exclude The refusal needs a transition guard that fails for one case and passes for another, which is a seeded status-type guard; the per-case reporting is covered by the vitest unit test on bulkTransitionHelpers.summariseResults and the existing board e2e. + +- **GIVEN** two cases selected, one of which a transition guard refuses +- **WHEN** you execute the transition +- **THEN** the dialog SHALL report 1 of 2 cases transitioned and name the refused case with its reason diff --git a/openspec/specs/case-dashboard-view/spec.md b/openspec/specs/case-dashboard-view/spec.md index 69d820a79..2f450ed25 100644 --- a/openspec/specs/case-dashboard-view/spec.md +++ b/openspec/specs/case-dashboard-view/spec.md @@ -84,7 +84,6 @@ The system MUST provide a single integrated view that combines all case-related **Feature tier**: MVP - #### Scenario CDV-01a: Load case dashboard - GIVEN case "Bouwvergunning Keizersgracht 100" (identifier "2026-042") @@ -126,7 +125,6 @@ Actions in one panel MUST immediately reflect in other panels without requiring **Feature tier**: MVP - #### Scenario CDV-02a: Status change updates timeline - GIVEN the behandelaar changes status from "Ontvangen" to "In behandeling" via the status timeline @@ -170,7 +168,6 @@ The case dashboard MUST provide quick actions for the most common operations wit **Feature tier**: MVP - #### Scenario CDV-03a: Quick status change - GIVEN the case dashboard is open @@ -218,7 +215,6 @@ The case dashboard MUST display contactmomenten (contact moments) linked to the **Feature tier**: V1 - #### Scenario CDV-04a: Display contactmomenten in timeline - GIVEN a case with 3 contactmomenten from Pipelinq: @@ -251,7 +247,6 @@ The case dashboard MUST display linked cases (parent/child, related) and linked **Feature tier**: V1 - #### Scenario CDV-05a: Display sub-cases - GIVEN a parent case "Bouwproject Centrum" with 2 sub-cases: "Sloopvergunning" (status: Afgehandeld) and "Bouwvergunning" (status: In behandeling) @@ -297,7 +292,6 @@ The case dashboard MUST display a document checklist showing required and upload **Feature tier**: V1 - #### Scenario CDV-06a: Display required documents - GIVEN a case type "Omgevingsvergunning Bouw" with required documents: bouwtekening, constructieberekening, situatietekening, welstandsadvies, foto's bestaande situatie @@ -330,7 +324,6 @@ The case dashboard MUST be usable on different screen sizes, following Nextcloud **Feature tier**: MVP - #### Scenario CDV-07a: Desktop layout (>1200px) - GIVEN a desktop screen with width 1440px @@ -359,7 +352,6 @@ The case dashboard SHALL support keyboard shortcuts for power users, consistent **Feature tier**: V1 - #### Scenario CDV-08a: Keyboard shortcuts - GIVEN the case dashboard is focused @@ -392,7 +384,6 @@ The case dashboard MUST display case-specific custom properties defined by the c **Feature tier**: V1 - #### Scenario CDV-09a: Display custom properties - GIVEN a case type "Omgevingsvergunning" with property definitions: bouwkosten (currency), oppervlakte (number + unit m2), aantal bouwlagen (integer) @@ -421,7 +412,6 @@ The case dashboard MUST validate edits before saving and provide clear feedback **Feature tier**: MVP - #### Scenario CDV-10a: Validate required fields - GIVEN the behandelaar clears the case title (required field) and clicks Save @@ -452,7 +442,6 @@ The case dashboard MUST render in read-only mode when the case is at a final sta **Feature tier**: MVP - #### Scenario CDV-11a: Final status read-only - GIVEN a case at final status "Afgehandeld" @@ -477,7 +466,6 @@ The case dashboard MUST support deleting a case with appropriate warnings. **Feature tier**: MVP - #### Scenario CDV-12a: Delete case with linked tasks - GIVEN a case with 5 linked tasks @@ -498,6 +486,264 @@ The case dashboard MUST support deleting a case with appropriate warnings. - THEN the Delete button MUST still be available (cases may need to be purged) - BUT a stronger warning MUST be shown: "Deze zaak is afgehandeld. Verwijderen is onomkeerbaar." +### Requirement: The case names itself in the header (REQ-CDV-14) + +You see which case you are on without reading the data widget. `CaseDetail` +SHALL set `config.subtitleField` to `identifier`, so the case number reads +under the title. The page SHALL render a header row widget `case-header` +on the first layout row that shows a `CnStatusBadge` with the name of the +case's status type and the deadline countdown (days left, or days overdue +in the danger variant), replacing the Time left tile. A case without a +status record SHALL show the badge as Unknown rather than nothing; a case +without a deadline SHALL show no countdown. The subtitle line built from +identifier, case type and assignee together is a nextcloud-vue need +(`CnDetailPage` has `subtitleField` only); until it lands the identifier +alone is the subtitle. + +#### Scenario: The number reads under the title +@e2e tests/e2e/case-header.spec.ts + +- **GIVEN** a case with identifier 2026-0015 and title Aanbouw Beethovenlaan 8 +- **WHEN** the handler opens the case page +- **THEN** the page title SHALL read Aanbouw Beethovenlaan 8 +- **AND** the subtitle SHALL read 2026-0015 + +#### Scenario: Status and deadline sit in the header row +@e2e tests/e2e/case-header.spec.ts + +- **GIVEN** a case in status In behandeling with a deadline 26 days ago +- **WHEN** the handler opens the case page +- **THEN** the header row SHALL show a status badge reading In behandeling +- **AND** the header row SHALL show 26 days overdue in the danger variant +- **AND** no tile labelled Time left SHALL render in the KPI row + +#### Scenario: A case without a status or a deadline still has a header +@e2e tests/e2e/case-header.spec.ts + +- **GIVEN** a case with no status record and no deadline +- **WHEN** the handler opens the case page +- **THEN** the status badge SHALL read Unknown +- **AND** the header row SHALL show no countdown + +#### Scenario: The full subtitle line waits on nextcloud-vue +@e2e exclude The templated subtitle (identifier, case type, assignee) needs a `subtitle` template on `CnDetailPage`, which 2.40.0 does not have; the manifest unit test asserts the interim `subtitleField` and the tasks.md marker tracks the block. + +- **GIVEN** nextcloud-vue ships a templated `subtitle` on the detail page +- **WHEN** `CaseDetail` sets it to identifier, case type and assignee +- **THEN** the subtitle SHALL read 2026-0015, Omgevingsvergunning, Jan de Vries + +### Requirement: A breadcrumb leads back to the list (REQ-CDV-15) + +You go back to the case list from the case without the menu. `CaseDetail` +SHALL declare `breadcrumbs` with Cases (route `Cases`) followed by the case +title, and the page SHALL render them through `CnBreadcrumbs` above the +title. The last crumb SHALL be the current page and SHALL not be a link. + +#### Scenario: Cases is one click away +@e2e tests/e2e/case-header.spec.ts + +- **GIVEN** the handler is on a case page +- **WHEN** they click Cases in the breadcrumb +- **THEN** the Cases page SHALL open +- **AND** the case list SHALL keep the lens it had before + +#### Scenario: The current crumb is not a link +@e2e tests/e2e/case-header.spec.ts + +- **GIVEN** a case titled Aanbouw Beethovenlaan 8 +- **WHEN** the handler opens the case page +- **THEN** the breadcrumb SHALL read Cases, Aanbouw Beethovenlaan 8 +- **AND** the last crumb SHALL have `aria-current="page"` and no `href` + +### Requirement: The case shows its step (REQ-CDV-13) + +You see which step the case is in and how many steps remain. `CaseDetail` +SHALL render a stepper over the case type's status types in their `order`, +with the current status marked as active and the statuses before it as done. +The stepper SHALL take the place of the milestone progress tile. + +#### Scenario: The current step is marked +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** a case type with statuses Ontvangen, In behandeling, Afgehandeld in that order +- **AND** a case in status In behandeling +- **WHEN** the handler opens the case page +- **THEN** the stepper SHALL show three stages +- **AND** Ontvangen SHALL be done, In behandeling active, Afgehandeld pending + +#### Scenario: The stepper follows a transition +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** the same case +- **WHEN** the handler moves it to Afgehandeld +- **THEN** the stepper SHALL mark Afgehandeld as active without a reload + +#### Scenario: The progress tile is gone +@e2e tests/e2e/case-detail-kpis-and-tabs.spec.ts + +- **WHEN** the handler opens any case page +- **THEN** no tile labelled Completed SHALL render in the KPI row +- **AND** the page SHALL make no request to `/milestones/progress` + +### Requirement: The case keeps one history (REQ-CDV-17) + +You read what happened on the case in one list, newest first, with who did +it. The `CaseDetail` sidebar SHALL carry exactly one history tab: the +`audit` tab labelled History, rendering `CnAuditTrailTab` over the case's +OpenRegister audit trail. The `version-history` tab +(`VersionHistoryLeafTab`) SHALL be absent from the `CaseDetail` sidebar. +Other detail pages keep their version history tab; this requirement +covers the case page only. Each row SHALL show the action, the user and +the time, and the list SHALL order newest first. + +#### Scenario: One history tab in the sidebar +@e2e tests/e2e/case-timeline.spec.ts + +- **GIVEN** a case with identifier 2026-0015 +- **WHEN** the handler opens the case page and opens the sidebar +- **THEN** the sidebar SHALL offer a tab with id `audit` +- **AND** no tab with id `version-history` SHALL be present + +#### Scenario: The newest write reads first, with its actor +@e2e tests/e2e/case-timeline.spec.ts + +- **GIVEN** a case whose description admin changed one minute ago +- **WHEN** the handler opens the History tab +- **THEN** the first row SHALL read action update, user admin +- **AND** the row that created the case SHALL sit below it + +#### Scenario: Other detail pages keep their version history +@e2e exclude The other 13 sidebars are `ncvue-w2-leaves-adoption`'s surface; the manifest unit test in `tests/vitest/manifestCaseTimeline.spec.js` asserts their tab counts are unchanged, and no journey opens them. + +- **GIVEN** the `TaskDetail` page +- **WHEN** the handler opens its sidebar +- **THEN** the Version history tab SHALL still be there + +### Requirement: The timeline shows writes, with reads on request (REQ-CDV-18) + +You are not scrolling past your own reads to find a change. The History +tab SHALL open on writes (create, update, delete) and SHALL keep reads +behind the Action filter. Presetting the filter is a nextcloud-vue need: +`CnAuditTrailTab` holds `actionFilter` as internal state and the `audit` +sidebar widget declares no prop for it. Until it lands the tab opens +unfiltered and the Action and User filters the tab already renders SHALL +work, not sit on Loading. + +#### Scenario: The Action filter narrows to updates +@e2e tests/e2e/case-timeline.spec.ts + +- **GIVEN** a case with one create, one update and several reads on its trail +- **WHEN** the handler picks update in the Action filter +- **THEN** the list SHALL show the update row only +- **AND** the filter SHALL not read Loading + +#### Scenario: The tab opens on writes +@e2e exclude The preset needs an `actions` (or equivalent) prop on the `audit` sidebar widget, placement section 3 row A05; the tasks.md marker tracks the block and the interim is the unfiltered tab with a working filter. + +- **GIVEN** nextcloud-vue ships a preset for `actionFilter` on the `audit` sidebar widget +- **WHEN** the handler opens the History tab +- **THEN** no row with action read SHALL show until the handler widens the filter + +### Requirement: The timeline can be exported (REQ-CDV-19) + +You hand the history of a case to someone outside the system. The History +tab SHALL offer an Export action that downloads the rows under the current +filter as CSV with time, action, user and the changed fields. The action is +a nextcloud-vue need on `CnAuditTrailTab`; until it lands there is no +export and the tab says nothing about one. + +#### Scenario: Export follows the filter +@e2e exclude `CnAuditTrailTab` has no export; placement section 3 row A05 files it against nextcloud-vue and the tasks.md marker tracks the block. + +- **GIVEN** the History tab filtered to updates +- **WHEN** the handler clicks Export +- **THEN** a CSV SHALL download holding the update rows only + +### Requirement: Documents, notes and mail land on the same timeline (REQ-CDV-20) + +You see a document upload or a sent mail in the same list as a status +change. The History tab SHALL merge document, note and mail events for the +case with its audit rows, in one order by time. A merged feed per object +is the OpenRegister `activity` leaf, which does not exist yet: +`[blocked: openregister activity leaf]`. Interim: the audit trail over +writes only, as REQ-CDV-17 and REQ-CDV-18 describe. + +#### Scenario: A document upload appears in the history +@e2e exclude The merged feed is the OpenRegister `activity` leaf (placement section 3, A05); until it ships the History tab holds audit rows only and the tasks.md marker tracks the block. + +- **GIVEN** a case to which the handler uploaded bouwtekening.pdf after a status change +- **WHEN** the handler opens the History tab +- **THEN** the upload SHALL read above the status change, with the handler as actor + +### Requirement: Six tabs hold every panel of the case (REQ-CDV-16) + +You reach every panel of the case from one row of six tabs. The `case-panels` +widget on `CaseDetail` SHALL list exactly six tabs, in the order Data +(`case-core`), Documents (`case-documents-panel`), People +(`case-people-panel`), Work (`case-work-panel`), Related +(`case-related-panel`) and Objects and locations (`case-objects-panel`). All +six SHALL fit on one line at 1024 pixels wide, above the fold. + +A tab MAY hold more than one panel, as a `case-sections` widget whose sections +render stacked under their own headings. Documents SHALL hold the dossier list +and the case folder; People SHALL hold the parties and the contact moments; +Work SHALL hold the tasks and the appointments; Related SHALL hold the related +cases and the sub-cases; Objects and locations SHALL hold the case objects and +the case locations. + +The strip SHALL carry no Notes, Mail, Decisions or Timeline tab. Each of those +duplicates a sidebar tab on the same page, and one surface in two places is +duplication rather than coverage (REQ-CDV-17). A panel SHALL NOT be removed +from the strip unless it is reachable elsewhere on the page: folding it into a +tab and deleting it look identical in a tab count. + +The strip SHALL NOT need `visibleIf` on a tab entry. That was wanted so a +collection holding nothing could be absent rather than empty; with six tabs +each holding two collections, an empty section is a line of text inside a tab +the handler opened deliberately. + +#### Scenario: The strip holds six tabs and no more +@e2e tests/e2e/case-detail-kpis-and-tabs.spec.ts +@e2e tests/e2e/case-header.spec.ts + +- **GIVEN** a case with three tasks and one document +- **WHEN** the handler opens the case page +- **THEN** the tab strip SHALL contain exactly six tabs +- **AND** they SHALL read Data, Documents, People, Work, Related, Objects and locations, in that order +- **AND** the strip SHALL carry no tab named Files, Notes, Mail or Decisions + +#### Scenario: The six tabs fit a laptop screen +@e2e tests/e2e/case-header.spec.ts + +- **GIVEN** a viewport 1024 pixels wide +- **WHEN** the handler opens the case page +- **THEN** all six tabs SHALL share one line +- **AND** the tab strip SHALL sit above the fold + +#### Scenario: Every folded panel still renders, inside the tab it moved to +@e2e tests/e2e/case-detail-kpis-and-tabs.spec.ts + +- **GIVEN** a case page whose strip holds six tabs +- **WHEN** the handler opens each tab in turn +- **THEN** each `case-sections` tab SHALL render both of its sections +- **AND** each section SHALL carry its own heading + +#### Scenario: Files keeps its share and comment surface +@e2e tests/e2e/case-documents.spec.ts +@e2e tests/e2e/case-detail-kpis-and-tabs.spec.ts + +- **GIVEN** a case page +- **WHEN** the handler opens the Documents tab +- **THEN** the dossier list SHALL render first +- **AND** the case folder SHALL render under it, as a section rather than a tab + +#### Scenario: A panel that leaves the strip is still on the page +@e2e exclude The three removed panels are sidebar tabs, and each already has its own e2e coverage on the sidebar; what needs guarding is that a LATER change cannot drop one body tab without the sidebar tab existing, which is a manifest shape rather than a rendered page. Asserted in tests/vitest/caseTabConsolidation.spec.js, which pairs each removed widget id with the sidebar tab id that carries it and fails when either half is missing. + +- **GIVEN** the Notes, Mail and Decisions panels are gone from the strip +- **WHEN** the manifest is read +- **THEN** the sidebar SHALL declare a notes, an email and a besluitvorming tab + ## Dependencies - **Case Management spec** (`../case-management/spec.md`): Defines all individual panels (REQ-CM-06 through REQ-CM-13). diff --git a/openspec/specs/case-flow-human-steps/spec.md b/openspec/specs/case-flow-human-steps/spec.md new file mode 100644 index 000000000..64dd2fdff --- /dev/null +++ b/openspec/specs/case-flow-human-steps/spec.md @@ -0,0 +1,383 @@ +# case-flow-human-steps Specification + +## Purpose +Defines how a case is walked by one flow run from intake to closure: where that run pauses for a person, what the person is shown while it waits, how their answer wakes exactly the step that asked, and what the applicant sees of the case's progress throughout. + +## Requirements + +### Requirement: One run walks one case @e2e exclude run lifecycle asserted by flow-engine integration tests; the user-visible halves are covered by the task and status scenarios below + +A case SHALL be walked by a single flow run created when the case is created. That run SHALL remain the case's run across every suspension and resume, so the case's whole history is one run rather than a series of unrelated ones. + +The flow SHALL be shipped with the app as a declaration on the `case` schema rather than authored by hand per installation. It SHALL arrive disabled and unowned: shipping a flow is not the same as an operator consenting to run it as themselves. + +#### Scenario: Creating a case starts its run +- **WHEN** a case is created and the case flow is enabled and adopted +- **THEN** exactly one run is started for that case +- **AND** the run names the case as its subject + +#### Scenario: The shipped flow is inert until adopted +- **WHEN** the app's register is imported or re-imported +- **THEN** the case flow exists in the flow store +- **AND** it is disabled and has no owner until somebody adopts it +- **AND** re-importing updates it rather than creating a second copy + +#### Scenario: A resumed run is the same run +- **WHEN** a case's run suspends on a human step and is later resumed +- **THEN** the case is still walked by the run that started it +- **AND** the steps recorded before and after the suspension read as one ordered history + +### Requirement: An incomplete case asks the applicant, and stops asking @e2e exclude covered by the case-flow e2e journey + +When the completeness check does not pass, the run SHALL create a task addressed to the applicant naming what is missing, and suspend until it is answered. On resume the completeness check SHALL run again. + +The loop SHALL be capped. A case whose applicant never supplies what is asked SHALL leave the loop by a declared route rather than cycling until the engine's transition ceiling stops it — a run that dies on the ceiling reports as a broken flow rather than as a case nobody answered. + +#### Scenario: An incomplete case produces a task for the applicant +- **WHEN** a newly created case fails the completeness check +- **THEN** a task is created addressed to the applicant +- **AND** the task states what is missing +- **AND** the run suspends rather than continuing + +#### Scenario: Supplying the information resumes the check +- **WHEN** the applicant completes the task +- **THEN** the run resumes at the step that asked +- **AND** the completeness check runs again +- **AND** a case that is now complete proceeds to the next stage + +#### Scenario: The loop is bounded +- **WHEN** the applicant has been asked the capped number of times without the case becoming complete +- **THEN** the run leaves the loop by its declared route +- **AND** the case's status says it is stalled awaiting the applicant +- **AND** the run does not terminate on the engine's transition ceiling + +### Requirement: A decision is asked of decidiq and waited for @e2e exclude cross-app delegation; asserted by node unit tests and the listener's resume test + +Where the flow requires a decision, it SHALL delegate to decidiq and suspend until decidiq reports the outcome. Dossiq SHALL NOT decide: it records the reference it was given and projects the outcome it is told. + +The run SHALL resume only on the outcome of the decision it is waiting for. An outcome for a different decision SHALL leave the run suspended. + +When decidiq is unavailable the request SHALL fail closed — the run does not proceed as though a decision had been made. + +#### Scenario: Requesting a decision suspends the run +- **WHEN** the flow reaches a decision step +- **THEN** a decision is raised in decidiq for this case +- **AND** the reference decidiq returns is recorded on the case +- **AND** the run suspends + +#### Scenario: The concluded decision resumes the waiting run +- **WHEN** decidiq concludes the decision the run is waiting for +- **THEN** the run resumes at the step that asked +- **AND** the outcome is available to the steps after it, so the flow can route on it + +#### Scenario: An unrelated outcome does not resume the run +- **WHEN** a decision concludes that this run is not waiting for +- **THEN** the run remains suspended + +#### Scenario: An unavailable decision service does not become an approval +- **WHEN** the decision cannot be raised because decidiq is unavailable +- **THEN** the step fails +- **AND** the run does not continue past the decision + +### Requirement: The final approval produces a decision document attached to the case @e2e exclude document generation; asserted by the action handler's tests + +After the final approval the flow SHALL generate the decision document from the configured template and attach it to the case, then close the case. + +A case SHALL NOT be closed without its decision document. If generation fails, the case remains open and the failure is visible — a closed case with no decision is a case whose outcome cannot be evidenced. + +#### Scenario: An approved case is closed with its document +- **WHEN** the planning commission approves +- **THEN** the decision document is generated from the template +- **AND** it is attached to the case +- **AND** the case moves to its final status + +#### Scenario: A failed generation does not close the case +- **WHEN** the decision document cannot be generated +- **THEN** the case is not moved to its final status +- **AND** the failure is recorded on the run + +#### Scenario: A rejected case is closed as rejected +- **WHEN** the planning commission rejects +- **THEN** the case is closed with a result recording the rejection +- **AND** a decision document recording the rejection is attached + +### Requirement: Flow storage work runs under the engine's native scoping + +Flow nodes and the transition/action handlers they delegate to SHALL perform +their storage work bare: on the flow path the engine's +`RegistryStepDispatcher` executes every contributed node inside +`ObjectService::runAs()` as the run's validated acting identity +(openregister#3332), and on the interactive path the ambient session user +answers the permission checks. The system SHALL NOT keep a local runAs +wrapper, and no flow-facing file may wrap its storage work in one — a manual +wrap re-creates the per-consumer copy of an engine rule and nests a second +scope inside the dispatcher's. + +`lib/Service/FlowRunAsScope.php` SHALL stay deleted. + +#### Scenario: No flow file wraps runAs manually + +- **GIVEN** the flow-facing directories (lib/Flow, lib/Service/Transitions, lib/Service/Actions) +- **WHEN** the structural sweep runs +- **THEN** no file references the retired wrapper, storage-performing files still exist (the detector self-check), and the wrapper file itself is absent + +`@e2e exclude` a structural source sweep, not a user journey; pinned by the +inverted FlowStorageRunsAsTheRunsIdentityTest. + +#### Scenario: A worker-driven flow write acts as the run's identity + +- **GIVEN** a flow run whose runAs names an enabled account, executing under FlowRunWorker +- **WHEN** a dossiq node or handler performs storage work +- **THEN** the write happens under that identity, scoped by the dispatcher, with no dossiq wrap involved + +`@e2e case-flow-live-journeys.spec.ts` exercises the seeded case flow under +the worker end to end; the scoping mechanism is OpenRegister's +(RegistryStepDispatcherRunAsTest). + +### Requirement: An ask advances on its task, not on a signal + +`dossiq.askPerson` SHALL create exactly one task on its first pass, remember it +in this node's resume slot, and on EVERY later pass read that task back before +deciding anything. The task's status, not the presence of a signal, SHALL +determine whether the run advances. + +- A task at `completed` SHALL advance the run, whether or not a wake arrived. +- A task at any other non-terminal status SHALL re-suspend the run WITHOUT + touching the resume slot, so the remembered task and the time it was asked + survive every heartbeat. +- A task at `terminated` or `disabled` SHALL fail the step. The ask was + withdrawn: continuing would move the case past a question nobody answered, + and suspending would wait for an answer that can never come. +- A task that no longer exists SHALL fail the step naming it, rather than + waiting forever on a row that is gone. +- A read that FAILS — an unreachable or unconfigured store — SHALL re-suspend + rather than fail. A missing row and an unreadable store are different facts, + and treating a hiccup as "gone" would fail a case whose task is sitting there + answered. + +The system SHALL NOT create a second task on a re-entry, and SHALL NOT restamp +when the task was asked. + +#### Scenario: A heartbeat delivers a completion whose signal was refused + +- **GIVEN** a run suspended on an ask, whose task's completion signal the engine's assignee guard refused +- **WHEN** the heartbeat wakes the run and no signal is in hand +- **THEN** the node re-reads the task, finds it completed, and the run advances with the answer on its items + +#### Scenario: A heartbeat with the task still open parks again on the same task + +- **GIVEN** a run suspended on an ask whose task is still open +- **WHEN** the heartbeat wakes the run +- **THEN** the run suspends again on the same task, no second task is created, and the asked-at time is unchanged + +#### Scenario: Only the node whose task was answered advances + +- **GIVEN** a run parked on two asks and only the first task completed +- **WHEN** the heartbeat wakes the run +- **THEN** the answered node advances and its slot is consumed, while the other keeps waiting on its own task + +#### Scenario: A withdrawn ask fails the step + +- **GIVEN** a run suspended on an ask whose task was terminated +- **WHEN** the run next re-enters the step +- **THEN** the step fails naming the task, and the run neither advances nor waits on + +`@e2e exclude` a suspend/resume timing path with no user-visible surface of its +own; pinned end to end through the real engine by +tests/Unit/Flow/AskPersonHeartbeatRecoveryTest.php. + +### Requirement: The task decides the answer and the wake decorates it + +The answer `dossiq.askPerson` writes onto every item under its `signalKey` +SHALL be derived from the task row — its status, its id, this node's id, and +when it was completed — and SHALL record whether it was delivered by a wake or +recovered by a heartbeat. A signal payload MAY contribute fields the row does +not carry, such as who completed the task, and SHALL NOT override the fields +the row decides. + +A signal SHALL NOT be able to answer for a task that is still open. The run +holds ONE signal slot, so a flow with two asks would otherwise have the second +read the answer given to the first. + +#### Scenario: A signal cannot answer for an open task + +- **GIVEN** a run suspended on an ask whose task is still open +- **WHEN** a signal carrying a decision reaches the run +- **THEN** the node suspends again, because the row says the question is unanswered + +#### Scenario: The delivered and recovered paths agree + +- **GIVEN** two runs on the same ask, one answered through the guarded wake and one recovered by a heartbeat +- **THEN** both carry the same decision, status, task id and node under the step's key, differing only in who answered and whether it was recovered + +`@e2e exclude` the shape of a value passed between flow steps; pinned by +AskPersonHeartbeatRecoveryTest and DossiqAskPersonNodeTest. + +### Requirement: A flow-engine test may drive the real engine + +The unit suite SHALL be able to run a test against OpenRegister's real flow +engine — its own source and its composer dependencies — when that app is +checked out beside this one, and SHALL do so only for the suites that ask for +it, in a separate process. Every other suite keeps the stubs. + +A suite that asks for the real engine and does not get it SHALL NOT pass. On a +developer machine it reports as skipped, naming what is missing; under CI, where +the sibling checkout is part of the job, it FAILS — a skip there would be the +instrument lying about the thing it exists to measure. + +A stub of an OpenRegister class SHALL declare the same constructor as the real +class. A stub that is easier to build than the thing it stands for teaches the +suite a shape that fatals in production. + +#### Scenario: The real engine is absent under CI + +- **GIVEN** a CI run whose OpenRegister checkout or install did not complete +- **WHEN** the real-engine suite starts +- **THEN** it fails, naming the missing checkout, rather than skipping + +`@e2e exclude` test-infrastructure behaviour with no runtime surface. + +### Requirement: A consumer can read back a decision it raised + +`ContractDecisionDelegationService` SHALL be able to ask decidiq what became of +a Decision it raised, by dispatching decidiq's `DecisionStateRequestedEvent` +and reading the answer the listener writes back synchronously — the same +request/response-over-the-bus shape the raise already uses (ADR-041). + +The read SHALL name the Nextcloud uid it is scoped to, and SHALL NOT be +dispatched with an empty one. decidiq refuses a read that names no identity +rather than treating it as a system caller, and an app that cannot name one has +nothing to ask. + +The result SHALL distinguish six facts, because a caller acts differently on +each: the seam could not answer, the read was refused, no such decision exists, +the decision is still open, the decision was concluded with an outcome, and the +decision was withdrawn. In particular, an UNREADABLE seam SHALL NOT be reported +as a refusal or as a missing decision. + +A status word this app does not recognise SHALL be reported as still open. It +can only come from a newer decidiq, and waiting through a vocabulary extension +costs a heartbeat while guessing that it means "decided" would advance a case on +an outcome nobody here can name. + +This is NOT a second delivery mechanism. `DecisionConcludedEvent` remains how a +conclusion arrives; this is what a consumer consults when it did not. + +#### Scenario: The read seam is not installed + +- **GIVEN** an instance where decidiq's `DecisionStateRequestedEvent` class does not exist +- **WHEN** the delegation service is asked for a decision's state +- **THEN** it reports the state as unreadable, and never as a missing or refused decision + +#### Scenario: A read naming no identity is not dispatched + +- **GIVEN** a caller with no acting uid to name +- **WHEN** it asks the delegation service for a decision's state +- **THEN** no event is dispatched and the state is reported as unreadable + +`@e2e exclude` an in-process cross-app event contract with no user-visible +surface of its own; pinned by ContractDecisionDelegationReadTest and end to end +through the real engine by tests/Unit/Flow/RequestDecisionHeartbeatRecoveryTest.php. + +### Requirement: A decision step advances on its decision, not on a signal + +`dossiq.requestDecision` SHALL raise exactly one decision on its first pass, +remember its ref in this node's resume slot, and on EVERY later pass read that +decision back before deciding anything. The decision's state, not the presence +of a signal, SHALL determine whether the run advances. + +- A decision concluded with an outcome SHALL advance the run, whether or not an + announcement arrived. +- A decision still open SHALL re-suspend the run WITHOUT touching the resume + slot, so the remembered ref and the time it was asked survive every heartbeat. +- A decision that was WITHDRAWN SHALL fail the step. The question was taken off + the table: continuing would move the case past a decision nobody made, and + suspending would wait for an answer that can never come. +- A decision that no longer exists SHALL fail the step naming it, rather than + waiting forever on a record that is gone. +- A read that is REFUSED SHALL fail the step. decidiq answered and would not + report the decision to the identity this run raised it as, which is a + misconfiguration to surface rather than a state to poll. +- A read that is UNREADABLE SHALL re-suspend rather than fail. An unreachable + seam says nothing about the decision, and treating a hiccup as "gone" would + fail a case whose decision is sitting there taken. + +The system SHALL NOT raise a second decision on a re-entry, and SHALL NOT +restamp when the decision was asked. + +#### Scenario: A heartbeat delivers a conclusion whose announcement never arrived + +- **GIVEN** a run suspended on a decision that decidiq has since concluded, whose conclusion never reached the run +- **WHEN** the heartbeat wakes the run and no signal is in hand +- **THEN** the node re-reads the decision, finds it concluded, and the run advances with the outcome on its items + +#### Scenario: A heartbeat with the decision still open parks again on the same decision + +- **GIVEN** a run suspended on a decision decidiq has not concluded +- **WHEN** the heartbeat wakes the run +- **THEN** the run suspends again on the same ref, no second decision is raised, and the asked-at time is unchanged + +#### Scenario: A withdrawn decision fails the step + +- **GIVEN** a run suspended on a decision decidiq reports as withdrawn +- **WHEN** the run next re-enters the step +- **THEN** the step fails naming the decision, and the run neither advances nor waits on + +#### Scenario: An unreadable seam buys another heartbeat + +- **GIVEN** a run suspended on a decision, and a decidiq that cannot answer the read +- **WHEN** the heartbeat wakes the run +- **THEN** the run suspends again rather than failing, and the decision is read again on the next heartbeat + +`@e2e exclude` a suspend/resume timing path with no user-visible surface of its +own; pinned end to end through the real engine by +tests/Unit/Flow/RequestDecisionHeartbeatRecoveryTest.php. + +### Requirement: The decision decides the outcome and the wake decorates it + +The outcome `dossiq.requestDecision` writes onto every item under its +`signalKey` SHALL be derived from what decidiq reported — its status, the +decision ref, this node's id, when it was decided and whether it was signed — +and SHALL record whether it was delivered by a wake or recovered by a heartbeat. +A signal payload MAY contribute fields the read does not carry, and SHALL NOT +override the fields the decision decides. + +A signal SHALL NOT be able to answer for a decision that is still open. The run +holds ONE signal slot, so a flow with two decisions would otherwise have the +second read the answer given to the first. + +#### Scenario: A signal cannot answer for an open decision + +- **GIVEN** a run suspended on a decision decidiq has not concluded +- **WHEN** a signal carrying a decision reaches the run +- **THEN** the node suspends again, because decidiq says the question is unanswered + +#### Scenario: The announced and recovered paths agree + +- **GIVEN** two runs on the same decision, one advanced by the announcement and one recovered by a heartbeat +- **THEN** both carry the same decision, status, ref and node under the step's key, differing only in whether it was recovered + +`@e2e exclude` the shape of a value passed between flow steps; pinned by +RequestDecisionHeartbeatRecoveryTest and DossiqRequestDecisionNodeTest. + +### Requirement: A decision step names the identity it raised the decision as + +`dossiq.requestDecision` SHALL record the run's acting identity in its resume +slot when it raises a decision, and SHALL scope its read back to that identity. +decidiq stamps a Decision's owner from the uid that created it, so a read naming +any other uid is answered "not permitted". + +A run that names no acting identity SHALL re-suspend and log, rather than +dispatching a read decidiq would refuse or inventing a system caller. A run +parked BEFORE this behaviour shipped, whose slot therefore records no identity, +SHALL fall back to the run's current acting identity, so it gains the recovery +on its next heartbeat without a repair step. + +#### Scenario: A run parked before the change recovers without a repair + +- **GIVEN** a run suspended on a decision whose resume slot records a ref but no raising identity +- **WHEN** the heartbeat wakes the run +- **THEN** the node reads the decision back as the run's current acting identity and recovers + +`@e2e exclude` an authorization-scoping detail of an in-process read with no +user-visible surface; pinned by RequestDecisionHeartbeatRecoveryTest. diff --git a/openspec/specs/case-history-surface/spec.md b/openspec/specs/case-history-surface/spec.md new file mode 100644 index 000000000..b1370b36f --- /dev/null +++ b/openspec/specs/case-history-surface/spec.md @@ -0,0 +1,25 @@ +# case-history-surface Specification + +## Purpose + +A case keeps one change history, and you read it on the case. The history is a +sidebar tab on the case page rather than a page of its own, so there is one +place to look instead of two. + +## Requirements + +### Requirement: Change history is a case-detail sidebar tab, not a standalone page + +A case's status/change history SHALL be presented as a sidebar tab on the `CaseDetail` page (an `audit-trail` tab titled "Change history"), and SHALL NOT be exposed as a standalone top-level page or menu item. The app SHALL NOT declare a `StatusRecords` page or `StatusRecordsMenu` navigation entry. + +#### Scenario: Case detail exposes change history in context + +- **GIVEN** a user opens a case's detail page +- **THEN** a "Change history" sidebar tab (type `audit-trail`) shows that case's status/change records +- **AND** no standalone "Status history" page or menu item exists in the app navigation + +#### Scenario: Retired page is not routable from the menu + +- **GIVEN** the app navigation +- **THEN** there is no `StatusRecordsMenu` entry and no relocation of it into the Reports group +- **AND** the `StatusRecords` page is absent from the manifest diff --git a/openspec/specs/case-management/spec.md b/openspec/specs/case-management/spec.md index f1f6f6bf9..a185d000f 100644 --- a/openspec/specs/case-management/spec.md +++ b/openspec/specs/case-management/spec.md @@ -1144,6 +1144,159 @@ sidebar can group on it. - **THEN** the list SHALL show only the building rows - **AND** All objects SHALL bring every row back +### Requirement: You copy a case from its page (REQ-CM-24) + +You copy a case with its type, requester and properties into a new case. The +Actions menu of `CaseDetail` SHALL offer Copy case. Confirming SHALL create a +new case of the same type in the type's initial status, carrying the source's +requester, confidentiality, priority, intake channel and properties, with the +source listed under related cases. The number, deadline, result, status +history, decisions and publications SHALL NOT be copied. When you tick Include +documents, the source's open documents SHALL be linked to the new case, not +duplicated. + +**Feature tier**: V1 + +#### Scenario: A handler copies a case +@e2e tests/e2e/case-actions-menu.spec.ts + +- **GIVEN** a case of type Melding openbare ruimte with a requester and two filled properties +- **WHEN** the handler chooses Copy case and confirms with the proposed title +- **THEN** a new case SHALL open with the same type, requester and property values +- **AND** its status SHALL be the type's initial status +- **AND** its number SHALL differ from the source's number +- **AND** the source SHALL be listed under its related cases + +#### Scenario: Documents come along as links +@e2e tests/e2e/case-actions-menu.spec.ts + +- **GIVEN** a case with one document +- **WHEN** the handler copies it with Include documents ticked +- **THEN** the new case's Documents tab SHALL list that document +- **AND** the file SHALL exist once in storage + +#### Scenario: A reader cannot copy +@e2e exclude Playwright signs in as admin and cannot take a lesser role; PHPUnit covers the refusal in CaseActionsControllerTest, which asserts the guard is asked before the service and that no case is created + +- **GIVEN** a user who may read the case but not write cases +- **WHEN** they post to the copy endpoint +- **THEN** the answer SHALL be 403 +- **AND** no case SHALL be created + +### Requirement: REQ-CASE-LTA-001 The Cases index MUST offer a Due this week lens + +You see the week ahead without reading every deadline. The `Cases` page SHALL +carry a Due this week chip after Overdue, filtering +`deadline[gte] = "@today"`, `deadline[lt] = "@today+7d"` and +`isFinalStatus = false`. + +The window is half-open on both sides. Without the near edge the chip would +list every overdue case as well and still read as a plausible list, which is +the failure mode a reader cannot see. + +The chip SHALL NOT carry a `statusHiddenInLists` condition, matching its +sibling Overdue rather than All. + +#### Scenario: Due this week shows the case due in three days and neither neighbour +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open case due in three days, an open case due in thirty days and an open case that was due two days ago +- **WHEN** you choose the chip Due this week +- **THEN** the list SHALL show the case due in three days +- **AND** the list SHALL NOT show the case due in thirty days +- **AND** the list SHALL NOT show the case that was due two days ago + +### Requirement: The case task is namespaced (REQ-CM-070) + +The case task schema SHALL be `caseTask` and SHALL NOT be `task`. planninq's +project task keeps the bare slug; pipelinq uses `crmTask`. + +The three claiming schemas share `description`, `priority` and `status` alone, +so all three are renamed apart rather than folded onto one owner. + +Every local schema-id map keyed by the slug SHALL move with it, including +`KpiAggregationService::ids()` and `DemoCaseloadGateway::schemaIds()`, together +with their declared array shapes. A reader renamed without its builder resolves +to null and fails several frames away, where the cause is no longer visible. + +`tests/e2e/ci-seed.sh` SHALL name the new slug in its required-schema list. + +The rename SHALL NOT touch `task` where it is a row or item type label: +`WorkQueueService`'s `itemType`, or the `type` key in +`CaseReassignmentService`, `BulkReassignModal`, `taskApi` and +`dashboardHelpers`. + +#### Scenario: The KPI counts still resolve their schema + +- **WHEN** the dashboard KPIs are computed +- **THEN** the task count resolves a schema id rather than null. + +#### Scenario: The demo caseload still seeds its tasks + +- **WHEN** the demo caseload is seeded +- **THEN** each task is created against a resolved schema id. + +### Requirement: REQ-CM-31 Open work by default, closed work on request + +Your work list shows open cases only unless you ask for closed ones. On the +`Cases` page the chips Mine and Unclaimed MUST carry `isFinalStatus = false` +and the chip Closed MUST carry `isFinalStatus = true`, so a closed case +appears under Closed and under All and nowhere else. `isFinalStatus` is a +stored boolean on every case row, so plain equality reaches it and no +derived filter is needed. + +#### Scenario: A closed case leaves Mine +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** a case assigned to the signed-in user whose status is final +- **WHEN** you open the Cases page with the chip Mine active +- **THEN** the list SHALL NOT show the closed case + +#### Scenario: Closed shows the closed case +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** a case whose status is final and an open case +- **WHEN** you choose the chip Closed +- **THEN** the list SHALL show the closed case and SHALL NOT show the open one + +### Requirement: REQ-CM-32 Deadline before in the sidebar + +You narrow the case list on a deadline. The `Cases` page sidebar MUST offer +a filter Deadline before, a date input that adds `deadline lt ` to the +active query. + +**[blocked: the index sidebar has no manifest-declared filter and no +operator]** `CnIndexSidebar` builds its Filters section entirely from the +SCHEMA — `filtersFromSchema` walks the properties marked `facetable: true` +and renders each as a checkbox (booleans) or a values select (everything +else), whose `filter-change` event carries `{ key, values }`. There is no +manifest key for a sidebar filter, no date input among the widget types and +no operator anywhere in that path: a facet can only say `field = one of +these values`, never `field < this date`. Nothing in `@conduction/nextcloud-vue` +2.41 can express this requirement, so no configuration in this repo +satisfies it. + +Interim, the state is reachable but not offerable: `resolveQueryFilters` +passes any non-underscore route query through to the fetch, so +`/cases?deadline[lt]=2026-10-01` narrows the list exactly as this +requirement describes, and that is the path the Overdue dashboard tiles +take. What is missing is a control a reader can operate. Unblocking it is a +nextcloud-vue change: a `sidebar.filters[]` array of +`{ field, operator, label, type }` on `CnIndexPage`, rendered beside the +schema facets and merged into the fetch the way the facets already are. It MUST combine with the active chip rather than replace it, +so Mine plus Deadline before shows your cases due before that date. The +Requester text filter on `initiatorDisplayName` sits in the same sidebar and +is specified by `requester-on-the-case` (`initiator-display` REQ-ID-2); this +requirement does not restate it. + +#### Scenario: Deadline before narrows the list +@e2e exclude The control does not exist to drive: nextcloud-vue 2.41's index sidebar derives its filters from schema `facetable` properties as value lists, with no date input and no operator, so there is no Deadline before field for a spec to fill. The narrowing itself is covered where it IS reachable, by the Overdue tile's View all in tests/e2e/case-list-lenses.spec.ts, which sends `deadline[lt]` through the route query. + +- **GIVEN** two open cases assigned to the signed-in user, one due in 3 days and one due in 30 days +- **WHEN** you set Deadline before to 10 days from today +- **THEN** the list SHALL show the case due in 3 days and SHALL NOT show the case due in 30 days +- **AND** the chip Mine SHALL still be active + ## Sharing, Transfer, Email & Public Access (retrofit) ### REQ-101: Dossiq SHALL expose case-sharing endpoints via CaseSharingController diff --git a/openspec/specs/case-search-via-or-unified-search/spec.md b/openspec/specs/case-search-via-or-unified-search/spec.md index 0e890d932..b53cdf9d9 100644 --- a/openspec/specs/case-search-via-or-unified-search/spec.md +++ b/openspec/specs/case-search-via-or-unified-search/spec.md @@ -2,10 +2,29 @@ ## Purpose TBD - created by archiving change case-search-via-or-unified-search. Update Purpose after archive. + ## Requirements + ### Requirement: Searchable schema opt-in -The dossiq register definition SHALL flag exactly these schemas `searchable: true`: `case`, `task`, `bezwaar`, `voorstel`, `beroep`. Schemas without a standalone detail route SHALL NOT be flagged. +A schema in the dossiq register is searchable unless it opts out. +OpenRegister's `Schema` entity declares `protected bool $searchable = true`, +so a definition that carries no `searchable` key imports as searchable and +`ObjectsProvider` includes it. Measured on the running instance 2026-09-08: +`brpPerson` and `kvkCompany` both report `searchable: true` with no flag +anywhere in `lib/Settings/register.d/25-brp-kvk.json`, and a search for a +seeded person returned that person. + +The dossiq register definition therefore SHALL NOT be read as the allow-list +for unified search. It flags `case`, `caseTask`, `objectionProceeding` and +`beroep` explicitly, which is a statement of intent and changes nothing at +runtime; a schema SHALL only be flagged `searchable: false` when it should be +kept OUT of search, and a schema SHALL NOT be flagged `true` merely to +document that it is searchable, because the flag only lands on an instance +behind a schema version bump and a re-import. + +What decides whether a result is USEFUL is the deep link, not the flag. See +the next requirement. #### Scenario: Case findable in unified search @@ -14,7 +33,7 @@ The dossiq register definition SHALL flag exactly these schemas `searchable: tru - **THEN** the case appears as a result under the OpenRegister objects provider - **AND** activating it navigates to `/apps/dossiq/cases/{uuid}` -@e2e exclude Requires the OR ObjectsProvider pipeline and NC search UI; provider behaviour is covered by openregister's own unified-search-provider e2e suite — dossiq only supplies declarative flags, asserted by unit test on the register JSON. +@e2e exclude Requires the OR ObjectsProvider pipeline and NC search UI; provider behaviour is covered by openregister's own unified-search-provider e2e suite. #### Scenario: Non-flagged schema absent from search @@ -24,6 +43,14 @@ The dossiq register definition SHALL flag exactly these schemas `searchable: tru @e2e exclude Same rationale — declarative flag asserted by unit test on the register JSON. +#### Scenario: A schema flagged out stays out + +- **GIVEN** a schema flagged `searchable: false` in the register definition +- **WHEN** a user searches for the title of one of its objects +- **THEN** it does not appear in unified search results + +@e2e exclude Declarative opt-out asserted by unit test on the register JSON; the provider's allow-list behaviour is openregister's. + #### Scenario: RBAC-restricted case hidden - **GIVEN** a confidential case the user has no OR RBAC read access to @@ -32,18 +59,6 @@ The dossiq register definition SHALL flag exactly these schemas `searchable: tru @e2e exclude Enforced and tested in openregister (provider security contract); dossiq adds no code path. -### Requirement: Deep links for searchable schemas - -Every schema flagged `searchable: true` SHALL have a `deepLinks` entry in `src/manifest.json` mapping `(dossiq, )` to its detail route, so the OR provider can render result URLs and display names. - -#### Scenario: Deep links cover all searchable schemas - -- **GIVEN** the manifest and register definition at HEAD -- **WHEN** the searchable schema slugs are compared against `deepLinks[].schemaSlug` -- **THEN** `case`, `task`, `bezwaar`, `voorstel`, `beroep` each have an entry with url templates `/apps/dossiq/cases/{uuid}`, `/apps/dossiq/tasks/{uuid}`, `/apps/dossiq/bezwaren/{uuid}`, `/apps/dossiq/voorstellen/{uuid}`, `/apps/dossiq/beroepen/{uuid}` - -@e2e exclude Declarative cross-file consistency asserted by unit test (vitest) on the two JSON files. - ### Requirement: Version-gated re-import The change SHALL bump both the register `info.version` and the app version in `appinfo/info.xml`, because the OR register repair step only re-imports schema definitions when the version advances. @@ -56,3 +71,41 @@ The change SHALL bump both the register `info.version` and the app version in `a @e2e exclude Repair-step import mechanics are owned and tested by openregister; the version bump is asserted by unit test comparing info.xml and register JSON versions advanced together. +### Requirement: Deep links resolve to real pages + +Every dossiq schema that has a standalone detail page SHALL have a `deepLinks` +entry in `src/manifest.json` mapping `(dossiq, )` to that route, +and the entry's `urlTemplate` SHALL name a route the manifest carries. + +This is the requirement that bites. `ObjectSearchResultFormatter` asks +`DeepLinkRegistryService::resolveUrl()` and, when no app has claimed the +`(register, schema)` pair, falls back to `openregister.objects.show` — the +JSON API endpoint. A search result with no deep link therefore looks correct +and opens a JSON document; a search result whose template names a route the +manifest does not carry looks correct and opens a 404. Neither failure is +visible in the search itself. + +`brpPerson` maps to `/apps/dossiq/contacts/{uuid}` and `kvkCompany` to +`/apps/dossiq/organisations/{uuid}`, the pages `contacts-domain` added. + +#### Scenario: A search hit on a person opens the contact page +@e2e tests/e2e/contacts-domain.spec.ts + +- **GIVEN** a seeded `brpPerson` row +- **WHEN** the OpenRegister objects provider is searched for its display name +- **THEN** a result SHALL carry the resource url `/apps/dossiq/contacts/` for that row + +#### Scenario: A search hit on an organisation opens the organisation page +@e2e tests/e2e/contacts-domain.spec.ts + +- **GIVEN** a seeded `kvkCompany` row +- **WHEN** the OpenRegister objects provider is searched for its trade name +- **THEN** a result SHALL carry the resource url `/apps/dossiq/organisations/` for that row + +#### Scenario: Every deep link names a real route + +- **GIVEN** the manifest at HEAD +- **WHEN** each `deepLinks[].urlTemplate` is compared against `pages[].route` +- **THEN** each SHALL correspond to a page route, with `{uuid}` standing where the route has `:id` + +@e2e exclude Declarative cross-file consistency asserted by unit test (vitest) on the manifest. diff --git a/openspec/specs/case-status-machinery/spec.md b/openspec/specs/case-status-machinery/spec.md new file mode 100644 index 000000000..13785056e --- /dev/null +++ b/openspec/specs/case-status-machinery/spec.md @@ -0,0 +1,89 @@ +# case-status-machinery Specification + +## Purpose + +One write path decides a case's status. The lifecycle is declared on the schema +and driven by OpenRegister's transition engine, so dossiq keeps no second state +machine of its own. A declared lifecycle, its enum and the service that reads it +must agree. + +## Requirements + +### Requirement: one write path for case status + +`case.status` MUST change only through `StatusTransitionService` (its +guarded `execute()`, the admin `executeFreeForm()`, and the flow-node +seam that wraps the same `lib/Service/Transitions/` handlers). No second +public entry point to the transition engine may exist: a facade or +wrapper without production callers is dead machinery and MUST be +removed. + +#### Scenario: no second entry point to the transition engine + +- GIVEN the tree after this change +- WHEN `lib/` is scanned for the retired classes +- THEN `WorkflowEngineService` does not exist as a file +- AND the scanner test fails naming any file that brings it back + +`@e2e exclude` structural backend assertion; pinned by +LocalStatusMachineryTest. + +### Requirement: no dead local state machines + +A status machine whose states cannot occur in stored data (a literal +vocabulary written against a reference-valued field, or a scan whose +filter can never match) is dead machinery and MUST be removed rather +than migrated. The vergadering case machine +(`VergaderingCaseService`, `VergaderingDeadlineJob`) is retired under +this rule. + +#### Scenario: the vergadering machine stays retired + +- GIVEN the tree after this change +- WHEN `lib/` is scanned for the retired classes +- THEN `VergaderingCaseService` and `VergaderingDeadlineJob` do not + exist as files +- AND `appinfo/info.xml` registers no vergadering background job + +`@e2e exclude` structural backend assertion; pinned by +LocalStatusMachineryTest; the retired UI surface is already asserted by +tests/e2e/spec-coverage/retired-surfaces.spec.ts. + +### Requirement: a declared lifecycle agrees with its enum and its service + +Every `x-openregister-lifecycle` a dossiq schema declares MUST use the +object-form dialect OR validates (`field`, `initial`, `transitions`, +`final`), MUST name only states of the field's enum, and where an +app-side transition table exists for the same object it MUST agree with +the declaration edge for edge. The complaint lifecycle is rewritten +under this rule in both register manifests. + +#### Scenario: the complaint declaration mirrors the service table + +- GIVEN the complaint schema in `dossiq_register.json` and + `dossiq_mock_register.json` +- WHEN the declared transitions are compared with + `ComplaintService::TRANSITIONS` +- THEN the edge sets are equal +- AND every declared state is a member of the status enum + +`@e2e exclude` manifest and service parity; pinned by +LocalStatusMachineryTest. + +### Requirement: the transition-table census is closed + +Every transition-table constant under `lib/` (`const *TRANSITIONS =`, +`const *VALID_STATUSES =`) MUST sit in the scanner's closed allowlist +with the reason it may exist (domain machine, staged thinning, or +another change's ownership). A new file declaring one fails the suite +naming itself. + +#### Scenario: a new local status machine cannot ship quietly + +- GIVEN a new file under `lib/` declaring a transition-table constant +- WHEN the unit suite runs +- THEN `LocalStatusMachineryTest` fails naming the file +- AND the failure message says where status sequencing belongs + +`@e2e exclude` structural backend assertion; pinned by +LocalStatusMachineryTest. diff --git a/openspec/specs/case-type-navigation/spec.md b/openspec/specs/case-type-navigation/spec.md new file mode 100644 index 000000000..34a1fdf13 --- /dev/null +++ b/openspec/specs/case-type-navigation/spec.md @@ -0,0 +1,60 @@ +# case-type-navigation Specification + +## Purpose +Model objections, appeals and subsidies as case TYPES rather than standalone navigation areas: the "Cases" group carries one navigation child per case type, resolved from live OpenRegister data via a backend `/api/manifest` delta, and the Cases index offers a map view. + +## Requirements + +### Requirement: REQ-CTN-001 — One Navigation Child Per Case Type Via /api/manifest Delta + +dossiq SHALL expose `GET /api/manifest` (authenticated, `#[NoAdminRequired]`) returning a `mergeStrategy: 'delta'` menu payload that adds one navigation child per `caseType` object the current user may see under the existing `CasesGroup`. Each child SHALL carry `id: 'ct-'`, `label: `, `route: 'Cases'` and `query: { caseType: }`, sorted deterministically by name. The frontend SHALL consume this delta through `useAppManifest('dossiq', builtManifest, { mergeStrategy: 'delta' })` so the resolved manifest — and thus the navigation — updates reactively when the delta lands. + +#### Scenario: Case types appear as Cases children + +- **GIVEN** the user may see two case types "Aanvraag" and "Bezwaar" +- **WHEN** the app shell loads and fetches `/api/manifest` +- **THEN** the `CasesGroup` menu group SHALL gain two children `ct-` labelled "Aanvraag" and "Bezwaar" +- **AND** each child SHALL navigate to the `Cases` route with `query.caseType` set to its case-type uuid +- **AND** the children SHALL be ordered by name + +#### Scenario: Delta never breaks the shell + +- **GIVEN** OpenRegister is unavailable, the register/schema is unconfigured, the user is anonymous, or no case types exist +- **WHEN** the app shell fetches `/api/manifest` +- **THEN** the endpoint SHALL return a no-op delta `{ "menu": [] }` +- **AND** the app navigation SHALL render from the built manifest unchanged + +### Requirement: REQ-CTN-002 — Objections, Appeals And Subsidies Have No Dedicated Menu Group + +dossiq SHALL NOT present dedicated navigation groups for objections/appeals or subsidies. The `BezwaarBeroepGroup` and `SubsidiesGroup` menu groups SHALL be removed, and the standalone `BezwaarDecisions` and `BezwaarAdviceRequests` workflow pages (page objects, menu entries and routes) SHALL be removed. The `Bezwaren`, `Beroepen` and `Subsidies` index pages SHALL remain routable for deep links and end-to-end tests, but SHALL NOT appear as dedicated menu leaves. + +#### Scenario: No dedicated objection/appeal/subsidy menu group + +- **GIVEN** the app shell has rendered its navigation +- **WHEN** an administrator inspects the menu +- **THEN** there SHALL be no "Objections & Appeals" or "Subsidies" menu group +- **AND** there SHALL be no "Objection decisions" or "Committee advice" menu item + +#### Scenario: Retired index pages stay deep-linkable + +- **GIVEN** the objection/appeal/subsidy menu leaves are gone +- **WHEN** a user navigates directly to `/bezwaren`, `/beroepen` or the subsidy index route +- **THEN** the index page SHALL still render (route retained for deep links) + +### Requirement: REQ-CTN-003 — Cases Index Offers A Map View + +The `Cases` index page SHALL offer a map view alongside table and cards (`viewModes: ["table","cards","map"]`), plotting the current filtered case rows on a map using the case `geometry` GeoJSON (`mapConfig` with `geoField: "geometry"`, `popupField: "title"`). A marker click SHALL behave like a case row click. The standalone `CaseMap` menu leaf SHALL be removed while its `/map` route stays reachable for deep links. + +#### Scenario: Map is a Cases view mode + +- **GIVEN** the user opens the Cases index +- **WHEN** the view-mode toggle is shown +- **THEN** a "Map" segment SHALL be offered next to Table and Cards +- **AND** selecting it SHALL plot the filtered cases on a map by their `geometry` + +#### Scenario: Standalone Case Map menu retired + +- **GIVEN** the Cases index now covers the map surface +- **WHEN** an administrator inspects the menu +- **THEN** there SHALL be no standalone "Map" menu leaf +- **AND** the `/map` route SHALL still be reachable by deep link diff --git a/openspec/specs/case-types/spec.md b/openspec/specs/case-types/spec.md index e29850921..d0e0315fe 100644 --- a/openspec/specs/case-types/spec.md +++ b/openspec/specs/case-types/spec.md @@ -975,6 +975,50 @@ parent's. A chain that returns to itself SHALL be refused on save. - **WHEN** you set Bezwaar's parent to Bezwaar (verkort) and save - **THEN** the save SHALL fail with a message naming the cycle +### Requirement: You configure everything a status is, on the page (REQ-CT-10) + +You configure a status without editing register JSON. The Statuses tab of a +case type SHALL edit every property the `statusType` schema declares: name, +order, description, colour, role, whether it is final, whether cases in it stay +out of the Cases index, and its checklist. The add form and the edit form SHALL +be the same form. A status saved before a property existed SHALL open with that +property unset rather than with a guessed value, and saving it SHALL NOT write +back any property the schema does not declare. + +**Feature tier**: MVP + +#### Scenario: A functional administrator gives a status a colour and a role +@e2e tests/e2e/case-type-authoring-extras.spec.ts + +- **GIVEN** a case type with a status called In behandeling +- **WHEN** the administrator edits it, picks the colour orange and the role in progress, and saves +- **THEN** the status row SHALL show that colour +- **AND** a flow addressing the in-progress role SHALL resolve to this status + +#### Scenario: A status asks for a checklist +@e2e tests/e2e/case-type-authoring-extras.spec.ts + +- **GIVEN** a status being edited +- **WHEN** the administrator adds the checklist item Check identity and marks it required +- **AND** saves +- **THEN** the status row SHALL report one checklist item +- **AND** a case entering that status SHALL get a task called Check identity + +#### Scenario: A blank checklist row is not saved +@e2e exclude Pure mapping, covered by tests/vitest/statusTypeForm.spec.js. + +- **GIVEN** a status with one filled checklist item and one empty row +- **WHEN** the administrator saves +- **THEN** only the filled item SHALL be stored, because a task with no title is unactionable + +#### Scenario: An older status opens without inventing values +@e2e exclude Pure mapping, covered by tests/vitest/statusTypeForm.spec.js. + +- **GIVEN** a status saved before colour, role, hiddenInLists and checklist existed +- **WHEN** the administrator opens it +- **THEN** its colour SHALL be unset rather than grey, because a form that opens on grey saves grey +- **AND** saving it SHALL NOT carry back the notifyInitiator and notificationText properties it arrived with + ## UI References - **Case Type List**: See wireframe 3.6 in DESIGN-REFERENCES.md (admin settings, case type cards with status/deadline/validity) diff --git a/openspec/specs/contract-decision-delegation/spec.md b/openspec/specs/contract-decision-delegation/spec.md index a78d99cdd..87ec9525a 100644 --- a/openspec/specs/contract-decision-delegation/spec.md +++ b/openspec/specs/contract-decision-delegation/spec.md @@ -2,70 +2,8 @@ ## Purpose TBD - created by archiving change dossiq-delegate-contract-decision. Update Purpose after archive. -## Requirements -### Requirement: REQ-PDCD-001 — Contract Decisions Are Raised As decidesk Decisions - -dossiq SHALL raise a **decidesk `Decision`** for any contract approval / renewal / sign-off via the -OpenRegister integration registry (ADR-019), through a new `ContractDecisionDelegationService`. -dossiq SHALL NOT advance a dossiq-local approval state machine for the decision after this change. - -#### Scenario: Renewal request raises a decidesk Decision - -- **GIVEN** a supplier contract within the 90-day renewal window and the `decidesk` integration leaf available -- **WHEN** a contracts/admin user requests renewal via `ContractController::requestRenewal` -- **THEN** dossiq SHALL still open the `leverancier-contractverlenging-verzoek` ZGW case -- **AND** dossiq SHALL call `ContractDecisionDelegationService::raiseContractDecision(...)`, creating a decidesk `Decision` -- **AND** the returned `decisionRef` SHALL be persisted on the case -- **AND** no dossiq-local approval state machine SHALL advance the decision - -#### Scenario: Beslissing-op-bezwaar raises a decidesk Decision - -- **GIVEN** a bezwaar case and the `decidesk` integration leaf available -- **WHEN** a behandelaar submits `BezwaarDecisionForm.vue` -- **THEN** dossiq SHALL raise a decidesk `Decision` carrying the disposition, motivation and follows-advice values -- **AND** dossiq SHALL NOT author the besluit through a dossiq-local besluit engine - ---- - -### Requirement: REQ-PDCD-002 — Delegation Fails Closed When decidesk Is Unavailable - -The `ContractDecisionDelegationService` SHALL **fail closed** when the `decidesk` integration leaf is -unavailable: it SHALL surface a clear "decision service unavailable" error and SHALL NOT auto-approve -or fall back to a dossiq-local approval. (Mirrors the `unsafe-auth-resolver` rule — an unavailable -decision service is not "decision skipped".) - -#### Scenario: decidesk leaf unavailable blocks the decision -- **GIVEN** the `decidesk` integration leaf is not registered or returns an error -- **WHEN** dossiq attempts to raise a contract decision -- **THEN** the call SHALL fail with a "decision service unavailable" error -- **AND** no contract SHALL be marked approved/renewed -- **AND** no dossiq-local approval state SHALL be set as a fallback - ---- - -### Requirement: REQ-PDCD-003 — The ZGW Besluit Is Materialised From The decidesk Outcome - -dossiq SHALL materialise the ZGW `Besluit` on the case **from the decidesk Decision outcome**, not -from a dossiq-local besluit-authoring path. The materialised `Besluit` SHALL preserve the Besluiten-API -shape: decidesk `result` → Besluit result, decidesk `decidedAt` → `Besluit.datum`, decidesk -motivering/advice → `Besluit.toelichting`, decidesk signer/mandaathouder + method → recorded audit -fields. ZGW compliance SHALL NOT regress. - -#### Scenario: Approved decidesk Decision materialises a ZGW Besluit - -- **GIVEN** a decidesk `Decision` for a contract reaches outcome `verleend` with a datum, motivering and mandaathouder -- **WHEN** dossiq consumes the outcome via `ContractDecisionDelegationService::consumeOutcome(...)` -- **THEN** dossiq SHALL write a ZGW `Besluit` on the case with result `verleend`, the decided datum, and the motivering as `toelichting` -- **AND** the `Besluit` SHALL match the prior Besluiten-API schema shape (verified by a contract test) - -#### Scenario: Rejected decidesk Decision is recorded on the case file - -- **GIVEN** a decidesk `Decision` reaches outcome `geweigerd` -- **WHEN** dossiq consumes the outcome -- **THEN** dossiq SHALL record a `Besluit` with result `geweigerd` and the motivering on the case dossier - ---- +## Requirements ### Requirement: REQ-PDCD-004 — dossiq Keeps ZGW Case Management And The Expiry Scan @@ -150,3 +88,82 @@ SHALL be dropped by the migration. - **THEN** the existing `Besluit` SHALL be retained as the authoritative historical record - **AND** no `Besluit` data SHALL be dropped or overwritten +### Requirement: REQ-PDCD-001 — Contract Decisions Are Raised As decidesk Decisions Via Events + +dossiq SHALL raise a decidesk `Decision` for any contract / bezwaar / advice approval, renewal or +sign-off by dispatching `OCA\Decidesk\Event\DecisionRequestedEvent` through +`OCP\EventDispatcher\IEventDispatcher::dispatchTyped()`, and SHALL persist `getDecisionId()` as the +decisionRef on the case. dossiq SHALL NOT call the `OCA\OpenRegister\Service\IntegrationService` +registry, `getLeaf()`, or `createDecision(payload:...)`, and SHALL NOT advance a dossiq-local approval +state machine for the decision. + +#### Scenario: Renewal request dispatches a DecisionRequestedEvent + +- **GIVEN** a supplier contract within the renewal window and decidesk installed +- **WHEN** a contracts/admin user requests renewal via `ContractController::requestRenewal` +- **THEN** dossiq SHALL still open the `leverancier-contractverlenging-verzoek` ZGW case +- **AND** dossiq SHALL `dispatchTyped()` a `DecisionRequestedEvent` with `sourceApp` `dossiq` +- **AND** the `getDecisionId()` returned on the handled event SHALL be persisted as the case `decisionRef` +- **AND** no dossiq-local approval state machine SHALL advance the decision + +#### Scenario: Bezwaar and advice decisions dispatch the same event + +- **GIVEN** a bezwaar or advice request and decidesk installed +- **WHEN** dossiq delegates the decision via `BezwaarDecisionDelegationService` or `AdviceDelegationService` +- **THEN** dossiq SHALL dispatch a `DecisionRequestedEvent` (carrying the disposition / advice context in `payload`) +- **AND** dossiq SHALL NOT resolve decidesk through `IntegrationService::getLeaf` + +--- + +### Requirement: REQ-PDCD-002 — Delegation fails closed when decidesk does not answer the event + +dossiq SHALL fail closed when decidesk cannot handle the decision: if +`class_exists(\OCA\Decidesk\Event\DecisionRequestedEvent::class)` is false (decidesk not installed), OR +the dispatched event returns `isHandled() === false`, OR `getDecisionId()` is null, the delegation SHALL +throw a "decision service unavailable" error and SHALL NOT auto-approve or fall back to a dossiq-local +approval. + +#### Scenario: decidesk not installed blocks the decision + +- **GIVEN** decidesk is not installed (the `DecisionRequestedEvent` class does not exist) +- **WHEN** dossiq attempts to raise a decision +- **THEN** the call SHALL throw a "decision service unavailable" error +- **AND** no contract SHALL be marked approved/renewed and no dossiq-local approval state SHALL be set + +#### Scenario: Unhandled event blocks the decision + +- **GIVEN** decidesk is installed but its listener does not handle the event (`isHandled()` false or `getDecisionId()` null) +- **WHEN** dossiq dispatches the `DecisionRequestedEvent` +- **THEN** the call SHALL throw a "decision service unavailable" error +- **AND** no dossiq-local approval state SHALL be set as a fallback + +--- + +### Requirement: REQ-PDCD-003 — The ZGW Besluit Is Materialised From The DecisionConcludedEvent + +dossiq SHALL register a listener for `OCA\Decidesk\Event\DecisionConcludedEvent` in +`lib/AppInfo/Application.php`. The listener SHALL filter to `getSourceApp() === 'dossiq'`, build the +normalised outcome from the event getters (`getStatus()` / `getOutcome()` → Besluit result, +`getDecidedAt()` → `Besluit.datum`, the decision motivering/advice → `Besluit.toelichting`, signers / +signing reference → recorded audit fields) and materialise the ZGW `Besluit` on the case via +`BesluitMaterialisationService`. The Besluiten-API shape SHALL be preserved; ZGW compliance SHALL NOT +regress. The old `consumeOutcome()` / `getDecisionOutcome()` poll path SHALL be removed. + +#### Scenario: Concluded decision materialises a ZGW Besluit + +- **GIVEN** decidesk dispatches a `DecisionConcludedEvent` with `getSourceApp()` `dossiq`, `getStatus()` `approved`, an outcome and a decidedAt +- **WHEN** the `DecisionConcludedListener` handles the event +- **THEN** dossiq SHALL write a ZGW `Besluit` on the matching case via `BesluitMaterialisationService` +- **AND** the `Besluit` SHALL preserve the prior Besluiten-API schema shape + +#### Scenario: Event from another source app is ignored + +- **GIVEN** a `DecisionConcludedEvent` with `getSourceApp()` not equal to `dossiq` +- **WHEN** the listener receives the event +- **THEN** dossiq SHALL ignore it and SHALL NOT materialise a Besluit + +#### Scenario: No dossiq-local poll of decidesk remains + +- **GIVEN** this change has shipped +- **WHEN** the source is searched for `consumeOutcome`, `getDecisionOutcome`, `getLeaf` and `OCA\OpenRegister\Service\IntegrationService` +- **THEN** none SHALL remain in `lib/` diff --git a/openspec/specs/dashboard/spec.md b/openspec/specs/dashboard/spec.md index 9dbdc7d10..e367289b5 100644 --- a/openspec/specs/dashboard/spec.md +++ b/openspec/specs/dashboard/spec.md @@ -661,26 +661,31 @@ The dashboard SHALL provide an "Analytics" navigation item in the left sidebar ( ### Requirement: REQ-DASH-V1-006 Workflow Board View [V1] -`WorkflowBoard.vue` at `/workflow-board` SHALL provide a Kanban board with one column per non-final status type, case cards in each column, and drag-to-advance status transition. +`WorkflowBoard.vue` at `/workflow-board` SHALL provide a Kanban board with one column per +non-final status type, case cards in each column, and a status transition control that MUST be +operable by both drag-and-drop AND keyboard alone — drag-and-drop MUST NOT be the only path to +advance a case's status. #### Scenario DASH-V1-006a: Board columns reflect status types + - GIVEN status types: Ontvangen (order 1), In behandeling (order 2), Besluitvorming (order 3), each non-final - WHEN the user views the Workflow Board - THEN the board MUST display 3 columns in order: Ontvangen, In behandeling, Besluitvorming - AND each column header MUST show the status name and the count of cases in that status #### Scenario DASH-V1-006b: Case cards show key information + - GIVEN case "2026-0042 Omgevingsvergunning - Bakkersdijk 12" in status "In behandeling" - WHEN the user views the Workflow Board - THEN the case card MUST show: case identifier, title (truncated if necessary), case type badge, assignee name, and deadline with color indicator #### Scenario DASH-V1-006c: Drag to advance case status + - GIVEN case "2026-0042" is in the "Ontvangen" column - WHEN the user drags the card to the "In behandeling" column and drops it - THEN the system MUST update the case's `status` to the "In behandeling" statusType ID - AND the card MUST move to the "In behandeling" column - AND if the update fails (e.g., permission denied), the card MUST return to its original column -- AND a user-facing error message MUST be displayed on failure #### Scenario DASH-V1-006d: Click on case card navigates to detail - GIVEN a case card is visible on the board @@ -693,6 +698,26 @@ The dashboard SHALL provide an "Analytics" navigation item in the left sidebar ( - THEN the "Besluitvorming" column MUST still be displayed with count "0" - AND the column body MUST show an empty state placeholder +#### Scenario DASH-V1-006f: Keyboard-only status transition (NEW) + +- GIVEN case "2026-0042" is in the "Ontvangen" column and the user is navigating with only a + keyboard (no mouse/touch) +- WHEN the user tabs to the case card's "Move to…" control and selects "In behandeling" via + Enter/Space +- THEN the system MUST update the case's `status` to the "In behandeling" statusType ID via the + same persistence path as the drag-and-drop scenario (optimistic move, `saveObject('case', …)`, + revert-and-toast on failure) +- AND the card MUST move to the "In behandeling" column +- AND the card's existing "open case detail" keyboard activation (Enter/Space on the card body) + MUST remain unaffected by the new control + +#### Scenario DASH-V1-006g: Drag path unchanged (NEW) + +- GIVEN a mouse/touch user +- WHEN they drag a card between columns as in Scenario DASH-V1-006c +- THEN the behaviour MUST be identical to before this change — no regression to the existing drag + gesture + ### Requirement: REQ-DASH-FIX-001 Application.php Widget Registration [FIX] The system SHALL register the three existing Nextcloud Dashboard widget classes (`CasesOverviewWidget`, `MyTasksWidget`, `OverdueCasesWidget`) in `Application.php` via `$context->registerDashboardWidget()`. It MUST fix `CasesOverviewWidget`'s route reference from the non-existent `.dashboard.index` to the correct `.dashboard.page`. diff --git a/openspec/specs/dmn-decision-tables/spec.md b/openspec/specs/dmn-decision-tables/spec.md index 7f718dcc9..f9363cba4 100644 --- a/openspec/specs/dmn-decision-tables/spec.md +++ b/openspec/specs/dmn-decision-tables/spec.md @@ -2,7 +2,9 @@ ## Purpose TBD - created by archiving change dmn-decision-tables. Update Purpose after archive. + ## Requirements + ### Requirement: Decision tables are OpenRegister-defined data The system MUST store decision tables (name, key, hitPolicy, inputs, outputs, rules) as OpenRegister objects on the `decisionTable` schema, and @@ -20,17 +22,19 @@ MUST expose admin-gated CRUD for them. - **WHEN** they POST/PUT/DELETE against `/api/decisions` - **THEN** the system MUST return 403 and MUST NOT persist any change -### Requirement: Expression grammar is a closed, safe subset +### Requirement: Expression grammar is a closed, safe subset, owned by OpenRegister The system MUST evaluate rule input-entry expressions using only a fixed, -bounded grammar (wildcard, comparisons `< > <= >= = !=`, inclusive/ -exclusive ranges, set membership `in (...)`, bare-literal equality) and -MUST NOT execute arbitrary code (no `eval()`, no dynamic PHP execution) for -any expression. +bounded grammar (wildcard, comparisons `< > <= >= = !=`, inclusive/exclusive +ranges, set membership `in (...)`, bare-literal equality) and MUST NOT execute +arbitrary code for any expression. -#### Scenario: Range expression matches inclusively -- **GIVEN** an input entry `[0..25000]` on a `number`-typed input -- **WHEN** the input value is exactly `25000` -- **THEN** the entry MUST match +The grammar itself MUST live in OpenRegister's `UnaryTestEvaluator`, and dossiq +MUST NOT keep a second implementation of it. Two copies of a security-relevant +grammar are two things to keep in step, and the one that drifts is the one +nobody is reading. + +The behaviour is unchanged: the grammar matrix that proves it moved to +openregister with the class, unaltered. #### Scenario: Exclusive range boundary does not match - **GIVEN** an input entry `(25000..40000]` @@ -42,11 +46,20 @@ any expression. - **WHEN** the input value is `silver` - **THEN** the entry MUST match +#### Scenario: Range expression matches inclusively +- **GIVEN** an input entry `[0..25000]` on a `number`-typed input +- **WHEN** the input value is exactly `25000` +- **THEN** the entry MUST match + #### Scenario: Malformed expression is rejected, never treated as a match - **GIVEN** an input entry `[1..` (unbalanced range) - **WHEN** the decision is evaluated -- **THEN** the system MUST return a `invalid_expression` error and MUST NOT - treat the malformed cell as matching or non-matching by default +- **THEN** the system MUST return an `invalid_expression` error + +#### Scenario: dossiq ships no evaluator of its own +- **GIVEN** dossiq's source tree +- **THEN** it MUST contain no class implementing the unary-test grammar or the + hit policies, and MUST resolve both from OpenRegister ### Requirement: Runtime input validation never silently defaults The system MUST reject evaluation requests that supply an input key not @@ -116,17 +129,6 @@ singular-answer requirement. - **THEN** each output MUST be an empty array, and the response MUST NOT be an error -### Requirement: PRIORITY and ANY hit policies are explicitly unsupported -The system MUST reject evaluation of a decision table declaring `PRIORITY` -or `ANY` hit policy with a distinct `hit_policy_not_implemented` error -rather than silently falling back to different semantics. - -#### Scenario: PRIORITY hit policy is rejected -- **GIVEN** a decision table with `hitPolicy: PRIORITY` -- **WHEN** it is evaluated -- **THEN** the system MUST return `hit_policy_not_implemented` and MUST NOT - evaluate any rule as if it were FIRST or UNIQUE - ### Requirement: A workflow transition can invoke a named decision The system MUST support an `evaluateDecision` automatic action on a workflow transition that evaluates a decision table by `decisionKey`, @@ -164,3 +166,32 @@ table can be tested or consumed outside a case's lifecycle. - **THEN** the system MUST return the computed outputs without requiring any case or workflow transition +### Requirement: PRIORITY and ANY hit policies are evaluated by OpenRegister +The system MUST evaluate a decision table declaring `PRIORITY` or `ANY` rather +than refusing it. `PRIORITY` MUST return the matching rule with the highest +priority, breaking ties by declaration order. `ANY` MUST return the shared +output of the matching rules and MUST raise `hit_policy_violation` when they +disagree, because a table declaring `ANY` asserts that its overlapping rules +agree. + +This replaces the previous requirement that both were rejected with +`hit_policy_not_implemented`. That requirement described a limitation of +dossiq's own engine, not a decision about DMN. The schema has offered all five +policies in its enum throughout, so the refusal was visible to users as a form +that offered a choice the engine would not honour. + +#### Scenario: PRIORITY returns the highest-priority match +- **GIVEN** a decision table with `hitPolicy: PRIORITY` and matching rules of priority 1, 10 and 5 +- **WHEN** it is evaluated +- **THEN** the rule with priority 10 MUST win + +#### Scenario: ANY refuses rules that disagree +- **GIVEN** a table with `hitPolicy: ANY` and two matching rules with different outputs +- **WHEN** it is evaluated +- **THEN** the system MUST raise `hit_policy_violation` and MUST NOT pick one + +#### Scenario: A PRIORITY table is no longer refused for its policy +- **GIVEN** any decision table declaring `hitPolicy: PRIORITY` +- **WHEN** it is evaluated +- **THEN** the system MUST NOT return `hit_policy_not_implemented`, and MUST + fail only on the table's own contents if those are invalid diff --git a/openspec/specs/dossiq-store-surface/spec.md b/openspec/specs/dossiq-store-surface/spec.md new file mode 100644 index 000000000..53422594f --- /dev/null +++ b/openspec/specs/dossiq-store-surface/spec.md @@ -0,0 +1,177 @@ +# dossiq-store-surface Specification + +## Purpose +Dossiq browses a remote OpenRegister registry for shareable case configuration +and installs it locally, using the engine's discovery client rather than one of +its own. + +## Requirements + +### Requirement: REQ-DSS-001 Discovery goes through the engine + +The system SHALL reach the remote registry only through OpenRegister's +`GenericStoreService`, injected as a constructor type-hint. + +Dossiq SHALL NOT build or fetch an `/apps/openregister/api/objects/` URL of its +own. The SSRF guard, the redirect refusal, the timeout bounds and the token +handling exist once, in the app that owns the protocol. + +The dependency SHALL be composition, never `extends`. A cross-app base class is +resolved by the autoloader rather than the container, and Nextcloud's router +reflects every controller during route matching, so an absent OpenRegister +would return 500 on every route in this app rather than only the store's. + +@e2e exclude Structural, not behavioural. That the controller INJECTS GenericStoreService rather than extending it, and that no file builds an OpenRegister objects-API URL, are properties of the source tree: proven by StoreControllerTest (which asserts the descriptor the engine is called with) and by gate-62's Decision-3 scan. A browser cannot see which class a response came from. + +#### Scenario: The controller injects the engine client + +- **GIVEN** `StoreController` +- **THEN** it MUST take `GenericStoreService` as a constructor parameter +- **AND** it MUST NOT extend any class from the `OCA\OpenRegister` namespace + +#### Scenario: No app-local discovery + +- **GIVEN** dossiq's `lib/` tree +- **THEN** no file MUST both name the OpenRegister objects-API path and use an + HTTP client to fetch it + +### Requirement: REQ-DSS-002 No registry means no network call + +With no `registry_url` configured, the store surface SHALL report +`not_configured`, render the app's built-in templates, and make no outbound +request. + +A local-only card grid is a Templates page. It may only be labelled "Store" +because the registry path exists and is merely unconfigured. + +#### Scenario: An unconfigured instance stays offline + +- **GIVEN** an instance with no `registry_url` set +- **WHEN** the store surface is opened +- **THEN** the response outcome MUST be `not_configured` +- **AND** no outbound HTTP request MUST be made + +#### Scenario: The page still renders + +- **GIVEN** the same unconfigured instance +- **THEN** the Store page MUST render dossiq's built-in templates rather than + an error + +### Requirement: REQ-DSS-003 Install accepts configuration and refuses records + +The install action SHALL write only components whose schema slug is on the +configuration allowlist: `caseType`, `statusType`, `resultType`, `roleType`, +`propertyDefinition`, `documentType`, `decisionType`, `workflowTemplate`, +`inspectionChecklistTemplate`, `automaticAction`, `lhsMatrix`, `parafeerroute`. + +A component naming any other schema SHALL be refused and reported, and the +remaining components SHALL still install. + +Without this the install path is a remote write primitive against live case +records. The allowlist is the boundary between installing configuration and +accepting data. + +@e2e exclude The install allowlist is a server-side boundary. Proven by StoreControllerTest with a negative control: widening INSTALLABLE_SLUGS to include `case` and `task` makes the refusal and mixed-item tests fail, so the assertions are known to bind. A browser can see that an install reported a refusal but not which schema a write would have gone to, which is the property that matters. + +#### Scenario: A configuration component installs + +- **GIVEN** a store item carrying one `caseType` component +- **WHEN** it is installed +- **THEN** the case type MUST be written through the app's configured registry +- **AND** the result MUST report that component as installed + +#### Scenario: A record component is refused + +- **GIVEN** a store item carrying one `case` component +- **WHEN** it is installed +- **THEN** nothing MUST be written for that component +- **AND** the result MUST name it as refused + +#### Scenario: A mixed item installs the half it may + +- **GIVEN** a store item carrying a `caseType` and a `task` +- **WHEN** it is installed +- **THEN** the case type MUST be written and the task MUST be refused + +### Requirement: REQ-DSS-004 The registry token is write-only + +Reading the store settings SHALL expose whether a token is set, never its value. + +Saving an empty token SHALL leave the stored token unchanged, so an +administrator editing the registry URL does not silently clear the credential. + +@e2e exclude The token never reaches the browser, which is the whole requirement, so the browser is the one place it cannot be observed. Proven by StoreControllerTest over the serialised settings body (a leak under a different key would pass a per-key check) plus a positive control that a supplied token IS written. + +#### Scenario: A read never returns the token + +- **GIVEN** a configured registry with a token +- **WHEN** the store settings are read +- **THEN** the response MUST carry `registryTokenSet: true` +- **AND** the response MUST NOT carry the token value under any key + +#### Scenario: An empty token preserves the stored one + +- **GIVEN** a stored token +- **WHEN** the settings are saved with an empty token field +- **THEN** the stored token MUST be unchanged + +### Requirement: REQ-DSS-005 Browsing is authenticated, installing is administrative + +Search SHALL require an authenticated user. Install and the store settings +SHALL require administrator authorization. + +Installing writes case-type configuration that every handler then works +against, which is an administrative act even though browsing is not. + +@e2e exclude Auth posture is enforced by Nextcloud middleware from the controller attributes. The anonymous-401 path is proven by StoreControllerTest; the admin guard is the `AuthorizedAdminSetting` attribute, which gate-5 and gate-7 read statically. Driving it in a browser would need a second, non-admin fixture user this suite does not provision. + +#### Scenario: An anonymous caller cannot search + +- **GIVEN** no signed-in user +- **WHEN** search is called +- **THEN** the response status MUST be 401 + +#### Scenario: Install is admin-guarded + +- **GIVEN** `StoreController::install()` +- **THEN** it MUST declare an administrator authorization attribute + +### Requirement: REQ-DSS-006 The menu entry names the concept + +The store menu entry SHALL be labelled `Store` and render the `StoreOutline` +icon, in the footer section between Documentation and Reports. + +#### Scenario: The entry carries the Tier A glyph + +- **GIVEN** the dossiq manifest +- **THEN** the entry labelled `Store` MUST declare `icon: "StoreOutline"` +- **AND** it MUST sit in the `footer` section with an order between + Documentation and Reports + +### Requirement: REQ-DSS-007 An install creates, and can never replace + +The install action SHALL strip every identity key the remote payload carries +(`id`, `uuid`, `@self`) before writing, so an installed component is always a +NEW local object. + +OpenRegister resolves the object it writes from the payload itself: `saveObject` +reads `@self.id` first and `id` second, and treats a match as the uuid to +UPDATE. The write is PUT-semantic, so keys the payload omits are nulled rather +than left alone. A store item carrying the uuid of a live case type would +therefore not merely change it, it would gut it. + +The schema allowlist does NOT cover this. It governs which schema a component +may write, never whether the write creates or replaces, so a component naming a +perfectly legitimate configuration schema is the attack. + +Identity is not a remote registry's to supply. If install ever needs to be +idempotent it SHALL key on something dossiq controls. + +@e2e exclude Server-side, and the property is the ABSENCE of an addressed object. A browser sees an install succeed either way; only the payload handed to the write reveals which object it addressed. Proven by StoreControllerTest with a negative control: removing the strip makes the assertion fail. + +#### Scenario: A component carrying an id installs as a new object + +- **GIVEN** a store item whose component object carries `id`, `uuid` and `@self` +- **WHEN** it is installed +- **THEN** none of those keys MUST reach the write +- **AND** the rest of the component MUST still install diff --git a/openspec/specs/financial-integration/spec.md b/openspec/specs/financial-integration/spec.md index e528eff48..5a88b81c9 100644 --- a/openspec/specs/financial-integration/spec.md +++ b/openspec/specs/financial-integration/spec.md @@ -6,10 +6,18 @@ status-note: Reverse-synced 2026-06-13 from an archived fully-implemented change ## Purpose Prepares an ERP-ready dwangsom payment signal once an amount is locked and processes the financial system's payment-confirmation callback via openconnector. The payment signal carries the full metadata (bedrag, rekeninghouder, IBAN, referentie, legal basis, payment deadline) and is blocked when the IBAN is missing or malformed; the signed callback is validated, looked up by referentie, updates the payment status to betaald, and triggers a burger notification, rejecting unknown references with HTTP 404. + ## Requirements + ### Requirement: Uitbetaling-signaal aan financieel systeem (REQ-TERM-007) -The system SHALL prepare an ERP-ready payment signal with all required metadata via openconnector and SHALL process the ERP payment-confirmation callback. +The system SHALL prepare an ERP-ready payment signal with all required metadata via openconnector +and SHALL process the ERP payment-confirmation callback. The callback endpoint MUST be +configured with a shared secret and MUST reject every request with HTTP 401 when that secret is +not configured — an unconfigured secret MUST NEVER be treated as an implicit pass. The secret +MUST be configurable via the dossiq admin settings UI. + +**Feature tier**: MVP #### Scenario: Payment signal generation @@ -27,14 +35,35 @@ The system SHALL prepare an ERP-ready payment signal with all required metadata #### Scenario: Payment confirmation callback updates status and notifies burger - **GIVEN** the ERP sends a payment-confirmation callback via openconnector -- **WHEN** the signed callback arrives with `{referentie, status: betaald, werkelijkeBetaaldatum, betalingsreferentie}` -- **THEN** the callback signature SHALL be validated and the `DwangsomUitbetaling` SHALL be looked up by `referentie` -- **AND** its `status` SHALL be set to `betaald` with `werkelijkeBetaaldatum` and `betalingsreferentie` recorded -- **AND** a `dwangsom-betaald` event SHALL be emitted and a burger payment notification SHALL be triggered +- **WHEN** the signed callback arrives with `{referentie, status: betaald, werkelijkeBetaaldatum, betalingsreferentie}` and the configured `dwangsom_callback_secret` matches the request's HMAC-SHA256 signature +- **THEN** the `DwangsomUitbetaling` SHALL be looked up by `referentie` and its status updated to `betaald` +- **AND** a burger notification SHALL be triggered #### Scenario: Unknown referentie is rejected - **GIVEN** a callback arrives with a `referentie` that matches no `DwangsomUitbetaling` - **WHEN** the callback is processed -- **THEN** the system SHALL return HTTP 404 with no side effects +- **THEN** the system SHALL respond with HTTP 404 + +#### Scenario: Missing or incorrect signature is rejected (existing, preserved) + +- **GIVEN** the callback endpoint has a `dwangsom_callback_secret` configured +- **WHEN** a request arrives whose `X-Procest-Signature` header does not match the HMAC-SHA256 of + the raw body under that secret +- **THEN** the system SHALL respond with HTTP 401 and MUST NOT process the payload + +#### Scenario: Unconfigured secret fails closed (NEW) + +- **GIVEN** the `dwangsom_callback_secret` app config value has never been set (empty string) +- **WHEN** any request — signed or unsigned — arrives at the payment-callback endpoint +- **THEN** the system SHALL respond with HTTP 401 and MUST NOT update any `DwangsomUitbetaling` +- **AND** the system SHALL log a `warning`-level entry (not `info`) so the missing configuration is + operationally visible + +#### Scenario: Admin can configure the secret (NEW) +- **GIVEN** an admin opens the dossiq admin settings page with the financial-integration + capability enabled +- **WHEN** they view the dwangsom callback section +- **THEN** they SHALL see a field to set `dwangsom_callback_secret` (masked input) and a + visible warning if it is currently unset diff --git a/openspec/specs/first-time-setup/spec.md b/openspec/specs/first-time-setup/spec.md new file mode 100644 index 000000000..dfdfdfe78 --- /dev/null +++ b/openspec/specs/first-time-setup/spec.md @@ -0,0 +1,53 @@ +# first-time-setup Specification + +## Purpose +Give dossiq a first-time setup flow that (a) is rendered by the abstract `CnSetupWizard`, (b) gates the app until the OpenRegister register/schemas are initialised, and (c) lets an admin seed bezwaar/beroep (and the other repair seeds) from the UI — which is impossible today because OpenRegister enforces RBAC on `saveObject` and the browser request runs without create rights. Seeding therefore MUST run server-side with system privileges. + +## Requirements + +### Requirement: REQ-SETUP-PRO-001 — dossiq Declares Its Setup Steps In The Manifest + +dossiq SHALL declare a `setup` block in `src/manifest.json` with steps `welcome` (`info`, optional), `register-check` (`config-fields`, **required**), `seed` (`run-action`, optional) and `done` (`summary`, optional), and SHALL set `completionConfigKey` to `setup_completed_version`. + +#### Scenario: Required register-check gates the app + +- **GIVEN** dossiq is enabled but its OpenRegister `register` / `case_type_schema` are not configured +- **WHEN** an admin opens the app +- **THEN** the abstract `CnSetupWizard` SHALL gate the shell on the `register-check` step +- **AND** the app's normal navigation SHALL NOT be reachable until `register-check` reports done + +#### Scenario: Optional seed does not gate + +- **GIVEN** the register is initialised but no bezwaar/beroep data is seeded +- **WHEN** an admin opens the app +- **THEN** the app SHALL be usable +- **AND** the `seed` step SHALL be offered (auto-opened once, dismissible) and re-runnable from the admin page + +### Requirement: REQ-SETUP-PRO-002 — Seeding Runs Server-Side With System Privileges + +dossiq SHALL expose `POST /apps/dossiq/api/setup/action/{actionId}` (admin-only, CSRF-protected) whose `seed` action runs `SeedDataService::seedBezwaarBeroepData()` and the other `Seed*` repair steps **server-side with system privileges** (admin-context or `_rbac:false`), so OpenRegister `saveObject` succeeds regardless of the requesting user's object-create rights. The wizard SHALL NOT write OpenRegister objects directly from the browser. + +#### Scenario: Wizard seed action succeeds where a browser write would be denied + +- **GIVEN** an admin on the `seed` step and bezwaar/beroep not yet seeded +- **WHEN** the wizard POSTs `setup/action/seed` +- **THEN** the server SHALL create the Bezwaar + Beroep caseTypes, their status types and role types +- **AND** the call SHALL NOT fail with *"User 'Anonymous' does not have permission to 'create'"* +- **AND** the action SHALL be idempotent (re-running reports the existing objects as skipped) + +#### Scenario: occ command remains a CLI fallback + +- **GIVEN** the same `SeedDataService` wiring +- **WHEN** an operator runs `occ dossiq:bezwaar:seed` +- **THEN** the seed SHALL produce the identical result as the wizard `seed` action + +### Requirement: REQ-SETUP-PRO-003 — Setup Status Is Reported For The Wizard + +dossiq SHALL expose `GET /apps/dossiq/api/setup/status` returning `{ version, completed, steps: { : { done, detail } } }`, where `register-check.done` reflects OpenRegister enabled AND `register` + `case_type_schema` configured, and `seed.done` reflects the Bezwaar/Beroep caseTypes existing. + +#### Scenario: Status drives gating and completion + +- **GIVEN** the wizard queries setup status +- **WHEN** `register-check.done` is false +- **THEN** `completed` SHALL be false and the wizard SHALL gate on `register-check` +- **AND** once all required steps report done, dossiq SHALL write `setup_completed_version` to app config and the wizard SHALL stop gating diff --git a/openspec/specs/friendly-case-create-form/spec.md b/openspec/specs/friendly-case-create-form/spec.md new file mode 100644 index 000000000..db6c320ea --- /dev/null +++ b/openspec/specs/friendly-case-create-form/spec.md @@ -0,0 +1,117 @@ +# friendly-case-create-form Specification + +## Purpose +The New case dialog asks a case handler for the fields a case handler fills. The case schema states which of its properties a person deals with and which are engine plumbing, the New case action narrows to what somebody filing a case types, and a chosen case type contributes its own questions, whose answers are stored as `caseProperty` rows. + +## Requirements + +### Requirement: REQ-FCF-001 The New Case Dialog Is The Plain Form + +The `new-case` header action SHALL open the plain schema-driven form, never the properties-and-JSON table. The action SHALL declare `includeFields` naming exactly the fields collected at create time: `caseType`, `title`, `description`, `assignee`, `priority`, `confidentiality`, `intakeChannel`, `startDate`, `plannedEndDate`. The case detail page remains the surface for every other property. + +#### Scenario: The dialog carries no schema-inspection tabs + +- **GIVEN** a case handler is on the dossiq dashboard +- **WHEN** they press "New case" +- **THEN** the dialog SHALL show neither a "Properties" tab nor a "Data" tab +- **AND** it SHALL offer a "Create" button, disabled until the required fields are answered + +#### Scenario: The dialog asks only for create-time fields + +- **GIVEN** the New case dialog is open +- **THEN** it SHALL render a field for each of the nine declared `includeFields` +- **AND** it SHALL render no field for `result`, `workflowTemplate`, `archiveNomination`, `qualityScore`, `casePlanState`, `statusHistory` or `portalSubject` + +### Requirement: REQ-FCF-002 The Schema States Which Properties A Person Deals With + +The `case` schema SHALL carry an `order` on every property a handler reads or edits, and `visible: false` on every property written by an engine, computed by OpenRegister, or carried as a flow signal payload. + +A property that carries an `order`, or that a manifest `include`/`columns` list names, SHALL NOT be `visible: false`. `fieldsFromSchema` evaluates visibility before the include whitelist, so a hidden property named by a widget renders a blank cell and reports nothing. + +#### Scenario: A displayed property is never hidden + +- **GIVEN** the case detail Process widget lists `workflowVersion`, `extensionCount` and `handoffSource` in its `include` +- **THEN** none of those properties SHALL be `visible: false` +- **AND** the case `description`, carrying `order: 3`, SHALL render on the create form, the detail data widget and the table + +### Requirement: REQ-FCF-003 A Case Type Brings Its Own Questions + +`case.caseType` SHALL declare `x-openregister-extends-form`, naming `propertyDefinition` as the definitions schema filtered by the chosen case type, and `caseProperty` as the values schema keyed `case` / `propertyDefinition` / `value`. + +When a case type is chosen, the form SHALL render one field per property definition of that type, each with the widget its declared `propertyType` implies and its `defaultValue` seeded. A definition name that is identifier-shaped SHALL be rendered in sentence case, keeping acronyms whole; a name containing a space SHALL be shown exactly as it was typed. Changing the case type SHALL drop the previous type's answers. Answers SHALL be written as `caseProperty` rows AFTER the case exists, never as properties of the case itself. + +#### Scenario: Choosing a case type adds its questions + +- **GIVEN** the case type "Cultuursubsidie 2026" has property definitions `plafond` (number, default 800000) and `interimReportFrequency` (enum) +- **WHEN** a handler chooses that case type in the New case dialog +- **THEN** the form SHALL gain a number field labelled "Plafond" holding 800000 +- **AND** a dropdown labelled "Interim report frequency" offering that definition's enum values + +#### Scenario: Answers are stored against the case, not in it + +- **GIVEN** a handler has answered a case type question and pressed Create +- **THEN** the case SHALL be created without any dynamic key among its own properties +- **AND** one `caseProperty` row SHALL exist referencing the created case, the property definition, and the answer + +### Requirement: REQ-FCF-004 The Properties Tab Writes What The Schema Declares + +The case-type properties tab SHALL write only fields the `propertyDefinition` schema declares. It SHALL write the data type as `propertyType`, using that schema's own enum, and SHALL offer a Required toggle writing `isRequired`. `maxLength` and `requiredAtStatus` SHALL be declared on the schema; `requiredAtStatus` SHALL store a status reference and be resolved back to a name for display. + +#### Scenario: A chosen type survives a save + +- **GIVEN** a functional admin adds a property definition and chooses the type "Date" +- **WHEN** they save it and the list reloads +- **THEN** the definition SHALL read back as "Date", not as the default + +### Requirement: REQ-FCF-005 the form answers what the case type already knows + +`case.caseType` SHALL declare `x-openregister-prefill`, mapping the case's `title`, `status` and `assignee` to the chosen case type's `title`, `initialStatus` and `defaultAssignee`. + +Only an EMPTY target SHALL be written, so a value the person typed survives both the first choice and every later one. Prefill SHALL run in create mode only: in edit mode a blank field is a decision someone already made about an existing record. A source the chosen record leaves empty SHALL write nothing rather than blanking the target. + +`status` SHALL be prefilled without being offered as a field, because a handler filing a case does not choose the status it starts in. + +#### Scenario: The case type fills the title + +- **GIVEN** a handler has opened the New case dialog and typed nothing +- **WHEN** they choose a case type called "Subsidieaanvraag" +- **THEN** the title field SHALL read "Subsidieaanvraag" + +#### Scenario: A typed title survives + +- **GIVEN** a handler has typed a title of their own +- **WHEN** they choose a case type +- **THEN** the title SHALL still read what they typed +- **AND** the fields they left empty SHALL still take the case type's answers + +#### Scenario: The starting status is stored but never asked for + +- **GIVEN** a case type whose `initialStatus` is "Ontvangen" +- **WHEN** a handler files a case of that type +- **THEN** the New case dialog SHALL NOT show a status field +- **AND** the created case SHALL hold that status + +### Requirement: REQ-FCF-006 the dialog reads as a form, not a schema + +The New case action SHALL open a wide dialog laying its fields out in two columns. A multi-line widget (textarea, JSON or code) SHALL span both columns, and the layout SHALL collapse to a single column below 700px. + +Two columns SHALL be opt-in per action, so that no other form in the fleet is reflowed. + +#### Scenario: The create form uses two columns + +- **GIVEN** a handler opens the New case dialog +- **THEN** the single-line fields SHALL occupy two distinct columns +- **AND** the description textarea SHALL span the full width + +### Requirement: REQ-FCF-007 a field kept off the create form stays reachable on the case + +A property the New case action does not ask for SHALL remain editable on the case detail page unless it is platform plumbing. + +`parentCase` SHALL NOT appear on the New case form, because a case is not filed as somebody's sub-case, and SHALL appear on the case detail page, because a case becomes one later. `decisions` SHALL appear on neither: they are decidiq objects reached through the Besluitvorming widget, and a raw reference list is not an edit surface for them. + +#### Scenario: Parent case is an edit-time field + +- **GIVEN** a handler opens the New case dialog +- **THEN** there SHALL be no parent case field +- **WHEN** they open an existing case +- **THEN** the core case data SHALL offer a parent case field diff --git a/openspec/specs/frontend-build-hygiene/spec.md b/openspec/specs/frontend-build-hygiene/spec.md new file mode 100644 index 000000000..a261319c7 --- /dev/null +++ b/openspec/specs/frontend-build-hygiene/spec.md @@ -0,0 +1,34 @@ +# frontend-build-hygiene Specification + +## Purpose + +Every dependency the app declares is a dependency the app uses. A package listed +in `package.json` and imported nowhere under `src/` ships bytes to the browser +for nothing, and it hides in the lockfile until somebody counts. + +## Requirements + +### Requirement: Declared runtime dependencies MUST be imported somewhere in `src/` + +Every package listed in `package.json` `dependencies` MUST have at least one static import (or a +documented dynamic-import path) somewhere under `src/`. A dependency with zero references is dead +weight in the install tree, the license report, and the security-audit surface, and MUST be +removed rather than carried forward "in case it's needed later." + +**Feature tier**: internal / build-quality (not user-facing) + +#### Scenario: `leaflet.markercluster` has no consumer + +- **GIVEN** `package.json` lists `leaflet.markercluster` as a dependency +- **WHEN** `src/` is searched for any import of `leaflet.markercluster` or use of + `MarkerCluster`/`markerClusterGroup` +- **THEN** zero matches are found +- **AND** the package MUST be removed from `package.json` and `package-lock.json` + +#### Scenario: Remaining Leaflet dependencies stay because they are consumed + +- **GIVEN** `package.json` lists `leaflet` and `leaflet-draw` +- **WHEN** `src/components/map/LocationPicker.vue` is inspected +- **THEN** both packages are statically imported and actively used for the point/polygon location + picker +- **AND** both packages remain in `package.json` diff --git a/openspec/specs/frontend-locale-completeness/spec.md b/openspec/specs/frontend-locale-completeness/spec.md new file mode 100644 index 000000000..d2bb2e66f --- /dev/null +++ b/openspec/specs/frontend-locale-completeness/spec.md @@ -0,0 +1,47 @@ +# frontend-locale-completeness Specification + +## Purpose + +English is the source language and Dutch is a translation of it. Translation keys +read as English, the two locale files carry the same key set, and no key in use +renders Dutch to an English reader. + +## Requirements + +### Requirement: Translation keys are English source + +Every `t('dossiq', ...)` / `n('dossiq', ...)` call site MUST use an English string as the +translation key. Dutch text MUST NOT be used directly as a translation key. + +#### Scenario: Dutch-literal key is rejected + +- **GIVEN** a developer writes `t('dossiq', 'Afgesloten')` where `'Afgesloten'` is the intended + Dutch UI text +- **WHEN** the string is reviewed against this requirement +- **THEN** the call MUST use an English source key instead (e.g. `t('dossiq', 'Closed')`) +- **AND** `l10n/nl.json` MUST map that English key to the Dutch text (`"Closed": "Afgesloten"`) + +#### Scenario: English-locale users never see raw Dutch text + +- **GIVEN** the Nextcloud UI language is set to English +- **WHEN** any dossiq page renders +- **THEN** no visible string SHALL be untranslated Dutch text (e.g. `Aanbestedingen`, `Actie`, + `Afgesloten`, `Adres bijgewerkt` rendering verbatim instead of an English equivalent) + +### Requirement: en.json and nl.json key sets match + +`l10n/en.json` and `l10n/nl.json` MUST contain the same set of translation keys — every key +present in one MUST be present in the other with a non-empty value. + +#### Scenario: No missing Dutch translation + +- **GIVEN** a translation key exists in `l10n/en.json` +- **WHEN** `l10n/nl.json` is checked for the same key +- **THEN** the key MUST be present with a non-empty Dutch translation +- **AND** a Dutch-locale user MUST NOT see the raw English key literal as fallback text + +#### Scenario: Coverage check is automated + +- **GIVEN** a new translation key is added to `en.json` without a corresponding `nl.json` entry +- **WHEN** `npm run test:l10n` is executed +- **THEN** the check MUST fail, reporting the specific key(s) missing from `nl.json` diff --git a/openspec/specs/initiator-display/spec.md b/openspec/specs/initiator-display/spec.md index f8835d3b5..22455125c 100644 --- a/openspec/specs/initiator-display/spec.md +++ b/openspec/specs/initiator-display/spec.md @@ -7,26 +7,167 @@ **Standards:** GEMMA Zaakafhandel (initiator visible on the zaak), ZGW ZRC Rol betrokkene **Feature tier:** MVP +## Purpose + +How the case shows who filed it. The stored initiator reference is rendered on +the case detail view, so a handler can see the person or company behind the case +without leaving the page. + ## Requirements ### Requirement: Case detail shows the initiator -The manifest `CaseDetail` page SHALL display the case's initiator in its overview when the -initiator fields are set: `initiatorDisplayName` with the `initiatorType` (Person / Company / -Contact) and the identifying `initiatorSourceId` (BSN / KvK number / contact reference) as a link -to the source record: the person's or the organisation's page in this app for a `brpPerson` or -`kvkCompany` row, and the contact otherwise. Cases without an initiator SHALL show no empty -initiator block. +You see who asked for the case at the top of the case page. The `initiator` +widget on `CaseDetail` SHALL render a person card in the first row of the +layout, beside `case-core`, when the projection fields are set. The card +SHALL show `initiatorDisplayName`, the type (Person, Company or Contact), the +identifying `initiatorSourceId` as a link to the source record (the +`brpPerson` or `kvkCompany` register object, or the contact), and the address +resolved from the source row. When `requester` is set and the projection is +empty, the card SHALL resolve the row by uuid and fill the projection. Cases +without a requester SHALL show no empty card. #### Scenario: Initiator visible on the case +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** a case with `initiatorType: person` and a seeded persona as requester +- **WHEN** a handler opens the case page +- **THEN** the first row SHALL hold a card with the persona's name, the type Person, the BSN and the address +- **AND** the BSN SHALL link to the seeded `brpPerson` record + +#### Scenario: A company card links to the KvK record +@e2e tests/e2e/case-requester.spec.ts - **GIVEN** a case with `initiatorType: company` and `initiatorSourceId: 69599084` -- **WHEN** a user opens the case detail -- **THEN** the overview MUST show the company's display name, the type Company, and the KvK number -- **AND** the KvK number MUST link to the organisation page of the seeded `kvkCompany` record +- **WHEN** a handler opens the case page +- **THEN** the card SHALL show the company's display name, the type Company and the KvK number +- **AND** the KvK number SHALL link to the seeded `kvkCompany` record #### Scenario: No initiator, no clutter +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** a case without a requester +- **WHEN** a handler opens the case page +- **THEN** no initiator card SHALL render + +### Requirement: The case list names the requester (REQ-ID-2) + +You see who asked for each case in the list and filter on their name. Page +`Cases` SHALL render a Requester column over `initiatorDisplayName` after +`title`, and its sidebar SHALL offer a text filter on the same field. The +column reads the projection until `CnIndexPage` renders a label field for a +`$ref` column; the data does not change when that lands. + +#### Scenario: The requester is a column +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** a seeded case whose requester is a seeded persona +- **WHEN** a handler opens Cases in table view +- **THEN** the row SHALL show the persona's name in the Requester column + +#### Scenario: The list filters on the requester's name +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** two seeded cases with different requesters +- **WHEN** the handler types the first requester's surname into the Requester filter +- **THEN** the list SHALL show the first case and not the second + +### Requirement: A protected person's number stays masked until you reveal it (REQ-ID-3) + +You see when a person's data is protected, and the BSN stays hidden until you +ask for it. When the source `brpPerson` row carries `indicatieGeheim: true`, +the card SHALL show a Protected marker and render the BSN as five dots plus +the last four digits. A Reveal button SHALL fetch the `brpPerson` row through +OpenRegister with `_reason: "bsn-reveal"` and then show the full number. +OpenRegister SHALL log that read, attributed to the case's processing +activity; dossiq SHALL write no log row itself. A person without the flag +SHALL render the BSN in full. + +#### Scenario: The BSN is masked +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** a case whose requester is the seeded protected persona +- **WHEN** a handler opens the case page +- **THEN** the card SHALL show the Protected marker +- **AND** the BSN SHALL read as five dots followed by its last four digits + +#### Scenario: A reveal shows the number and is a logged read +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** the same case +- **WHEN** the handler presses Reveal +- **THEN** the card SHALL show the full BSN +- **AND** the page SHALL make one read of the `brpPerson` row carrying `_reason=bsn-reveal` + +#### Scenario: An unprotected person is not masked +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** a case whose requester is a seeded persona without `indicatieGeheim` +- **WHEN** a handler opens the case page +- **THEN** the card SHALL show the full BSN and no Protected marker + +### Requirement: The Contacts index lists the people dossiq knows (REQ-ID-4) + +You find a person by name or number. The manifest page `Contacts` +(route `/contacts`, type `index`, register `dossiq`, schema `brpPerson`) +SHALL list `displayName`, `citizenServiceNumber`, the residence city and +`description`, with the index search box covering name and number. The view +action of a row SHALL open `ContactDetail`. + +The page SHALL NOT carry a `folderSidebar`. `contacts-domain` asked for one +with a People folder and a hidden Organisations folder; measured against +`@conduction/nextcloud-vue` 2.41.0, `CnIndexPage.folderSidebarFolders()` +returns `folders[]` verbatim so there is no hidden state, and +`filterField: "@self.schema"` is not a filter OpenRegister answers, so the +only folder that could ship would have emptied the list on its first click. +Organisations are reached through REQ-ID-6 instead, and the manifest SHALL +carry the measurement as a note on the page. + +#### Scenario: Find a person by name +@e2e tests/e2e/contacts-domain.spec.ts + +- **GIVEN** a seeded `brpPerson` row with display name Jansen +- **WHEN** you open Contacts and type Jansen in the search box +- **THEN** the list SHALL show that row with its citizen service number + +#### Scenario: No folder pane, and the way to organisations beside it +@e2e tests/e2e/contacts-domain.spec.ts + +- **GIVEN** the Contacts index +- **WHEN** you read the page +- **THEN** it SHALL render no folder pane +- **AND** the navigation SHALL offer the Organisations index without expanding anything + +### Requirement: The Organisations index lists the organisations dossiq knows (REQ-ID-6) + +You find an organisation without already holding a case that names it. The +manifest page `Organisations` (route `/organisations`, type `index`, register +`dossiq`, schema `kvkCompany`) SHALL list `tradeName`, `kvkNumber`, +`legalForm`, the address place and `description`, with the index search box +covering name and number. The view action of a row SHALL open +`OrganisationDetail`. A column whose key is a dot-path SHALL be +`sortable: false`, because such a key resolves client-side for rendering but +is not a column OpenRegister can order by. + +The page SHALL be reached by a menu entry `OrganisationsMenu` that +`src/menu-layout.json` relocates under `Contacts`, so the top-level count does +not move: ADR-097 Decision 1 counts top-level entries only. `Contacts` SHALL +carry `open: true`, because `CnAppNav` auto-expands a group only when one of +its children is the active route, so on `/contacts` the child would otherwise +be hidden behind a chevron. + +#### Scenario: The organisations index is a child of Contacts +@e2e tests/e2e/contacts-domain.spec.ts + +- **GIVEN** the Contacts index +- **WHEN** you read the navigation +- **THEN** an Organisations entry SHALL be visible under Contacts +- **AND** the top-level entries SHALL still be Dashboard, My work, Contacts and Objects + +#### Scenario: An organisation opens on its own page +@e2e tests/e2e/contacts-domain.spec.ts -- **GIVEN** a case without initiator fields -- **WHEN** a user opens the case detail -- **THEN** no initiator section/fields MUST be rendered +- **GIVEN** a seeded `kvkCompany` row +- **WHEN** you open the Organisations index and follow its row +- **THEN** you SHALL land on `/organisations/:id` for that row +- **AND** the card SHALL show the trade name and the KvK number diff --git a/openspec/specs/initiator-selection/spec.md b/openspec/specs/initiator-selection/spec.md index 0d478e7eb..8a58d30e1 100644 --- a/openspec/specs/initiator-selection/spec.md +++ b/openspec/specs/initiator-selection/spec.md @@ -7,23 +7,40 @@ **Standards:** GEMMA Zaakafhandel (Rol "initiator" is a required betrokkene on every zaak), ZGW ZRC Rol betrokkene (natuurlijk persoon / niet-natuurlijk persoon), CMMN CaseFileItem (initiator as case file data) **Feature tier:** MVP +## Purpose + +How the initiator is picked while a case is being created: one search across the +BRP and KvK register sets and Nextcloud Contacts, so the person filing the case +is chosen from a source that already knows them rather than typed in again. + ## Requirements ### Requirement: Case creation offers an initiator type selector -The case create/edit flow SHALL offer an initiator picker (registered component in -`src/registry.js`) — the flow is manifest-driven at HEAD: the Cases index add form and -`StartCaseWidget` (there is no `CaseCreateDialog.vue`) — with type selection -**Person / Company / Contact**, mapping to the -ZGW betrokkene types (natuurlijk persoon / niet-natuurlijk persoon / intern contact). Selecting an -initiator SHALL be optional at creation time (existing flows keep working unchanged). +You pick the citizen, company or contact when you file a case, and again when +you edit it. The New case form on `Dashboard` (header action `new-case`) and +the edit form that widget `case-core` opens on `CaseDetail` SHALL render the +`requester` field with `InitiatorPicker` through `fieldOverrides`. The picker +SHALL offer the types Person, Company and Contact, mapping to the ZGW +betrokkene types (natuurlijk persoon, niet-natuurlijk persoon, intern contact). +Selecting a requester SHALL stay optional; a case without one saves as before. #### Scenario: Agent picks an initiator type +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** a handler on `Dashboard` +- **WHEN** they open New case +- **THEN** the form SHALL show a Requester field rendered by the initiator picker, enabled +- **AND** the picker SHALL offer the types Person, Company and Contact +- **AND** Create SHALL stay enabled with no requester chosen + +#### Scenario: The edit form carries the picker +@e2e tests/e2e/case-requester.spec.ts -- **GIVEN** a user creating a case -- **WHEN** they open the initiator picker -- **THEN** the picker MUST offer the types Person, Company, and Contact -- **AND** the case MUST remain creatable without selecting any initiator +- **GIVEN** a seeded case without a requester +- **WHEN** the handler opens Edit on the case page +- **THEN** the Requester field SHALL be enabled and rendered by the initiator picker +- **AND** no tooltip SHALL say the field is set by an integration ### Requirement: Cross-source initiator search returns unified results @@ -54,18 +71,30 @@ out of scope here: this picker queries the register tier. ### Requirement: Selected initiator is stored on the case as a projection of the canonical requester -Confirming a selection SHALL store the initiator on the case via the additive `case` schema fields -`initiatorType` (`person | company | contact`), `initiatorSourceId` (BSN / KvK number / contact -URI), and `initiatorDisplayName`. These fields are the **display projection** of the canonical -ADR-048 requester semantic reference owned by `semantic-case-intake`: where that change has landed, -the picker SHALL write the semantic reference and fill the projection from it — one write path, -never a second requester field. +You save the case and the requester is on it once, with a display projection. +Confirming a selection SHALL write `case.requester` (the uuid of the chosen +`brpPerson` or `kvkCompany` row) and the projection fields `initiatorType` +(`person | company | contact`), `initiatorSourceId` (BSN, KvK number or +contact URI) and `initiatorDisplayName` in the same save. A contact has no +register row, so a contact selection SHALL fill the projection and leave +`requester` empty. There SHALL be one write path: the picker never writes a +second requester field, and a case that arrives through the `ns#Case` +semantic handoff keeps writing `requester` as today. #### Scenario: Selection persists on the case +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** the seeded BRP register set +- **WHEN** a handler files a case and picks a seeded persona as Person +- **THEN** the saved case SHALL carry `requester` equal to that `brpPerson` row's uuid +- **AND** `initiatorType` SHALL be `person`, `initiatorSourceId` the persona's BSN, `initiatorDisplayName` the persona's name + +#### Scenario: A company selection persists as uuid and projection +@e2e tests/e2e/case-requester.spec.ts -- **WHEN** a user selects seeded persona X and saves the case -- **THEN** the case object MUST carry `initiatorType: person`, the persona's BSN as - `initiatorSourceId`, and the persona's display name as `initiatorDisplayName` +- **WHEN** a handler edits a case and picks KvK 69599084 as Company +- **THEN** the saved case SHALL carry `requester` equal to that `kvkCompany` row's uuid +- **AND** `initiatorType` SHALL be `company` and `initiatorSourceId` `69599084` #### Scenario: Schema extension is additive @@ -74,3 +103,26 @@ never a second requester field. - **GIVEN** cases created before this change - **WHEN** the extended `case` schema is imported - **THEN** existing cases MUST remain valid with the initiator fields absent + +### Requirement: The register sets provide the requester type (REQ-IS-4) + +You get an enabled Requester field because the fleet can answer for it. +`brpPerson` and `kvkCompany` in `lib/Settings/register.d/25-brp-kvk.json` +SHALL declare `implements: ["https://openregister.app/ns#Requester"]`. +`case.requester` SHALL keep its `referenceSemanticType`; it SHALL NOT become a +`$ref` to one schema, so the semantic handoff keeps its write path. + +#### Scenario: Two schemas implement the requester type + +@e2e exclude Schema declaration with no browser surface: PHPUnit (BrpKvkRegisterSetsTest::testPartySchemasImplementRequester) reads the fragment and asserts both schemas list the URI; the enabled field it produces is asserted by the picker scenarios above. + +- **WHEN** the register configuration is imported +- **THEN** `brpPerson` and `kvkCompany` MUST list `https://openregister.app/ns#Requester` under `implements` +- **AND** `case.requester` MUST still carry `referenceSemanticType` with that URI + +#### Scenario: The form no longer disables the field +@e2e tests/e2e/case-requester.spec.ts + +- **GIVEN** the imported register configuration +- **WHEN** a handler opens New case on `Dashboard` +- **THEN** the Requester field SHALL NOT carry the disabled state or the no-provider tooltip diff --git a/openspec/specs/kcc-routing/spec.md b/openspec/specs/kcc-routing/spec.md new file mode 100644 index 000000000..793908a36 --- /dev/null +++ b/openspec/specs/kcc-routing/spec.md @@ -0,0 +1,95 @@ +# kcc-routing Specification + +## Purpose +KCC routing-rule evaluation runs on OpenRegister's shared decision-table +evaluator; dossiq keeps the KCC condition dialect, its derivations, and agent +ranking. + +## Requirements + +### Requirement: Routing rules evaluate through the shared decision-table engine + +The system SHALL evaluate KCC routing rules by compiling them into an inline +decision table (hit policy FIRST, enabled rules ascending by priority, +declaration order breaking ties) and running it through +`OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator`. The system SHALL NOT +keep a private rule-matching engine on the runtime path. + +The observable contract SHALL be the legacy one: the first enabled rule whose +conditions all hold wins; no match answers null; a rule that could never +match under the legacy engine (contradictory equalities, malformed time +window, unknown condition type, empty conditions, disabled) still never +matches. + +#### Scenario: A keyword rule routes a contact moment + +- **GIVEN** an enabled rule with a keyword condition and a contact moment whose subject carries the keyword +- **WHEN** routing is evaluated +- **THEN** the rule's domain, team and escalation team are answered via the shared evaluator + +`@e2e exclude` pure backend evaluation with no owned UI surface; pinned by +RoutingTableEvaluatorTest and the parity matrix. + +#### Scenario: The lowest-priority match wins regardless of listing order + +- **GIVEN** two matching enabled rules with priorities 9 and 1, listed in that order +- **WHEN** routing is evaluated +- **THEN** the priority-1 rule is answered + +`@e2e exclude` pure backend evaluation; pinned by RoutingTableEvaluatorTest. + +#### Scenario: No matching rule answers null + +- **GIVEN** rules none of which match the contact moment +- **WHEN** routing is evaluated +- **THEN** the evaluation answers null and the caller reports `matched: false` + +`@e2e exclude` pure backend evaluation; pinned by RoutingTableEvaluatorTest. + +### Requirement: Domain derivations stay in dossiq + +The system SHALL derive the shared engine's inputs itself: the lower-cased +subject+summary haystack, keyword and regex predicates as boolean columns, +the KvK-number customer-type rule, minutes-since-midnight for time windows, +and the lower-cased day of week. The shared unary-test grammar SHALL NOT be +extended with substring or regex operators for this consumer. + +#### Scenario: Customer type is derived from the reference + +- **GIVEN** rules routing bedrijf, burger and anoniem to different teams +- **WHEN** contact moments with an 8-digit reference, a non-numeric reference and no reference are routed +- **THEN** they route to the bedrijf, burger and anoniem teams respectively + +`@e2e exclude` pure backend derivation; pinned by RoutingTableEvaluatorTest +and the parity matrix. + +### Requirement: The evaluation fails closed without the shared engine + +When OpenRegister's evaluator class is unavailable the system SHALL refuse +routing loudly. The system SHALL NOT fall back to a private matcher. + +#### Scenario: A missing evaluator refuses rather than guesses + +- **GIVEN** an environment where the shared evaluator class does not exist +- **WHEN** routing is evaluated +- **THEN** a RuntimeException names what is missing and nothing is routed + +`@e2e exclude` requires an instance without OpenRegister, which the e2e rig +cannot provide; the guard is a two-line class_exists refusal exercised only +when the autoloadable stub is absent. + +### Requirement: The legacy engine survives only as the parity oracle + +The legacy `RoutingEngine::evaluate()` SHALL remain, deprecated and closed to +new callers, until the staged retirement completes; a parity test SHALL drive +both paths over one pinned fixture matrix covering every condition type and +every never-matching rule shape. + +#### Scenario: The two paths agree across the fixture matrix + +- **GIVEN** the pinned rule set and the moment-by-time sweep +- **WHEN** both the legacy engine and the table compilation evaluate every cell +- **THEN** every cell agrees, and the sweep contains both matches and refusals + +`@e2e exclude` a structural parity assertion between two backend evaluators; +KccRoutingParityTest is the pin. diff --git a/openspec/specs/kvk-register/spec.md b/openspec/specs/kvk-register/spec.md index 94fa177b5..17acd9376 100644 --- a/openspec/specs/kvk-register/spec.md +++ b/openspec/specs/kvk-register/spec.md @@ -7,6 +7,12 @@ **Standards:** KvK Zoeken API (field naming), GEMMA Zaakafhandel (initiator betrokkene), ZGW ZRC Rol `niet_natuurlijk_persoon`, Schema.org `schema:Organization` **Feature tier:** MVP +## Purpose + +The KvK company register set in OpenRegister: the `kvkCompany` schema and its +fictitious seed rows, named after the KvK Zoeken API so the live adapter and the +seed data describe the same company the same way. + ## Requirements ### Requirement: KvK company register schema exists in OpenRegister diff --git a/openspec/specs/lhs-decision-table/spec.md b/openspec/specs/lhs-decision-table/spec.md new file mode 100644 index 000000000..90da65580 --- /dev/null +++ b/openspec/specs/lhs-decision-table/spec.md @@ -0,0 +1,133 @@ +# lhs-decision-table Specification + +## Purpose +The enforcement matrix becomes a decision table, evaluated by the one evaluator +the fleet shares, instead of a hand-indexed dictionary in dossiq. + +## Requirements + +### Requirement: REQ-LDT-001 A matrix projects onto a decision table + +The system SHALL project each stored LHS matrix onto a decision table whose +inputs are severity, behaviour and actorType, whose output is the intervention, +and whose rules are the matrix cells. + +Each rule SHALL be identified by its own triple, so a rule is traceable back to +the cell it came from without depending on order. + +#### Scenario: Each cell becomes a rule + +- **GIVEN** a matrix of four cells over two severities, two behaviours and one actor type +- **WHEN** it is projected +- **THEN** the table MUST declare those three inputs and one output, and carry + four rules, each identified by its own `severity:behaviour:actorType` + +#### Scenario: Both stored shapes are read + +- **GIVEN** a matrix whose axes and cells are stored as JSON strings +- **THEN** it MUST project identically to one storing them as arrays + +### Requirement: REQ-LDT-002 The table declares UNIQUE + +The projected table SHALL declare the `UNIQUE` hit policy. + +A grid has exactly one cell per triple. UNIQUE turns an overlapping pair into a +refusal, where the hand-indexed dictionary silently keeps whichever cell was +read last. + +#### Scenario: The projection declares UNIQUE + +- **GIVEN** any projected matrix +- **THEN** its `hitPolicy` MUST be `UNIQUE` + +### Requirement: REQ-LDT-003 An inconsistent matrix is refused, not projected + +The system SHALL skip a matrix in which any cell names a value that is not on +the corresponding axis, and SHALL report the reason. + +Projecting it would carry the defect across while looking like a migration that +worked: the rule would be unreachable in the table exactly as the cell is +unreachable in the matrix. dossiq#1596 is that defect, shipped. + +#### Scenario: A cell off its axis skips the matrix + +- **GIVEN** a matrix whose cell names an actor type absent from its actorTypeAxis +- **WHEN** the migration runs +- **THEN** no table MUST be written for it, and the summary MUST count it + skipped with the reason + +### Requirement: REQ-LDT-004 The projection arrives disabled + +The projected table SHALL be created disabled. + +The matrix still drives recommendations. A table that also answered would be a +second source of truth for an enforcement decision. + +#### Scenario: A freshly projected matrix is disabled + +- **GIVEN** any projected matrix +- **THEN** the table MUST carry `enabled: false` + +### Requirement: REQ-LDT-005 The evaluator is the lookup + +`LhsRecommendationService::recommend()` SHALL resolve the prescribed +intervention by evaluating the projected decision table through OpenRegister, +and SHALL read the matrix directly only when this instance has no enabled +projection for it. + +The projection SHALL arrive ENABLED. With the evaluator as the lookup, a +disabled table does not withhold a second opinion, it silently hands the +question back to the matrix and makes the migration a no-op that reports +success. + +The matrix path SHALL be retained. Projecting a table needs an owner for the +object it writes, so it is a command a person runs rather than something an +upgrade performs; an instance that has not run it must still be able to +enforce. + +@e2e exclude Server-side resolution between two lookup paths. Which of them answered is not observable in a browser: both produce the same recommendation row for a consistent matrix, and that identity is the point. Covered by LhsOverrideAuthorizationTest, which pins the matrix path by injecting a lookup that answers null, and by the migrator suite for the table path. + +#### Scenario: An enabled projection answers + +- **GIVEN** a matrix with an enabled projected decision table +- **WHEN** a recommendation is requested for a triple the table covers +- **THEN** the intervention MUST come from the evaluator + +#### Scenario: No projection falls back to the matrix + +- **GIVEN** an instance where the projection has never been run +- **WHEN** a recommendation is requested +- **THEN** the intervention MUST be read from the matrix cells + +#### Scenario: A disabled projection is not consulted + +- **GIVEN** a projected table an administrator has switched off +- **THEN** the matrix MUST answer instead + +### Requirement: REQ-LDT-006 A re-run updates, and refuses an edited table + +A re-run SHALL resolve the existing table by its provenance marker and update +it, rather than writing a second table carrying the same marker. + +A re-run SHALL REFUSE a table whose rules differ from the projection, and +report the refusal. The projection is one-way and the matrix no longer has an +authoring surface, so overwriting edited rules would replace an +administrator's work with a source they cannot read. + +Only the rules SHALL be compared. A renamed table, a reworded description or a +toggled enabled flag are an administrator's business. + +@e2e exclude A property of the occ migration, which has no browser surface at all. Covered by LhsMatrixDecisionTableMigratorTest over a schema-aware fake register: a fake answering both schemas alike could not express "a table already exists" and the guard would never fire. + +#### Scenario: A re-run does not duplicate + +- **GIVEN** a matrix already projected once +- **WHEN** the migration runs again +- **THEN** exactly one table MUST exist, and the write MUST target it + +#### Scenario: An edited table is refused + +- **GIVEN** a projected table whose rules have been changed +- **WHEN** the migration runs again +- **THEN** nothing MUST be written for it +- **AND** the run MUST report it as skipped, naming the edit diff --git a/openspec/specs/my-work/spec.md b/openspec/specs/my-work/spec.md index 767954fcd..294e4d6bb 100644 --- a/openspec/specs/my-work/spec.md +++ b/openspec/specs/my-work/spec.md @@ -134,6 +134,63 @@ covered by PHPUnit + smoke tests, not Playwright browser assertions. - THEN `src/views/dashboard/MyWorkPreview.vue` MUST show a summary of the user's assigned work +### Requirement: Lenses on the Cases index [V1] + +You switch between your cases, unclaimed cases and all cases on one list. +The `Cases` page (`src/manifest.json`, type `index` over `case`) SHALL +carry `quickFilters` chips in this order: All (no filter), Mine +(`assignee = @me`, `isFinalStatus = false`), Unclaimed +(`assignee = "IS NULL"`, `isFinalStatus = false`), Closed +(`isFinalStatus = true`) and Overdue (`deadline lt @today`, +`isFinalStatus = false`). All SHALL be the default chip. Exactly one chip +is active at a time and choosing a chip SHALL replace the previous chip's +filter, not stack on it. The Unclaimed chip SHALL use the same filter as +the Queue page's base filter, so the two lists agree. The Queue page and +the My Work page SHALL stay as they are: no page is folded, retired or +moved by this requirement. + +**Decision D-default, revised by Ruben.** This requirement asked for Mine +as the default chip and it now asks for All. A `quickFilters` list +activates a chip on mount: the one marked `default`, and the first one +when none is marked. A Mine default therefore narrows every reader's first +paint to their own rows before they have chosen anything, and a person +with no cases lands on an empty list that reads as an empty register +rather than as a filter. All as the default keeps the landing view the one +the page has always shown; Mine is one click away and stays visibly +active once chosen. This is the pattern `parties-on-the-case` established +on both indexes. + +#### Scenario: All is the lens you land on, Mine is one click away +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open case assigned to the signed-in user and an open case assigned to another user +- **WHEN** you open the Cases page +- **THEN** the chip All SHALL be active and the list SHALL show both cases +- **AND** choosing the chip Mine SHALL show the case assigned to you and SHALL NOT show the other user's case + +#### Scenario: Unclaimed shows what nobody has picked up +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open case with no assignee and an open case assigned to the signed-in user +- **WHEN** you choose the chip Unclaimed +- **THEN** the list SHALL show the unassigned case and SHALL NOT show the assigned one +- **AND** the Queue page SHALL show the same unassigned case + +#### Scenario: All shows every open and closed case +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open case assigned to another user and a closed case +- **WHEN** you choose the chip All +- **THEN** the list SHALL show both cases + +#### Scenario: Chips replace each other +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** the Cases page with the chip Unclaimed active +- **WHEN** you choose the chip Mine +- **THEN** only Mine SHALL be active +- **AND** the list SHALL show no unassigned case + ## Non-Functional Requirements - **Performance**: My Work reuses the index self-fetch; it MUST page/limit like diff --git a/openspec/specs/ori-removal/spec.md b/openspec/specs/ori-removal/spec.md new file mode 100644 index 000000000..b3d60b992 --- /dev/null +++ b/openspec/specs/ori-removal/spec.md @@ -0,0 +1,112 @@ +# ori-removal Specification + +## Purpose + +dossiq stops owning the Open Raadsinformatie (ORI) register. The register, its +public feeds and its quality checks move to decidesk, and cases link to decidesk +meetings across apps. Nothing is deleted here until the migration is verified. + +## Requirements + +### Requirement: REQ-ORIR-001 — Removal MUST be gated on verified migration parity + +dossiq MUST NOT delete the `ori` OpenRegister register, or any object in it, +until its objects are verifiably migrated into decidesk. The move renames +schemas, properties and enum values, so it is a **data migration** executed by +decidesk's `occ decidesk:import-ori` (mapping table, dry-run and rollback per +decidesk `ori-adoption`), never by editing schema JSON in place. Parity MUST be +verified by measuring object counts on both sides (source register counts vs +`ori:*`-tagged target counts, accounting for the 1→2 split of `stemming` into +Decision + VotingRound), not by trusting the import command's own report. + +#### Scenario: Removal blocked while unmigrated objects exist + +- GIVEN an upgraded dossiq whose `ori` register still contains objects without matching `ori:*` import tags in decidesk +- WHEN the `RetireOriRegister` repair step runs during `occ upgrade` +- THEN it warns, leaves the register and all its objects intact, and the upgrade still succeeds + +#### Scenario: Retirement after verified parity + +- GIVEN every object in the `ori` register has a matching `ori:*`-tagged counterpart in decidesk +- WHEN the repair step runs +- THEN it first writes a timestamped JSON export of the register (schemas + objects) to the app data directory, then deletes the register and its six schemas + +#### Scenario: Repair step is idempotent and fail-safe + +- GIVEN OpenRegister is unavailable, or the `ori` register was already retired +- WHEN the repair step runs +- THEN it is a no-op with an informational message and the upgrade succeeds + +### Requirement: REQ-ORIR-002 — dossiq MUST stop shipping and provisioning the ORI register + +`lib/Settings/ori_register.json` and `lib/Repair/RegisterOriRegister.php` MUST +be deleted, together with their `appinfo/info.xml` registrations (both the +`` and `` entries); fresh installs MUST NOT create an +`ori` register. The `` slot is taken by `RetireOriRegister` +(REQ-ORIR-001). The `OriDataQualityCheck` background job and its `` +registration MUST be deleted. After removal, a repository-wide search for +`ori_register`, `register: 'ori'`, `RegisterOriRegister`, +`OriDataQualityCheck` and `RaadsinformatieFeed` MUST match only +`RetireOriRegister` (which necessarily names the `ori` slug it retires) — and +the check MUST count the files searched, since "no matches" over zero files is +indistinguishable from a pass. + +#### Scenario: Fresh install has no ORI register + +- GIVEN a clean Nextcloud with OpenRegister and the new dossiq version +- WHEN dossiq is installed and its repair steps run +- THEN no OpenRegister register with slug `ori` exists and no ORI schemas were created + +#### Scenario: No dangling ORI code references + +- GIVEN the change is fully applied +- WHEN the repo-wide grep gate from design.md runs over `lib/`, `src/` and `appinfo/` (file count > 0) +- THEN the only matches are inside `RetireOriRegister` and its tests + +### Requirement: REQ-ORIR-003 — The public ORI feeds MUST move to decidesk + +`lib/Controller/RaadsinformatieFeedController.php` and the routes +`/feed/ori/vergaderingen.rss`, `/feed/ori/agendapunten.rss` and +`/feed/ori/documenten.rss` MUST be removed from dossiq. Consumers are pointed +at decidesk's replacement feeds (`/apps/decidesk/feed/ori/meetings.rss`, +`/feed/ori/agenda-items.rss`, `/feed/ori/documents.rss` — same ORI wire shape, +served by decidesk's adapter). dossiq's release notes MUST document the URL +change; dossiq MUST NOT proxy or redirect (the app may be installed without +decidesk, and a half-alive feed is worse than a documented move). + +#### Scenario: dossiq feed routes are gone + +- GIVEN the new dossiq version +- WHEN an anonymous client requests `/apps/dossiq/feed/ori/vergaderingen.rss` +- THEN it receives a 404 (route not registered), and the routes file contains no `/feed/ori/` entries + +### Requirement: REQ-ORIR-004 — Cases MUST link to decidesk meetings cross-app + +dossiq's vergadering-backed cases (the `case` objects managed by +`VergaderingCaseService` and advanced by `VergaderingDeadlineJob`) are KEPT. +Where such a case references a council meeting record, the dossiq `case` +schema MUST gain an optional additive `meetingRef` property holding the +decidesk `Meeting` uuid, and the meeting detail surface +(`VergaderingDetailView`, route `/besluitvorming/vergaderingen/:id`) MUST +render case-side data plus a link into decidesk (deep link or decidesk's +ADR-019 integration leaf) instead of reading ORI objects. The route MUST stay +registered (deep links and e2e MUST NOT break). When decidesk is absent or +`meetingRef` is empty the surface MUST degrade to a quiet unavailable notice. + +#### Scenario: Case deep-links to its decidesk meeting + +- GIVEN a vergadering-backed case whose `meetingRef` holds a decidesk Meeting uuid and decidesk is installed +- WHEN the user opens `/besluitvorming/vergaderingen/:id` for that case +- THEN the page shows the case data and an "Open meeting in decidesk" link targeting that Meeting, and no ORI register read is performed + +#### Scenario: Graceful degradation without decidesk + +- GIVEN decidesk is not installed +- WHEN the same page renders +- THEN the case data renders normally and the meeting panel shows an unavailable notice instead of an error + +#### Scenario: Case lifecycle unaffected by the removal + +- GIVEN vergadering-backed cases in status `gepland` with a start date in the past +- WHEN `VergaderingDeadlineJob` runs on the new version +- THEN the cases advance exactly as before (the job reads only case data, no ORI objects) diff --git a/openspec/specs/partner-organisations/spec.md b/openspec/specs/partner-organisations/spec.md new file mode 100644 index 000000000..2e556ba14 --- /dev/null +++ b/openspec/specs/partner-organisations/spec.md @@ -0,0 +1,79 @@ +# partner-organisations Specification + +## Purpose +A ketenpartner is stored once, as an OpenRegister Organisation, without losing +what case sharing needs from it. + +## Requirements + +### Requirement: REQ-PRT-001 A partner migrates without losing a field + +The system SHALL provide a migration that projects each `partnerOrganization` +onto an OpenRegister Organisation. + +Every property the partner schema declares SHALL have a destination: +`name`, `slug`, `oin`, `contactEmail`, `isActive`, `groupId`, +`defaultPermissionLevel`, `qualityScore` and `qualityStatus`. + +The last three are why dossiq kept its own copy. A migration that dropped them +would leave case sharing without the permission default it reads, and would do +so silently. + +#### Scenario: The case-sharing fields survive + +- **GIVEN** a partner carrying a default permission level and a quality score +- **WHEN** it is migrated +- **THEN** the Organisation MUST carry both + +### Requirement: REQ-PRT-002 The partner's identity is preserved + +The migration SHALL set the Organisation's uuid to the partner's own. + +Case shares reference partners by id. Minting a new one would strand every +existing share while reporting success. + +#### Scenario: The uuid carries over + +- **GIVEN** a partner with a uuid +- **THEN** the Organisation MUST carry the same uuid + +### Requirement: REQ-PRT-003 A partner is external, and says so + +A migrated partner SHALL be marked as an external organisation rather than a +tenant of this instance. + +Once partners and tenants share one table, the difference has to be recorded on +the row. Inferring it later from which app wrote it is not something the data +supports. + +#### Scenario: A migrated partner is not a local tenant + +- **GIVEN** a migrated partner +- **THEN** it MUST NOT be marked as a local tenant + +### Requirement: REQ-PRT-004 Running it twice changes nothing + +The migration SHALL be idempotent by the partner's own id. + +The id, not the slug. `partnerOrganization` requires only `name` and +`contactEmail`, so a partner is free to carry no slug and keying on one would +fail the migration on ordinary data. Deriving a slug from the name is worse +still: two partners sharing a name derive one slug, and the second is then +skipped as already migrated, silently merging two organisations into one. + +#### Scenario: A second run creates nothing + +- **GIVEN** a partner already migrated +- **WHEN** the migration runs again +- **THEN** it MUST skip that partner and create no second Organisation + +#### Scenario: A partner with no slug still migrates + +- **GIVEN** a partner row carrying no slug +- **THEN** it MUST migrate, taking a slug derived from its id + +#### Scenario: A partner with no id is refused + +- **GIVEN** a partner row carrying no id +- **THEN** the migration MUST refuse it and report it as failed, because the + id is the idempotency key and without one a re-run would duplicate it diff --git a/openspec/specs/performance-hardening/spec.md b/openspec/specs/performance-hardening/spec.md new file mode 100644 index 000000000..b216e6627 --- /dev/null +++ b/openspec/specs/performance-hardening/spec.md @@ -0,0 +1,61 @@ +# performance-hardening Specification + +## Purpose + +Three ways dossiq used to get slow, closed. Audit-log endpoints are bounded and +filtered on the server. The production build ships no full source maps. The app +manifest is not made deeply reactive at boot. + +## Requirements + +### Requirement: Audit-log endpoints are bounded and server-filtered + +The system MUST NOT serve the full archief audit-log register in a single response, and MUST NOT +filter audit-log rows by loading the entire register into PHP memory first. + +#### Scenario: Audit-log endpoint is paginated + +- **GIVEN** the `overdracht_audit_log_schema` register holds more rows than one page +- **WHEN** a client calls `GET /api/archief/audit-log` +- **THEN** the response SHALL respect `_limit`/`_offset` query parameters +- **AND** SHALL NOT return every row in the register in one payload + +#### Scenario: Batch audit lookup filters server-side + +- **GIVEN** a client requests the audit trail for one archiving batch id +- **WHEN** `ArchiefController` resolves the matching audit-log rows +- **THEN** the batch-id filter SHALL be applied by the OpenRegister query (object-field filter), + not by fetching all rows and scanning them in PHP with `array_filter`/`str_contains` + +#### Scenario: Substitution index is bounded + +- **GIVEN** the substitution schema register +- **WHEN** `SubstitutionController` resolves the full substitution list for its index/filter logic +- **THEN** the underlying `searchObjectsAsArrays()` call SHALL include a `_limit`, consistent with + the pagination pattern used elsewhere in this app (e.g. `RaadsinformatieFeedController`) + +### Requirement: Production build does not ship full source maps + +The webpack production build MUST NOT emit a full-fidelity `'source-map'` devtool artifact that +exposes original, unminified source alongside the deployed bundle. + +#### Scenario: Production devtool is not full source-map + +- **GIVEN** `webpack.config.js` builds with `isDev === false` +- **WHEN** the `devtool` option is resolved +- **THEN** it SHALL NOT be `'source-map'` +- **AND** SHALL either be a non-source-exposing variant (e.g. `'nosources-source-map'`) or absent + +### Requirement: App manifest is not deeply reactive at boot + +The manifest object passed into the Vue render tree MUST be marked non-reactive (`markRaw`) so +Vue does not instrument the entire navigation/widget tree with per-property reactivity on boot. + +#### Scenario: Manifest prop is markRaw'd + +- **GIVEN** `src/main.js` builds the merged manifest from `manifest.json` + `manifest.d/*.json` + fragments and the backend `/api/manifest` delta +- **WHEN** the manifest is passed as a prop into the root `App` component +- **THEN** the object SHALL be wrapped in Vue's `markRaw()` before assignment +- **AND** the case-type-navigation backend delta SHALL still update the rendered nav without a + full page reload (via ref reassignment, not deep-property reactivity) diff --git a/openspec/specs/portal-contribution/spec.md b/openspec/specs/portal-contribution/spec.md new file mode 100644 index 000000000..eb7ffa783 --- /dev/null +++ b/openspec/specs/portal-contribution/spec.md @@ -0,0 +1,91 @@ +# portal-contribution Specification + +## Purpose + +dossiq contributes its portal data to portaliq rather than rendering portals +itself. One provider declares three audiences: supplier, citizen and inspector. +Each audience is scoped to what that reader may see, by subject and by field. +The in-app portal views retire; the backend API and the schemas stay. + +## Requirements + +### Requirement: REQ-PORTAL-001 — The provider MUST declare three portal audiences (supplier, citizen, inspector) + +`OCA\Dossiq\Portal\PortalContributionProvider` MUST expose +`getAudiences()` returning exactly `['supplier','citizen','inspector']` and keep +`getAudience()` returning `'supplier'` as the contract-v1 fallback. It MUST remain +a plain class — no Portaliq import, no `implements`, no info.xml dependency, no +constructor dependencies — so it is inert when Portaliq is absent. +`getContribution($subject)` MUST branch on `$subject['audience']` and MUST return +`null` for any audience dossiq does not serve (fail-closed, ADR-005). + +#### Scenario: Provider advertises all three audiences + +- GIVEN the Portaliq registry probes the dossiq provider +- WHEN it calls `getAudiences()` +- THEN it receives `['supplier','citizen','inspector']` and `getAudience()` returns `'supplier'` + +#### Scenario: Unserved audience contributes nothing + +- GIVEN a resolved subject whose `audience` is not one dossiq serves +- WHEN `getContribution($subject)` is called +- THEN it returns `null` + +### Requirement: REQ-PORTAL-002 — The citizen audience MUST expose the 'Mijn gemeente' surface as subject-scoped, field-projected collections plus one safe create + +For `audience: 'citizen'`, `getContribution()` MUST return the collections +`mijnZaken` (`case`, scopeField `portaalSubject`), `berichten` +(`portaalBericht`, `kind: 'inbox'`, scopeField `recipientRef`) and `verzoeken` +(`portaalVerzoek`, scopeField `submitterRef`), each carrying a `fields` whitelist +that omits staff/internal columns, and exactly one action `createKlacht` +(`portaalVerzoek`, scopeField `submitterRef`) whitelisting only citizen-authored +content with no case cross-reference. The bezwaar and message-reply creates MUST +NOT be declared (deferred write-IDOR, portaliq#16). + +#### Scenario: Citizen sees their own cases, inbox and requests + +- GIVEN a resolved citizen subject +- WHEN `getContribution()` runs for `audience: 'citizen'` +- THEN the manifest lists `mijnZaken`, `berichten` and `verzoeken`, each with a `fields` whitelist, and a single `createKlacht` action + +#### Scenario: Every citizen scopeField and projected field exists on its schema + +- GIVEN the citizen collections' schemas in `dossiq_register.json` +- WHEN each collection's `scopeField` and each `fields` entry is checked against the schema properties +- THEN every one exists (register-drift pin) + +### Requirement: REQ-PORTAL-003 — The inspector audience MUST expose an external field inspector's assigned inspections, scoped by a non-NC-account reference + +For `audience: 'inspector'`, `getContribution()` MUST return the read collections +`inspectieRapporten` (`inspectieRapport`) and `checklistRuns` +(`inspectionChecklistRun`), both scoped by `assignedInspectorRef` — the external +inspector's pseudonymous portal reference, distinct from the internal `inspector` +NC-user-UID column — and both field-projected to the inspector's own result-level +data. No create action is declared (deferred run-submit, portaliq#16). + +#### Scenario: External inspector sees only their assigned inspections + +- GIVEN a resolved inspector subject +- WHEN `getContribution()` runs for `audience: 'inspector'` +- THEN the manifest lists `inspectieRapporten` and `checklistRuns`, both scoped by `assignedInspectorRef`, with no create action + +### Requirement: REQ-PORTAL-004 — The in-app portal Vue surfaces MUST be retired while the backend API and schemas remain + +The in-app supplier portal (`/leverancier`), citizen portal (`/portaal/*`) and +field-inspection nav page (`/inspecties`) Vue views, their manifest fragments, +their `PortaalGroup` nav group and their routes MUST be removed from the dossiq +frontend. The backend controllers/services and their `/api/leverancier-portaal/*`, +`/api/portaal/*` and `/api/inspections/*` endpoints, and the OpenRegister schemas, +MUST remain unchanged (Portaliq reads OpenRegister directly). + +#### Scenario: Retired portal nav entries no longer render + +- GIVEN the dossiq app navigation is built from the manifest fragments + menu-layout +- WHEN the sidebar renders +- THEN no `LeverancierDashboard`, `MijnZaken`, `MijnNotificaties` or `Inspecties` menu entry and no `PortaalGroup` group appears + +#### Scenario: Backend supplier/portal/inspection endpoints still resolve + +- GIVEN the retired in-app views are deleted +- WHEN a request hits `/api/leverancier-portaal/*`, `/api/portaal/*` or `/api/inspections/*` +- THEN the backend controllers still resolve (only the in-app Vue surfaces were removed) diff --git a/openspec/specs/realtime-updates-ui/spec.md b/openspec/specs/realtime-updates-ui/spec.md new file mode 100644 index 000000000..6fe879f4f --- /dev/null +++ b/openspec/specs/realtime-updates-ui/spec.md @@ -0,0 +1,51 @@ +# realtime-updates-ui Specification + +## Purpose + +A view that renders from the store subscribes to changes in what it renders. A +handler watching the workflow board sees somebody else's move without pressing +reload. + +## Requirements + +### Requirement: Store-rendered views MUST subscribe to live updates for their scope + +Views that render from Dossiq's `createObjectStore`-based object store MUST subscribe to +live updates for the data they display: collection-scoped views subscribe to +`or-collection-{register-slug}-{schema-slug}` per rendered object type, object-scoped views +subscribe to `or-object-{uuid}`. Subscriptions MUST be re-scoped when the viewed scope +changes and released when the view is destroyed. Events are refetch HINTS only: views MUST +refetch through their existing fetch paths and MUST NOT patch rendered state from an event +payload. + +@e2e exclude Requires a second concurrent authenticated session plus a notify_push (or poll-tick) round-trip; covered by the shared library's transport tests and manual two-browser verification. + +#### Scenario: Workflow board refreshes when a case changes elsewhere + +- **GIVEN** the workflow board is open +- **WHEN** another user creates, updates or transitions a case +- **THEN** the board receives the `or-collection-{register}-{schema}` hint and re-runs its + existing `fetchData()` path (debounced, non-blanking background mode), so the card + moves/updates without a manual refresh + +#### Scenario: Board refetch deferred during drag or bulk transition + +- **GIVEN** the workflow board is open and the user is mid-drag (or the bulk-transition + dialog is open) +- **WHEN** a live event hint arrives +- **THEN** the refetch is skipped for that hint — the post-save server event re-hints once + the interaction completes, so no state is lost + +#### Scenario: Sub-case detail refreshes when the viewed object changes elsewhere + +- **GIVEN** the deelzaak detail view is open for sub-case `{uuid}` +- **WHEN** another user updates that sub-case +- **THEN** the `or-object-{uuid}` hint triggers a debounced `reload({ background: true })` + through the existing fetch path, and the view re-renders the fresh data + +#### Scenario: Subscription released on scope change and destroy + +- **GIVEN** a live subscription is active for the current scope +- **WHEN** the user opens another sub-case (or navigates away) +- **THEN** the previous subscription is released — including one still in flight, which is + invalidated via an epoch counter and unsubscribes itself on resolution diff --git a/openspec/specs/reassignment-bulk-action/spec.md b/openspec/specs/reassignment-bulk-action/spec.md new file mode 100644 index 000000000..240ac213d --- /dev/null +++ b/openspec/specs/reassignment-bulk-action/spec.md @@ -0,0 +1,81 @@ +# reassignment-bulk-action Specification + +## Purpose +Reassigning cases becomes an act on the cases a user is looking at, rather than +a settings screen that first asks which handler to empty. + +## Requirements + +### Requirement: REQ-RBA-001 A selection is reassigned as one act + +The system SHALL reassign an explicitly named set of cases to one receiving +handler, and SHALL report the outcome per case. + +The rows SHALL share one batch identifier, so a selection remains recoverable +as a single act. + +#### Scenario: The selected cases move + +- **GIVEN** two cases and a receiving handler +- **WHEN** the selection is reassigned +- **THEN** both cases MUST carry the new assignee, and the summary MUST report + two requested and two succeeded + +#### Scenario: One failure does not abort the rest + +- **GIVEN** a selection whose first case cannot be written +- **THEN** the remaining cases MUST still be attempted, and the failure MUST be + reported for that case rather than raised + +### Requirement: REQ-RBA-002 The audit records each case's own previous assignee + +The system SHALL record `reassignedFrom` from the case's OWN assignee at the +time of the move. + +A hand-picked selection may hold rows belonging to different handlers. A +batch-level value is truthful only when they all came from the same one, so +recording it would name the wrong person on every other row. + +#### Scenario: A mixed selection records mixed origins + +- **GIVEN** one case assigned to `jan` and one to `klaas`, both moved to `piet` +- **THEN** the first case's audit entry MUST name `jan` and the second's `klaas` + +#### Scenario: A case already assigned to the receiver is left alone + +- **GIVEN** a case whose assignee is already the receiving handler +- **THEN** it MUST be counted as succeeded and MUST NOT be rewritten, because + an audit entry saying it moved from somebody to themselves is not true + +### Requirement: REQ-RBA-003 The endpoint refuses what it cannot honour + +The system SHALL answer 400 when the selection is empty, when no receiving +handler is named, or when `caseIds` is not an array. + +`caseIds` arrives off the wire, so a caller can send a string; that is a bad +request, not a crash. + +#### Scenario: A non-array selection is a bad request + +- **GIVEN** a request whose `caseIds` is a string +- **THEN** the response MUST be 400 and the service MUST NOT be called + +#### Scenario: An unexpected failure is a logged 500 + +- **GIVEN** the service raises an unexpected error +- **THEN** the response MUST be 500 and the failure MUST be logged, because a + selection that half moved must not be reported as a clean 200 + +### Requirement: REQ-RBA-004 Reassignment is reachable from the cases + +The Cases index SHALL declare a `reassign` bulk action, and the admin page +whose only job was to reach the same operation SHALL be retired from the +navigation. + +The page remains routable for deep links and e2e specs. + +#### Scenario: The bulk action is declared + +- **GIVEN** the Cases page +- **THEN** its config MUST declare a bulk action whose handler opens the + reassignment dialog with the live selection diff --git a/openspec/specs/role-routing-via-or-rbac/spec.md b/openspec/specs/role-routing-via-or-rbac/spec.md index 2497e660d..e9e2583f4 100644 --- a/openspec/specs/role-routing-via-or-rbac/spec.md +++ b/openspec/specs/role-routing-via-or-rbac/spec.md @@ -2,7 +2,9 @@ ## Purpose TBD - created by archiving change migrate-role-routing-to-or-rbac. Update Purpose after archive. + ## Requirements + ### Requirement: roleType Schema Carries an ncGroupId Bridge Field The `roleType` schema in `dossiq_register.json` MUST include a nullable @@ -107,3 +109,40 @@ store access permissions for OR-owned objects. Access configuration lives in the - WHEN its schemas and tables are inspected - THEN no new `*Permission*` / `*AccessRule*` schema or `*_perm*` table MUST exist +### Requirement: Cases and tasks carry a team assignment + +You assign a case or a task to a team, not only to a person. Schema `case` +SHALL gain the property `assignedGroup` and schema `caseTask` the property +`assigneeGroup`, both a `$ref` to `organisatieRol`, titled Team, facetable. +The Cases and Tasks indexes SHALL show a Team column and a Team facet in the +sidebar, and the edit forms SHALL offer the team as a picker over +`organisatieRol`. Assigning a team SHALL NOT clear the personal assignee. + +#### Scenario: Assign a case to a team +@e2e tests/e2e/case-parties.spec.ts + +- **GIVEN** an organisation role Team Permits exists +- **WHEN** you edit a case and pick Team Permits as its team +- **THEN** the Cases index SHALL show Team Permits in the Team column for that case +- **AND** the Team facet SHALL list Team Permits with a count of one + +#### Scenario: Assign a task to a team +@e2e tests/e2e/case-parties.spec.ts + +- **GIVEN** an open task on a case +- **WHEN** you set its team to Team Permits +- **THEN** the Tasks index SHALL show the team on that row and the assignee SHALL stay as it was + +### Requirement: Mine is a quick filter on both indexes + +You switch to your own work with one click. The `Cases` and `Tasks` indexes +SHALL carry a `quickFilters` chip Mine with the filter `assignee = @me`. A +Team chip waits for the platform to resolve the signed-in handler's teams; +until then the Team facet is the way to narrow the list to a team. + +#### Scenario: Mine shows only my cases +@e2e tests/e2e/case-parties.spec.ts + +- **GIVEN** cases assigned to you and to a colleague +- **WHEN** you press the Mine chip on Cases +- **THEN** only the cases assigned to you SHALL remain in the list diff --git a/openspec/specs/roles-decisions/spec.md b/openspec/specs/roles-decisions/spec.md index 67cfcd527..0fc6b4319 100644 --- a/openspec/specs/roles-decisions/spec.md +++ b/openspec/specs/roles-decisions/spec.md @@ -725,6 +725,56 @@ The case detail view MUST display all decisions linked to the case. --- +### Requirement: The case page lists every party in a Parties tab (REQ-ROLE-007) + +You see everyone involved in the case with their role. The `case-panels` tabs +widget on `CaseDetail` SHALL carry a tab Parties that renders widget +`case-roles`, type `object-list`, over schema `role` filtered on +`case = @objectId`, sorted by role type, with the columns role type, +participant, delegate and delegation end date. The tab SHALL sit between +Documents and Tasks in the tab order. The list SHALL show the empty state +"No parties yet" when the case has no role rows. This refines REQ-ROLE-005: +the grouped ParticipantsSection is replaced by the declarative list. + +#### Scenario: Parties visible on the case +@e2e tests/e2e/case-parties.spec.ts + +- **GIVEN** a case with a handler role and an advisor role +- **WHEN** you open the case page and pick the Parties tab +- **THEN** the list SHALL show both rows with role type, participant, delegate and delegation end date +- **AND** the tab SHALL be reachable by its id `case-roles` without scrolling past Data + +#### Scenario: A case without parties says so +@e2e tests/e2e/case-parties.spec.ts + +- **GIVEN** a case with no role rows +- **WHEN** you open the Parties tab +- **THEN** the list SHALL show the empty state and the Add party action + +### Requirement: You add a party from the case page with the case filled in (REQ-ROLE-008) + +You add a person or an organisation with a role without leaving the case. The +Parties tab SHALL carry a header action Add party of type `open-form` over +schema `role` with `props: {"case": "@objectId"}`, showing the fields role +type, participant, delegate, delegate until and description. On success the +list SHALL refresh and show the new row. The `case` field SHALL be prefilled +and read only. + +#### Scenario: Add a party with the case prefilled +@e2e tests/e2e/case-parties.spec.ts + +- **GIVEN** an open case +- **WHEN** you press Add party, choose the role type Advisor and a participant, and save +- **THEN** the new row SHALL appear in the Parties list +- **AND** the saved role row SHALL reference the case you were on + +#### Scenario: Role validation still runs +@e2e exclude REQ-ROLE-006 validation runs in OpenRegister and is covered by tests/Unit for the schema; the form only forwards the error + +- **GIVEN** the Add party form +- **WHEN** you save without a role type +- **THEN** the form SHALL show the validation error from the platform and keep your input + ## Error Scenarios Summary | Error | Expected Behavior | Tier | diff --git a/openspec/specs/signalering-widgets/spec.md b/openspec/specs/signalering-widgets/spec.md index afc5fc8bb..05a1ba257 100644 --- a/openspec/specs/signalering-widgets/spec.md +++ b/openspec/specs/signalering-widgets/spec.md @@ -247,6 +247,71 @@ The Dashboard SHALL show open cases whose deadline is past or within three days - **THEN** the Deadlines table does not list it - @e2e covered by `tests/e2e/dashboard-tiles.spec.ts` (tasks.md 3.1) +### Requirement: Countdown Deadline Column [V1] + +You see the days left on each row and filter the list on overdue cases. The +Deadline column on the `Cases` page SHALL render through the countdown cell: +the number of days left until `deadline`, and once the deadline is past the +cell SHALL read as overdue in the signalering red the dashboard tiles use, +with the count of days past. A case without a deadline SHALL show an empty +cell, not zero. The `quickFilters` chip Overdue (`deadline lt @today`, +`isFinalStatus = false`) SHALL show the same cases the `kpi-overdue` +dashboard tile counts, and that tile's link SHALL lead to the Cases page +carrying that same filter, so it no longer drops on the way (triage item 4). +The `deadlines` table `dashboard-tiles` merged from `overdue-cases` and +`deadline-alerts` carries a WIDER window — past due AND due within three +days — so its View all SHALL carry its OWN filter rather than the chip's: a +table that counts one set of cases and a View all that lands on another is +the same dropped filter in the other direction. The column SHALL sort on +`deadline`, not on the rendered text. + +**The chip cannot be activated FROM the query** in `@conduction/nextcloud-vue` +2.41: `resolveInitialQuickFilterIndex` reads only the `default` flag, so a +reader arriving from the tile lands on the All chip with the tile's filter +applied through the route query. The list is right and the filter is in the +URL; what is missing is the chip lighting up to say which lens is on. The +requirement therefore asks for the filter to survive the trip, which is the +defect triage item 4 named, and naming a chip from a query is left to a +nextcloud-vue change. + +#### Scenario: Days left on each row +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open case assigned to the signed-in user due in 3 days +- **WHEN** you open the Cases page +- **THEN** the Deadline cell of that case SHALL read 3 days left + +#### Scenario: Past due reads red +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open case assigned to the signed-in user whose deadline was 2 days ago +- **WHEN** you open the Cases page +- **THEN** the Deadline cell SHALL read 2 days overdue +- **AND** the cell SHALL carry the overdue styling class + +#### Scenario: Overdue shows only open cases past their deadline +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open case past its deadline, a closed case past its deadline and an open case due in 3 days +- **WHEN** you choose the chip Overdue +- **THEN** the list SHALL show the open overdue case only + +#### Scenario: The Overdue tile keeps its filter +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** the dashboard with the Overdue stat tile counting 1 case +- **WHEN** you follow that tile +- **THEN** the Cases page SHALL open carrying `deadline lt @today` and `isFinalStatus false` +- **AND** the list SHALL show the open overdue case and not the closed overdue one + +#### Scenario: The Deadlines table keeps its own, wider filter +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** the dashboard's Deadlines table, which lists what is past due and what is due within three days +- **WHEN** you follow its View all +- **THEN** the Cases page SHALL open carrying `deadline lte @today+3d` and `isFinalStatus false`, this table's own filter +- **AND** it SHALL NOT carry the Overdue chip's narrower filter, which would show fewer cases than the table just listed + ## Work Queue Page Render (UI surface) ### REQ-SIG-UI-01: The Work Queue page SHALL render its KPI shell and filters diff --git a/openspec/specs/status-transition-engine/spec.md b/openspec/specs/status-transition-engine/spec.md index af802bc1c..a8f523c39 100644 --- a/openspec/specs/status-transition-engine/spec.md +++ b/openspec/specs/status-transition-engine/spec.md @@ -77,22 +77,32 @@ The system SHALL execute status transitions atomically: the case status changes, ### Requirement: Available Transitions for Current User -The system SHALL compute and display only the transitions available to the current user based on their role, the current case status, and guard satisfaction. +You see, on the case page, only the transitions you may take from the case's +current status. The system SHALL compute the available transitions from the +case type's workflow template, the current status, the signed-in user's role +and guard satisfaction, and SHALL render them as buttons in the header row of +`CaseDetail`. The transitions SHALL come from dossiq's own transition endpoint, +not from OpenRegister's `available-actions`, until the case graph is projected +onto the engine lifecycle. **Feature tier**: V1 #### Scenario: Display available transitions on case detail +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts -- **WHEN** a case handler views case detail for a case in status "In behandeling" +- **GIVEN** a case in status "In behandeling" - **AND** the workflow defines transitions "Goedkeuren" (requires role Afdelingshoofd) and "Terugsturen" (any role) - **AND** the user has role "Behandelaar" -- **THEN** only the "Terugsturen" button SHALL be displayed +- **WHEN** the user opens the case page +- **THEN** only the "Terugsturen" button SHALL be displayed in the header row #### Scenario: No transitions available +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts -- **WHEN** a case is in a final status "Afgehandeld" +- **GIVEN** a case in a final status "Afgehandeld" +- **WHEN** the user opens the case page - **THEN** no transition buttons SHALL be displayed -- **AND** the case status area SHALL indicate "Zaak is afgehandeld" +- **AND** the case status area SHALL say the case is closed ### Requirement: A status brings its checklist tasks with it (REQ-STE-01) @@ -232,3 +242,112 @@ the zaak and what an archivist works from. - **WHEN** the case is reopened - **THEN** its `endDate`, `archiveNomination` and `archiveActionDate` SHALL all be cleared - **AND** the case SHALL NOT appear in an archivist's due list while it is being worked + +### Requirement: A status move performed by a flow names the node that performed it @e2e exclude attribution is asserted in openregister; this requirement fixes dossiq's side of the contract + +When a case's status is moved by a flow, the resulting audit record SHALL carry the run and node that moved it, so "who moved this case, and why" is answerable without inferring it from timing. + +Each status move in a case flow SHALL be its own step rather than a side effect of another step. A status that changes as a by-product of an unrelated node cannot be attributed to an intention, and it is the applicant-facing signal — it is the thing the case's progress is read from. + +#### Scenario: A flow-driven status move is attributed +- **WHEN** a flow node moves a case's status +- **THEN** the audit record for that change names the run and the node + +#### Scenario: A status move is a step of its own +- **WHEN** a case flow moves a case between stages +- **THEN** the move is performed by a node whose purpose is the move +- **AND** the run's history shows it as a step + +#### Scenario: A status move outside a flow is unattributed, not mis-attributed +- **WHEN** a person changes a case's status directly +- **THEN** the audit record names the person +- **AND** it names no flow run + +### Requirement: A transition executes from the case page (REQ-STE-11) + +You move the case to its next status from the case page. Pressing a transition +button SHALL open a confirmation with an optional comment, and confirming SHALL +post the transition to `StatusTransitionService`. The page SHALL then show the +new status without a reload, and the guard failures the service reports SHALL +be shown in the dialog instead of moving the case. + +#### Scenario: A handler advances a case +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** a seeded case whose current status allows one transition to "In behandeling" +- **WHEN** the handler presses that transition and confirms +- **THEN** the case's status SHALL be the target status +- **AND** the transition history SHALL hold one new row with the handler as actor +- **AND** the header row SHALL list the transitions of the new status + +#### Scenario: A failed guard keeps the case where it is +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** a transition whose guard requires a document the case lacks +- **WHEN** the handler confirms that transition +- **THEN** the dialog SHALL show the guard's message +- **AND** the case's status SHALL be unchanged + +### Requirement: Closing a case asks for the result (REQ-STE-12) + +When you close a case, you pick the result. A transition whose target status is +final SHALL require a result type from the case type's result types before it +executes, and SHALL write the result on the case in the same request. + +#### Scenario: A final transition records a result +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** a case one transition away from a final status +- **AND** the case type has result types "Verleend" and "Geweigerd" +- **WHEN** the handler takes that transition and picks "Verleend" +- **THEN** the case SHALL be in the final status +- **AND** the case's `result` SHALL reference a result of type "Verleend" + +#### Scenario: No result, no close +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** the same case +- **WHEN** the handler tries to confirm the final transition without a result +- **THEN** the confirm button SHALL be disabled +- **AND** the case SHALL stay in its current status + +### Requirement: Suspend, resume, extend and reopen from the Actions menu (REQ-STE-13) + +You suspend, resume, extend or reopen a case from its Actions menu. The Actions +menu on `CaseDetail` SHALL offer Suspend when the case type allows suspension, +Resume when the case is suspended, Extend term when the case type allows +extension, and Reopen when the case is in a final status. Each SHALL ask for a +reason and SHALL run through the existing deadline and transition services, so +the deadline moves and the audit row is written. + +#### Scenario: Suspend then resume +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** an open case of a case type with `suspensionAllowed` true +- **WHEN** the handler chooses Suspend, gives a reason and confirms +- **THEN** the case SHALL show a suspended marker and the Resume action +- **AND** choosing Resume SHALL clear the marker and recompute the deadline + +#### Scenario: Extend the term +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** an open case of a case type with `extensionAllowed` true and `extensionPeriod` P14D +- **WHEN** the handler chooses Extend term and confirms +- **THEN** the case's deadline SHALL be fourteen days later than before +- **AND** `extensionCount` SHALL be one higher + +#### Scenario: Reopen a closed case +@e2e tests/e2e/case-lifecycle-on-the-page.spec.ts + +- **GIVEN** a case in a final status +- **WHEN** a handler with the reopen scope chooses Reopen, gives a reason and confirms +- **THEN** the case SHALL be in the case type's initial status +- **AND** `isFinalStatus` SHALL be false + +#### Scenario: A user without the reopen scope is refused +@e2e exclude authorization is asserted by a PHPUnit test on CaseLifecycleController; Playwright runs as admin and cannot take a lesser role + +- **GIVEN** a case in a final status +- **WHEN** a user without the reopen scope posts the reopen request +- **THEN** the request SHALL be refused with 403 +- **AND** the case SHALL stay closed diff --git a/openspec/specs/supplier-portal/spec.md b/openspec/specs/supplier-portal/spec.md index 4fe4629bf..17c046ade 100644 --- a/openspec/specs/supplier-portal/spec.md +++ b/openspec/specs/supplier-portal/spec.md @@ -30,7 +30,9 @@ status-note: >- ## Purpose Provides a self-service portal where suppliers authenticate via eHerkenning and view their own tenders, contracts, invoices, KPIs, and case messages, scoped strictly to their organisation. It registers the supplier OpenRegister schemas and case types, enforces supplier-scoped access with audit logging and PII masking, and supports sensitive mutations such as IBAN changes through re-authentication and 4-eyes Dossiq workflows. It also surfaces expected payment dates, invoice age analysis, contract expiry warnings, renewal requests, and nightly-aggregated supplier KPIs with municipal benchmarks. + ## Requirements + ### Requirement: Supplier-Portal Schemas Are Registered The system SHALL register seven OpenRegister schemas — `Supplier`, `SupplierUser`, @@ -565,3 +567,34 @@ app sets enable-newman false. Portaliq now renders the supplier experience. contract reference and an email sent to the account manager - AND a cross-supplier contract request SHALL return 403 +### Requirement: The case supplier invoice is namespaced (REQ-SP-030) + +The supplier invoice schema SHALL be `caseSupplierInvoice` and SHALL NOT be +`supplierInvoice`. + +A schema slug is global per organisation and `SchemaMapper::find()` matches +`LOWER(slug)`, so a bare `supplierInvoice` was answered for by shillinq's +accounts-payable record as readily as by this app's case-side view. shillinq +owns the invoice and keeps the bare slug. + +They SHALL be renamed apart and SHALL NOT be folded. The two share only +`invoiceDate` and `dueDate`; there is no invoice number and nothing else that +identifies the document. + +A repair step SHALL rename the row IN PLACE before the register import, scoped +to this app's own rows, and SHALL be registered in both the post-migration and +the install block. Enabling the app is a fresh install as far as Nextcloud is +concerned, so an instance holding the old slug reaches the import through +either path. + +#### Scenario: The slug is renamed in place + +- **GIVEN** an install carrying a dossiq-owned `supplierInvoice` schema +- **WHEN** the repair step runs +- **THEN** the row keeps its schema id, and so its shard table and objects. + +#### Scenario: The register declares the namespaced slug + +- **WHEN** the register JSON is read +- **THEN** `caseSupplierInvoice` is declared and `supplierInvoice` is not, so the + import cannot create a second schema behind the renamed row. diff --git a/openspec/specs/task-management/spec.md b/openspec/specs/task-management/spec.md index 9e8a3f6dc..4c7f5dffc 100644 --- a/openspec/specs/task-management/spec.md +++ b/openspec/specs/task-management/spec.md @@ -778,6 +778,159 @@ case SHALL show no link and no empty box. - **WHEN** you open the task page - **THEN** no case link and no empty box SHALL render +### Requirement: A completed task resumes its run through the guarded seam + +When a flow task transitions to completed, the system SHALL resume the run it +names via `FlowRunSignalService::signalAs()`, handing the seam the session +user as the actor and the task's node as the addressed node. The system SHALL +NOT consult the assignee rule itself and SHALL NOT call the unguarded +`FlowRunService::signal()` primitive. + +A `FlowSignalRefused` SHALL be obeyed: the task stays completed, the run is +not advanced, nothing is retried. A NOT_ASSIGNEE refusal is recorded as a +warning tying the engine's audit to the task; RUN_NOT_FOUND and NOT_SUSPENDED +are recorded quietly, because a task naming a vanished or already-advanced +run is completable and its completer did nothing wrong. + +#### Scenario: Completing a flow task resumes its run + +- **GIVEN** a suspended run awaiting a task +- **WHEN** the task transitions to completed +- **THEN** the run is signalled through `signalAs` with the completer as actor and the task's node addressed + +`@e2e case-flow-live-journeys.spec.ts` drives the live task-completion resume; +the seam call shape is pinned by TaskCompletionResumeListenerTest. + +#### Scenario: A refusal from the seam withholds the resume + +- **GIVEN** a completed task whose completer is not the awaiting step's assignee +- **WHEN** the seam refuses with NOT_ASSIGNEE +- **THEN** the run is not advanced and the refusal is recorded as a warning + +`@e2e exclude` the guard itself is OpenRegister's, mutation-tested there; the +listener's obedience is unit-pinned (TaskCompletionResumeListenerTest). + +#### Scenario: A task whose run has gone is still completable + +- **GIVEN** a completed task naming a run uuid the engine cannot resolve +- **WHEN** the seam refuses with RUN_NOT_FOUND +- **THEN** the completion stands and the refusal is recorded as information, not an error + +`@e2e exclude` requires deleting a run out from under a task mid-journey; +unit-pinned (TaskCompletionResumeListenerTest). + +#### Scenario: A task without a run resumes nothing + +- **WHEN** a task recording no run is completed +- **THEN** the task is completed normally +- **AND** no run is resumed and no error is raised + +#### Scenario: Completing a task twice resumes once + +- **WHEN** an already-completed task is completed again +- **THEN** the run is not resumed a second time +- **AND** the run does not advance past the step twice + +### Requirement: A person can see what their task is holding up @e2e exclude read surface; covered by the case-flow e2e journey + +A task belonging to a flow run SHALL show the case it belongs to and that something is waiting on it. A person answering a question is entitled to know that work is blocked on their answer, and which work. + +#### Scenario: A flow task names its case +- **WHEN** a person views a task created by a case flow +- **THEN** the task names the case it belongs to +- **AND** indicates that the case is waiting on this task + +### Requirement: REQ-TASK-017 The Tasks index MUST carry the same six lenses as Cases + +You narrow the task list to closed, late or this week's work without leaving +it. The `Tasks` page (`src/manifest.json`, type `index` over `caseTask`) SHALL +extend its `quickFilters` with Closed (`isTerminalStatus = true`), Overdue +(`dueDate[lt] = "@today"`, `isTerminalStatus = false`) and Due this week +(`dueDate[gte] = "@today"`, `dueDate[lt] = "@today+7d"`, +`isTerminalStatus = false`), so both index pages declare the same six labels +in the same order and only the underlying field differs. + +The far edge SHALL be `lt` rather than `lte`, so a task due on day seven +belongs to next week's window and not to two windows at once. + +Each operator SHALL be spelled as a flat bracket key. The nested +`{ dueDate: { lt } }` form is JSON-stringified by `buildQueryString` and +reaches the API as a literal string that matches nothing, with no error. + +#### Scenario: Closed shows the completed task +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** a completed task and an open task assigned to the signed-in user +- **WHEN** you choose the chip Closed +- **THEN** the list SHALL show the completed task and SHALL NOT show the open one + +#### Scenario: A task due later today is not overdue +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open task due two days ago, an open task due at nine this morning and an open task due in thirty days +- **WHEN** you choose the chip Overdue +- **THEN** the list SHALL show the task due two days ago +- **AND** the list SHALL NOT show the task due this morning, because a task due later today is not late yet +- **AND** the list SHALL NOT show the task due in thirty days + +#### Scenario: Due this week holds both edges of the window +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open task due two days ago, an open task due at nine this morning, an open task due in two days and an open task due in thirty days +- **WHEN** you choose the chip Due this week +- **THEN** the list SHALL show the task due this morning and the task due in two days +- **AND** the list SHALL NOT show the task due two days ago or the task due in thirty days + +### Requirement: REQ-TASK-018 The task row MUST show its priority + +You see which task jumps the queue without opening it. The `Tasks` page SHALL +declare `priority` as a column, after `dueDate`, because the two answer the +same question in order: when is this due, and does it come first anyway. + +`caseTask.priority` is `facetable`, so before this it was reachable through the +sidebar facet and absent from the row. REQ-TASK-004's first scenario has always +required the row to show it. + +#### Scenario: The task list shows a priority column +@e2e tests/e2e/case-list-lenses.spec.ts + +- **WHEN** you open the Tasks page +- **THEN** the table SHALL carry a Priority column header + +### Requirement: REQ-TASK-016 Task lenses MUST sit on the Tasks index + +You switch between your tasks, unclaimed tasks and all tasks on one list. +The `Tasks` page (`src/manifest.json`, type `index` over `caseTask`) SHALL +carry `quickFilters` chips All (no filter), Mine (`assignee = @me`, +`isTerminalStatus = false`) and Unclaimed (`assignee = "IS NULL"`, +`isTerminalStatus = false`), in that order, with All as the default +(decision D-default, revised — see `my-work`). The chips SHALL use the +same shape and the same behaviour as the chips on `Cases`, so a person who +learned one list has learned the other. The page SHALL keep its +saved views and its generic sidebar filters. + +#### Scenario: All is the default lens on tasks, Mine is one click away +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open task assigned to the signed-in user and an open task assigned to another user +- **WHEN** you open the Tasks page +- **THEN** the chip All SHALL be active and the list SHALL show both tasks +- **AND** choosing the chip Mine SHALL show your task and SHALL NOT show the other user's task + +#### Scenario: Unclaimed shows tasks nobody holds +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open task with no assignee and a completed task with no assignee +- **WHEN** you choose the chip Unclaimed +- **THEN** the list SHALL show the open task and SHALL NOT show the completed one + +#### Scenario: All shows every task +@e2e tests/e2e/case-list-lenses.spec.ts + +- **GIVEN** an open task assigned to another user and a completed task +- **WHEN** you choose the chip All +- **THEN** the list SHALL show both tasks + ## Accessibility All task management interfaces MUST comply with WCAG AA: diff --git a/openspec/specs/vth-workflow-templates/spec.md b/openspec/specs/vth-workflow-templates/spec.md index 65ef4da37..c51464220 100644 --- a/openspec/specs/vth-workflow-templates/spec.md +++ b/openspec/specs/vth-workflow-templates/spec.md @@ -79,33 +79,23 @@ The system SHALL provide pre-built workflow templates for Handhavingszaak coveri | `regulier` | Handhavingstraject | Awb 5:24 and the LHS | Announce, hear the offender, decide, run the recovery period, re-inspect. The default route. | | `spoedeisend` | Spoedig herstel (Awb 5:31) | Awb 5:31 | Act on the spot, write the decision afterwards. | -Neither route SHALL deprecate the other. `regulier` SHALL be the case type's default route, because Awb 5:31 is the exception in law: acting first is what you do when the ordinary route is too slow. A route is defined by `workflow-variants`; it is not a case type, and it is not workflow inheritance. +Neither route SHALL deprecate the other. `regulier` SHALL be the case type's default route, because Awb 5:31 is the exception in law: acting first is what you do when the ordinary route is too slow. -A catalogue entry sharing a case type with another SHALL declare a `variant`, and the variants on one case type SHALL be distinct. An entry SHALL still name every entry it shares a case type with in `_sharesItsCaseTypeWith`. A third enforcement template can therefore land, and it has to say which route it is. - -**Feature tier**: V1 -**ZGW mapping**: Zaaktype "Handhavingszaak", StatusType per enforcement phase -**CMMN**: CasePlanModel with EventListener (begunstigingstermijn) and HumanTask (hercontrole) +A catalogue entry sharing a case type with another SHALL declare a `variant`, and the variants on one case type SHALL be distinct. An entry SHALL still name every entry it shares a case type with. A third enforcement template can therefore land, and it has to say which route it is, which is a stronger guarantee than refusing the pairing outright. #### Scenario: Import Handhavingszaak workflow - **WHEN** the beheerder imports the "Handhavingszaak" workflow template - **THEN** the system SHALL create a workflowTemplate with the following steps: - 1. Constatering (initial) - action: link to source inspection rapport - 2. Vooraankondiging - action: generate vooraankondigingsbrief, set zienswijzetermijn - 3. Zienswijze - guard: zienswijzetermijn expired or zienswijze received - 4. Handhavingsbesluit - role guard: mandated beslisser, checklist guard: LHS matrix classification completed - 5. Begunstigingstermijn - timer guard: begunstigingstermijn days elapsed - 6. Hercontrole - checklist guard: hercontrole inspection completed - 7. Afgehandeld (final) - conditional: overtreding resolved OR dwangsom verbeurd + 1. Constatering (initial), action: link to source inspection rapport + 2. Vooraankondiging, action: generate vooraankondigingsbrief, set zienswijzetermijn + 3. Zienswijze, guard: zienswijzetermijn expired or zienswijze received + 4. Handhavingsbesluit, role guard: mandated beslisser, checklist guard: LHS matrix classification completed + 5. Begunstigingstermijn, timer guard: begunstigingstermijn days elapsed + 6. Hercontrole, checklist guard: hercontrole inspection completed + 7. Afgehandeld (final), conditional: overtreding resolved OR dwangsom verbeurd - **THEN** the Begunstigingstermijn step SHALL automatically create a follow-up task when the timer expires -- **THEN** transitions from Hercontrole SHALL branch: "Overtreding opgeheven" -> Afgehandeld, "Overtreding voortdurend" -> next enforcement cycle - -#### Scenario: Enforcement escalation path - -- **WHEN** the hercontrole shows the overtreding persists after last onder dwangsom -- **THEN** the workflow SHALL support escalation transitions: last onder dwangsom -> verbeuring -> bestuursdwang -- **THEN** each escalation step SHALL require a new handhavingsactie record with updated ernst/gedrag classification +- **THEN** transitions from Hercontrole SHALL branch: "Overtreding opgeheven" to Afgehandeld, "Overtreding voortdurend" to the next enforcement cycle #### Scenario: Both enforcement routes land active on a fresh install @@ -129,6 +119,12 @@ A catalogue entry sharing a case type with another SHALL declare a `variant`, an - **AND** the two variants SHALL differ - **AND** each entry SHALL name the other in `_sharesItsCaseTypeWith` +#### Scenario: Enforcement escalation path + +- **WHEN** the hercontrole shows the overtreding persists after last onder dwangsom +- **THEN** the workflow SHALL support escalation transitions: last onder dwangsom, verbeuring, bestuursdwang +- **THEN** each escalation step SHALL require a new handhavingsactie record with updated ernst/gedrag classification + #### Notes: how the two enforcement routes stopped deprecating each other The catalogue has always shipped two templates against `handhavingszaak`: @@ -148,6 +144,34 @@ entries declare different routes. `workflowTemplate.parentWorkflow` is still not that mechanism: it is Enterprise tier, unimplemented, and describes a hierarchy BETWEEN case types. See `openspec/specs/workflow-variants/spec.md`. +### REQ-001: SeedVthWorkflowTemplates repair step SHALL idempotently seed the VTH workflow catalog from bundled JSON files + +`OCA\Dossiq\Repair\SeedVthWorkflowTemplates` SHALL implement `IRepairStep` and SHALL run on every app enable / upgrade, with the behaviour the existing requirement describes: graceful no-ops when OpenRegister or the catalog directory is missing, per-file error containment, a per-entry summary, and idempotency keyed on case type plus title. + +It SHALL additionally: +- pass each catalogue entry's `variant` to the definition it creates; +- set the case type's default route from the entry declaring `isDefaultVariant`, rather than letting file order decide it; +- report the route each entry landed on; +- report a publish as having displaced something only when it displaced a previous version **of the same route**; +- name a catalogue entry it finds `deprecated` and say how to bring it back, without republishing it. + +An entry found deprecated SHALL NOT be republished by the seeder. A row is deprecated whether the old rule retired it or an administrator did, and the stored data cannot tell those apart. + +#### Scenario: The summary names the route +- **WHEN** the seed publishes a catalogue entry that declares a variant +- **THEN** the summary line for that entry SHALL name the route it landed on + +#### Scenario: A publish that displaces nothing says nothing about deprecation +- **GIVEN** a case type whose only active definition is on another route +- **WHEN** a new route is seeded and published for it +- **THEN** the summary line SHALL NOT report a deprecation + +#### Scenario: A deprecated entry is reported, not resurrected +- **GIVEN** an instance where a catalogue entry sits at `deprecated` from an earlier install +- **WHEN** the seed runs +- **THEN** the entry SHALL still be deprecated afterwards +- **AND** the summary SHALL name it, its route, and how an administrator brings it back + ### Requirement: VTH workflow template library The system SHALL provide a browsable library of VTH workflow templates that administrators can preview and import into their case types. @@ -239,6 +263,7 @@ The step SHALL additionally: #### Notes - The 9 private helpers (`processCatalogFile`, `resolveCaseTypeId`, `isAlreadySeeded`, `buildStatusMap`, `resolveSteps`, `resolveTransitions`, `deterministicId`, `extractFirstId`, `normalizeRow`) are not separately observable — they support the single `run()` contract above. Splitting them into separate REQs would inflate the spec without adding testable surface. - `crossLink` is reserved for templates that reference an unresolved caseType; the seeder logs the reference and counts it but does not block the run. + ### Requirement: VTH workflow template activation service The system SHALL provide a `VTHWorkflowService` that loads and activates the three VTH workflow templates declared by the config-foundation member, creating each template's statuses and roles, and SHALL be idempotent on re-activation. @@ -259,5 +284,3 @@ The system SHALL provide a `VTHWorkflowService` that loads and activates the thr - **WHEN** a template that has already been activated is activated again - **THEN** the service SHALL NOT create duplicate statuses or roles - - diff --git a/openspec/specs/woo-publication-via-opencatalogi/spec.md b/openspec/specs/woo-publication-via-opencatalogi/spec.md index 89cee4547..7b114c37c 100644 --- a/openspec/specs/woo-publication-via-opencatalogi/spec.md +++ b/openspec/specs/woo-publication-via-opencatalogi/spec.md @@ -2,7 +2,9 @@ ## Purpose TBD - created by archiving change woo-publication-via-opencatalogi. Update Purpose after archive. + ## Requirements + ### Requirement: WOO publication payload building The system MUST build a publication payload from an assembled WOO decision and its per-document assessments, mapping the decision to a DIWOO @@ -64,12 +66,20 @@ NEVER include a document assessed as `niet_openbaar` at all. any content reference it carries ### Requirement: Publish a WOO decision to OpenCatalogi + The system MUST create a publication (and its disclosable documents) in OpenCatalogi's publication register when a case worker triggers publication of an assembled WOO decision, and record the result on the dossiq decision object through a single write. +The publication, its documents and their file bytes MUST be written through +OpenRegister **in process** (`ObjectServiceInterface` for objects, +`FileService` for file bytes), not through a self-addressed HTTP call. The +acting identity is therefore the session user under OpenRegister's default +`_rbac` / `_multitenancy` scoping, not a stored service account. + #### Scenario: Successful publish creates the publication and records the result + - **GIVEN** an assembled WOO decision with at least one disclosable document, and OpenCatalogi installed and enabled - **WHEN** the case worker calls `POST /api/cases/{id}/woo/publish` @@ -83,13 +93,17 @@ object through a single write. - **AND** return the publication id and url in the response #### Scenario: Publish is idempotent per decision + - **GIVEN** a WOO decision that was already published (has `wooPublication.publicationId` set) - **WHEN** the case worker calls `POST /api/cases/{id}/woo/publish` again - **THEN** the system MUST update the existing publication rather than create a duplicate +- **AND** the update MUST preserve every stored publication field the new + payload does not name #### Scenario: No disclosable documents blocks publish with a clear reason + - **GIVEN** a WOO decision where every document is `niet_openbaar` or `deels_openbaar`-pending-redaction - **WHEN** the case worker calls `POST /api/cases/{id}/woo/publish` @@ -118,21 +132,31 @@ installed or not enabled, and MUST surface an actionable admin hint instead. `openregister_unavailable` ### Requirement: Withdraw a published WOO decision + The system MUST support withdrawing (depublishing) a previously published WOO decision, marking it withdrawn both in OpenCatalogi and on the dossiq decision object. +Withdrawal sends a single-key partial payload to OpenCatalogi's publication +object. Because OpenRegister's published write is PUT-semantic, that write +MUST be performed as a read-merge-write; a bare save of the partial payload +would null every other property of the publication while reporting success. + #### Scenario: Withdraw a published decision + - **GIVEN** a WOO decision with `wooPublication.status` "published" and a known `publicationId` - **WHEN** the case worker calls `POST /api/cases/{id}/woo/withdraw` - **THEN** the system MUST set the OpenCatalogi publication's depublication date to now +- **AND** the publication MUST retain its title, summary, publication date and + category - **AND** update `wooPublication.status` to "withdrawn" and set `wooPublication.withdrawnAt` on the dossiq decision object via one `ObjectService::saveObject()` call #### Scenario: Withdraw without a prior publish is rejected + - **GIVEN** a WOO decision with no `wooPublication.publicationId` - **WHEN** the case worker calls `POST /api/cases/{id}/woo/withdraw` - **THEN** the system MUST return an error indicating there is nothing to @@ -182,4 +206,133 @@ status/link on the existing WOO document-assessment view. - **THEN** the published status MUST be shown along with a link to the publication +### Requirement: REQ-WPI-001 — Publication objects MUST be written through OpenRegister's published in-process contract, not over HTTP + +`OpenCatalogiApiClient` MUST create and update publication and document +objects by calling OpenRegister's `ObjectServiceInterface` (ADR-084) in +process. It MUST NOT build or fetch an OpenRegister objects-API URL with +`IClientService` (ADR-080 D2/D3). The service MUST be resolved through +`SettingsService::getObjectService()`, which returns `null` when OpenRegister +is absent. + +Only methods published on `ObjectServiceInterface` may be used. Every call +MUST pass its arguments by name. + +#### Scenario: Creating a publication saves an object instead of posting a URL + +- **GIVEN** OpenCatalogi's publication register/schema are configured +- **WHEN** `OpenCatalogiApiClient::createPublication()` is called with a payload +- **THEN** it MUST call `saveObject(object: , register: , schema: , uuid: null)` on the OpenRegister object service +- **AND** it MUST NOT perform any HTTP request +- **AND** it MUST return the stored object as an associative array carrying the object's `id` + +#### Scenario: Creating a linked document saves an object instead of posting a URL + +- **GIVEN** a publication id +- **WHEN** `OpenCatalogiApiClient::attachDocument()` is called with a document payload +- **THEN** it MUST call `saveObject(object: , register: , schema: , uuid: null)` +- **AND** it MUST NOT perform any HTTP request + +#### Scenario: The OpenRegister objects-API URL is gone from the client + +- **GIVEN** the shipped `lib/Service/WooPublication/OpenCatalogiApiClient.php` +- **WHEN** its source is read +- **THEN** it MUST contain no `/apps/openregister/api/objects/` path in executable code + +### Requirement: REQ-WPI-002 — A partial publication update MUST be a read-merge-write, never a bare save + +`updatePublication()` receives a PARTIAL payload — `withdraw()` sends only +`depublicatiedatum`. `ObjectServiceInterface::saveObject()` is PUT-semantic: a +property absent from the payload is written as null. +`ObjectServiceInterface::updateObject()` does not help — despite a docblock +reading "Apply a partial update to an existing object", its implementation +assigns `$data['id']` and calls `saveObject()` with no merge, and the one +method that really merges (`patchObject()`) is not published on the contract. + +`updatePublication()` MUST therefore read the stored object, shallow-merge the +partial payload over it, and save the merged result under the same uuid — +reproducing what OpenRegister's own `objects#patch` route does. It MUST NOT +call `updateObject()`. + +The read MUST use `findSilent()` so a publication update does not write a +spurious read entry into the audit trail, and MUST leave `_rbac` and +`_multitenancy` at their contract defaults. + +#### Scenario: Withdrawing a publication preserves every field it did not name + +- **GIVEN** a stored publication carrying `title`, `summary`, `publicationDate`, `tooiCategorieUri` and `status` +- **WHEN** `updatePublication()` is called with the single-key payload `{ depublicatiedatum: }` +- **THEN** the object saved back MUST carry `depublicatiedatum` set to that value +- **AND** it MUST still carry the original `title`, `summary`, `publicationDate`, `tooiCategorieUri` and `status` + +#### Scenario: A key present in both the stored object and the payload takes the payload's value + +- **GIVEN** a stored publication with `status: "published"` +- **WHEN** `updatePublication()` is called with `{ status: "withdrawn" }` +- **THEN** the object saved back MUST carry `status: "withdrawn"` + +#### Scenario: The merged object is saved under the same uuid + +- **GIVEN** an existing publication with uuid `pub-001` +- **WHEN** `updatePublication()` is called for `pub-001` +- **THEN** `saveObject()` MUST be called with `uuid: "pub-001"`, so the write updates that object rather than creating a second one + +### Requirement: REQ-WPI-003 — File bytes MUST be attached in process, and the transport failure contract MUST be unchanged + +`attachFile()` MUST attach file content through OpenRegister's +`FileService::addFile()` resolved from the DI container, rather than posting to +OpenRegister's per-object files route. `ObjectServiceInterface` publishes no +file operation, so this is the only in-process route available; the gap is +recorded in the proposal. + +`attachFile()`'s `$mimeType` parameter MUST be kept for call-shape +compatibility and MUST be documented as unused by OpenRegister — the HTTP +route it replaces also ignored it, because `FileService::addFile()` takes no +MIME argument. + +Every failure of any operation on this client MUST continue to surface as +`RuntimeException` with message `opencatalogi_api_error`, so +`WooPublicationService`'s existing `catch (Throwable)` arms — which map it to +`['available' => false, 'reason' => 'opencatalogi_api_error']` — keep working +unchanged. + +#### Scenario: Attaching file bytes calls the in-process file service + +- **GIVEN** a created document object with id `doc-001` and base64 file content +- **WHEN** `attachFile()` is called +- **THEN** it MUST call `addFile(objectEntity: "doc-001", fileName: , content: , share: false, tags: [])` +- **AND** it MUST NOT perform any HTTP request + +#### Scenario: An OpenRegister failure is reported as the existing domain error + +- **GIVEN** the OpenRegister object service throws on `saveObject()` +- **WHEN** `createPublication()` is called +- **THEN** it MUST throw `RuntimeException` with message `opencatalogi_api_error` + +#### Scenario: OpenRegister being unavailable is reported as the existing domain error + +- **GIVEN** `SettingsService::getObjectService()` returns null +- **WHEN** `createPublication()` is called +- **THEN** it MUST throw `RuntimeException` with message `opencatalogi_api_error` +- **AND** it MUST NOT dereference the null service + +### Requirement: REQ-WPI-004 — Catalog discovery stays an OpenCatalogi HTTP read and MUST keep its swallow-and-continue contract + +`resolveCatalog()` reads OpenCatalogi's own catalog listing +(`/index.php/apps/opencatalogi/api/catalogi`), which is not OpenRegister's +Objects API and is therefore outside ADR-080 D2/D3. It MUST keep using +`IClientService`, MUST keep sending the configured service-account credentials +when both are set, and MUST keep swallowing every failure and returning `null` +so discovery never gates publication. + +#### Scenario: Discovery still returns the first WOO-flagged catalog + +- **GIVEN** OpenCatalogi answers with a list containing a catalog whose `hasWooSitemap` is true +- **WHEN** `resolveCatalog()` is called +- **THEN** it MUST return that catalog + +#### Scenario: A discovery transport failure returns null rather than throwing +- **GIVEN** the HTTP client throws +- **WHEN** `resolveCatalog()` is called +- **THEN** it MUST return `null` and MUST NOT throw diff --git a/openspec/specs/workflow-definition-engine/spec.md b/openspec/specs/workflow-definition-engine/spec.md index bacce72bc..9505f3270 100644 --- a/openspec/specs/workflow-definition-engine/spec.md +++ b/openspec/specs/workflow-definition-engine/spec.md @@ -6,7 +6,9 @@ status-note: Reverse-synced 2026-06-13 from an archived fully-implemented change ## Purpose Provides a configurable workflow engine where functional administrators define case lifecycles as OpenRegister objects, with process steps, guarded status transitions (checklist, required field, required document, role), and automatic actions (email, task, sub-case, webhook, set-field, notify). It supports workflow versioning that pins running cases to their bound version, workflow inheritance, JSON export/import, REST endpoints for CRUD and transition execution, and visual editing without developer involvement. + ## Requirements + ### Requirement: Workflow Definition Model A workflow definition SHALL consist of: @@ -382,3 +384,62 @@ Workflow definitions are user-configurable through a visual editor. Functional a - AND no code deployment, git commit, or developer intervention is required - AND the workflow is immediately usable for new cases +### Requirement: You start an allowed flow from the case (REQ-WDE-01) + +You start an allowed sub-process for the case from its page. A case type SHALL +list the flows a handler may start in `startableFlows`. The Actions menu of +`CaseDetail` SHALL offer Start, listing those flows by title. Running one +SHALL post the case as the flow's subject, and the run SHALL appear in the +case's flow runs. + +**Feature tier**: V1 + +#### Scenario: The Start list follows the case type +@e2e tests/e2e/case-actions-menu.spec.ts + +- **GIVEN** a case type whose `startableFlows` names the flow Create sub-case +- **AND** a case of that type +- **WHEN** the handler opens Start on the case page +- **THEN** the list SHALL show Create sub-case +- **AND** SHALL NOT show flows the type does not list + +#### Scenario: A run appears on the case +@e2e exclude running a flow needs an ADOPTED, published and enabled flow, and a fresh install deliberately has none: OpenRegister stores a shipped flow ownerless and disabled, and adoption is an administrator's separate act, so arranging one would make this a test of the adoption path instead + +- **GIVEN** the same case +- **WHEN** the handler runs Create sub-case +- **THEN** the Flow runs widget SHALL list one new run with this case as subject + +#### Scenario: A type without startable flows hides Start +@e2e tests/e2e/case-actions-menu.spec.ts + +- **GIVEN** a case type with an empty `startableFlows` +- **WHEN** the handler opens the Actions menu on a case of that type +- **THEN** Start SHALL NOT be offered + +### Requirement: You plan a follow-up case (REQ-WDE-02) + +You plan a follow-up case for a later date. It appears when it is due. The +Actions menu and the Related cases tab of `CaseDetail` SHALL offer Plan +follow-up, asking for a case type, a date and a title. Confirming SHALL write +one scheduled flow that creates the case on that date, related to this one, +running as the person who planned it. Until it fires, the Related cases tab +SHALL show the planned case with its date. + +**Feature tier**: V1 + +#### Scenario: A planned case shows on the tab +@e2e tests/e2e/case-actions-menu.spec.ts + +- **GIVEN** an open case +- **WHEN** the handler plans a follow-up of type Controle for a date next month +- **THEN** the Related cases tab SHALL show a planned row with that type and date +- **AND** no case of type Controle SHALL exist yet + +#### Scenario: The follow-up is created when due +@e2e exclude the schedule trigger fires from OpenRegister's timer; PHPUnit covers the flow the controller writes, including `runAs` + +- **GIVEN** a planned follow-up whose date has arrived +- **WHEN** OpenRegister runs the scheduled flow +- **THEN** a case of the planned type SHALL exist with this case under related cases +- **AND** the planned row SHALL be gone from the Related cases tab diff --git a/openspec/specs/workflow-definition-model/spec.md b/openspec/specs/workflow-definition-model/spec.md index e5dac288d..e5d6bc5a9 100644 --- a/openspec/specs/workflow-definition-model/spec.md +++ b/openspec/specs/workflow-definition-model/spec.md @@ -182,7 +182,7 @@ The system SHALL provide a pre-seeded workflow template for the Beroep case type -### REQ-001: WorkflowDefinitionController SHALL expose lifecycle + lookup endpoints +### Requirement: REQ-001 WorkflowDefinitionController SHALL expose lifecycle + lookup endpoints `OCA\Dossiq\Controller\WorkflowDefinitionController` SHALL provide HTTP endpoints for: `publish($id)` (move draft → active), `deprecate($id)` (active → deprecated), `cloneDefinition($id)` (create new draft from existing), `active($caseTypeId)` (lookup currently-active version for a case type), and `forCase($caseId)` (lookup version bound to a specific case). Each endpoint SHALL delegate to `WorkflowDefinitionService` and SHALL reject lifecycle transitions that violate the draft → active → deprecated state machine. @@ -197,13 +197,15 @@ The system SHALL provide a pre-seeded workflow template for the Beroep case type - **WHEN** `POST /api/workflow-definitions/{id}/clone` is called on a definition of the `spoedeisend` route - **THEN** the resulting draft SHALL be on the `spoedeisend` route -### REQ-002: WorkflowDefinitionService SHALL implement the full lifecycle + version selection +### Requirement: REQ-002 WorkflowDefinitionService SHALL implement the full lifecycle + version selection `OCA\Dossiq\Service\WorkflowDefinitionService` SHALL provide the canonical version-selection logic (`getActiveDefinitionFor($caseTypeId, $variant)`, `getDefinitionForCase($caseId)`, `listVersions($caseTypeId)`) and the full lifecycle (`createDraft`, `publish`, `deprecate`, `cloneDefinition`, `getDefinition`). -Version selection SHALL be deterministic: **at most one active version per (case type, route) at any time**; a case bound to a specific version SHALL continue to use that version even after a newer one is published (versions, not branches). +Version selection SHALL be deterministic: **at most one active version per (case type, route) at any time**. A case bound to a specific version SHALL continue to use that version even after a newer one is published: versions, not branches. -Several routes MAY be active on one case type at the same time. That is what a route is for, and it is the one thing this rule permits that the per-case-type rule did not. A case type with one route behaves exactly as it did before. `getActiveDefinitionFor($caseTypeId)` called without a route SHALL return the case type's default route, so every caller written before routes existed keeps reading what it read before. See `openspec/specs/workflow-variants/spec.md`. +Several routes MAY be active on one case type at the same time. That is what a route is for, and it is the one thing this rule now permits that it did not before. A case type with one route behaves exactly as it did under the per-case-type rule. + +`getActiveDefinitionFor($caseTypeId)` called without a route SHALL return the case type's default route, so every existing caller keeps reading what it read before. #### Scenario: Existing case keeps its bound version after a new one is published - **GIVEN** case C bound to workflow version v1 @@ -222,7 +224,12 @@ Several routes MAY be active on one case type at the same time. That is what a r - **THEN** the previously active version SHALL be deprecated - **AND** the case type SHALL have exactly one active definition -### REQ-003: MigrateWorkflowDefinitions SHALL be a one-shot repair step for legacy data +#### Scenario: A case type may not be left without a route +- **GIVEN** a case type with open cases and exactly one published definition +- **WHEN** that definition is deprecated +- **THEN** the deprecation SHALL be refused and the reason SHALL be logged + +### Requirement: REQ-003 MigrateWorkflowDefinitions SHALL be a one-shot repair step for legacy data `OCA\Dossiq\Repair\MigrateWorkflowDefinitions` SHALL run as a Nextcloud repair step that detects legacy inline workflow definitions on case-type records and lifts them into stand-alone workflow definition entities. The repair step SHALL be idempotent: on a fully-migrated dataset it SHALL be a no-op, and re-running it SHALL NOT duplicate definitions. diff --git a/openspec/specs/workflow-definitions-to-flow/spec.md b/openspec/specs/workflow-definitions-to-flow/spec.md new file mode 100644 index 000000000..22442b288 --- /dev/null +++ b/openspec/specs/workflow-definitions-to-flow/spec.md @@ -0,0 +1,122 @@ +# workflow-definitions-to-flow Specification + +## Purpose +Give each `workflowTemplate` a representation in the one place ADR-065 says a +flow may live, without taking the running engine away from it. + +## Requirements + +### Requirement: REQ-WDF-001 A definition is projected onto a flow + +The system SHALL provide an `occ` command that, for each stored +`workflowTemplate`, writes an OpenRegister flow whose nodes are the template's +statuses and whose edges are its transitions. + +It SHALL be a command and not a repair step: `FlowService` refuses to create a +flow without a signed-in owner, and a repair step running under `occ upgrade` +has none. + +#### Scenario: A template becomes a flow of its statuses + +- **GIVEN** a template with three distinct statuses across two transitions +- **WHEN** the migration runs +- **THEN** a flow exists with three `dossiq.setStatus` nodes and two edges + +#### Scenario: A template with no usable transitions is skipped + +- **GIVEN** a template whose transitions are empty +- **WHEN** the migration runs +- **THEN** it is reported as skipped and NO flow is written, because a flow with + nodes and no way between them looks like a migration that worked + +#### Scenario: A wildcard source is skipped + +- **GIVEN** a transition whose `fromStatus` is `*` +- **WHEN** the template is projected +- **THEN** that transition contributes no edge, because an edge with no source + node is not drawable +- **AND** the remaining transitions still project + +### Requirement: REQ-WDF-002 The projection arrives disabled + +A projected flow SHALL be created with `enabled: false`. + +The template still drives cases through `StatusTransitionService`. An enabled +projection is a second thing driving the same case, so every status change would +fire twice from the moment the migration runs — and would look like it worked. + +#### Scenario: A freshly projected flow is disabled + +- **WHEN** a template is projected +- **THEN** the stored flow reports `enabled: false` + +### Requirement: REQ-WDF-003 Statuses travel by name + +A projected node SHALL carry the status NAME, never a `statusType` id. + +A statusType uuid is minted per installation, so a flow carrying one is portable +nowhere. `dossiq.setStatus` resolves a name inside the case's own case type. + +#### Scenario: The nodes carry names + +- **GIVEN** a template whose transitions name `Ontvangen` and `In behandeling` +- **WHEN** it is projected +- **THEN** the nodes' `status` config values are those names + +### Requirement: REQ-WDF-004 A re-run updates rather than duplicating + +The migration SHALL resolve an already-projected flow by a provenance marker +stored on the flow, and update it. It SHALL NOT match on the flow's name: a name +is editable, and matching on one would mint a second flow as soon as somebody +renamed the first. + +#### Scenario: Running twice yields one flow + +- **GIVEN** a template already projected +- **WHEN** the migration runs again +- **THEN** the existing flow is updated and the run reports it as updated + +#### Scenario: A dry run writes nothing + +- **WHEN** the migration runs with `--dry-run` +- **THEN** no flow is written and the report still names what would happen + +### Requirement: REQ-WDF-005 A partial run reports itself as partial + +One template that cannot be projected SHALL NOT abandon the rest, and the +command SHALL exit non-zero when any template failed. + +#### Scenario: A failed write is counted + +- **GIVEN** a flow write that is refused +- **WHEN** the migration runs +- **THEN** that template is counted as failed and the others still project + +### Requirement: REQ-WDF-006 Flows is the single authoring entry + +The settings menu SHALL offer exactly one entry for authoring how a case moves, +and it SHALL be Flows. + +Two entries stood next to each other at orders 96 and 97, named Flows and +Workflow definitions, wearing `Sitemap` and `SitemapOutline`. They read as one +feature listed twice, and the pair was actively misleading rather than merely +redundant: editing a definition does not reach the running flow unless somebody +re-runs the projection, and re-running it overwrites whatever was authored on +the canvas. A reader who picked the wrong one of two near-identical entries got +a screen whose edits quietly did nothing. + +The definitions page SHALL remain routable. Losing a menu entry is not losing +the page: a projected flow was generated FROM a definition, so a legacy link +must still land on the definition rather than on the dashboard. This follows the +precedent set for Approval routes, which lost its entry for the same reason when +an approval route became a flow. + +#### Scenario: The menu offers Flows once + +- **GIVEN** the settings menu +- **THEN** Flows is present and Workflow definitions is absent + +#### Scenario: The definitions page survives its menu entry + +- **WHEN** `/settings/workflow-definitions` is opened directly +- **THEN** the definitions page renders rather than the dashboard diff --git a/openspec/specs/zaaktype-versioning/spec.md b/openspec/specs/zaaktype-versioning/spec.md index 1d97a7a8a..e7ed99ea9 100644 --- a/openspec/specs/zaaktype-versioning/spec.md +++ b/openspec/specs/zaaktype-versioning/spec.md @@ -85,3 +85,69 @@ with version, lifecycle status and change note. - **GIVEN** a draft type without an initial status - **WHEN** you choose Publish - **THEN** the page SHALL list the finding and the type SHALL stay a draft + +### Requirement: A case type is versioned, and a running case stays on its version (REQ-ZV-02) + +You change a published case type by making a new version of it, not by editing +it. A published case type SHALL offer New version, which SHALL create a draft +carrying the same title and the same identifier, one version number higher, +linked back through `previousVersion`, with its own copy of every status, +result, role, property, document type and decision type. The version being +succeeded SHALL NOT be written to. Publishing the new version SHALL write +`supersededBy` onto the version it replaces. A superseded version SHALL keep +its cases running and SHALL NOT be offered for new cases. + +A case SHALL stay on the version it started under, and SHALL NOT be migrated to +a newer one. No property is added to the case: `caseType` already names one +specific version. + +**Feature tier**: V1 + +#### Scenario: A new version leaves the running cases alone +@e2e exclude Version-chain writes are backend logic, covered by tests/Unit/Service/CaseTypeCopyServiceTest.php and CaseTypePublishServiceTest.php. + +- **GIVEN** a published case type Vergunning at version 2 with five running cases +- **WHEN** the administrator chooses New version +- **THEN** a draft version 3 SHALL exist, linked back to version 2 +- **AND** version 2 SHALL be unchanged, including its statuses and its initial status +- **AND** the five cases SHALL still resolve their type, statuses and deadline through version 2 + +#### Scenario: Publishing a version closes the one it replaces +@e2e exclude Backend write, covered by tests/Unit/Service/CaseTypePublishServiceTest.php. + +- **GIVEN** draft version 3 linked back to published version 2 +- **WHEN** the administrator publishes version 3 with a change note +- **THEN** version 2 SHALL carry `supersededBy` naming version 3 +- **AND** version 2 SHALL still not be a draft, because its cases still run on it + +#### Scenario: Only the current version is offered for a new case +@e2e exclude Pure rule, covered by tests/vitest/caseTypeVersion.spec.js. + +- **GIVEN** version 2 superseded by version 3, both published and both valid today +- **WHEN** somebody starts a new case +- **THEN** only version 3 SHALL be offered +- **AND** asking for version 2 by id SHALL be refused with a sentence naming the replacement, not one about drafts or expiry + +#### Scenario: A duplicate is not a version +@e2e exclude Backend write, covered by tests/Unit/Service/CaseTypeCopyServiceTest.php. + +- **GIVEN** a published case type +- **WHEN** the administrator chooses Duplicate +- **THEN** the copy SHALL carry a new identifier, a "Copy of" title and version 1 +- **AND** it SHALL carry no `previousVersion`, because it is a second case type rather than the same one later on + +### Requirement: A copied case type starts its own cases in its own status (REQ-ZV-03) + +Copying or versioning a case type SHALL repoint the new type's `initialStatus` +at its own copy of that status. A new type whose initial status names a status +owned by another type files cases into a lifecycle it cannot move them out of. + +**Feature tier**: V1 + +#### Scenario: The initial status follows the copy +@e2e exclude Backend write, covered by tests/Unit/Service/CaseTypeCopyServiceTest.php. + +- **GIVEN** a case type whose initial status is its own status Ontvangen +- **WHEN** it is duplicated or versioned +- **THEN** the new type's initial status SHALL be the new type's own copy of Ontvangen +- **AND** publish validation SHALL NOT ask for a status the administrator already picked diff --git a/openspec/specs/zgw-brc/spec.md b/openspec/specs/zgw-brc/spec.md new file mode 100644 index 000000000..21f267522 --- /dev/null +++ b/openspec/specs/zgw-brc/spec.md @@ -0,0 +1,126 @@ +# zgw-brc Specification + +## Purpose + +A ZGW Besluit resolves to decidiq's Decision. dossiq stops keeping a besluit of +its own where decidiq already holds one. Existing besluiten move only when +somebody asks for the migration, never as a side effect of an upgrade. + +## Requirements + +### Requirement: The Besluit resolves to decidiq's Decision (REQ-BRC-020) + +`decision_schema` SHALL resolve to decidiq's `decision` schema when this app has +no value of its own configured. + +A schema slug is global per organisation and `SchemaMapper::find()` matches +`LOWER(slug)`, so two apps declaring a `decision` meant whichever row was +reached first answered for both. decidiq's Decision now carries the five BRC +fields it lacked (`deliveryDate`, `expiryDate`, `publicationDate`, +`responsibleOrganisation`, `governingBody`), so it can hold the Besluit. + +`BrcController` SHALL stay in this app. The standard belongs where it is served +from; only the schema it reads moves. + +The lookup SHALL use the `(application, slug)` PAIR, never the slug alone. Slug +alone is the ambiguity this exists to end: it matches this app's own row as +readily as decidiq's, and which one it returns depends on insertion order. + +Resolution SHALL happen LAST, only when nothing local answered. An instance that +still has its own `decision_schema` keeps it, because its besluiten are in that +schema; a fresh install has none and lands on decidiq's. Preferring decidiq +unconditionally would point every existing instance at a schema holding none of +its besluiten, and the BRC would answer 404 for every one it has. + +Resolution SHALL fail to the empty string when decidiq is absent or carries no +such schema, so the caller behaves exactly as it did when the key was unset. + +It SHALL apply to `decision_schema` alone, and not to any other key whose name +contains `decision`. + +#### Scenario: A configured instance keeps its own schema + +- **GIVEN** an instance with `decision_schema` set +- **WHEN** the value is read +- **THEN** the configured value is returned, not decidiq's. + +#### Scenario: An unconfigured instance resolves to decidiq + +- **GIVEN** an instance with no `decision_schema` set, and decidiq installed +- **WHEN** the value is read +- **THEN** decidiq's `decision` schema id is returned. + +#### Scenario: Without decidiq it resolves to empty + +- **GIVEN** an instance with no `decision_schema` set and decidiq absent +- **WHEN** the value is read +- **THEN** the empty string is returned. + +#### Scenario: Sibling keys are unaffected + +- **WHEN** `decision_type_schema`, `decision_document_schema` or + `case_decision_schema` is read with no value set +- **THEN** each resolves as it always did, with no fallback. + +### Requirement: Besluiten move onto decidiq only when asked (REQ-BRC-021) + +Because REQ-BRC-020 resolves LAST, an instance that already has a +`decision_schema` never moves on its own. `occ dossiq:migrate-besluiten` SHALL be +the supported way to move it. + +It SHALL report and change nothing unless `--commit` is given. It moves records +across an app boundary, and an upgrade is not the moment to do that silently: +the operator reads the counts first. For the same reason this SHALL NOT be a +repair step. + +Each migrated Decision SHALL carry `externalReference` of `dossiq:`, +and a run SHALL skip every source already stamped. The source UUID is the key +because a besluit is free to carry no slug and no unique title. + +The `case` reference SHALL travel through the generic subject block +(`sourceApp`, `subjectRegister`, `subjectSchema`, `subjectId`) and not through a +`case` field. decidiq's Decision has none and is not gaining one: cases and +decisions are already linked, and a second link would compete with the first. + +`governingBody` SHALL map to decidiq's `governingBody` and never to `targetBody`. +`targetBody` is the body an appointment is made FOR and is format `uuid`; the +bestuursorgaan that TOOK the decision is a different thing and is not a uuid. + +A source field declared `date` whose target declares `date-time` SHALL be widened +to midnight UTC. OpenRegister validates on write, so an unwidened `decisionDate` +does not move the besluit at all. + +`decision_schema` SHALL be removed only when every besluit is accounted for. +Detaching while one is behind would point the BRC at decidiq for a record that +never arrived, and it would answer 404 with nothing saying why. The source schema +and its rows SHALL be left in place, so restoring the key reverses the move. + +#### Scenario: A dry run writes nothing + +- **GIVEN** an instance with besluiten and a local `decision_schema` +- **WHEN** `occ dossiq:migrate-besluiten` runs without `--commit` +- **THEN** it reports the count, writes no Decision and keeps the key. + +#### Scenario: A commit moves the besluiten and detaches + +- **WHEN** the same command runs with `--commit` +- **THEN** each besluit is written to decidiq's schema with its provenance stamp +- **AND** `decision_schema` is removed. + +#### Scenario: A second run does not duplicate + +- **GIVEN** besluiten already migrated +- **WHEN** the command runs again with `--commit` +- **THEN** every already-stamped source is skipped and no Decision is written twice. + +#### Scenario: A besluit left behind keeps the key + +- **GIVEN** one besluit that cannot be written +- **WHEN** the command runs with `--commit` +- **THEN** it is reported as failed and `decision_schema` is kept. + +#### Scenario: Without decidiq there is nothing to migrate onto + +- **GIVEN** decidiq is not installed +- **WHEN** the command runs +- **THEN** it reports that it is blocked, rather than a successful run of zero.