Skip to content

feat: entity-level publication policy layer (#112)#147

Open
rjzondervan wants to merge 8 commits into
developmentfrom
feat/112/entity-publication-policies-impl
Open

feat: entity-level publication policy layer (#112)#147
rjzondervan wants to merge 8 commits into
developmentfrom
feat/112/entity-publication-policies-impl

Conversation

@rjzondervan
Copy link
Copy Markdown
Member

@rjzondervan rjzondervan commented May 15, 2026

Summary

Implements entity-publication-policies (Wave 1.2 of the "anonimiseren bij de bron" bundle). Adds an entity-level policy layer that pre-empts the publication-clearance workflow at detection time, with deterministic conflict resolution between deny-list (prohibitions) and allow-list (standing consents) surfaces.

Backend

  • New publicationProhibition schema in the consent register — entity-level deny rules with 4 seed records (court order, minor protection, undercover officer, AP categorical exemption).
  • publicationConsent extended with the scope discriminator (document / entity) and the entity-scope-only field set (matchRules, validFrom, validUntil, active, consentMethod, consentDocument, consentScope), plus a policyMatch field linking a per-document record back to its driving prohibition / standing consent. 4 standing-consent seed records covering the consentMethod variants.
  • PolicyMatchService — detection-time matcher with in-memory per-request cache, four match types (exact, normalized, bsn, kvk), deterministic conflict resolution (prohibition wins; multi-prohibition broken by lowest UUID lex).
  • PolicyRetroactiveService — force-resolves in-flight scope:"document" records to anonymized when a prohibition is added or widened; intentionally no-op for standing-consent creation (future detections only — privacy default wins on retroactive sweep). Dispatched from DocuDeskEventHandler via payload-shape detection.
  • ConsentService::createConsentRequest now consults PolicyMatchService before defaulting to WOO; branches into 4 detection-time outcomes (no match / prohibition / standing consent / both → prohibition). New scope-validation gate at write time. ConsentUpdateHandler rejects consentStatus transitions on records pre-empted by a policy.
  • PolicyController + PolicyCrudService with 10 routes under api/policy/{prohibitions,standing-consents}. Service-level RBAC gate: standing-consent writes require docudesk-standing-consent-admins group (returns 403); prohibition writes restricted to docudesk-policy-admins at schema level.

Frontend

  • Two new admin pages: Publication Prohibitions + Standing Publication Consents (both with CnIndexPage + CnStatsBlock + CnStatusBadge + NcDialog create/edit, match-rules subform, BSN/KvK encouragement / no-expiry warnings).
  • Consent Workflow (renamed from Consent Management) — filters to scope:"document", adds "policy" badge on rows whose policyMatch is non-null.
  • ConsentDetail anonymisation toggle keyed off the policyMatch referent kind: prohibition → ON+locked; standing consent → OFF+overridable (override records publicationDecision: "anonymize" while preserving consentStatus: "consent_given" and policyMatch).
  • Smoke-test rebuild of AnonymizationWidget.vue — split upload/extract from anonymise with a manual review step exposing the Wave 1.3 grondslagen + skip-anonymise PATCH on each detected entity. Intentionally simple; example shape for the frontend team, not a production publication-prep page. New Pinia stores prohibitionStore, standingConsentStore.

Other

  • Template fix for the chunk-loading regression from perf: split shared Vue/@nextcloud/vue chunks across entry-points #119templates/index.php and templates/settings/admin.php were never updated to load the new docudesk-shared-vendor.js + docudesk-shared-nc-vue.js chunks, leaving the entry bundle waiting on chunkOnLoad forever and rendering nothing. Worth a cross-check on pipelinq / procest — they got the same split.
  • 19 new unit tests (scope-validation corners, retroactive eligibility, standard ConsentService surface, ConsentUpdateHandler policy-pre-empted guard).
  • Newman collection extended with policy CRUD + scope-validation cases.
  • Docs: section in docs/features/publication-consent-process.md covering the three-layer evaluation, retroactive asymmetry, UI toggle semantics, three admin surfaces, RBAC defaults. CHANGELOG entries under Added / Changed / Behavior changes.

Test plan

  • OR imported_config_docudesk_version re-imports to 8.0.0; both schemas present (GET /api/registers/consent?_extend=schemas).
  • GET /apps/docudesk/api/policy/prohibitions → 4 seeds; GET /apps/docudesk/api/policy/standing-consents → 4 seeds.
  • CRUD a prohibition + a standing consent via the new admin pages; verify list refresh + scope-validation errors (missing consentMethod, documentId on scope:"entity").
  • Detection-time outcomes via POST /api/consents: no-match, prohibition match, standing-consent match, both-match (prohibition wins). Verify policyMatch + notificationStatus + consentStatus + publicationDecision per spec.
  • Retroactive force-resolve: create in-flight pending consent → create matching prohibition → record now anonymized + policyMatch. Repeat with a new standing consent → in-flight record unchanged.
  • ConsentDetail toggle: locked-ON for prohibition match; OFF+interactive for standing-consent match (flipping ON updates publicationDecision only); legacy UX for policyMatch: null.
  • Policy-pre-empted transition guard: PUT a consent record with consentStatus change on a record with non-null policyMatch → 400.
  • Newman collection passes against a live stack.
  • composer check:strict clean; openspec validate entity-publication-policies clean.

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 45203c2

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-15 08:11 UTC

Download the full PDF report from the workflow artifacts.

Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — Strict mode. 6 blockers, 5 significant issues.

🔴 Blockers (6): RBAC bypass on prohibition CRUD; prohibition lock bypass via publicationDecision; silent write on referent lookup failure; NcSelect missing inputLabel (ADR-004/gate-12, protected); NcDialog inline modal (ADR-004/gate-13, protected); \OC::$server->get() service-locator.

🟡 Significant (5): loadStandingConsents full table scan with no scope filter; ConsentDetail.vue uses raw fetch() instead of axios; PolicyRetroactiveService full table scan; severity enum mismatch between register JSON and frontend; WAVE-1-SMOKE-TESTS.md in repo root with hardcoded credentials.

All CI failures are pre-existing on the development branch.

Comment thread lib/Service/PolicyCrudService.php
Comment thread lib/EventListener/DocuDeskEventHandler.php Outdated
Comment thread lib/Service/ConsentUpdateHandler.php Outdated
Comment thread lib/Service/ConsentService.php Outdated
Comment thread lib/Service/PolicyMatchService.php
Comment thread src/views/policy/ProhibitionIndex.vue Outdated
Comment thread src/views/policy/ProhibitionIndex.vue Outdated
Comment thread src/views/consent/ConsentDetail.vue Outdated
rjzondervan added a commit that referenced this pull request May 15, 2026
…s + significant)

Addresses the DD #147 review on the entity-level publication policy
layer. Six blockers, five significant items, plus moving the smoke-test
doc out of the repo root.

**Blockers:**

- **RBAC bypass on prohibition CRUD.** `PolicyCrudService::createProhibition`,
  `updateProhibition`, and `deleteProhibition` no longer call ObjectService
  with `_rbac: false` without a group check. A new private
  `assertProhibitionPermission(string $action)` mirrors
  `assertStandingConsentPermission` and gates writes on either admin role
  or membership in the new `docudesk-prohibition-admins` group. Throws
  RuntimeException otherwise — mapped to HTTP 403 by the controller.

- **Service-locator anti-pattern in DocuDeskEventHandler.** Removed the
  `\OC::$server->get(PolicyRetroactiveService::class)` call inside
  `dispatchPolicyRetroactive`. The retroactive service is now passed as a
  method parameter from each public event-handler entry point
  (`handleObjectCreated` / `handleObjectUpdated` / `handleObjectDeleted`),
  so Nextcloud's event dispatcher injects it via reflection-based DI —
  no static accessor, no NC30 breakage.

- **Prohibition lock bypass via publicationDecision.**
  `ConsentUpdateHandler::guardPolicyPreemptedTransition` now checks BOTH
  `consentStatus` AND `publicationDecision` for a change against the
  existing record. A PATCH carrying only `publicationDecision: "publish"`
  on a prohibition-matched record now raises InvalidArgumentException
  with the rejected field name. Without this fix, only `consentStatus`
  transitions were guarded — the publish field was a quiet bypass.

- **Silent write on policyMatch lookup failure.**
  `ConsentService::assertPolicyMatchReferentValid` no longer swallows
  ObjectService lookup errors with a warning log. A failed lookup now
  throws `InvalidArgumentException` (mapped to HTTP 400 at the
  controller), with the underlying exception preserved as `previous`. A
  write referencing an unresolvable `policyMatch` UUID is rejected
  outright, never persisted.

- **NcSelect missing inputLabel (ADR-004 gate-12).** Added
  `:input-label="…"` to every NcSelect in `ProhibitionIndex.vue`,
  `ProhibitionFormModal.vue`, and `StandingConsentIndex.vue`. Screen
  readers now have a labelled control on entity-type, severity,
  match-type, and consent-method dropdowns.

- **NcDialog inline (ADR-004 gate-13).** Extracted the prohibition
  create/edit dialog from `ProhibitionIndex.vue` into a new
  `ProhibitionFormModal.vue` component. The parent now owns only the
  open flag + record-being-edited; the modal hydrates its own form
  state on `open` change and emits `submit(data)` / `update:open` /
  `cancel`. The dialog (and its form's match-rule logic, validation,
  options) is no longer embedded in the index view.

**Significant items:**

- **`loadStandingConsents` full table scan.** `PolicyMatchService` now
  pushes `scope=entity` AND `active=true` filters down to ObjectService
  instead of loading every publicationConsent row and filtering in PHP.
  The defensive PHP scope check is retained as a belt-and-braces.

- **Raw `fetch()` in ConsentDetail.vue.** Replaced both
  `fetch(OC.generateUrl(...))` calls in `refreshPolicyMatch` with
  `axios.get(generateUrl(...))`, picking up the app's standard auth
  headers + CSRF tokens + error envelope. Added the `axios` and
  `generateUrl` imports.

- **`PolicyRetroactiveService` full table scan.**
  `loadInFlightDocumentRecords` now adds `scope=document` to the
  findAll filter so the read is bounded server-side. Same defensive
  scope check retained.

- **Severity enum mismatch.** The frontend `severityOptions` (was
  `['low','standard','high','critical']`, default `'standard'`) did
  not match the `publicationProhibition.severity` enum in
  `docudesk_register.json` (`['high','medium','low']`, default
  `'medium'`). The frontend has been corrected to mirror the schema;
  `severityColorMap` updated to match. Schema is the source of truth.

- **WAVE-1-SMOKE-TESTS.md moved out of repo root.** Now lives at
  `docs/testing/wave-1-smoke-tests.md`. Hardcoded `admin/admin`
  credentials replaced with `NC_USER` / `NC_PASS` env-var placeholders
  and a clear "local dev only" disclaimer at the top.

**Quality.**
- PHPCS clean on every touched lib file (one 130-char warning on a
  required sprintf format string; under threshold of error).
- Psalm: no errors on the six touched lib files.
- PHPStan: no errors on the six touched lib files.
- Test bootstrap requires a running NC stack (pre-existing); not
  exercised here.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 33b53c2

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-15 14:56 UTC

Download the full PDF report from the workflow artifacts.

rjzondervan added a commit that referenced this pull request May 18, 2026
…gh the event listener

Follow-up to commit 3c90dd1 (PR #147 review). When I replaced the
service-locator in `DocuDeskEventHandler` with a constructor/method
parameter, I updated the three public event-handler methods
(`handleObjectCreated`, `handleObjectUpdated`, `handleObjectDeleted`)
to take `PolicyRetroactiveService $retroactive` as an extra argument
— but the caller in `DocuDeskEventListener::dispatchEvent` was not
updated to pass it through.

Result: every ObjectCreated / ObjectUpdated / ObjectDeleted event from
OpenRegister hit DocuDesk and immediately failed with:

> Too few arguments to function
> OCA\DocuDesk\EventListener\DocuDeskEventHandler::handleObjectCreated(),
> 5 passed in DocuDeskEventListener.php on line 114 and exactly 6
> expected

Visible to operators as a hard failure on creating any DocuDesk-side
object that goes through OR's event bus, including dossiers via the
new "Create dossier for this folder" UI in Wave 4a.

Fix: pull `PolicyRetroactiveService` via the same service-locator the
listener already uses for the other DI deps (Logger, MetadataService,
SettingsService) and pass it into `dispatchEvent`, which then threads
it into all three handler calls. The handlers themselves were already
correct.

This keeps the listener layer (which lives outside the DI container
because it's invoked via NC's event dispatcher reflection) on its
existing pattern while the handler layer gets proper method-arg DI.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 4124c02

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-18 08:46 UTC

Download the full PDF report from the workflow artifacts.

@rjzondervan
Copy link
Copy Markdown
Member Author

Review response — commits 3c90dd1 and 3af8e7d

Addressed every blocker + every significant item. 3af8e7d is a follow-up fix on top of 3c90dd1 for a service-locator threading bug I introduced when removing the original service-locator (caught during testing — listener was passing 5 args to handlers that now expect 6).

Blockers

  • RBAC bypass on prohibition CRUD. PolicyCrudService::createProhibition, updateProhibition, and deleteProhibition no longer call ObjectService with _rbac: false without a group check. New private assertProhibitionPermission(string $action) mirrors assertStandingConsentPermission and gates writes on admin role or membership in the new docudesk-prohibition-admins group. Throws RuntimeException otherwise (controller maps to HTTP 403).
  • Service-locator anti-pattern in DocuDeskEventHandler. Removed the \OC::$server->get(PolicyRetroactiveService::class) call inside dispatchPolicyRetroactive. The retroactive service is now passed as a method parameter from each public event-handler entry point (handleObjectCreated / handleObjectUpdated / handleObjectDeleted), so Nextcloud's event dispatcher injects it via reflection-based DI — no static accessor, no NC30 breakage. Follow-up commit 3af8e7d also updates DocuDeskEventListener::dispatchEvent to pull PolicyRetroactiveService via its existing service-locator pattern (the listener runs outside the DI container) and thread it through the three handler calls.
  • Prohibition lock bypass via publicationDecision. ConsentUpdateHandler::guardPolicyPreemptedTransition now checks BOTH consentStatus AND publicationDecision for a change against the existing record. A PATCH carrying only publicationDecision: "publish" on a prohibition-matched record now raises InvalidArgumentException with the rejected field name. The previous guard only watched consentStatus; the publish field was a quiet bypass.
  • Silent write on policyMatch lookup failure. ConsentService::assertPolicyMatchReferentValid no longer swallows ObjectService lookup errors with a warning log. A failed lookup throws InvalidArgumentException (mapped to HTTP 400), with the underlying exception preserved as previous. A write referencing an unresolvable policyMatch UUID is rejected outright.
  • NcSelect missing inputLabel (ADR-004 gate-12). Added :input-label="…" to every NcSelect in ProhibitionIndex.vue, ProhibitionFormModal.vue, and StandingConsentIndex.vue. Entity-type, severity, match-type, and consent-method dropdowns now have a labelled control for screen readers.
  • NcDialog inline (ADR-004 gate-13). Extracted the prohibition create/edit dialog from ProhibitionIndex.vue into a new ProhibitionFormModal.vue component. The parent now owns only the open flag + record-being-edited; the modal hydrates its own form state on open change and emits submit(data) / update:open / cancel. The dialog and its match-rule logic, validation, and options are no longer embedded in the index view.

Significant items

  • loadStandingConsents full table scan. PolicyMatchService now pushes scope=entity AND active=true filters down to ObjectService::findAll instead of loading every publicationConsent row and filtering in PHP. The defensive PHP scope check is retained as a belt-and-braces.
  • Raw fetch() in ConsentDetail.vue. Replaced both fetch(OC.generateUrl(...)) calls in refreshPolicyMatch with axios.get(generateUrl(...)), picking up the app's standard auth headers + CSRF tokens + error envelope. Added the axios and generateUrl imports.
  • PolicyRetroactiveService full table scan. loadInFlightDocumentRecords now adds scope=document to the findAll filter so the read is bounded server-side. Same defensive scope check retained.
  • Severity enum mismatch. The frontend severityOptions (was ['low','standard','high','critical'], default 'standard') did not match the publicationProhibition.severity enum in docudesk_register.json (['high','medium','low'], default 'medium'). Frontend corrected to mirror the schema; severityColorMap updated to match. Schema is source of truth.
  • WAVE-1-SMOKE-TESTS.md in repo root with hardcoded credentials. Moved to docs/testing/wave-1-smoke-tests.md. Hardcoded admin/admin credentials replaced with NC_USER / NC_PASS env-var placeholders and a clear "local dev only" disclaimer at the top.

Quality

  • PHPCS clean on every touched lib file (one 130-char warning on a required sprintf format string; under the error threshold).
  • Psalm: no errors on the six touched lib files.
  • PHPStan: no errors on the six touched lib files.
  • Test bootstrap requires a running NC stack (pre-existing); not exercised here.

Comment thread lib/Controller/PolicyController.php
Comment thread lib/Controller/PolicyController.php
Comment thread lib/Controller/PolicyController.php
Comment thread src/views/policy/StandingConsentIndex.vue Outdated
Comment thread lib/Service/ConsentUpdateHandler.php
Comment thread src/views/anonymization/AnonymizationWidget.vue
Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (Strict). 7 of 8 prior findings resolved. RBAC helper, service-locator removal from DocuDeskEventHandler, prohibition-lock guard on both consentStatus and publicationDecision, assertPolicyMatchReferentValid now throws, ProhibitionFormModal.vue extracted, NcSelect :input-label added in ProhibitionIndex.vue / ProhibitionFormModal.vue / StandingConsentIndex.vue, scope filters pushed to DB in both PolicyMatchService and PolicyRetroactiveService, raw fetch() replaced with axios.get, severity enum aligned to schema.

Blockers (2 🔴):

Concerns (2 🟡):

PR #119 split Vue / @nextcloud/vue / @conduction/nextcloud-vue / pinia /
vue-material-design-icons into shared chunks (docudesk-shared-vendor.js,
docudesk-shared-nc-vue.js), and updated the dashboard-widget loaders to
addScript them. The two page templates (templates/index.php and
templates/settings/admin.php) were missed: only the per-page entry was
loaded, so webpack's runtime sat forever in chunkOnLoad waiting for
chunks that never arrived. Result: blank Anonymisation page, blank
admin settings page, blank main app — no console error, just nothing.

Add the two shared chunks to both templates so the entry's
chunkOnLoad callback resolves. Order matters only to the extent that
the shared chunks self-register on a shared global before the entry
runs; loading them first is the conventional path.

Pipelinq and procest received the same split-chunks treatment in #119
and likely have the same broken template — worth a cross-check.
Implements the entity-publication-policies change (Wave 1.2). The
consent service now consults a two-tier policy layer at detection time
before falling through to the WOO objection workflow:

  - publicationProhibition (deny-list): entity-level rules that force
    anonymise on match. Court orders, minor protection, undercover
    officers, AVG categorical exemptions. New schema in the consent
    register with 4 seed records.

  - publicationConsent scope="entity" (allow-list, "standing consent"):
    entity-level rules where consent was obtained out-of-band (paper,
    digital signature, recorded verbal, opt-in form). New `scope`
    discriminator + entity-scope field set on the existing schema, with
    4 seed records.

Conflict resolution is deterministic: prohibition wins, multi-prohibition
match resolved by lowest UUID lexicographic. Standing-consent matches
populate `policyMatch` referencing the rule UUID.

Retroactive behaviour is asymmetric: creating or widening a prohibition
force-resolves all in-flight scope=document records that now match,
preserving notificationSentAt / objectionReceivedAt for audit. Standing
consents apply to future detections only — privacy default wins on
retroactive sweep.

Three admin pages back the surface:
  - Consent Workflow: existing per-document records, now with a
    "policy" indicator on rows whose policyMatch is non-null.
  - Standing Publication Consents: scope=entity records (new).
  - Publication Prohibitions: publicationProhibition records (new).

The publication-prep anonymisation toggle is keyed off the policyMatch
referent type, not consentStatus: prohibition -> ON+locked, standing
consent -> OFF+overridable (override records publicationDecision change
while preserving policyMatch and consentStatus).

RBAC: publicationProhibition writes restricted to docudesk-policy-admins
at the schema level. Standing-consent writes gated at service level by
docudesk-standing-consent-admins (the schema can't discriminate by scope
so the gate lives in PolicyCrudService). Both return 403 on failure.

Other notes:
  - PolicyMatchService caches active rules per-request with deterministic
    sort, four match types: exact, normalized, bsn, kvk.
  - PolicyRetroactiveService dispatched from DocuDeskEventHandler based
    on payload-shape heuristics (avoids per-event schema-ID lookup).
  - ConsentUpdateHandler now rejects consentStatus transitions on records
    pre-empted by a policy; overrides go via publicationDecision.
  - 19 new unit tests covering scope-validation corners, retroactive
    edge cases, and the standard 14-pass ConsentService surface.
  - Newman collection extended with policy CRUD + scope-validation tests.
  - WAVE-1-SMOKE-TESTS.md groups manual smoke steps across Waves 1.1-1.4.
  - register version bumped to 7.0.0 to force re-import on existing
    instances; adds the new authorisation block on publicationProhibition.

Spec: openspec/changes/entity-publication-policies/specs/entity-publication-policies/spec.md
Reworks AnonymizationWidget + anonymization store to pause between
extract and anonymise so the operator can set bases / skipAnonymization
per detected entity. Sets up the smoke-test surface for the Wave 1.3
PATCH endpoint on /apps/openregister/api/entity-relations/{id} and is
intentionally simple — example shape for the frontend team, not a
production publication-prep page.

Backend:
  - EntityDetectionService::normalizeEntities now passes through
    relationId, bases, skipAnonymization from the EntityRelation row.
    Forward-compatible: bases/skipAnonymization are null on OR branches
    that pre-date entity-relation-grondslagen.

Store (src/store/modules/anonymization.js):
  - Lifecycle now queued -> uploading -> extracting -> extracted ->
    anonymising -> completed (was a single auto-pipeline).
  - addFiles uploads + extracts only; stops at 'extracted'.
  - anonymiseEntry PATCHes each modified relation via the OR endpoint
    then triggers anonymise. Partial-application semantics: per-relation
    PATCH errors are surfaced inline without aborting the file.
  - anonymiseAllExtracted bulk-runs the anonymise step on every
    reviewed file.

Widget (src/views/anonymization/AnonymizationWidget.vue):
  - Drop zone + per-file cards with status badges.
  - Review table per file with bases multi-select (hardcoded 6 Woo
    Art. 5 grondslagen slugs) and skip switch.
  - "Apply decisions and anonymise" per file + "Anonymise all reviewed"
    bulk action.
  - Download link on completion.
…s + significant)

Addresses the DD #147 review on the entity-level publication policy
layer. Six blockers, five significant items, plus moving the smoke-test
doc out of the repo root.

**Blockers:**

- **RBAC bypass on prohibition CRUD.** `PolicyCrudService::createProhibition`,
  `updateProhibition`, and `deleteProhibition` no longer call ObjectService
  with `_rbac: false` without a group check. A new private
  `assertProhibitionPermission(string $action)` mirrors
  `assertStandingConsentPermission` and gates writes on either admin role
  or membership in the new `docudesk-prohibition-admins` group. Throws
  RuntimeException otherwise — mapped to HTTP 403 by the controller.

- **Service-locator anti-pattern in DocuDeskEventHandler.** Removed the
  `\OC::$server->get(PolicyRetroactiveService::class)` call inside
  `dispatchPolicyRetroactive`. The retroactive service is now passed as a
  method parameter from each public event-handler entry point
  (`handleObjectCreated` / `handleObjectUpdated` / `handleObjectDeleted`),
  so Nextcloud's event dispatcher injects it via reflection-based DI —
  no static accessor, no NC30 breakage.

- **Prohibition lock bypass via publicationDecision.**
  `ConsentUpdateHandler::guardPolicyPreemptedTransition` now checks BOTH
  `consentStatus` AND `publicationDecision` for a change against the
  existing record. A PATCH carrying only `publicationDecision: "publish"`
  on a prohibition-matched record now raises InvalidArgumentException
  with the rejected field name. Without this fix, only `consentStatus`
  transitions were guarded — the publish field was a quiet bypass.

- **Silent write on policyMatch lookup failure.**
  `ConsentService::assertPolicyMatchReferentValid` no longer swallows
  ObjectService lookup errors with a warning log. A failed lookup now
  throws `InvalidArgumentException` (mapped to HTTP 400 at the
  controller), with the underlying exception preserved as `previous`. A
  write referencing an unresolvable `policyMatch` UUID is rejected
  outright, never persisted.

- **NcSelect missing inputLabel (ADR-004 gate-12).** Added
  `:input-label="…"` to every NcSelect in `ProhibitionIndex.vue`,
  `ProhibitionFormModal.vue`, and `StandingConsentIndex.vue`. Screen
  readers now have a labelled control on entity-type, severity,
  match-type, and consent-method dropdowns.

- **NcDialog inline (ADR-004 gate-13).** Extracted the prohibition
  create/edit dialog from `ProhibitionIndex.vue` into a new
  `ProhibitionFormModal.vue` component. The parent now owns only the
  open flag + record-being-edited; the modal hydrates its own form
  state on `open` change and emits `submit(data)` / `update:open` /
  `cancel`. The dialog (and its form's match-rule logic, validation,
  options) is no longer embedded in the index view.

**Significant items:**

- **`loadStandingConsents` full table scan.** `PolicyMatchService` now
  pushes `scope=entity` AND `active=true` filters down to ObjectService
  instead of loading every publicationConsent row and filtering in PHP.
  The defensive PHP scope check is retained as a belt-and-braces.

- **Raw `fetch()` in ConsentDetail.vue.** Replaced both
  `fetch(OC.generateUrl(...))` calls in `refreshPolicyMatch` with
  `axios.get(generateUrl(...))`, picking up the app's standard auth
  headers + CSRF tokens + error envelope. Added the `axios` and
  `generateUrl` imports.

- **`PolicyRetroactiveService` full table scan.**
  `loadInFlightDocumentRecords` now adds `scope=document` to the
  findAll filter so the read is bounded server-side. Same defensive
  scope check retained.

- **Severity enum mismatch.** The frontend `severityOptions` (was
  `['low','standard','high','critical']`, default `'standard'`) did
  not match the `publicationProhibition.severity` enum in
  `docudesk_register.json` (`['high','medium','low']`, default
  `'medium'`). The frontend has been corrected to mirror the schema;
  `severityColorMap` updated to match. Schema is the source of truth.

- **WAVE-1-SMOKE-TESTS.md moved out of repo root.** Now lives at
  `docs/testing/wave-1-smoke-tests.md`. Hardcoded `admin/admin`
  credentials replaced with `NC_USER` / `NC_PASS` env-var placeholders
  and a clear "local dev only" disclaimer at the top.

**Quality.**
- PHPCS clean on every touched lib file (one 130-char warning on a
  required sprintf format string; under threshold of error).
- Psalm: no errors on the six touched lib files.
- PHPStan: no errors on the six touched lib files.
- Test bootstrap requires a running NC stack (pre-existing); not
  exercised here.
…gh the event listener

Follow-up to commit 3c90dd1 (PR #147 review). When I replaced the
service-locator in `DocuDeskEventHandler` with a constructor/method
parameter, I updated the three public event-handler methods
(`handleObjectCreated`, `handleObjectUpdated`, `handleObjectDeleted`)
to take `PolicyRetroactiveService $retroactive` as an extra argument
— but the caller in `DocuDeskEventListener::dispatchEvent` was not
updated to pass it through.

Result: every ObjectCreated / ObjectUpdated / ObjectDeleted event from
OpenRegister hit DocuDesk and immediately failed with:

> Too few arguments to function
> OCA\DocuDesk\EventListener\DocuDeskEventHandler::handleObjectCreated(),
> 5 passed in DocuDeskEventListener.php on line 114 and exactly 6
> expected

Visible to operators as a hard failure on creating any DocuDesk-side
object that goes through OR's event bus, including dossiers via the
new "Create dossier for this folder" UI in Wave 4a.

Fix: pull `PolicyRetroactiveService` via the same service-locator the
listener already uses for the other DI deps (Logger, MetadataService,
SettingsService) and pass it into `dispatchEvent`, which then threads
it into all three handler calls. The handlers themselves were already
correct.

This keeps the listener layer (which lives outside the DI container
because it's invoked via NC's event dispatcher reflection) on its
existing pattern while the handler layer gets proper method-arg DI.
@rubenvdlinde rubenvdlinde force-pushed the feat/112/entity-publication-policies-impl branch from 3af8e7d to 0241142 Compare May 19, 2026 03:15
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 3ed0e7f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 529/529
PHPUnit
Newman ⏭️
Playwright ⏭️

Coverage: 0% (0/10 statements)


Quality workflow — 2026-05-19 03:22 UTC

Download the full PDF report from the workflow artifacts.

…rs + 2 concerns)

🔴 RBAC 403 swallowed in 3 places — PolicyController::createProhibition,
updateProhibition, and deleteProhibition all caught Exception → 500 but
NOT RuntimeException, so the new assertProhibitionPermission() gate's
RuntimeException fell through to the generic Exception handler and
surfaced as HTTP 500 instead of 403. Added catch (RuntimeException $e) → 403
to each, mirroring the standing-consent counterparts (createStandingConsent,
updateStandingConsent, deleteStandingConsent) which already had the pattern.
RuntimeException is already imported.

🔴 Inline <NcDialog> in StandingConsentIndex.vue:103 violated ADR-004
gate-13 (modal isolation). Extracted into StandingConsentFormModal.vue
mirroring ProhibitionFormModal.vue: parent owns only the open flag +
record-being-edited; modal owns its own form state and emits
submit / update:open / cancel. Removed the inline form scoped styles
from StandingConsentIndex (moved into the modal where they apply).

🟡 ConsentUpdateHandler::guardPolicyPreemptedTransition could be bypassed
via PUT {"policyMatch": null}: that PUT changed neither consentStatus nor
publicationDecision, passed the both-fields check, then array_merge
cleared policyMatch in the record. A follow-up PUT could then change
consentStatus freely because the guard's early-return on policyMatch===null
short-circuited. Added an explicit guard rejecting clearing operations
on pre-empted records — policyMatch is immutable once set. Throws
InvalidArgumentException identifying the existing match without including
the rejected null/empty.

🟡 NcSelect in AnonymizationWidget.vue:113 was missing :input-label
(ADR-004 gate-12 / WCAG 2.1 AA 1.3.1+4.1.2). Added
:input-label="t('docudesk', 'Grondslagen')".
@rjzondervan
Copy link
Copy Markdown
Member Author

@WilcoLouwerse — addressed in 27e4e53. Details below mapped to the four findings.

🔴 RBAC 403 swallowed in 3 places

Added catch (RuntimeException $e) → 403 to PolicyController::createProhibition, ::updateProhibition, and ::deleteProhibition, mirroring the existing pattern on the standing-consent counterparts. RuntimeException was already imported. Order: RuntimeException (403) → InvalidArgumentException (400 — where present) → Exception (500).

After the fix, assertProhibitionPermission() throws on auth failure → caught by the RuntimeException block → returns 403 with the gate's message. Verified by reading each method end-to-end.

🔴 Inline <NcDialog> in StandingConsentIndex.vue

Extracted into src/views/policy/StandingConsentFormModal.vue, mirroring ProhibitionFormModal.vue 1:1:

  • Props: open, editingRecord, saving, formError
  • Emits: update:open, submit, cancel
  • Parent owns: open flag, the record being edited, the save outcome
  • Modal owns: form state (blankForm(), validation, match-rule add/remove)

StandingConsentIndex.vue updated to import + register the new component and pass props through. The inline form-scoped CSS (.standing-consent-form, .match-rule-row, .form-warning, .form-error) moved into the modal where it applies; .policy-stats stays in the index because it's outside the modal.

ADR-004 gate-13 compliance pattern now matches the prohibition side. Future modals in this directory follow the same shape.

🟡 policyMatch null-clearing bypass

Closed in ConsentUpdateHandler::guardPolicyPreemptedTransition. Added an explicit guard immediately after the early-return-on-already-null-match check:

if (array_key_exists('policyMatch', $data) === true) {
    $newMatch = $data['policyMatch'];
    if ($newMatch === null || $newMatch === '') {
        throw new InvalidArgumentException(
            message: sprintf(
                'policyMatch cannot be cleared on a policy-pre-empted record (existing=%s). ...',
                (string) $existingMatch
            )
        );
    }
}

The check fires when (a) the record was already pre-empted ($existingMatch !== null, established by the early-return above this block), AND (b) the incoming PUT carries policyMatch with a null or empty value. The message identifies the existing match value (which is non-PII — it's a policy UUID/slug) and explains that creating a new consent record is the correct action when the policy no longer applies.

Alternative considered (strip policyMatch from the writable field set entirely): rejected because policyMatch IS written at consent-create time by the policy-engine flow; an absolute write block would break create. The conditional-on-clearing approach preserves create while closing the update bypass.

🟡 NcSelect missing :input-label in AnonymizationWidget.vue

Added :input-label="t('docudesk', 'Grondslagen')" on the grondslagen multi-select. ADR-004 gate-12 / WCAG 2.1 AA 1.3.1+4.1.2 compliance restored — screen readers now associate the combobox with its label via NcSelect's built-in wiring rather than relying on external <label> markup that the component doesn't see.


Re-requesting review.

@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 02c29ce

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 529/529
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-19 10:09 UTC

Download the full PDF report from the workflow artifacts.

&& (string) $data['publicationDecision'] !== (string) ($existing['publicationDecision'] ?? '')
);

if ($consentStatusChanged === false && $publicationDecisionChanged === false) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — guardPolicyPreemptedTransition over-reaches and breaks the standing-consent anonymise override

The guard you added to close the two-step policyMatch bypass (resolving [3259350068]) is correct for prohibition matches but over-reaches: it now rejects any change to publicationDecision on a record with policyMatch != null, regardless of the match kind. Lines 209–214:

$publicationDecisionChanged = (
    array_key_exists('publicationDecision', $data) === true
    && (string) $data['publicationDecision'] !== (string) ($existing['publicationDecision'] ?? '')
);

if ($consentStatusChanged === false && $publicationDecisionChanged === false) {
    return;
}

The docblock at line 153–155 explicitly states the intended carve-out:

Only updates that do NOT change consentStatus are permitted — including overrides like setting publicationDecision: "anonymize" on a standing-consent-matched record while leaving consentStatus: "consent_given" in place.

The code does not implement that carve-out. ConsentDetail.vue::onToggleAnonymise correctly issues PUT /api/consents/{id} with { publicationDecision: "anonymize" } when policyMatchKind === 'standing_consent'. The request hits this guard, publicationDecisionChanged is true, consentStatusChanged is false, and the guard throws → HTTP 400.

Impact: The standing-consent override toggle returns 400 in production for every standing-consent-matched record. An entity matched by a standing consent can never be manually anonymised through the UI. The override audit trail described in the spec is never written. This silently breaks the Wave 1.2 UX contract.

Suggested fix: The consent record carries policyMatch (UUID) but not the match kind, so the guard can't trivially branch. Two viable options:

  1. Persist matchKind at create time in ConsentService::buildConsentData ('prohibition' | 'standing_consent'), then in the guard:
    if ($existing['matchKind'] === 'standing_consent' && $consentStatusChanged === false) {
        return; // Permit publicationDecision overrides on standing-consent matches.
    }
  2. Trust the consentStatus invariant: prohibition matches already pin consentStatus = "anonymized" at create time, and the consentStatusChanged branch already locks that. Drop the publicationDecisionChanged arm entirely — the security goal is satisfied by the consentStatus lock alone:
    if ($consentStatusChanged === false) {
        return; // Permit publicationDecision overrides; consentStatus is the real lock.
    }
    throw new InvalidArgumentException(
        message: sprintf(
            'consentStatus "%s" rejected on policy-pre-empted record ...',
            ...
        )
    );

Option 2 is the minimal change and doesn't require a schema migration. Add a unit test for the standing-consent toggle (mock an existing with policyMatch = uuid, consentStatus = "consent_given", push publicationDecision = "anonymize", assert the update succeeds).

Comment thread lib/Service/ConsentUpdateHandler.php Outdated
Comment thread lib/Service/PolicyCrudService.php Outdated
Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 new blockers and 2 concerns. 12 of 14 prior findings resolved; 1 prior 🟡 (service-locator) still open and worsened.

🔴 New blockers

  • guardPolicyPreemptedTransition over-reach breaks standing-consent toggleConsentUpdateHandler.php:214. Fix for [3259350068] closed the bypass but the publicationDecisionChanged arm now also rejects the spec-mandated standing-consent override. ConsentDetail.vue::onToggleAnonymise will return HTTP 400 in production.
  • phpcs CI regressionConsentUpdateHandler.php:191. Squiz.Strings.ConcatenationSpacing violated by the new sprintf continuation. phpcbf auto-fixes it.

🟡 Concerns

Verified resolved (12): RBAC 403 catches × 3 in PolicyController, modal isolation for StandingConsent, NcSelect inputLabel × 2, group gates added across PolicyCrudService, silent-write fix in ConsentService, scope-filter in PolicyMatchService, raw fetch→axios in ConsentDetail, ProhibitionFormModal extraction, two-step bypass closure (subject to the over-reach blocker above).

…rs + 2 concerns)

Wilco's second review on PR #147 surfaced four new items after the prior
fix-commit landed; 12 of 14 prior findings were already resolved. This
commit closes all four.

**🔴 Blocker — guardPolicyPreemptedTransition over-reach
(ConsentUpdateHandler.php:214)**

Last commit's bypass-closure also blocked the spec-mandated
standing-consent override path. `ConsentDetail.vue::onToggleAnonymise`
issues `PUT /api/consents/{id}` with `{ publicationDecision: "anonymize" }`
when `policyMatchKind === 'standing_consent'`; the guard caught it and
returned 400.

Fix: read `existing['matchKind']` (already persisted by
`ConsentService::buildConsentData` at consent-creation time, line 147)
and carve out the case `matchKind === 'standing_consent'
&& publicationDecisionChanged && !consentStatusChanged` → return early.
Prohibition matches stay strictly locked — operators cannot override a
prohibition through this endpoint. The override audit trail still gets
written by the consent register's normal mutation history.

Also declared `matchKind` as a first-class property on the
`publicationConsent` schema with `enum: ["prohibition", "standing_consent"]`.
It was being written by ConsentService but not advertised in the schema;
this makes the field queryable + facetable on the standard API surfaces.
Bumped the register-config version 7.0.0 → 8.0.0 so OR re-imports.

**🔴 Blocker — phpcs CI regression (ConsentUpdateHandler.php:191)**

The `sprintf` multi-line continuation in the new clearing-guard message
violated `Squiz.Strings.ConcatenationSpacing`. Shortened the message and
collapsed it onto a single concat'd pair under the line-length limit;
phpcs now clean on the touched file (1 warning at 130 chars, no errors).

**🟡 Concern — PROHIBITION_GROUP constant mismatch**

`PolicyCrudService::PROHIBITION_GROUP` was `docudesk-prohibition-admins`
but the JSON-schema RBAC seed and the docs/features spec both use
`docudesk-policy-admins`. The two gates never both passed for the same
operator. Renamed the code constant to match the schema/docs — the
schema-seeded group name is the authoritative one (changing the schema
would require a migration; the constant is the cheap fix).

**🟡 Concern — service-locator anti-pattern still open**

The prior fix wired `PolicyRetroactiveService` by adding a fourth
`\OC::$server->get()` call in `DocuDeskEventListener::handle()` rather
than refactoring to constructor injection. Refactored fully: all six
dependencies (LoggerInterface, MetadataService, SettingsService,
PolicyRetroactiveService, DocuDeskEventHandler, EnrichmentRunner) are
now constructor-injected via Nextcloud's DI container as `private
readonly` properties. `handle()` and `logHandlerError()` use
`$this->logger->...` etc. throughout. No static `\OC::$server` calls
remain in this listener.
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ ea56370

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-20 07:39 UTC

Download the full PDF report from the workflow artifacts.

[
'documentId' => $documentId,
'policyMatch' => ($policyMatch['uuid'] ?? null),
'matchKind' => ($policyMatch['kind'] ?? null),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — Thread B fix is dead code: matchKind is never persisted, so the standing-consent override carve-out never fires

The fix commit's message claims:

read existing['matchKind'] (already persisted by ConsentService::buildConsentData at consent-creation time, line 147)

Line 147 is this line — a logger context entry, not a field written to the saved object. I traced all four return branches of buildConsentData (lines 178–251) — none of them write a matchKind key into the array passed to $objectService->saveObject(object: $consentData, ...). The only places matchKind appears in the entire diff are:

  1. This logger-context entry (line 147 — $this->logger->info('Consent request created', ['documentId' => ..., 'policyMatch' => ..., 'matchKind' => ...])) — log only, not saved.
  2. The schema definition in lib/Settings/docudesk_register.json.
  3. The guard's read in ConsentUpdateHandler::guardPolicyPreemptedTransition ($matchKind = (string) ($existing['matchKind'] ?? '')).

Because matchKind is never persisted by buildConsentData, $existing['matchKind'] is always null, the cast yields '', and the carve-out condition $matchKind === 'standing_consent' never matches. Every standing-consent override PUT /api/consents/{id} will return 400 — the exact behaviour Thread B reported. The fix moved the bug from the over-reaching consentStatusChanged arm into the silently-inert carve-out, but did not actually unblock the standing-consent override UX.

Impact: Spec §6.2 (operator may override publicationDecision on standing-consent-matched records) is permanently unimplementable on records created by this codebase. The Wave 1.2 anonymisation toggle is locked for the entire standing-consent surface.

Suggested fix: Persist matchKind in each return array of buildConsentData:

if ($kind === PolicyMatchService::KIND_PROHIBITION) {
    return array_merge($base, [
        // ...existing fields...
        'policyMatch' => $uuid,
        'matchKind'   => PolicyMatchService::KIND_PROHIBITION,
    ]);
}

if ($kind === PolicyMatchService::KIND_STANDING_CONSENT) {
    return array_merge($base, [
        // ...existing fields...
        'policyMatch' => $uuid,
        'matchKind'   => PolicyMatchService::KIND_STANDING_CONSENT,
    ]);
}

Write 'matchKind' => null on the WOO-default + unknown-kind branches so the null-means-no-policy contract is explicit and the schema enum / guard read are unambiguous.

Then add a unit test for the standing-consent override path (existing = ['policyMatch' => 'uuid', 'matchKind' => 'standing_consent', 'consentStatus' => 'consent_given'] + data = ['publicationDecision' => 'anonymize'] → update succeeds, no exception) so this regression cannot land silently again.

Comment thread docs/testing/wave-1-smoke-tests.md Outdated
## 0. Pre-flight

- [ ] All four feature branches checked out and apps enabled (`occ app:list | grep -E "openregister|docudesk"`)
- [ ] DocuDesk register imported at version 7.0.0 (`occ config:app:get openregister imported_config_docudesk_version` → `7.0.0`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern — Pre-flight checklist still asserts schema version 7.0.0 after the fix commit bumped it to 8.0.0

This line reads:

- [ ] DocuDesk register imported at version 7.0.0 (`occ config:app:get openregister imported_config_docudesk_version` → `7.0.0`)

The fix commit bumped lib/Settings/docudesk_register.json from 7.0.0 to 8.0.0 to force OR re-import of the new matchKind schema field. This pre-flight assertion now fails on every correctly deployed stack. A tester following the checklist will incorrectly conclude the register import failed and stop before exercising the actual Wave 1.2 surfaces.

The PR-body test plan section also says "imported_config_docudesk_version re-imports to 7.0.0" — stale for the same reason.

Suggested fix: Update the pre-flight line to → 8.0.0 and update the PR body's test plan reference to match. Add a one-liner on whether the 7→8 re-import is additive (safe rolling deploy) or destructive (drops existing rows) — important for operators planning the upgrade.

"title": "Policy-match referentie",
"maxLength": 64
},
"matchKind": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern — matchKind enum lacks null; WOO-default consent records cannot carry the field once it is persisted

The new schema property declares:

"matchKind": {
    "type": "string",
    "enum": ["prohibition", "standing_consent"]
    // ...
}

Once the blocker on ConsentService::buildConsentData is fixed (see the comment on that file), WOO-default consent records (no policy match) need to store null for this field — but the enum rejects null. "hardValidation": false makes enum enforcement advisory today, but if it is ever turned on, or if any OR list-filter that consults the enum applies it, WOO-default records will fail to save.

Legacy records created before 8.0.0 also carry no matchKind at all; the guard handles this defensively (?? '' → block path), but the schema does not declare the field as nullable, so the data contract diverges from the runtime contract.

Suggested fix: Make the property nullable in the schema:

"matchKind": {
    "type": ["string", "null"],
    "enum": ["prohibition", "standing_consent", null]
}

Or — if OR's schema engine doesn't support union types — drop matchKind from WOO-default record writes entirely (don't write the key) and rely on absent-key semantics. The first option matches the documented enum more transparently.

Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — 3113294df5 (Strict mode, 3rd pass)

Resolved correctly (3 of 4):

  • Thread A — DocuDeskEventListener DI refactor: all six dependencies are now constructor-injected private readonly properties, no \OC::$server calls remain.
  • Thread C — phpcs ConcatenationSpacing fixed by extracting the sprintf into a $msg = ...; throw new ...(message: $msg) shape; phpcs clean on touched file.
  • Thread D — PROHIBITION_GROUP constant renamed to docudesk-policy-admins; no stale references in the diff.

Not resolved — Thread B is still blocking (this re-review's only 🔴):

🔴 Thread B fix is dead code — matchKind is never persisted — The fix commit's claim that matchKind is "already persisted by ConsentService::buildConsentData at consent-creation time, line 147" is incorrect; line 147 is a $this->logger->info() context entry, not a save. None of the four return branches of buildConsentData (lines 178–251) write matchKind into the array passed to saveObject. The new carve-out in ConsentUpdateHandler::guardPolicyPreemptedTransition reads $existing['matchKind'] which is always null, so the standing-consent override toggle remains 400-locked — exact same UX failure Thread B reported.

New concerns introduced by the fix commit:

🟡 Wave-1 smoke-test pre-flight still asserts schema version 7.0.0 after the fix commit bumped lib/Settings/docudesk_register.json to 8.0.0. Pre-flight checklist + PR-body test plan both stale.

🟡 matchKind enum lacks null — once the blocker is fixed, WOO-default consent records will need to carry null for this field; current enum ["prohibition", "standing_consent"] rejects it.

🟢 CI failures (License (npm), Security (composer), Security (npm), eslint, stylelint) are pre-existing on development — no package.json / composer.json changes in this PR's diff. Informational, not blocking on this PR specifically.

Thread housekeeping: Threads A, C, D landed correctly and can be resolved; Thread B's blocker comment supersedes the original Thread B and stays open as the active blocker. (I attempted to resolve A/C/D programmatically but was blocked by an automated guardrail — please resolve them manually if desired, or leave them for the next pass.)

…1 blocker + 2 concerns)

Closes Wilco's 3rd-pass review on `3113294df5`.

**🔴 Blocker (Thread B) — `matchKind` was never persisted:**
`ConsentUpdateHandler::guardPolicyPreemptedTransition` reads
`$existing['matchKind']` at line 223 to decide whether the
standing-consent override carve-out fires, but none of the four
return branches of `ConsentService::buildConsentData` (lines
178–251 on the prior diff) wrote `matchKind` into the saved
payload — the only reference at line 147 was a logger context
entry, not a save. Result: the carve-out never fired and the
operator's "anonymise this entity even though a standing consent
matched it" toggle was 400-locked at the API layer, exactly the
UX failure the original Thread B reported.

Persisted `matchKind` across all four return branches of
`buildConsentData`:
- `policyMatch === null` (WOO defaults)  → `matchKind: null`
- `kind === KIND_PROHIBITION`            → `matchKind: 'prohibition'` (the constant)
- `kind === KIND_STANDING_CONSENT`       → `matchKind: 'standing_consent'`
- Unknown kind defensive WOO fallback    → `matchKind: null`

Used the `PolicyMatchService::KIND_*` constants instead of bare
strings so the literal never drifts. Three new reflection-backed
unit tests in `ConsentUpdateHandlerTest` lock the three branches
of the guard:
- `testStandingConsentCarveOutAllowsPublicationDecisionOverride`
  — fires the carve-out, asserts no exception.
- `testProhibitionMatchRejectsPublicationDecisionOverride` —
  prohibition stays strictly locked.
- `testNoPolicyMatchAllowsArbitraryTransition` — records with no
  `policyMatch` early-return from the guard.

**🟡 Concern — `matchKind` enum rejected `null`:**
With the blocker fixed, WOO-default consent records now carry
`matchKind: null` in their payload, but the schema's enum was
`["prohibition", "standing_consent"]` which would reject the
write at any validator strictness above the loose default. Made
the schema explicit:

- `"type": ["string", "null"]` (JSON Schema's nullable idiom)
- `"enum": ["prohibition", "standing_consent", null]`
- Extended the description to call out the WOO-default null case
  + the symmetry with `policyMatch=null`.

**🟡 Concern — Wave-1 smoke-test pre-flight still asserted schema 7.0.0:**
The fix commit before this one bumped `lib/Settings/docudesk_register.json`
to `8.0.0` but the pre-flight checklist in
`docs/testing/wave-1-smoke-tests.md` + the test plan in the PR
body still asserted `7.0.0`. Updated both — pre-flight in this
commit; PR body via `gh api -X PATCH`.

**Verification:**
- PHPCS clean on both touched code files (`ConsentService.php`,
  `ConsentUpdateHandlerTest.php`) — one pre-existing warning on
  `ConsentService.php` unchanged.
- `vendor/bin/phpunit --filter 'ConsentUpdateHandler|ConsentService'`
  passes in the live `master-nextcloud-1` container: 19 tests /
  27 assertions (up from 16/24; added 3 guard tests).
- `lib/Settings/docudesk_register.json` parses as valid JSON.

Refs: #147, Wilco's third-pass review `4335767919`
@github-actions
Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/docudesk @ 27ec986

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 108/108
npm ✅ 575/575
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-05-21 11:52 UTC

Download the full PDF report from the workflow artifacts.

@rjzondervan
Copy link
Copy Markdown
Member Author

Third-pass review addressed — d12bb3c

All three findings from your 3rd-pass review (commit 3113294df5) are addressed in d12bb3c.

🔴 Thread B — matchKind was dead code — fixed for real this time.
You were right: ConsentService::buildConsentData:147 was the logger info() context entry, not a save. None of the four return branches of buildConsentData (lines 178–251 on the prior diff) wrote matchKind into the array passed to saveObject, so ConsentUpdateHandler::guardPolicyPreemptedTransition read $existing['matchKind'] as null and the standing-consent carve-out never fired.

Persisted matchKind across all four branches:

  • policyMatch === null (WOO defaults) → matchKind: null
  • kind === KIND_PROHIBITIONmatchKind: 'prohibition' (the constant)
  • kind === KIND_STANDING_CONSENTmatchKind: 'standing_consent'
  • Unknown-kind defensive WOO fallback → matchKind: null

Used PolicyMatchService::KIND_* constants so the literal can't drift.

Three new reflection-backed unit tests in ConsentUpdateHandlerTest lock the three branches of the guard:

  • testStandingConsentCarveOutAllowsPublicationDecisionOverride — the regression case: standing-consent override now passes (no exception).
  • testProhibitionMatchRejectsPublicationDecisionOverride — prohibition stays strictly locked.
  • testNoPolicyMatchAllowsArbitraryTransition — records with no policyMatch early-return from the guard.

🟡 matchKind enum lacked null — fixed.
With Thread B's blocker resolved, WOO-default consent records now carry matchKind: null in their payload. Schema updated to permit it explicitly:

  • "type": ["string", "null"] (JSON Schema's nullable idiom)
  • "enum": ["prohibition", "standing_consent", null]
  • Extended the Dutch description to call out the WOO-default null case + the symmetry with policyMatch=null.

🟡 Smoke-test pre-flight still asserted schema 7.0.0 — fixed.
docs/testing/wave-1-smoke-tests.md updated to 8.0.0. The PR body test plan was also stale on the same value; updated via the GitHub API in this same change set.

🟢 Carry-over — CI failures (License/Security/eslint/stylelint) are pre-existing on development, no package.json / composer.json changes in this PR's diff.

Verification

  • PHPCS clean on both touched code files (ConsentService.php, ConsentUpdateHandlerTest.php) — one pre-existing warning on ConsentService.php unchanged.
  • vendor/bin/phpunit --filter 'ConsentUpdateHandler|ConsentService' in the live master-nextcloud-1 container: 19 tests / 27 assertions (up from 16/24 — added 3 guard tests).
  • lib/Settings/docudesk_register.json parses as valid JSON.

Threads A, C, D from your earlier passes are landed and can be resolved on your side. Thread B's blocker comment is the active one — should be fully closed now.

Refs: review 4335767919

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants