diff --git a/lib/Migration/Version1Date20250828120000.php b/lib/Migration/Version1Date20250828120000.php index 993c88ce3..1887f1f9e 100644 --- a/lib/Migration/Version1Date20250828120000.php +++ b/lib/Migration/Version1Date20250828120000.php @@ -40,15 +40,17 @@ */ class Version1Date20250828120000 extends SimpleMigrationStep { - /** - * @param IDBConnection $connection The database connection - * @param IConfig $config The configuration interface - */ + /** + * Constructor. + * + * @param IDBConnection $connection The database connection + * @param IConfig $config The configuration interface + */ public function __construct( private readonly IDBConnection $connection, private readonly IConfig $config, ) { - } + }//end __construct() /** * Apply database schema changes for faceting performance. @@ -65,7 +67,7 @@ public function __construct( */ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { - /** + /* * @var ISchemaWrapper $schema */ diff --git a/openspec/changes/archive/2026-03-22-archival-destruction-workflow/.openspec.yaml b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/.openspec.yaml new file mode 100644 index 000000000..caac5173b --- /dev/null +++ b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-22 diff --git a/openspec/changes/archive/2026-03-22-archival-destruction-workflow/design.md b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/design.md new file mode 100644 index 000000000..38085f254 --- /dev/null +++ b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/design.md @@ -0,0 +1,95 @@ +## Context + +OpenRegister already has retention metadata infrastructure (`ObjectEntity.retention`, `ObjectRetentionHandler`, `Schema.archive`) and a comprehensive draft spec (`archivering-vernietiging`) defining MDTO-compliant archival metadata, selectielijsten, destruction workflows, legal holds, and e-Depot export. The deletion pipeline (`DeleteObject`) supports soft delete with audit trails, and the `AuditTrailMapper` provides hash-chained immutable logging. + +What is missing is the active workflow layer: no background jobs scan for objects past their `archiefactiedatum`, no destruction list management exists, no legal hold enforcement prevents premature destruction, and no destruction certificates are generated. This design bridges the existing metadata infrastructure with an operational destruction workflow. + +## Goals / Non-Goals + +**Goals:** +- Implement automated destruction scheduling via Nextcloud `TimedJob` that generates destruction lists +- Provide a multi-step approval workflow for destruction lists (single and two-step) +- Enforce legal holds that block destruction regardless of archival dates +- Generate immutable destruction certificates (verklaring van vernietiging) +- Calculate `archiefactiedatum` from configurable derivation methods (afleidingswijzen) +- Handle cascade destruction respecting referential integrity rules and legal holds on children + +**Non-Goals:** +- e-Depot SIP package generation and transfer (deferred to a separate change -- endpoints are stubbed) +- MDTO XML export format (already partially covered, full implementation deferred) +- Selectielijst import UI (API-only in this change) +- Frontend/Vue components for destruction list management (API-only) + +## Decisions + +### Decision 1: Destruction lists stored as register objects + +**Choice**: Store destruction lists as `ObjectEntity` instances in a dedicated `archival` register/schema, not as a separate database table. + +**Rationale**: OpenRegister's architecture stores all structured data as register objects. This gives destruction lists the same audit trail, versioning, search, and API access as any other object. It avoids schema migrations and follows the existing pattern where system data (e.g., selectielijsten) lives in registers. + +**Alternative considered**: A dedicated `destruction_lists` database table would give stronger typing but would break the register-centric architecture and require new mappers, controllers, and migration steps. + +### Decision 2: New `ArchivalController` for all archival API endpoints + +**Choice**: Create a dedicated `ArchivalController` (not extend `ObjectsController`) with routes under `/api/archival/`. + +**Rationale**: The archival workflow is a distinct domain concern with its own authorization (archivist role), its own state machine (destruction list lifecycle), and its own business rules. Mixing these into `ObjectsController` would violate single-responsibility and make the already large controller harder to maintain. + +**Endpoints**: +- `GET /api/archival/destruction-lists` -- list destruction lists with status filter +- `GET /api/archival/destruction-lists/{id}` -- get destruction list detail +- `POST /api/archival/destruction-lists/{id}/approve` -- approve (full or partial) +- `POST /api/archival/destruction-lists/{id}/reject` -- reject with reason +- `POST /api/archival/legal-holds` -- place legal hold on object(s) +- `DELETE /api/archival/legal-holds/{id}` -- release legal hold +- `GET /api/archival/legal-holds` -- list active legal holds +- `GET /api/archival/certificates` -- list destruction certificates + +### Decision 3: Service layer split into three focused services + +**Choice**: `DestructionService`, `LegalHoldService`, `ArchiefactiedatumCalculator` as separate service classes under `lib/Service/Archival/`. + +**Rationale**: Each has distinct responsibilities and dependencies. `DestructionService` orchestrates the workflow, `LegalHoldService` manages holds and checks, `ArchiefactiedatumCalculator` handles date derivation. This follows the existing handler pattern in `lib/Service/Object/`. + +### Decision 4: Background jobs use Nextcloud `TimedJob` + `QueuedJob` + +**Choice**: `DestructionCheckJob` extends `OCP\BackgroundJob\TimedJob` (runs daily by default). `DestructionExecutionJob` extends `OCP\BackgroundJob\QueuedJob` (one-shot, triggered after approval). + +**Rationale**: Nextcloud's job infrastructure handles scheduling, retries, and error reporting. `TimedJob` for periodic scanning follows the pattern of existing jobs (`CacheWarmupJob`, `SolrNightlyWarmupJob`). `QueuedJob` for execution avoids timeouts on large destruction batches -- the same pattern used by `WebhookDeliveryJob`. + +### Decision 5: Legal holds stored in object `retention` field + +**Choice**: Legal hold data is stored in the existing `ObjectEntity.retention` JSON field as `retention.legalHold`. + +**Rationale**: No schema migration required. The `retention` field already carries archival metadata. Adding `legalHold` as a nested structure keeps all archival state in one place. The `LegalHoldService` checks `retention.legalHold.active` before any destruction proceeds. + +### Decision 6: Two-step approval via schema configuration + +**Choice**: Schema's `archive` property gains `requireDualApproval: true` flag. The `DestructionService` checks this during approval and requires a second distinct approver. + +**Rationale**: Configuration-driven rather than code-driven. The schema already has an `archive` property for retention settings. Adding dual-approval there keeps archival policy centralized per object type. + +## Risks / Trade-offs + +- **[Performance] Large destruction lists** -- A destruction list with thousands of objects could cause memory issues during approval processing. Mitigation: `DestructionExecutionJob` processes in configurable batches (default 100), using `QueuedJob` chaining for continuation. + +- **[Data integrity] Cascade destruction with legal holds** -- If a parent is approved for destruction but a child has a legal hold, the entire parent destruction must halt. Mitigation: Pre-flight validation in `DestructionService::validateDestructionList()` checks all cascade targets for legal holds before execution begins. + +- **[Concurrency] Legal hold placed between approval and execution** -- A legal hold could be placed after approval but before the `DestructionExecutionJob` runs. Mitigation: The execution job re-checks legal holds immediately before each object deletion. + +- **[Complexity] Archiefactiedatum recalculation** -- When source properties change, the archival date must be recalculated. This requires hooking into `SaveObject`. Mitigation: A lightweight `ArchivalMetadataHook` in the save pipeline that only activates for schemas with archival configuration. + +## Migration Plan + +1. Deploy new service classes, controller, and background jobs -- no database migration needed +2. Register `DestructionCheckJob` in `Application::register()` with `IJobList::add()` +3. Register new routes in `appinfo/routes.php` under `/api/archival/` +4. Existing `retention` field data is forwards-compatible -- no data migration needed +5. Rollback: Remove the registered background job and routes; no data changes to undo + +## Open Questions + +- Should the destruction check frequency be configurable via admin settings or fixed at daily? +- Should destruction certificates be exportable as PDF, or is JSON/register object sufficient for v1? +- Should the archivist role be a Nextcloud group or an OpenRegister-specific role? diff --git a/openspec/changes/archive/2026-03-22-archival-destruction-workflow/proposal.md b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/proposal.md new file mode 100644 index 000000000..84a0dd75d --- /dev/null +++ b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/proposal.md @@ -0,0 +1,31 @@ +## Why + +Dutch government organisations using OpenRegister must comply with the Archiefwet 1995, Archiefbesluit 1995, and NEN 15489 (records management) for automated archival destruction of register objects. Currently, OpenRegister has retention metadata fields (`ObjectEntity.retention`, `ObjectRetentionHandler`, `Schema.archive`) and a comprehensive draft spec (`archivering-vernietiging`), but no background jobs for destruction scheduling, no destruction list workflow, no legal hold enforcement, and no destruction certificate generation. 77% of analyzed government tenders require these capabilities, making this a critical gap for municipal adoption. + +## What Changes + +- Add `DestructionCheckJob` background job that scans for objects past their `archiefactiedatum` with `archiefnominatie=vernietigen` and generates destruction lists +- Add `DestructionExecutionJob` queued job that permanently deletes approved objects in batches, including associated Nextcloud Files +- Add destruction list API endpoints for creating, reviewing, approving (full/partial), and rejecting destruction lists +- Add legal hold API endpoints for placing, releasing, and querying legal holds on objects and schemas +- Add destruction certificate generation (verklaring van vernietiging) upon completed destruction +- Add archiefactiedatum calculation service supporting multiple afleidingswijzen (afgehandeld, eigenschap, termijn) +- Extend `ObjectRetentionHandler` with legal hold checks and destruction eligibility validation +- Add two-step approval workflow for sensitive schemas +- Integrate with audit trail for all destruction and legal hold operations (`archival.destroyed`, `archival.legal_hold_placed`, etc.) + +## Capabilities + +### New Capabilities +- `archival-destruction-workflow`: Destruction list generation, multi-step approval workflow, batch execution, destruction certificates, and legal hold management + +### Modified Capabilities +- `archivering-vernietiging`: Implementing the draft spec requirements -- adding archiefactiedatum calculation, selectielijst integration, cascading destruction rules, WOO-published object handling, and e-Depot transfer preparation hooks + +## Impact + +- **Backend**: New background jobs (`DestructionCheckJob`, `DestructionExecutionJob`), new service classes (`DestructionService`, `LegalHoldService`, `ArchiefactiedatumCalculator`), new API controller methods on `ObjectsController` or a dedicated `ArchivalController` +- **Database**: No schema migration needed -- destruction lists and legal holds are stored as register objects using existing `ObjectEntity` infrastructure; retention metadata already exists on `ObjectEntity` +- **Dependencies**: Integrates with `audit-trail-immutable` (audit entries for all operations), `deletion-audit-trail` (soft delete before permanent destruction), `referential-integrity` (cascade handling) +- **Dependent apps**: OpenCatalogi and SoftwareCatalog objects with archival metadata will be subject to destruction workflows -- no code changes needed in those apps as this is register-level behavior +- **API surface**: New endpoints under `/api/archival/` for destruction lists and legal holds; existing object endpoints gain `retention.legalHold` field support diff --git a/openspec/changes/archive/2026-03-22-archival-destruction-workflow/specs/archival-destruction-workflow/spec.md b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/specs/archival-destruction-workflow/spec.md new file mode 100644 index 000000000..9c4af2743 --- /dev/null +++ b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/specs/archival-destruction-workflow/spec.md @@ -0,0 +1,224 @@ +## ADDED Requirements + +### Requirement: The system MUST provide a DestructionCheckJob that generates destruction lists from eligible objects + +A Nextcloud `TimedJob` MUST scan for objects that have reached their `archiefactiedatum` with `archiefnominatie` set to `vernietigen` and generate destruction lists as register objects for archivist review. + +#### Scenario: Daily destruction check generates a destruction list +- **GIVEN** 15 objects have `retention.archiefactiedatum` before today and `retention.archiefnominatie` set to `vernietigen` +- **AND** their `retention.archiefstatus` is `nog_te_archiveren` +- **AND** none of these objects have an active legal hold (`retention.legalHold.active` is not `true`) +- **WHEN** the `DestructionCheckJob` runs on its scheduled interval +- **THEN** the system MUST create a destruction list object in the archival register containing references to all 15 eligible objects +- **AND** each entry MUST include: object UUID, title, schema name, register name, `archiefactiedatum`, selectielijst category +- **AND** the destruction list MUST have status `in_review` +- **AND** an `INotification` MUST be sent to users with the archivist role + +#### Scenario: Objects with active legal holds are excluded from destruction lists +- **GIVEN** 10 objects are eligible for destruction based on `archiefactiedatum` +- **AND** 3 of those objects have `retention.legalHold.active` set to `true` +- **WHEN** the `DestructionCheckJob` generates the destruction list +- **THEN** only the 7 objects without legal holds MUST be included in the destruction list +- **AND** the 3 held objects MUST NOT appear on the list + +#### Scenario: Objects already on an existing destruction list are not duplicated +- **GIVEN** 10 objects are eligible for destruction +- **AND** a destruction list containing 8 of these objects already exists with status `in_review` +- **WHEN** the `DestructionCheckJob` runs again +- **THEN** only the 2 objects not already on an existing destruction list MUST be added to a new list +- **AND** the existing list MUST NOT be modified + +#### Scenario: Soft-deleted objects are included but flagged +- **GIVEN** 3 of the eligible objects have already been soft-deleted (have a `deleted` field set) +- **WHEN** the `DestructionCheckJob` generates the destruction list +- **THEN** the soft-deleted objects MUST be included in the destruction list +- **AND** they MUST be marked with `alreadySoftDeleted: true` in the list entry + +### Requirement: The system MUST provide API endpoints for destruction list management + +An `ArchivalController` MUST expose REST endpoints under `/api/archival/` for listing, viewing, approving, and rejecting destruction lists. + +#### Scenario: List destruction lists with status filter +- **GIVEN** 3 destruction lists exist: 1 with status `in_review`, 1 with `approved`, 1 with `rejected` +- **WHEN** `GET /api/archival/destruction-lists?status=in_review` is called by an authenticated archivist +- **THEN** the response MUST return only the 1 destruction list with status `in_review` +- **AND** each list item MUST include: UUID, status, creation date, object count, creator + +#### Scenario: Get destruction list detail +- **GIVEN** a destruction list `dl-001` with 15 objects +- **WHEN** `GET /api/archival/destruction-lists/dl-001` is called +- **THEN** the response MUST include the full list of objects with their archival metadata +- **AND** each object entry MUST include: UUID, title, schema, register, `archiefactiedatum`, selectielijst category, legal hold status + +#### Scenario: Unauthorized user cannot access destruction list endpoints +- **GIVEN** a user without the archivist role +- **WHEN** they call any `/api/archival/destruction-lists` endpoint +- **THEN** the system MUST return HTTP 403 Forbidden + +### Requirement: Destruction MUST follow a multi-step approval workflow with full, partial, and rejection paths + +Destruction lists MUST support full approval, partial approval (excluding specific objects), and full rejection, each with mandatory audit trail entries. + +#### Scenario: Full approval triggers batch destruction +- **GIVEN** a destruction list `dl-001` with 15 objects and status `in_review` +- **WHEN** an archivist calls `POST /api/archival/destruction-lists/dl-001/approve` with `{ "action": "approve_all" }` +- **THEN** the destruction list status MUST change to `approved` +- **AND** a `DestructionExecutionJob` MUST be queued to permanently delete all 15 objects +- **AND** an audit trail entry MUST be created with action `archival.destruction_approved` recording the approving archivist and timestamp + +#### Scenario: Partial approval excludes specific objects +- **GIVEN** a destruction list `dl-001` with 15 objects +- **WHEN** the archivist calls `POST /api/archival/destruction-lists/dl-001/approve` with `{ "action": "approve_partial", "excluded": ["obj-3", "obj-7", "obj-12"], "exclusionReasons": { "obj-3": "Lopend bezwaar", "obj-7": "Nader onderzoek", "obj-12": "Verkeerde classificatie" } }` +- **THEN** 12 objects MUST be approved for destruction +- **AND** the 3 excluded objects MUST have their `retention.archiefactiedatum` extended by a configurable period (default: 1 year) +- **AND** the exclusion reason MUST be stored per object in `retention.exclusionHistory[]` +- **AND** the destruction list MUST record both approved and excluded objects with their status + +#### Scenario: Full rejection with mandatory reason +- **GIVEN** a destruction list `dl-001` with 15 objects +- **WHEN** the archivist calls `POST /api/archival/destruction-lists/dl-001/reject` with `{ "reason": "Selectielijst niet actueel, herclassificatie nodig" }` +- **THEN** no objects MUST be destroyed +- **AND** the destruction list status MUST change to `rejected` +- **AND** all objects on the list MUST have their `retention.archiefactiedatum` extended by a configurable period +- **AND** the rejection reason MUST be recorded in the destruction list and audit trail + +#### Scenario: Two-step approval for sensitive schemas +- **GIVEN** schema `bezwaarschriften` is configured with `archive.requireDualApproval: true` +- **AND** a destruction list contains objects from this schema +- **WHEN** the first archivist approves the list +- **THEN** the status MUST change to `awaiting_second_approval` +- **AND** a second archivist (different user from the first) MUST approve before destruction proceeds +- **AND** if the same archivist attempts the second approval, the system MUST return HTTP 409 Conflict + +### Requirement: The DestructionExecutionJob MUST permanently delete approved objects in batches + +A `QueuedJob` MUST process approved destruction lists by permanently deleting objects, their associated files, and creating audit trail entries. + +#### Scenario: Batch destruction of approved objects +- **GIVEN** a destruction list `dl-001` is approved with 150 objects +- **WHEN** the `DestructionExecutionJob` runs +- **THEN** objects MUST be deleted in batches of 100 (configurable) +- **AND** for each object, `DeleteObject::delete()` MUST be called with `permanent: true` +- **AND** an audit trail entry MUST be created for each deletion with action `archival.destroyed` +- **AND** the audit trail entry MUST record: destruction list UUID, approving archivist, timestamp, selectielijst category + +#### Scenario: File attachments permanently deleted during destruction +- **GIVEN** object `zaak-789` on an approved destruction list has 3 files stored in Nextcloud Files +- **WHEN** the `DestructionExecutionJob` processes `zaak-789` +- **THEN** all 3 associated files MUST be permanently deleted from Nextcloud Files storage +- **AND** the files MUST NOT be recoverable from Nextcloud's trash +- **AND** each file deletion MUST be logged in the audit trail with action `archival.file_destroyed` + +#### Scenario: Legal hold placed between approval and execution halts object +- **GIVEN** destruction list `dl-001` is approved containing object `zaak-456` +- **AND** a legal hold is placed on `zaak-456` after approval but before `DestructionExecutionJob` runs +- **WHEN** the `DestructionExecutionJob` processes `zaak-456` +- **THEN** `zaak-456` MUST be skipped (not destroyed) +- **AND** the destruction list MUST be updated to note the skipped object with reason `legal_hold_placed_after_approval` +- **AND** all other objects on the list without holds MUST still be destroyed + +#### Scenario: Cascade destruction follows referential integrity rules +- **GIVEN** schema `zaakdossier` has property `documenten` referencing `zaakdocument` with `onDelete: CASCADE` +- **AND** zaakdossier `zaak-789` on an approved destruction list has 5 linked zaakdocumenten +- **WHEN** the `DestructionExecutionJob` destroys `zaak-789` +- **THEN** all 5 zaakdocumenten MUST also be permanently destroyed +- **AND** each cascaded destruction MUST produce an audit trail entry with action `archival.cascade_destroyed` + +### Requirement: The system MUST generate destruction certificates upon completed destruction + +After all objects on an approved destruction list are destroyed, the system MUST generate an immutable destruction certificate (verklaring van vernietiging). + +#### Scenario: Destruction certificate generated after full destruction +- **GIVEN** all 15 objects on destruction list `dl-001` have been permanently destroyed +- **WHEN** the `DestructionExecutionJob` completes +- **THEN** the system MUST create a destruction certificate as an immutable register object containing: + - Date of destruction + - Approving archivist(s) (including second approver if dual-approval) + - Number of objects destroyed, grouped by schema and selectielijst category + - Reference to the selectielijst version used + - Total number of associated files destroyed + - Statement of compliance with Archiefwet 1995 +- **AND** the certificate MUST NOT be editable or deletable through any API endpoint +- **AND** the destruction list status MUST change to `completed` + +#### Scenario: Destruction certificate for partial completion +- **GIVEN** destruction list `dl-001` had 15 objects approved but 2 were skipped due to legal holds +- **WHEN** the `DestructionExecutionJob` completes +- **THEN** the destruction certificate MUST record 13 objects destroyed and 2 skipped +- **AND** the skipped objects MUST be listed with their skip reason + +### Requirement: The system MUST support legal holds that prevent destruction + +Legal holds MUST be placeable on individual objects or all objects in a schema, preventing any destruction regardless of `archiefactiedatum`. + +#### Scenario: Place legal hold on a single object +- **GIVEN** object `zaak-456` with `archiefactiedatum` in the past and `archiefnominatie` `vernietigen` +- **WHEN** an authorized user calls `POST /api/archival/legal-holds` with `{ "objectId": "zaak-456", "reason": "WOO-verzoek 2025-0142" }` +- **THEN** the object's `retention.legalHold` MUST be set to `{ "active": true, "reason": "WOO-verzoek 2025-0142", "placedBy": "", "placedDate": "" }` +- **AND** the object MUST be excluded from all future destruction lists +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_placed` + +#### Scenario: Release legal hold +- **GIVEN** object `zaak-456` has an active legal hold +- **WHEN** an authorized user calls `DELETE /api/archival/legal-holds/{holdId}` with `{ "reason": "WOO-verzoek afgehandeld" }` +- **THEN** `retention.legalHold.active` MUST be set to `false` +- **AND** the hold MUST be preserved in `retention.legalHold.history[]` with release date and reason +- **AND** the object MUST become eligible for destruction again if `archiefactiedatum` has passed +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_released` + +#### Scenario: Bulk legal hold on schema +- **GIVEN** schema `subsidie-aanvragen` contains 200 objects +- **WHEN** an authorized user calls `POST /api/archival/legal-holds` with `{ "schemaId": "subsidie-aanvragen", "reason": "Rekenkameronderzoek 2026" }` +- **THEN** all 200 objects MUST receive a legal hold via a `QueuedJob` to avoid timeouts +- **AND** a single summary audit trail entry MUST be created for the bulk operation + +#### Scenario: Legal hold prevents destruction even when on active destruction list +- **GIVEN** a destruction list containing object `zaak-456` +- **AND** a legal hold is placed on `zaak-456` after the list was created but before approval +- **WHEN** the archivist approves the destruction list +- **THEN** `zaak-456` MUST be automatically excluded from destruction +- **AND** the archivist MUST be notified that 1 object was excluded due to legal hold + +### Requirement: The system MUST calculate archiefactiedatum using configurable afleidingswijzen + +The `ArchiefactiedatumCalculator` service MUST support multiple derivation methods for calculating the archive action date. + +#### Scenario: Calculate from case closure date (afgehandeld) +- **GIVEN** a zaakdossier mapped to selectielijst category B1 with `bewaartermijn: P5Y` +- **AND** `afleidingswijze` is set to `afgehandeld` +- **AND** the zaak is closed on 2026-03-01 +- **WHEN** `ArchiefactiedatumCalculator::calculate()` is called +- **THEN** `archiefactiedatum` MUST be set to 2031-03-01 (closure date + 5 years) + +#### Scenario: Calculate from a property value (eigenschap) +- **GIVEN** a vergunning with `afleidingswijze: eigenschap` pointing to property `vervaldatum` +- **AND** `vervaldatum` is 2028-06-15 +- **AND** `bewaartermijn` is `P10Y` +- **WHEN** `ArchiefactiedatumCalculator::calculate()` is called +- **THEN** `archiefactiedatum` MUST be set to 2038-06-15 + +#### Scenario: Calculate with termijn method +- **GIVEN** a zaak with `afleidingswijze: termijn` and `procestermijn: P2Y` +- **AND** the zaak is closed on 2026-01-01 +- **AND** `bewaartermijn` is `P5Y` +- **WHEN** `ArchiefactiedatumCalculator::calculate()` is called +- **THEN** the brondatum MUST be 2028-01-01 (closure + procestermijn) +- **AND** `archiefactiedatum` MUST be 2033-01-01 (brondatum + bewaartermijn) + +#### Scenario: Recalculate when source property changes +- **GIVEN** a vergunning with `afleidingswijze: eigenschap` pointing to `vervaldatum` +- **AND** current `archiefactiedatum` is 2038-06-15 +- **WHEN** `vervaldatum` is updated to 2030-12-31 +- **THEN** `archiefactiedatum` MUST be recalculated to 2040-12-31 +- **AND** the change MUST be logged in the audit trail + +### Requirement: WOO-published objects MUST be flagged on destruction lists + +Objects published under the Wet open overheid (WOO) MUST receive special handling during destruction workflows. + +#### Scenario: WOO-published object flagged on destruction list +- **GIVEN** object `besluit-123` has been published via WOO +- **AND** `besluit-123` appears on a destruction list based on its `archiefactiedatum` +- **WHEN** the destruction list is generated +- **THEN** `besluit-123` MUST be flagged with label `woo_gepubliceerd` +- **AND** the archivist MUST explicitly confirm destruction of publicly accessible records before approval proceeds diff --git a/openspec/changes/archive/2026-03-22-archival-destruction-workflow/specs/archivering-vernietiging/spec.md b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/specs/archivering-vernietiging/spec.md new file mode 100644 index 000000000..c963b195f --- /dev/null +++ b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/specs/archivering-vernietiging/spec.md @@ -0,0 +1,145 @@ +## MODIFIED Requirements + +### Requirement: The system MUST support automated destruction scheduling via background jobs + +Objects that have reached their `archiefactiedatum` with `archiefnominatie` set to `vernietigen` MUST be automatically identified and queued for destruction through a background job, following the pattern used by xxllnc Zaken for batch destruction processing. + +#### Scenario: Generate destruction list via background job +- **GIVEN** 15 objects have `archiefactiedatum` before today and `archiefnominatie` set to `vernietigen` +- **AND** their `archiefstatus` is `nog_te_archiveren` +- **WHEN** the `DestructionCheckJob` (extending `OCP\BackgroundJob\TimedJob`) runs on its configurable schedule (default: daily) +- **THEN** a destruction list MUST be generated as a register object containing references to all 15 objects +- **AND** the destruction list MUST include for each object: title, schema, register, UUID, `archiefactiedatum`, selectielijst category +- **AND** the destruction list MUST be assigned a status of `in_review` +- **AND** an `INotification` MUST be sent to users with the archivist role + +#### Scenario: Scheduled destruction respects soft-deleted objects +- **GIVEN** 3 of the 15 eligible objects have already been soft-deleted (have a `deleted` field set) +- **WHEN** the `DestructionCheckJob` generates the destruction list +- **THEN** the soft-deleted objects MUST still be included in the destruction list +- **AND** they MUST be clearly marked as already soft-deleted in the list + +#### Scenario: Prevent duplicate destruction list generation +- **GIVEN** 10 objects are eligible for destruction +- **AND** a destruction list containing 8 of these objects already exists with status `in_review` +- **WHEN** the `DestructionCheckJob` runs again +- **THEN** only the 2 objects not already on an existing destruction list MUST be added to a new list +- **AND** the existing list MUST NOT be modified + +#### Scenario: Configurable destruction check schedule +- **GIVEN** an admin wants destruction checks to run weekly instead of daily +- **WHEN** the admin updates the retention settings via `PUT /api/settings/retention` with `destructionCheckInterval` set to `604800` (7 days in seconds) +- **THEN** the `DestructionCheckJob` interval MUST be updated accordingly +- **AND** the setting MUST be persisted in the app configuration via `IAppConfig` + +### Requirement: Destruction MUST follow a multi-step approval workflow + +Destruction of objects MUST NOT occur automatically. A destruction list MUST be reviewed and approved by at least one authorized archivist before any objects are permanently deleted, conforming to Archiefbesluit 1995 Articles 6-8. + +#### Scenario: Approve destruction list (full approval) +- **GIVEN** a destruction list with 15 objects and status `in_review` +- **WHEN** an archivist with the `archivaris` role approves the entire list +- **THEN** the destruction list status MUST change to `approved` +- **AND** the system MUST permanently delete all 15 objects via a `DestructionExecutionJob` (`QueuedJob`) to avoid timeouts +- **AND** an audit trail entry MUST be created for each deletion with action `archival.destroyed` +- **AND** the audit trail entry MUST record: destruction list UUID, approving archivist, timestamp, selectielijst category +- **AND** the destruction list itself MUST be retained permanently as an archival record (verklaring van vernietiging) + +#### Scenario: Partially reject destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist removes 3 objects from the list (marking them as `uitgezonderd`) and approves the remaining 12 +- **THEN** only the 12 approved objects MUST be destroyed +- **AND** the 3 excluded objects MUST have their `archiefactiedatum` extended by a configurable period (default: 1 year) +- **AND** the exclusion reason MUST be recorded for each excluded object in `retention.exclusionHistory[]` +- **AND** the destruction list MUST record both the approved and excluded objects + +#### Scenario: Reject entire destruction list +- **GIVEN** a destruction list with 15 objects +- **WHEN** the archivist rejects the entire list +- **THEN** no objects MUST be destroyed +- **AND** the destruction list status MUST change to `rejected` +- **AND** the archivist MUST provide a reason for rejection +- **AND** all objects on the list MUST have their `archiefactiedatum` extended by a configurable period + +#### Scenario: Two-step approval for sensitive schemas +- **GIVEN** schema `bezwaarschriften` is configured with `archive.requireDualApproval: true` +- **AND** a destruction list contains objects from this schema +- **WHEN** the first archivist approves the list +- **THEN** the status MUST change to `awaiting_second_approval` +- **AND** a second archivist (different from the first) MUST approve before destruction proceeds + +#### Scenario: Destruction certificate generation (verklaring van vernietiging) +- **GIVEN** a destruction list has been fully approved and all objects destroyed +- **WHEN** the destruction process completes +- **THEN** the system MUST generate a destruction certificate containing: + - Date of destruction + - Approving archivist(s) + - Number of objects destroyed, grouped by schema and selectielijst category + - Reference to the selectielijst used + - Statement of compliance with Archiefwet 1995 +- **AND** the certificate MUST be stored as an immutable object in the archival register + +### Requirement: The system MUST support legal holds (bevriezing) + +Objects under legal hold MUST be exempt from all destruction processes, regardless of their `archiefactiedatum` or `archiefnominatie`. Legal holds support litigation, WOB/WOO requests, and regulatory investigations. + +#### Scenario: Place legal hold on an object +- **GIVEN** object `zaak-456` has `archiefactiedatum` of 2026-01-01 (in the past) and `archiefnominatie` `vernietigen` +- **WHEN** an authorized user places a legal hold with reason `WOO-verzoek 2025-0142` +- **THEN** the object's `retention` field MUST include `legalHold: { active: true, reason: "WOO-verzoek 2025-0142", placedBy: "user-id", placedDate: "2026-03-19T..." }` +- **AND** the object MUST be excluded from all destruction lists +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_placed` + +#### Scenario: Legal hold prevents destruction even when on destruction list +- **GIVEN** a destruction list containing object `zaak-456` +- **AND** a legal hold is placed on `zaak-456` after the destruction list was created but before approval +- **WHEN** the archivist approves the destruction list +- **THEN** `zaak-456` MUST be automatically excluded from destruction +- **AND** the archivist MUST be notified that 1 object was excluded due to legal hold + +#### Scenario: Release legal hold +- **GIVEN** object `zaak-456` has an active legal hold +- **WHEN** an authorized user releases the legal hold with reason `WOO-verzoek afgehandeld` +- **THEN** the `legalHold.active` MUST be set to `false` +- **AND** the hold history MUST be preserved in `legalHold.history[]` +- **AND** the object MUST become eligible for destruction again if `archiefactiedatum` has passed +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_released` + +#### Scenario: Bulk legal hold on schema +- **GIVEN** schema `subsidie-aanvragen` contains 200 objects +- **WHEN** an authorized user places a legal hold on all objects in this schema with reason `Rekenkameronderzoek 2026` +- **THEN** all 200 objects MUST receive a legal hold +- **AND** the operation MUST be executed via `QueuedJob` to avoid timeouts +- **AND** a single audit trail entry MUST summarize the bulk operation + +### Requirement: Cascading destruction MUST handle related objects + +When an object is destroyed, the system MUST evaluate and handle related objects according to configurable cascade rules, integrating with the existing referential integrity system (see `deletion-audit-trail` spec). + +#### Scenario: Cascade destruction to child objects +- **GIVEN** schema `zaakdossier` has a property `documenten` referencing schema `zaakdocument` with `onDelete: CASCADE` +- **AND** zaakdossier `zaak-789` has 5 linked zaakdocumenten +- **WHEN** `zaak-789` is destroyed via an approved destruction list +- **THEN** all 5 zaakdocumenten MUST also be destroyed +- **AND** each cascaded destruction MUST produce an audit trail entry with action `archival.cascade_destroyed` +- **AND** the audit trail entry MUST reference the original destruction list + +#### Scenario: Cascade destruction blocked by RESTRICT +- **GIVEN** zaakdossier `zaak-789` references `klant-001` with `onDelete: RESTRICT` +- **WHEN** `zaak-789` appears on a destruction list +- **THEN** the destruction list MUST flag `zaak-789` with a warning that it has RESTRICT references +- **AND** the archivist MUST resolve the reference before approving destruction + +#### Scenario: Cascade destruction with legal hold on child +- **GIVEN** zaakdossier `zaak-789` is approved for destruction +- **AND** one of its child zaakdocumenten has an active legal hold +- **WHEN** the destruction is executed +- **THEN** the system MUST halt destruction of the entire zaakdossier +- **AND** the archivist MUST be notified that destruction is blocked due to a legal hold on a child object + +#### Scenario: Destruction of objects with file attachments +- **GIVEN** object `zaak-789` has 3 files stored in Nextcloud Files +- **WHEN** the object is destroyed via an approved destruction list +- **THEN** all associated files MUST also be permanently deleted from Nextcloud Files storage +- **AND** the file deletion MUST be logged in the audit trail with action `archival.file_destroyed` +- **AND** the files MUST NOT be recoverable from Nextcloud's trash diff --git a/openspec/changes/archive/2026-03-22-archival-destruction-workflow/tasks.md b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/tasks.md new file mode 100644 index 000000000..37da8a69d --- /dev/null +++ b/openspec/changes/archive/2026-03-22-archival-destruction-workflow/tasks.md @@ -0,0 +1,44 @@ +## 1. Service Layer Foundation + +- [x] 1.1 Create `lib/Service/Archival/ArchiefactiedatumCalculator.php` with `calculate()` method supporting afleidingswijzen: `afgehandeld`, `eigenschap`, `termijn` — each deriving brondatum + bewaartermijn to produce archiefactiedatum +- [x] 1.2 Create `lib/Service/Archival/LegalHoldService.php` with methods: `placeHold()`, `releaseHold()`, `hasActiveHold()`, `bulkPlaceHold()` — storing holds in `retention.legalHold` on ObjectEntity +- [x] 1.3 Create `lib/Service/Archival/DestructionService.php` with methods: `findEligibleObjects()`, `createDestructionList()`, `approveList()`, `rejectList()`, `executeDestruction()`, `generateCertificate()` + +## 2. Background Jobs + +- [x] 2.1 Create `lib/BackgroundJob/DestructionCheckJob.php` extending `TimedJob` — queries for objects past archiefactiedatum with archiefnominatie=vernietigen, excludes legal holds and objects already on lists, calls `DestructionService::createDestructionList()` +- [x] 2.2 Create `lib/BackgroundJob/DestructionExecutionJob.php` extending `QueuedJob` — processes approved destruction lists in batches of 100, handles cascade destruction, file cleanup, re-checks legal holds before each deletion +- [x] 2.3 Create `lib/BackgroundJob/BulkLegalHoldJob.php` extending `QueuedJob` — applies legal holds to all objects in a schema when bulk hold is requested +- [x] 2.4 Register all three background jobs in `lib/AppInfo/Application.php` via `IJobList` + +## 3. API Controller and Routes + +- [x] 3.1 Create `lib/Controller/ArchivalController.php` with endpoints: GET/destruction-lists, GET/destruction-lists/{id}, POST/destruction-lists/{id}/approve, POST/destruction-lists/{id}/reject, POST/legal-holds, DELETE/legal-holds/{id}, GET/legal-holds, GET/certificates +- [x] 3.2 Add routes to `appinfo/routes.php` under `/api/archival/` prefix +- [x] 3.3 Add archivist role authorization check — return HTTP 403 for unauthorized users on all archival endpoints + +## 4. Integration with Existing Infrastructure + +- [x] 4.1 Add archival metadata hook in `SaveObject` pipeline — when an object in an archive-configured schema is saved and `afleidingswijze` source property changes, call `ArchiefactiedatumCalculator::calculate()` to recalculate archiefactiedatum +- [x] 4.2 Extend `DeleteObject` to support `permanent: true` flag for physical deletion (bypass soft-delete), used by `DestructionExecutionJob` +- [x] 4.3 Add legal hold check in `DestructionService` pre-flight validation — scan all objects and their cascade targets for active legal holds before execution +- [x] 4.4 Add WOO-published flag detection — check if object has been published via WOO and add `woo_gepubliceerd` label to destruction list entries + +## 5. Notification Integration + +- [x] 5.1 Add `INotification` for new destruction lists — notify archivist role users when DestructionCheckJob generates a new list +- [x] 5.2 Add `INotification` for legal hold exclusions — notify archivist when objects are auto-excluded from destruction due to legal holds +- [x] 5.3 Add `INotification` for cascade halt — notify archivist when destruction is blocked by legal hold on child object + +## 6. Destruction Certificate + +- [x] 6.1 Implement `DestructionService::generateCertificate()` — create immutable register object with destruction date, approvers, object counts by schema/selectielijst, selectielijst reference, Archiefwet compliance statement +- [x] 6.2 Ensure certificate objects cannot be edited or deleted — add protection check in SaveObject and DeleteObject for certificate schema objects + +## 7. Testing and Verification + +- [x] 7.1 Write unit tests for `ArchiefactiedatumCalculator` covering all three afleidingswijzen and recalculation on property change +- [x] 7.2 Write unit tests for `LegalHoldService` covering place, release, bulk hold, and hasActiveHold checks +- [x] 7.3 Write unit tests for `DestructionService` covering list creation, approval (full/partial/reject), dual-approval workflow, and certificate generation +- [x] 7.4 Write integration tests for `DestructionCheckJob` and `DestructionExecutionJob` with mock data +- [x] 7.5 Test with opencatalogi and softwarecatalog to verify no regressions on existing object operations diff --git a/openspec/specs/archival-destruction-workflow/spec.md b/openspec/specs/archival-destruction-workflow/spec.md new file mode 100644 index 000000000..f8484cef6 --- /dev/null +++ b/openspec/specs/archival-destruction-workflow/spec.md @@ -0,0 +1,236 @@ +--- +status: implemented +--- + +# Archival Destruction Workflow + +## Purpose + +Implement a NEN 15489 compliant destruction workflow for register objects, providing automated destruction scheduling via background jobs, multi-step approval workflows with destruction lists, legal hold management, destruction certificate generation, and archiefactiedatum calculation using configurable afleidingswijzen. This capability builds on the archivering-vernietiging spec and integrates with the immutable audit trail and deletion audit trail for legally required evidence trails. + +## Requirements + + + +### Requirement: The system MUST provide a DestructionCheckJob that generates destruction lists from eligible objects + +A Nextcloud `TimedJob` MUST scan for objects that have reached their `archiefactiedatum` with `archiefnominatie` set to `vernietigen` and generate destruction lists as register objects for archivist review. + +#### Scenario: Daily destruction check generates a destruction list +- **GIVEN** 15 objects have `retention.archiefactiedatum` before today and `retention.archiefnominatie` set to `vernietigen` +- **AND** their `retention.archiefstatus` is `nog_te_archiveren` +- **AND** none of these objects have an active legal hold (`retention.legalHold.active` is not `true`) +- **WHEN** the `DestructionCheckJob` runs on its scheduled interval +- **THEN** the system MUST create a destruction list object in the archival register containing references to all 15 eligible objects +- **AND** each entry MUST include: object UUID, title, schema name, register name, `archiefactiedatum`, selectielijst category +- **AND** the destruction list MUST have status `in_review` +- **AND** an `INotification` MUST be sent to users with the archivist role + +#### Scenario: Objects with active legal holds are excluded from destruction lists +- **GIVEN** 10 objects are eligible for destruction based on `archiefactiedatum` +- **AND** 3 of those objects have `retention.legalHold.active` set to `true` +- **WHEN** the `DestructionCheckJob` generates the destruction list +- **THEN** only the 7 objects without legal holds MUST be included in the destruction list +- **AND** the 3 held objects MUST NOT appear on the list + +#### Scenario: Objects already on an existing destruction list are not duplicated +- **GIVEN** 10 objects are eligible for destruction +- **AND** a destruction list containing 8 of these objects already exists with status `in_review` +- **WHEN** the `DestructionCheckJob` runs again +- **THEN** only the 2 objects not already on an existing destruction list MUST be added to a new list +- **AND** the existing list MUST NOT be modified + +#### Scenario: Soft-deleted objects are included but flagged +- **GIVEN** 3 of the eligible objects have already been soft-deleted (have a `deleted` field set) +- **WHEN** the `DestructionCheckJob` generates the destruction list +- **THEN** the soft-deleted objects MUST be included in the destruction list +- **AND** they MUST be marked with `alreadySoftDeleted: true` in the list entry + +### Requirement: The system MUST provide API endpoints for destruction list management + +An `ArchivalController` MUST expose REST endpoints under `/api/archival/` for listing, viewing, approving, and rejecting destruction lists. + +#### Scenario: List destruction lists with status filter +- **GIVEN** 3 destruction lists exist: 1 with status `in_review`, 1 with `approved`, 1 with `rejected` +- **WHEN** `GET /api/archival/destruction-lists?status=in_review` is called by an authenticated archivist +- **THEN** the response MUST return only the 1 destruction list with status `in_review` +- **AND** each list item MUST include: UUID, status, creation date, object count, creator + +#### Scenario: Get destruction list detail +- **GIVEN** a destruction list `dl-001` with 15 objects +- **WHEN** `GET /api/archival/destruction-lists/dl-001` is called +- **THEN** the response MUST include the full list of objects with their archival metadata +- **AND** each object entry MUST include: UUID, title, schema, register, `archiefactiedatum`, selectielijst category, legal hold status + +#### Scenario: Unauthorized user cannot access destruction list endpoints +- **GIVEN** a user without the archivist role +- **WHEN** they call any `/api/archival/destruction-lists` endpoint +- **THEN** the system MUST return HTTP 403 Forbidden + +### Requirement: Destruction MUST follow a multi-step approval workflow with full, partial, and rejection paths + +Destruction lists MUST support full approval, partial approval (excluding specific objects), and full rejection, each with mandatory audit trail entries. + +#### Scenario: Full approval triggers batch destruction +- **GIVEN** a destruction list `dl-001` with 15 objects and status `in_review` +- **WHEN** an archivist calls `POST /api/archival/destruction-lists/dl-001/approve` with `{ "action": "approve_all" }` +- **THEN** the destruction list status MUST change to `approved` +- **AND** a `DestructionExecutionJob` MUST be queued to permanently delete all 15 objects +- **AND** an audit trail entry MUST be created with action `archival.destruction_approved` recording the approving archivist and timestamp + +#### Scenario: Partial approval excludes specific objects +- **GIVEN** a destruction list `dl-001` with 15 objects +- **WHEN** the archivist calls `POST /api/archival/destruction-lists/dl-001/approve` with `{ "action": "approve_partial", "excluded": ["obj-3", "obj-7", "obj-12"], "exclusionReasons": { "obj-3": "Lopend bezwaar", "obj-7": "Nader onderzoek", "obj-12": "Verkeerde classificatie" } }` +- **THEN** 12 objects MUST be approved for destruction +- **AND** the 3 excluded objects MUST have their `retention.archiefactiedatum` extended by a configurable period (default: 1 year) +- **AND** the exclusion reason MUST be stored per object in `retention.exclusionHistory[]` +- **AND** the destruction list MUST record both approved and excluded objects with their status + +#### Scenario: Full rejection with mandatory reason +- **GIVEN** a destruction list `dl-001` with 15 objects +- **WHEN** the archivist calls `POST /api/archival/destruction-lists/dl-001/reject` with `{ "reason": "Selectielijst niet actueel, herclassificatie nodig" }` +- **THEN** no objects MUST be destroyed +- **AND** the destruction list status MUST change to `rejected` +- **AND** all objects on the list MUST have their `retention.archiefactiedatum` extended by a configurable period +- **AND** the rejection reason MUST be recorded in the destruction list and audit trail + +#### Scenario: Two-step approval for sensitive schemas +- **GIVEN** schema `bezwaarschriften` is configured with `archive.requireDualApproval: true` +- **AND** a destruction list contains objects from this schema +- **WHEN** the first archivist approves the list +- **THEN** the status MUST change to `awaiting_second_approval` +- **AND** a second archivist (different user from the first) MUST approve before destruction proceeds +- **AND** if the same archivist attempts the second approval, the system MUST return HTTP 409 Conflict + +### Requirement: The DestructionExecutionJob MUST permanently delete approved objects in batches + +A `QueuedJob` MUST process approved destruction lists by permanently deleting objects, their associated files, and creating audit trail entries. + +#### Scenario: Batch destruction of approved objects +- **GIVEN** a destruction list `dl-001` is approved with 150 objects +- **WHEN** the `DestructionExecutionJob` runs +- **THEN** objects MUST be deleted in batches of 100 (configurable) +- **AND** for each object, `DeleteObject::delete()` MUST be called with `permanent: true` +- **AND** an audit trail entry MUST be created for each deletion with action `archival.destroyed` +- **AND** the audit trail entry MUST record: destruction list UUID, approving archivist, timestamp, selectielijst category + +#### Scenario: File attachments permanently deleted during destruction +- **GIVEN** object `zaak-789` on an approved destruction list has 3 files stored in Nextcloud Files +- **WHEN** the `DestructionExecutionJob` processes `zaak-789` +- **THEN** all 3 associated files MUST be permanently deleted from Nextcloud Files storage +- **AND** the files MUST NOT be recoverable from Nextcloud's trash +- **AND** each file deletion MUST be logged in the audit trail with action `archival.file_destroyed` + +#### Scenario: Legal hold placed between approval and execution halts object +- **GIVEN** destruction list `dl-001` is approved containing object `zaak-456` +- **AND** a legal hold is placed on `zaak-456` after approval but before `DestructionExecutionJob` runs +- **WHEN** the `DestructionExecutionJob` processes `zaak-456` +- **THEN** `zaak-456` MUST be skipped (not destroyed) +- **AND** the destruction list MUST be updated to note the skipped object with reason `legal_hold_placed_after_approval` +- **AND** all other objects on the list without holds MUST still be destroyed + +#### Scenario: Cascade destruction follows referential integrity rules +- **GIVEN** schema `zaakdossier` has property `documenten` referencing `zaakdocument` with `onDelete: CASCADE` +- **AND** zaakdossier `zaak-789` on an approved destruction list has 5 linked zaakdocumenten +- **WHEN** the `DestructionExecutionJob` destroys `zaak-789` +- **THEN** all 5 zaakdocumenten MUST also be permanently destroyed +- **AND** each cascaded destruction MUST produce an audit trail entry with action `archival.cascade_destroyed` + +### Requirement: The system MUST generate destruction certificates upon completed destruction + +After all objects on an approved destruction list are destroyed, the system MUST generate an immutable destruction certificate (verklaring van vernietiging). + +#### Scenario: Destruction certificate generated after full destruction +- **GIVEN** all 15 objects on destruction list `dl-001` have been permanently destroyed +- **WHEN** the `DestructionExecutionJob` completes +- **THEN** the system MUST create a destruction certificate as an immutable register object containing: + - Date of destruction + - Approving archivist(s) (including second approver if dual-approval) + - Number of objects destroyed, grouped by schema and selectielijst category + - Reference to the selectielijst version used + - Total number of associated files destroyed + - Statement of compliance with Archiefwet 1995 +- **AND** the certificate MUST NOT be editable or deletable through any API endpoint +- **AND** the destruction list status MUST change to `completed` + +#### Scenario: Destruction certificate for partial completion +- **GIVEN** destruction list `dl-001` had 15 objects approved but 2 were skipped due to legal holds +- **WHEN** the `DestructionExecutionJob` completes +- **THEN** the destruction certificate MUST record 13 objects destroyed and 2 skipped +- **AND** the skipped objects MUST be listed with their skip reason + +### Requirement: The system MUST support legal holds that prevent destruction + +Legal holds MUST be placeable on individual objects or all objects in a schema, preventing any destruction regardless of `archiefactiedatum`. + +#### Scenario: Place legal hold on a single object +- **GIVEN** object `zaak-456` with `archiefactiedatum` in the past and `archiefnominatie` `vernietigen` +- **WHEN** an authorized user calls `POST /api/archival/legal-holds` with `{ "objectId": "zaak-456", "reason": "WOO-verzoek 2025-0142" }` +- **THEN** the object's `retention.legalHold` MUST be set to `{ "active": true, "reason": "WOO-verzoek 2025-0142", "placedBy": "", "placedDate": "" }` +- **AND** the object MUST be excluded from all future destruction lists +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_placed` + +#### Scenario: Release legal hold +- **GIVEN** object `zaak-456` has an active legal hold +- **WHEN** an authorized user calls `DELETE /api/archival/legal-holds/{holdId}` with `{ "reason": "WOO-verzoek afgehandeld" }` +- **THEN** `retention.legalHold.active` MUST be set to `false` +- **AND** the hold MUST be preserved in `retention.legalHold.history[]` with release date and reason +- **AND** the object MUST become eligible for destruction again if `archiefactiedatum` has passed +- **AND** an audit trail entry MUST be created with action `archival.legal_hold_released` + +#### Scenario: Bulk legal hold on schema +- **GIVEN** schema `subsidie-aanvragen` contains 200 objects +- **WHEN** an authorized user calls `POST /api/archival/legal-holds` with `{ "schemaId": "subsidie-aanvragen", "reason": "Rekenkameronderzoek 2026" }` +- **THEN** all 200 objects MUST receive a legal hold via a `QueuedJob` to avoid timeouts +- **AND** a single summary audit trail entry MUST be created for the bulk operation + +#### Scenario: Legal hold prevents destruction even when on active destruction list +- **GIVEN** a destruction list containing object `zaak-456` +- **AND** a legal hold is placed on `zaak-456` after the list was created but before approval +- **WHEN** the archivist approves the destruction list +- **THEN** `zaak-456` MUST be automatically excluded from destruction +- **AND** the archivist MUST be notified that 1 object was excluded due to legal hold + +### Requirement: The system MUST calculate archiefactiedatum using configurable afleidingswijzen + +The `ArchiefactiedatumCalculator` service MUST support multiple derivation methods for calculating the archive action date. + +#### Scenario: Calculate from case closure date (afgehandeld) +- **GIVEN** a zaakdossier mapped to selectielijst category B1 with `bewaartermijn: P5Y` +- **AND** `afleidingswijze` is set to `afgehandeld` +- **AND** the zaak is closed on 2026-03-01 +- **WHEN** `ArchiefactiedatumCalculator::calculate()` is called +- **THEN** `archiefactiedatum` MUST be set to 2031-03-01 (closure date + 5 years) + +#### Scenario: Calculate from a property value (eigenschap) +- **GIVEN** a vergunning with `afleidingswijze: eigenschap` pointing to property `vervaldatum` +- **AND** `vervaldatum` is 2028-06-15 +- **AND** `bewaartermijn` is `P10Y` +- **WHEN** `ArchiefactiedatumCalculator::calculate()` is called +- **THEN** `archiefactiedatum` MUST be set to 2038-06-15 + +#### Scenario: Calculate with termijn method +- **GIVEN** a zaak with `afleidingswijze: termijn` and `procestermijn: P2Y` +- **AND** the zaak is closed on 2026-01-01 +- **AND** `bewaartermijn` is `P5Y` +- **WHEN** `ArchiefactiedatumCalculator::calculate()` is called +- **THEN** the brondatum MUST be 2028-01-01 (closure + procestermijn) +- **AND** `archiefactiedatum` MUST be 2033-01-01 (brondatum + bewaartermijn) + +#### Scenario: Recalculate when source property changes +- **GIVEN** a vergunning with `afleidingswijze: eigenschap` pointing to `vervaldatum` +- **AND** current `archiefactiedatum` is 2038-06-15 +- **WHEN** `vervaldatum` is updated to 2030-12-31 +- **THEN** `archiefactiedatum` MUST be recalculated to 2040-12-31 +- **AND** the change MUST be logged in the audit trail + +### Requirement: WOO-published objects MUST be flagged on destruction lists + +Objects published under the Wet open overheid (WOO) MUST receive special handling during destruction workflows. + +#### Scenario: WOO-published object flagged on destruction list +- **GIVEN** object `besluit-123` has been published via WOO +- **AND** `besluit-123` appears on a destruction list based on its `archiefactiedatum` +- **WHEN** the destruction list is generated +- **THEN** `besluit-123` MUST be flagged with label `woo_gepubliceerd` +- **AND** the archivist MUST explicitly confirm destruction of publicly accessible records before approval proceeds