diff --git a/.procedure-code-baseline.json b/.procedure-code-baseline.json new file mode 100644 index 0000000000..57e568c367 --- /dev/null +++ b/.procedure-code-baseline.json @@ -0,0 +1 @@ +{"count": 0} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d64519b80..2506817ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - **Word-family text extraction now captures all content and supports legacy `.doc` and `.odt`.** `TextExtractionService::extractWord()` was rewritten around a recursive PhpWord element walker (`walkWordElements`, depth-capped at `MAX_WORD_DEPTH = 50`) that descends tables (rows → cells, including nested tables and in-cell text-runs / list items), section headers and footers, and document-level footnotes/endnotes — content the previous 2-level traversal silently dropped (notably entire tables, because a PhpWord `Table` exposes `getRows()`, not `getElements()`, so the old loop skipped it). The PhpWord reader is now selected from the file MIME / extension (`resolveWordReader`): `.docx` → `Word2007`, legacy `.doc` → `MsDoc`, `.odt` → `ODText` — previously the default `Word2007` reader was always used, so `.doc` and `.odt` failed to load even when routed. `application/vnd.oasis.opendocument.text` is added to the Word routing in `isWordDocument()`. (`text-extraction-word-completeness`) ### Behaviour changes +- **A draft no longer fires `ObjectCreatedEvent`, `ObjectUpdatedEvent` or `ObjectDeletedEvent` (`draft-creation-side-effects`, decision 227).** A draft is an object in status `draft`. Flows, webhooks, activity, push, metrics and notifications no longer run on a draft's saves or its delete. They run once, when the draft leaves draft: `ObjectCreatedEvent` is dispatched then, followed by `ObjectActivatedEvent`. A listener on `ObjectCreatedEvent` needs no status check. Do creation work on `ObjectCreatedEvent` only. A listener that does it on `ObjectActivatedEvent` too runs twice. Objects that never were a draft behave as before. +- **A draft can no longer be changed by somebody who cannot read it (`draft-owner-activation-and-lifetime`).** A draft is visible to its owner only. Until now a second account could still change it: a `PATCH` on the draft, or a `POST` that named its id, was judged by the schema's create rule and stored over the draft. The answer held the draft's content. Both now answer 403. `GET`, `PUT` and `DELETE` already answered 404. An administrator may still correct a draft, and a schema with `draftsVisible: true` keeps its own rules. +- **Upgrade note: drafts now expire after 30 days (`draft-owner-activation-and-lifetime`, decisions 216, 226 and 228).** A draft is an object in status `draft`. A draft that nobody changed for 30 days is removed by the daily job `DraftExpiryJob`, for good and with its files. This applies to drafts that exist today: **the first run of the job after the upgrade removes every draft older than 30 days**, at most 1,000 per run. Look before it runs: `occ openregister:drafts:expired` lists how many drafts the next run would remove per schema, and removes nothing. To keep the drafts of a schema, set `"draftLifetimeDays": false` in its configuration. A whole number of days, 1 or more, sets another lifetime. The lifetime counts from the draft's last change. Each removed draft leaves one audit-trail entry (`draft.removed`) that says who or which job removed it, when, in which register and schema, and which object. The entry never holds what the draft contained. The job logs a count per schema. - **Dashboard right sidebar is now collapsed by default on initial load.** The dashboard view (route `/`) renders with its right-hand app sidebar closed so the dashboard's primary content is unobstructed on arrival. The sidebar remains fully functional — the standard Nextcloud sidebar toggle opens it, and its filters, system totals, and orphaned-items sections behave identically once open. No other view's sidebar default open/closed state changes. (`dashboard-sidebar-default-state`) - **File access is permission-based and ownership-agnostic; objects can link files owned by other users.** `FileValidationHandler::checkOwnership()` now grants access on `Node::isReadable()` alone instead of denying when the file's owner UID differs from the session user — fixing `File is not owned by the current session` errors when uploading, viewing, updating, or deleting a file reached via a Nextcloud share or owned by a user other than the `openregister` system user. The update and delete paths now assert `Node::isUpdateable()` / `Node::isDeletable()` before mutating (failing fast with `NotPermittedException` in addition to Nextcloud's native enforcement), and `FileOwnershipHandler::transferFileOwnershipIfNeeded()` no longer re-owns a file to the system user when the current user already has write rights — ownership follows the folder's mount owner (the system user inside the OpenRegister folder; the original owner for a folder linked from outside it), with re-owning retained only as a fallback. (`file-permission-share-access`) - **`POST /api/files/{fileId}/anonymize` on PDF inputs now produces a clean output instead of a corrupted file.** Tenants that relied on the previous (broken) behaviour — where the byte-level `str_ireplace` path returned an unopenable PDF — need to verify nothing in their downstream pipeline assumed a corrupt PDF (e.g. mailers that retried on parse failure, viewer dashboards that rendered placeholders for unopenable attachments). The endpoint URL, auth model, and request shape are unchanged. PDF-specific failure responses are now structured (`{ "error": "pdf_anonymisation_failed", "reason": "", "details": { ... } }`) with reason-specific HTTP statuses — see the `### Added` entry for the full reason-to-status table. Encrypted PDFs are explicitly rejected (HTTP 422); image-only scans defer to the OCR capability (HTTP 422 with `text_layer_missing` reason); the strict-mode validation gate fails closed (HTTP 500 with `validation_failed`) when any substitution-map entry still appears in the re-extracted output. (`pdf-anonymisation`) diff --git a/appinfo/info.xml b/appinfo/info.xml index 611e320354..9ed1aa7004 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 2.1.35-unstable.20261009130000 + 2.1.35-unstable.20261012153000 EUPL-1.2 Conduction OpenRegister @@ -131,6 +131,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\BackgroundJob\LogCleanUpTask + OCA\OpenRegister\BackgroundJob\FormUploadPurgeJob OCA\OpenRegister\BackgroundJob\RecomputeTimersForCalendarJob OCA\OpenRegister\BackgroundJob\StateHistoryRebuildJob OCA\OpenRegister\BackgroundJob\ViewAlertSweepJob @@ -188,6 +189,13 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\BackgroundJob\OAuth2TokenRefreshJob OCA\OpenRegister\BackgroundJob\ScheduledReportJob OCA\OpenRegister\BackgroundJob\GroupReconcilerJob + + OCA\OpenRegister\BackgroundJob\DraftExpiryJob @@ -273,6 +281,7 @@ Vrij en open source onder de EUPL-licentie. and a reversal that has nothing to read. --> OCA\OpenRegister\Repair\ImportMergeOperationRegister OCA\OpenRegister\Repair\ImportDismissedPairRegister + OCA\OpenRegister\Repair\ImportDecisionTableRegister OCA\OpenRegister\Repair\ImportMergeOperationRegister OCA\OpenRegister\Repair\ImportDismissedPairRegister + OCA\OpenRegister\Repair\ImportDecisionTableRegister + OCA\OpenRegister\Command\DraftsExpiredCommand OCA\OpenRegister\Command\GenerateVapidKeys OCA\OpenRegister\Command\TablesSyncCommand OCA\OpenRegister\Command\ContextChatReindexCommand + + OCA\OpenRegister\Command\ExampleSetInstallCommand + OCA\OpenRegister\Command\ExampleSetRemoveCommand + OCA\OpenRegister\Command\ExampleSetListCommand OCA\OpenRegister\Settings\OpenRegisterAdmin OCA\OpenRegister\Settings\IntegrationsAdminSettings OCA\OpenRegister\Sections\OpenRegisterAdmin - OCA\OpenRegister\Settings\OpenRegisterPersonal + +## Impact + +- New code under `lib/Service/ExampleSet/` and three commands in `lib/Command/`, + registered in `appinfo/info.xml`. +- New data file `lib/Settings/example-sets/zuiddrecht.json`. +- No schema, migration, route or frontend change. Nothing installs itself: the + loader runs only when an administrator calls it (ADR-111 rule 3). +- Other apps add fragments in their own repositories later. portaliq's + `example-resident` keeps working unchanged; see design.md for how Sanne de + Vries moves onto the cast. diff --git a/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md new file mode 100644 index 0000000000..ce521c6911 --- /dev/null +++ b/openspec/changes/example-sets/specs/example-sets/spec.md @@ -0,0 +1,359 @@ +## ADDED Requirements + +### Requirement: A set is gathered from every enabled app in a declared order (REQ-EXS-001) + +The loader SHALL read `lib/Settings/example-sets/.json` from every app that Nextcloud's app manager reports as enabled. Each file is that app's fragment. OpenRegister's file SHALL carry the `cast` and SHALL be ordered first; a `cast` in any other app's file SHALL refuse the set. The remaining fragments SHALL be ordered so that every app named in a fragment's `after` list comes before it, with ties broken by app id. An app named in `after` (or in the cast file's `apps` list) that is not enabled SHALL be skipped and named in the output. A cycle in `after` SHALL refuse the set before anything is written, naming the apps in the cycle. + +#### Scenario: Fragments are ordered by `after` +- **GIVEN** enabled apps `alpha` with `after: ["beta"]` and `beta` with no `after`, both shipping a fragment of set `demo` +- **WHEN** the set is gathered +- **THEN** the order SHALL be `openregister`, `beta`, `alpha` + +#### Scenario: An absent app is skipped and named +- **GIVEN** a fragment with `after: ["gamma"]` and no enabled app `gamma` +- **WHEN** the set is gathered +- **THEN** the set SHALL still load, and the output SHALL name `gamma` as skipped + +#### Scenario: A cycle refuses the set +- **GIVEN** `alpha` with `after: ["beta"]` and `beta` with `after: ["alpha"]` +- **WHEN** the set is gathered +- **THEN** the command SHALL exit 1 with a message naming `alpha` and `beta`, and SHALL write nothing + +#### Scenario: Only OpenRegister carries the cast +- **GIVEN** an app other than openregister whose fragment has a `cast` key +- **WHEN** the set is gathered +- **THEN** the set SHALL be refused with a message naming that app's file + +### Requirement: Every placeholder resolves or the set is refused (REQ-EXS-002) + +Declared values SHALL support `{{cast:.}}` (a dot path into a cast person, organisation or group), `{{date:}}` and `{{datetime: HH:MM}}` (n days from the install day in the set's time zone), `{{object:.}}` (a field of an object declared earlier in the SAME fragment) and `{{lookup:}}` (the id of the one row a lookup by business key finds, in any register). A value that is exactly one placeholder SHALL take the type of what it stands for; a placeholder inside a longer string SHALL be interpolated as text. A placeholder that cannot be resolved SHALL be an error naming the file, the object key and the placeholder; it SHALL NEVER become an empty string. + +#### Scenario: A cast field fills a value +- **GIVEN** a cast person `sanne` with `bsn` `999994001` +- **WHEN** a declared value is `"{{cast:sanne.bsn}}"` +- **THEN** the written value SHALL be `"999994001"` + +#### Scenario: A relative date follows the install day +- **GIVEN** an install on 2026-10-13 in Europe/Amsterdam +- **WHEN** a declared value is `"{{date:-2}}"` +- **THEN** the written value SHALL be `"2026-10-11"` + +#### Scenario: An unknown cast key is an error that names its place +- **GIVEN** fragment file `dossiq/lib/Settings/example-sets/demo.json` with object `case-1` declaring `"{{cast:nobody.uid}}"` +- **WHEN** the set is checked +- **THEN** the refusal SHALL name that file, `case-1` and `{{cast:nobody.uid}}` + +#### Scenario: An object reference does not cross fragments +- **GIVEN** fragment `alpha` with object `a1`, and fragment `beta` declaring `"{{object:a1.id}}"` +- **WHEN** the set is checked +- **THEN** the placeholder SHALL be refused; `beta` must use a lookup on a business key + +### Requirement: Accounts and groups come from the cast and are never taken over (REQ-EXS-003) + +The loader SHALL create a Nextcloud account for every cast person with a `uid`, with the cast's display name and e-mail, and every group the cast declares. A generated password SHALL be printed once on the console and SHALL NOT be written to a file, the install record or a log; `--password-from-env` SHALL read one password for every new account from `OC_PASS` instead. An account that exists and that the install record does not list as created by this set SHALL refuse the install before anything is written, unless the administrator names its uid with `--adopt`; an adopted account SHALL be recorded as adopted and SHALL NOT be deleted by remove. A group that already exists SHALL be used and SHALL NOT be deleted by remove; only memberships this set added are removed. + +#### Scenario: A new account prints its password once +- **WHEN** an install creates the account `henk.bakker` +- **THEN** the output SHALL show its password once, and a second install SHALL show no password + +#### Scenario: A foreign account refuses the install +- **GIVEN** an existing account `sanne.devries` that the record does not list +- **WHEN** the set is installed without `--adopt sanne.devries` +- **THEN** the command SHALL exit 1 naming the account, and SHALL write no account, group or object + +#### Scenario: An adopted account survives remove +- **GIVEN** the set was installed with `--adopt sanne.devries` +- **WHEN** the set is removed +- **THEN** the account `sanne.devries` SHALL still exist + +### Requirement: Check validates every object against its real schema without writing (REQ-EXS-004) + +`--check` SHALL resolve every placeholder and lookup, verify each target register and schema exists on the instance, check every cast `bsn` against the eleven test, and validate every object with OpenRegister's own object validator against its target schema, whether or not that schema has hard validation on. It SHALL write none of the set's accounts, groups, objects or changes; the only thing it may write is the import of a register the set requires (REQ-EXS-009), because those schemas are what it checks against. It SHALL exit non-zero when anything is refused and name each refusal. An install SHALL run the same pass first and write none of the set's accounts, groups, objects or changes when it refuses. A reference to an object of the set that is not written yet SHALL be validated with a stand-in of the right shape, and a lookup SHALL also match an object declared earlier in the set. An account id the set creates (a cast member's `uid` or a service account's `uid`) SHALL pass a property of format `user` in the check although the account does not exist yet, because an install writes every account before any object; any other id that names no existing account SHALL still be refused, in an object and in a change. The validator SHALL accept those ids for the set's own validation only. + +#### Scenario: A value the schema refuses fails the check +- **GIVEN** an object whose `status` is not in its schema's enum +- **WHEN** `occ openregister:example-set:install demo --check` runs +- **THEN** it SHALL exit 1 naming the object key and the schema's message, and SHALL write nothing + +#### Scenario: A clean set passes the check +- **GIVEN** a set whose objects all satisfy their schemas +- **WHEN** the check runs +- **THEN** it SHALL exit 0 and report the number of objects checked per register and schema +- **AND** that number SHALL equal the number the install reports as declared for the same register and schema; changes SHALL be counted and reported apart + +#### Scenario: A change does not raise the object count +- **GIVEN** a fragment with ten `dossiq case` objects and two changes on `dossiq case` +- **WHEN** the set is checked and then installed +- **THEN** the check SHALL report `dossiq case: 10 checked` and `dossiq case: 2 change(s) checked`, and the install `dossiq case: 10 declared` +- @e2e exclude {console output; covered by ExampleSetLiveCheckTest} + +#### Scenario: An account the set creates passes a user format before it exists +- **GIVEN** a ticket whose `assignee` (format `user`) is `{{cast:daan.uid}}`, and no account `daan.verbeek` on the instance +- **WHEN** the check runs +- **THEN** it SHALL NOT refuse the ticket, and SHALL write no account +- **AND WHEN** the set is installed +- **THEN** the account SHALL be written before the ticket + +#### Scenario: An account id the set does not declare is still refused +- **GIVEN** a ticket whose `assignee` is `nobody.here`, which is neither on the instance nor a uid of the set +- **WHEN** the check runs +- **THEN** it SHALL refuse the ticket with the schema's format message + +### Requirement: A second install writes nothing (REQ-EXS-005) + +The loader SHALL keep an install record per set in OpenRegister's app config, listing the accounts it created or adopted, the groups it created, the memberships it added and the id of every object it wrote under `/`. A second install SHALL write nothing that the record holds and still finds on the instance, and SHALL report each as already there. An object the record holds but the instance lost SHALL be written again. + +#### Scenario: A repeated install is a no-op +- **GIVEN** a complete install of set `demo` +- **WHEN** the install runs again +- **THEN** it SHALL create no account, group, membership or object, report every one as already there, and exit 0 + +### Requirement: An install proves what arrived (REQ-EXS-006) + +After writing, the loader SHALL read every object it created in this run back from the instance and compare it with what it sent, and SHALL check that every recorded account, group and membership exists. The command SHALL exit 0 when everything declared is on the instance, 1 when nothing was written (refused set, check failure, foreign account) and 2 when something is missing or was not kept, naming each. + +#### Scenario: A key the schema drops is named +- **GIVEN** an object whose declared key the schema does not keep +- **WHEN** the install reads it back +- **THEN** the output SHALL name the object and the key, and the command SHALL exit 2 + +### Requirement: Remove deletes exactly what the install made (REQ-EXS-007) + +`openregister:example-set:remove ` SHALL delete, in reverse order of writing, every object the record holds, then the memberships it added, then the groups it created, then the accounts it created. It SHALL NOT touch an adopted account, a pre-existing group, or any object it did not write. What it cannot delete SHALL stay in the record and be named, with exit 2. + +#### Scenario: Remove leaves foreign data alone +- **GIVEN** an object in the same schema that the set did not write +- **WHEN** the set is removed +- **THEN** that object SHALL still exist and the set's own objects SHALL be gone + +### Requirement: An administrator can list the sets (REQ-EXS-008) + +`openregister:example-set:list` SHALL list every set for which an enabled app ships a fragment, with the apps that ship one and whether the set is installed. + +#### Scenario: The list names the fragments +- **GIVEN** openregister and portaliq both ship `zuiddrecht.json` +- **WHEN** the list runs +- **THEN** it SHALL show `zuiddrecht` with `openregister, portaliq` and its install state + +### Requirement: A set imports the OpenRegister registers it requires (REQ-EXS-009) + +The cast file MAY name OpenRegister registers in `requires`. An entry SHALL be either a register slug (for example `"publication"`), imported from OpenRegister's real register descriptor, or `{"register": "", "mock": true}`, imported from OpenRegister's mock (sample) descriptor that declares the slug, the way `occ openregister:descriptors:list --app=openregister --import= --mock` does. An example set is sample data, so it MAY require a register OpenRegister ships only as a mock (zuiddrecht requires `brp` and `kvk` that way). An entry that names no register slug SHALL refuse the set when it is gathered. Before the check, the loader SHALL import every required register that is not on the instance from the descriptor its entry names, and SHALL record the import jobs that import created. A required register that is already on the instance SHALL be used as it is and SHALL NOT be recorded. A second run SHALL import nothing. Remove SHALL soft-delete exactly the objects of the recorded import jobs, after the set's own objects, and SHALL NOT touch an import it did not make. A required register that cannot be imported SHALL refuse the set. + +#### Scenario: A missing register is imported once and removed again +- **GIVEN** a set that requires `brp` and an instance without it +- **WHEN** the set is installed twice and then removed +- **THEN** `brp` SHALL be imported by the first run only, reported as `imported` then `kept`, and remove SHALL delete the objects of that import + +#### Scenario: A register that was there stays there +- **GIVEN** a set that requires `brp` and an instance that already has it +- **WHEN** the set is installed and removed +- **THEN** nothing SHALL be imported and remove SHALL leave every import of `brp` alone + +#### Scenario: A register shipped only as a mock is imported from the mock +- **GIVEN** a set whose `requires` holds `{"register": "brp", "mock": true}` and a fresh instance without `brp` +- **WHEN** `occ openregister:example-set:install --check` runs +- **THEN** `brp` SHALL be imported from OpenRegister's mock `brp` descriptor under the `openregister.mock` configuration id, reported as `imported`, and recorded so remove deletes its objects + +#### Scenario: A plain slug never falls back to a mock +- **GIVEN** a set whose `requires` holds `"brp"` and OpenRegister ships `brp` only as a mock +- **WHEN** the set is checked +- **THEN** it SHALL be refused naming `brp`, and SHALL NOT import the mock + +#### Scenario: A register that cannot be imported refuses the set +- **GIVEN** a set that requires a register OpenRegister ships no descriptor for +- **WHEN** the set is installed +- **THEN** the command SHALL exit 1 naming the register, and SHALL write none of the set's accounts, groups or objects + +### Requirement: A set never adds anyone to Nextcloud's administrators (REQ-EXS-010) + +A cast group or fragment group whose gid is `admin` (in any letter case) SHALL refuse the set before anything is written. An administrator role in an app is given through that app's own admin group in its fragment. + +#### Scenario: An admin group refuses the set +- **GIVEN** a fragment that puts a cast person in group `admin` +- **WHEN** the set is checked or installed +- **THEN** it SHALL be refused naming the group, and no account or group SHALL be written + +### Requirement: A set may change an object it did not create and undo exactly that (REQ-EXS-011) + +A fragment MAY declare `changes`: each with a `key`, a `register`, a `schema`, a `where` that finds exactly one existing object by business key, and `set` (dot path to value) and/or `append` (dot path to one value added to the list there). The check SHALL refuse a change whose object is not found or whose result its schema refuses. Install SHALL write only what differs, SHALL record the previous value of every path it set, the values it appended, and any map it created on the way to a path. A change already fully present SHALL NOT be recorded. Remove SHALL put back exactly the recorded values and leave every other part of the object as it is then. + +#### Scenario: A sign-in mode added to a portal is taken off again +- **GIVEN** a portal row with sign-in modes `["password"]` that the set did not create +- **WHEN** a set appends `nextcloud` to `authentication.modes` and sets a card in `authentication.modeLabels`, somebody renames the portal, and the set is removed +- **THEN** the portal SHALL have modes `["password"]`, no `modeLabels`, and its new name + +### Requirement: A field a schema keeps as JSON text is sent as text (REQ-EXS-012) + +An object MAY name fields in `jsonFields`. Those fields SHALL be sent to the register as JSON text and validated in that form, and the read-back proof SHALL compare the decoded text with the declared structure. A change on such an object (REQ-EXS-018) SHALL carry the object's `jsonFields`, and a change found by `where` MAY name its own `jsonFields`: the change SHALL read those fields as their structure, apply `set` and `append` to it, and validate and write them as JSON text again, the same encoding the object was created with. Its proof and its remove SHALL decode the same fields. + +#### Scenario: A history list is stored as text and proved as a list +- **GIVEN** an object with `jsonFields: ["history"]` and a list in `history` +- **WHEN** the set is installed +- **THEN** the register SHALL receive the list as JSON text and the proof SHALL report nothing lost + +#### Scenario: A change on a case with a JSON text history is checked as it is written +- **GIVEN** a case with `jsonFields: ["statusHistory"]`, a result for it, and a change with `object: ` that sets `result` +- **WHEN** the check runs and the set is installed +- **THEN** the check SHALL NOT refuse the change for `statusHistory`, and the stored case SHALL hold its `statusHistory` as JSON text and point at the result + +#### Scenario: An append inside JSON text is proved and taken out as a list +- **GIVEN** an existing row whose `history` is the text `[{"at":"a"}]`, and a change with `jsonFields: ["history"]` that appends `{"at":"b"}` to `history` +- **WHEN** the set is installed and then removed +- **THEN** the row SHALL hold `[{"at":"a"},{"at":"b"}]` as text, the proof SHALL report nothing lost, and after remove it SHALL hold `[{"at":"a"}]` again + +### Requirement: An object of a set may carry files from its fragment (REQ-EXS-013) + +An object MAY list `files`, each with a `path` relative to the `lib/Settings/example-sets/` folder of the app that ships the fragment, and optionally `title`, `description` and `share`. A path that is absolute, holds a `..` segment, a backslash or a NUL, or resolves outside that folder SHALL refuse the set. The check SHALL refuse a file that is not a readable file there. After the object is written the loader SHALL add each file with OpenRegister's FileService, share it when `share` is true, and record it under the object. Remove SHALL delete the recorded files before the object. The proof SHALL name a recorded file the object no longer has. `title` and `description` are kept in the fragment for the reader; FileService stores the file under its file name. + +#### Scenario: A shared document arrives with its publication and leaves with it +- **GIVEN** a publication with `files: [{"path": "zuiddrecht/pub-1/01-besluit.pdf", "share": true}]` +- **WHEN** the set is installed and then removed +- **THEN** the publication SHALL have the shared file `01-besluit.pdf`, and after remove neither the file nor the publication SHALL exist + +#### Scenario: A path out of the folder is refused +- **GIVEN** a file path `../openregister/secret.pdf` +- **WHEN** the set is gathered +- **THEN** it SHALL be refused naming the fragment file + +### Requirement: A fragment may declare service accounts (REQ-EXS-014) + +A fragment MAY declare `serviceAccounts`, each with a `key`, a `uid`, a `displayName` and `groups`. The loader SHALL create each as an enabled Nextcloud account with a long random password that is never shown, logged or recorded, and SHALL add it to its groups, creating a group that is not there. A service account SHALL follow the account rules of REQ-EXS-003: an existing account the set did not create refuses the set unless adopted. A uid that is also a cast member's SHALL refuse the set. `{{account:.uid}}` SHALL fill its uid anywhere in the set. + +#### Scenario: A background account is made and joins its group +- **GIVEN** a service account `background` with uid `dossiq_background` in group `dossiq-background-service` +- **WHEN** the set is installed +- **THEN** the account SHALL exist, be in that group, and no password SHALL appear in the output or the record + +### Requirement: A fragment may set its own app's config and undo exactly that (REQ-EXS-015) + +A fragment MAY declare `appConfig`, each entry a `key` and a `value` (placeholders allowed) or `generate: true`. An entry SHALL only set config of the app that ships the fragment; naming another app SHALL refuse the set. A key the app keeps as sensitive SHALL be refused unless the set generates the value; a generated value SHALL be stored as sensitive and SHALL never be printed or recorded. Install SHALL write only a value that differs and SHALL record the old value. Remove SHALL put the old value back, or delete the key when there was none, but SHALL leave a key whose value changed after the install, naming it. Output SHALL name keys, never values. + +#### Scenario: The background account is configured and the old value comes back +- **GIVEN** dossiq's `woo_publication_catalog_slug` holds `publication` +- **WHEN** the set writes `zuiddrecht` there and is then removed +- **THEN** the key SHALL hold `publication` again + +#### Scenario: A sensitive key is not written from a file +- **GIVEN** a key the app keeps as sensitive +- **WHEN** a fragment gives it a value +- **THEN** the check SHALL refuse it, and nothing SHALL be written + +### Requirement: A set finds OpenRegister organisations and creates the ones its cast needs (REQ-EXS-016) + +`{{organisation:}}` SHALL fill the uuid of an OpenRegister organisation (the tenant entity, not an object), a slug winning over a name. The cast MAY declare `openregisterOrganisations` (key, name, slug, description). Before the check the loader SHALL create each one whose slug is not on the instance and record it; remove SHALL delete only the organisations the set created, last. + +#### Scenario: The municipality becomes an organisation once +- **GIVEN** the cast declares organisation `zuiddrecht` and the instance has none with that slug +- **WHEN** the set is installed twice and removed +- **THEN** it SHALL be created once, fill `{{organisation:zuiddrecht}}`, and be gone after remove + +### Requirement: The instance's address fills a placeholder (REQ-EXS-017) + +`{{instance:baseUrl}}` SHALL fill Nextcloud's `overwrite.cli.url` without a trailing slash. When the instance sets no address the placeholder SHALL be refused, never filled with nothing. + +#### Scenario: A publication link points at this instance +- **GIVEN** `overwrite.cli.url` is `https://cloud.example.test/` +- **WHEN** a value is `{{instance:baseUrl}}/apps/x` +- **THEN** it SHALL be written as `https://cloud.example.test/apps/x` + +### Requirement: A change may set a link on an object written earlier in the run (REQ-EXS-018) + +A change MAY name `object: ` of an object declared earlier in the same fragment instead of a `where`, so a later step can set a link once its target exists, such as a case's `result`. It SHALL be recorded like any change (REQ-EXS-011), and remove SHALL stay exact. A key that names no earlier object SHALL refuse the set. + +#### Scenario: A case gets its result +- **GIVEN** a case and then a result for it in one fragment, and a change on the case setting `result` to `{{object:result-1.id}}` +- **WHEN** the set is installed +- **THEN** the case SHALL point at the result, and remove SHALL leave neither + +### Requirement: A date may be pinned to a weekday (REQ-EXS-019) + +`{{date:@}}` and `{{datetime:@ HH:MM}}` SHALL fill the first given weekday on or after the day n days from the install day, so a council meeting stays on a Thursday. + +#### Scenario: Next week's council meeting +- **GIVEN** an install on Tuesday 2026-10-13 +- **WHEN** a value is `{{date:+7@thu}}` +- **THEN** it SHALL be `2026-10-22` + +### Requirement: Every cast member has a stable contactsUid (REQ-EXS-020) + +Every cast person and organisation that declares no `contactsUid` SHALL get `example-set--`, the same on every install, so an app that keeps a Contacts uid can quote `{{cast:.contactsUid}}`. The loader SHALL NOT create Contacts cards. + +#### Scenario: Henk's uid +- **WHEN** set `zuiddrecht` is gathered +- **THEN** `{{cast:henk.contactsUid}}` SHALL be `example-set-zuiddrecht-henk` + +### Requirement: A register or schema is named by slug and written as its id (REQ-EXS-021) + +`{{register:}}` SHALL fill the numeric database id of the register with that slug, and `{{schema:/}}` the numeric id of the schema with that slug inside that register, never one from another register. As a whole value either SHALL keep its integer type; inside a longer text it SHALL be written as digits. A slug the instance does not have SHALL refuse the set, naming the placeholder. The ids SHALL be looked up after the registers the set requires are imported (REQ-EXS-009), so a check finds them too. A fragment SHALL use these placeholders wherever the register stores an id, so the read-back proof compares an id with an id. + +#### Scenario: A catalog lists its register and schema by id +- **GIVEN** register `publication` with id 31 holding schema `publication` with id 1113, and a catalog with `registers: ["{{register:publication}}"]` and `schemas: ["{{schema:publication/publication}}"]` +- **WHEN** the set is installed +- **THEN** the catalog SHALL be written with `registers: [31]` and `schemas: [1113]`, and the proof SHALL report nothing lost + +#### Scenario: An unknown register refuses the set +- **WHEN** a value is `{{register:nope}}` and the instance has no register `nope` +- **THEN** the check SHALL refuse the object, naming `{{register:nope}}`, and SHALL write nothing + +#### Scenario: A register the set imports has its id in the check +- **GIVEN** a set that requires register `brp`, which is not on the instance, and a value `{{register:brp}}` +- **WHEN** the check runs +- **THEN** it SHALL import `brp` and fill its id, and refuse nothing + +### Requirement: Remove deletes what the set wrote even in an archival schema and says why anything stays (REQ-EXS-022) + +Removing an example set is an administrative act on data the set itself wrote, not a user-driven delete. The remover SHALL delete an object listed in the set's install record through OpenRegister's system delete path, the one the retention sweep uses, so a schema that declares `x-openregister-archival` does not refuse it. It SHALL use that path only for objects in the install record. As on the retention sweep, an object under an active legal hold SHALL NOT be deleted. A file the set attached SHALL be found by its id in OpenRegister's own account and deleted as that file, so remove works from `occ` without a session. Every object and file that stays SHALL be named in the output with the reason OpenRegister gave, and SHALL stay in the record so a later remove tries again. An object whose file stays SHALL stay too. + +#### Scenario: An archival case the set wrote is removed +- **GIVEN** a set that wrote a dossiq `case`, whose schema declares `x-openregister-archival` +- **WHEN** the set is removed +- **THEN** the case SHALL be deleted, and a user's delete of a case SHALL still be refused with `SCHEMA_ARCHIVAL_IMMUTABLE` + +#### Scenario: A held object stays and says so +- **GIVEN** an object of the set under an active legal hold +- **WHEN** the set is removed +- **THEN** the object SHALL stay, the output SHALL name it with `it is under an active legal hold`, and it SHALL stay in the record + +#### Scenario: Attached files are removed from occ +- **GIVEN** a publication of the set with an attached file +- **WHEN** `occ openregister:example-set:remove` runs +- **THEN** the file and then the publication SHALL be deleted + +#### Scenario: A file that stays is named with its reason +- **GIVEN** an attached file that OpenRegister refuses to delete +- **WHEN** the set is removed +- **THEN** the output SHALL name the file, its object and the reason, the object SHALL stay because of it, and both SHALL stay in the record for the next remove + + +### Requirement: A kept object follows the objects it references (REQ-EXS-023) + +When an install keeps an object an earlier run wrote, it SHALL resolve again every top-level field whose declared value holds an `{{object:}}` or `{{lookup:}}` placeholder and SHALL write only a field whose stored value differs, so a reinstall after a partial remove does not leave a kept object pointing at an object that was deleted and written again. The write SHALL be recorded as the change `/#references` with the value it replaced (the first replaced value is kept over later refreshes), the install SHALL prove it like any change (REQ-EXS-011), and remove SHALL stay exact. A kept object whose references already hold their values SHALL not be written. + +#### Scenario: A kept publication follows its theme +- **GIVEN** a set whose publication references a theme by `{{object:theme-1.id}}` and another by a lookup, and a remove that deleted the theme but could not delete the publications +- **WHEN** the set is installed again +- **THEN** the theme SHALL be written with a new id, the publications SHALL be kept and point at the new theme, and the refresh SHALL be recorded with the old id + +#### Scenario: A refreshed set installs again without writes +- **GIVEN** a set whose references were refreshed +- **WHEN** it is installed again and then removed +- **THEN** the second install SHALL write nothing, and remove SHALL leave none of the set's objects and no record + +### Requirement: A fragment may set its own app's preferences for the set's own accounts (REQ-EXS-024) + +A fragment MAY declare `userPreferences`, each entry a `user` (`{{cast:.uid}}` or `{{account:.uid}}`), a `key` and a scalar `value` (placeholders allowed), written with `IConfig::setUserValue`. An entry SHALL only set preferences of the app that ships the fragment; naming another app SHALL refuse the set. The account SHALL be one the set creates or adopts, SHALL NOT be in Nextcloud's administrators group, and a key whose name holds a secret (password, secret, token, credential, private, api key, session) or that Nextcloud keeps as sensitive SHALL be refused; each refusal is made by the check, before anything is written. Install SHALL write only a value that differs and SHALL record the old value. Remove SHALL put the old value back, or delete the key when there was none, but SHALL leave a value that changed after the install, naming it, and SHALL do so before it deletes the accounts it created. + +#### Scenario: The demo staff skip the tour +- **GIVEN** dossiq's fragment gives `{{cast:esra.uid}}` the preference `pref_walkthrough_completed_version` = `1.2.0` +- **WHEN** the set is installed twice and then removed +- **THEN** Esra SHALL hold `1.2.0` after the first install, the second install SHALL write nothing, and after remove the key SHALL be gone + +#### Scenario: An adopted account gets its own value back +- **GIVEN** an adopted account that held `0.9.0` +- **WHEN** the set writes `1.2.0` and is then removed +- **THEN** the account SHALL hold `0.9.0` again, unless somebody changed the value after the install, which SHALL stay and be named + +#### Scenario: An administrator, a foreign account and a secret are refused +- **GIVEN** a preference for an account the set does not declare, one for an adopted administrator, and one for a key named `api_token` +- **WHEN** the set is checked +- **THEN** each SHALL be refused with its reason, and nothing SHALL be written diff --git a/openspec/changes/example-sets/tasks.md b/openspec/changes/example-sets/tasks.md new file mode 100644 index 0000000000..77642ba7ef --- /dev/null +++ b/openspec/changes/example-sets/tasks.md @@ -0,0 +1,57 @@ +## 1. Loader + +- [x] 1.1 `ExampleSetCatalogue`: read `lib/Settings/example-sets/.json` from every enabled app, require the cast in openregister's file only, list sets (REQ-EXS-001, REQ-EXS-008) +- [x] 1.2 `ExampleSetOrder`: order fragments by `after`, skip and name absent apps, refuse a cycle naming its apps (REQ-EXS-001) +- [x] 1.3 `ExampleSetValues`: `{{cast:}}`, `{{date:}}`, `{{datetime:}}`, `{{object:}}` (same fragment), `{{lookup:}}`; whole-value placeholders keep their type; unresolved is an error naming file, key and placeholder (REQ-EXS-002) +- [x] 1.4 `ExampleSetAccounts`: create cast accounts and groups, generated password returned once or `OC_PASS`, refuse a foreign account unless adopted (REQ-EXS-003) +- [x] 1.5 `ExampleSetStore`: lazy adapter over ObjectService and ValidateObject; validate against the real schema regardless of hard validation (REQ-EXS-004) +- [x] 1.6 `ExampleSetRecord`: install record in app config (REQ-EXS-005) +- [x] 1.7 `ExampleSetInstaller`: check pass, write pass with record after each step, read-back proof (REQ-EXS-004, REQ-EXS-005, REQ-EXS-006) +- [x] 1.8 `ExampleSetRemover`: reverse-order exact removal, adopted accounts and pre-existing groups untouched (REQ-EXS-007) +- [x] 1.9 Commands `openregister:example-set:install|remove|list`, registered in `appinfo/info.xml`, exit codes 0/1/2 + +- [x] 1.10 `ExampleSetRegisters`: import a required OpenRegister register before the check, record its import jobs, remove exactly those (REQ-EXS-009) +- [x] 1.11 Refuse a cast or fragment group `admin` (REQ-EXS-010) +- [x] 1.12 `ExampleSetChanges`: set and append on an object found by business key, recorded and reverted exactly (REQ-EXS-011) +- [x] 1.13 `jsonFields` sent as JSON text and decoded by the proof (REQ-EXS-012) +- [x] 1.14 `ExampleSetFiles`: files on an object from the fragment's folder, shared, recorded, removed (REQ-EXS-013) +- [x] 1.15 Service accounts with a password nobody sees, in their groups (REQ-EXS-014) +- [x] 1.16 `ExampleSetAppConfig`: own-app config, sensitive keys refused unless generated, exact restore (REQ-EXS-015) +- [x] 1.17 `ExampleSetInstance`: `{{organisation:}}`, cast organisations created and removed; `{{instance:baseUrl}}` (REQ-EXS-016, REQ-EXS-017) +- [x] 1.18 A change on an object written earlier in the run (REQ-EXS-018) +- [x] 1.19 Weekday-pinned dates (REQ-EXS-019) and a stable `contactsUid` per cast member (REQ-EXS-020) + +- [x] 1.20 A change carries the `jsonFields` of the object it changes, reads them as structure and writes, validates, proves and reverts them as JSON text (REQ-EXS-012, found by the first live check) +- [x] 1.21 The check lets a `user` format accept the set's own cast and service account uids (scoped to the set's validation), and still refuses any other unknown uid (REQ-EXS-004, found by the first live check) +- [x] 1.22 `{{register:}}` and `{{schema:/}}` fill numeric ids, looked up after required registers are imported (REQ-EXS-021, found by the first live install) +- [x] 1.23 Remove deletes the set's own archival objects through the system path (never a held one), deletes attached files by their node, and names the reason for everything that stays (REQ-EXS-022, found by the first live remove) +- [x] 1.24 A kept object resolves its `{{object:}}` and `{{lookup:}}` fields again and writes only what differs, recorded as the change `/#references` (REQ-EXS-023, found after a partial remove on :8080) +- [x] 1.25 `userPreferences`: own app only, only for accounts the set created or adopted, never an administrator or a secret, old value recorded and restored while it is still the set's (REQ-EXS-024, asked by dossiq #3896 for the demo staff's tour state) + +## 2. Data + +- [x] 2.1 `lib/Settings/example-sets/zuiddrecht.json`: cast (residents, businesses, employees, department groups) and openregister's own BRP and KVK rows +- [x] 2.2 Test BSNs pass the eleven test and are unused in the fleet's registers; KVK numbers fictitious +- [x] 2.3 `requires: ["brp", "kvk"]`, the 18 apps that take part, Marleen Postma in Vergunningen +- [x] 2.4 The municipality as OpenRegister organisation `zuiddrecht` + +## 3. Tests + +- [x] 3.1 Placeholder resolution, typed whole values, unresolved placeholder names file, key and placeholder +- [x] 3.2 Ordering, absent apps, cycle refusal +- [x] 3.3 Idempotent second install, exact removal, foreign account refusal and adoption +- [x] 3.4 Command exit codes 0, 1 and 2, asserted through the command (the caller) +- [x] 3.5 The shipped zuiddrecht file parses, its cast BSNs pass the eleven test, and every `{{cast:}}` it uses resolves +- [x] 3.7 Files, service accounts, app config, organisations, instance address, changes on earlier objects, weekday dates, contactsUid (`ExampleSetFragmentPartsTest`, `ExampleSetValuesTest`) +- [x] 3.8 Live-check regressions through the installer (`ExampleSetLiveCheckTest`), the store's hand-over to the validator (`ExampleSetStoreTest`) and the real validator (`ValidateObjectPlannedUsersTest`) +- [x] 3.9 Register and schema ids (`ExampleSetRegisterIdsTest`, `ExampleSetStoreTest`) +- [x] 3.10 Remove from occ: files by node, archival and held objects, reasons in the output (`ExampleSetRemoveLiveTest`, `ExampleSetStoreTest`) +- [x] 3.11 Partial remove then reinstall: kept publications follow the theme written again, a further install writes nothing, remove is whole (`ExampleSetReferencesTest`) +- [x] 3.12 User preferences written, kept, restored, left when changed, and refused for a foreign account, an administrator and a secret (`ExampleSetUserPreferencesTest`) +- [x] 3.13 A required register shipped only as a mock is declared `{"register": "", "mock": true}` and imported from the mock; a plain slug never falls back to it; the shipped zuiddrecht requirements import on a fresh instance (`ExampleSetMockRegisterImportTest`) +- [x] 3.6 Required registers imported once and removed only when imported; admin refused; changes made, proved and reverted exactly; JSON text fields + +## 4. Verification + +- [ ] 4.1 `composer check:strict` once before push +- [ ] 4.2 Live install, check, second install and remove on a dev instance (not done in this change; see the PR body) diff --git a/openspec/changes/extends-form-form-roles/design.md b/openspec/changes/extends-form-form-roles/design.md new file mode 100644 index 0000000000..46dbd4e492 --- /dev/null +++ b/openspec/changes/extends-form-form-roles/design.md @@ -0,0 +1,26 @@ +# Design: extends-form-form-roles + +`ExtendingFormDeclaration::FORM_ROLES` maps each form role to a sentence that +says what it steers. `validate()` accepts a role that is a vocabulary key, a +source alias or a form role, and refuses anything else as before. +`forwards()` already skips a role the vocabulary does not hold, so a form role +is not counted. `toProperty()` skips form roles explicitly, so a submitted +`hideOnCreate` value never becomes a property key the property validator would +then refuse. + +`GET /api/schemas/extending-forms` adds `formRoles` and `sourceAliases`. They +are static and per platform, not per schema, so they sit beside +`vocabularyKeys`. + +The list is taken from the consumer: `DEFAULT_MAP` in +`src/utils/dynamicProperties.js` of `@conduction/nextcloud-vue` 2.79.0 reads +`title`, `description`, `type`, `enum`, `required`, `default` and +`hideOnCreate`, and `mapped()` is also called for `definition`, `format` and +`maxLength`. All of those but `definition` and `hideOnCreate` are vocabulary +keys; `definition` is the existing alias, `hideOnCreate` is the new form role. +A unit test pins that every role the consumer reads is accepted. + +`PropertyValidatorHandler::CLOSED_MODIFIER_VALUES` is the one list: the save +path's `validateSearchProfile()` iterates it, and `PropertyVocabulary::modifiers()` +copies it onto the matching rows as `allowedValues`. A unit test asks the +validator that every published value saves and that `textarea` is refused. diff --git a/openspec/changes/extends-form-form-roles/proposal.md b/openspec/changes/extends-form-form-roles/proposal.md new file mode 100644 index 0000000000..700190beda --- /dev/null +++ b/openspec/changes/extends-form-form-roles/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +depends_on: [property-vocabulary-published] +--- + +# Proposal: extends-form-form-roles + +## Summary + +`x-openregister-extends-form` refuses every map role that is not a property +vocabulary key. `@conduction/nextcloud-vue` 2.79.0 added a role to its +`DEFAULT_MAP` that is not a property key: `hideOnCreate`, which tells the +create form to leave a field out because the app fills it in itself. dossiq +declared it (dossiq #3716), and every register import since refuses the +whole `case` schema with `Invalid x-openregister-extends-form declaration: +'hideOnCreate'`. An upgraded instance keeps its old case schema and logs +`PARTIAL IMPORT`. + +## Why + +The validator exists so a form cannot claim to forward a key nobody defines. +`hideOnCreate` forwards nothing: it steers the form. Refusing it by name is +the same breakage the `definition` alias was added to prevent, one release +later. + +## What changes + +- A second list beside `SOURCE_ALIASES`: form roles, the roles the shipped + consumer reads to steer the form rather than to fill a property key. Today + that is `hideOnCreate`. +- A declaration mapping a form role is accepted. A form role forwards no key, + is not counted as forwarded and never lands in a property built from a form + submission. +- The extending-forms read publishes the form roles and the source aliases, so + an app can check its declaration against what this layer accepts. +- The property vocabulary publishes `allowedValues` for the modifiers whose + value the save path checks against a closed list (`inputControl`, + `matchType`). The same import refused dossiq's `aanvullingsverzoek` for + `inputControl: textarea`; `textarea` is a form widget, not a list filter + control, so dossiq drops it. Publishing the list lets dossiq pin its shipped + schemas against it at build time. + +## Out of scope + +Any other role. A role is added here when the shipped consumer reads it, not +before. diff --git a/openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md b/openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md new file mode 100644 index 0000000000..875ea5db30 --- /dev/null +++ b/openspec/changes/extends-form-form-roles/specs/runtime-schema-api/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: A form role the consumer reads is accepted and forwards nothing (REQ-PVP-004) + +An `x-openregister-extends-form` declaration SHALL accept a map role that the +shipped consumer reads to steer the form rather than to fill a property key. +Such a form role SHALL NOT count as a forwarded vocabulary key and SHALL NOT +become a key of a property built from a form submission. The accepted form +roles SHALL be published on the extending-forms read. Today the one form role +is `hideOnCreate`. + +#### Scenario: a declaration that maps hideOnCreate is accepted + +- **GIVEN** an app whose form declaration maps `hideOnCreate` to its own field +- **WHEN** a schema carrying that declaration is imported +- **THEN** the schema is saved and the declaration reports no errors +- @e2e exclude {validator, covered by unit tests and the dossiq register import} + +#### Scenario: a form role forwards no key + +- **GIVEN** a declaration mapping `hideOnCreate` +- **WHEN** its forwarded keys are read and a form submission is turned into a property +- **THEN** `hideOnCreate` is not among the forwarded keys and not on the property +- @e2e exclude {pure function, covered by unit tests} + +#### Scenario: a role nobody defines is still refused + +- **GIVEN** a declaration mapping a role that is neither a vocabulary key, a source alias nor a form role +- **WHEN** the declaration is validated +- **THEN** it is refused naming the role +- @e2e exclude {validator, covered by unit tests} + +### Requirement: A closed-list modifier publishes its accepted values (REQ-PVP-005) + +A modifier whose value the save path checks against a closed list SHALL +publish that list as `allowedValues` on its row of the property vocabulary. +The published list SHALL be the list the save path enforces, so an app can +check its shipped schemas before an import refuses them. Today that is +`inputControl` and `matchType`. + +#### Scenario: inputControl publishes the controls the save path accepts + +- **GIVEN** the published property vocabulary +- **WHEN** the `inputControl` modifier row is read +- **THEN** it carries `allowedValues`, every listed value saves, and `textarea` is not listed and is refused +- @e2e exclude {published read, covered by unit tests and the dossiq vocabulary guard} diff --git a/openspec/changes/extends-form-form-roles/tasks.md b/openspec/changes/extends-form-form-roles/tasks.md new file mode 100644 index 0000000000..b1901f2e38 --- /dev/null +++ b/openspec/changes/extends-form-form-roles/tasks.md @@ -0,0 +1,7 @@ +# Tasks: extends-form-form-roles + +- [x] 1. Add `FORM_ROLES` with `hideOnCreate` to `ExtendingFormDeclaration` and accept it in `validate()`. +- [x] 2. Keep form roles out of `forwards()` and `toProperty()`. +- [x] 3. Publish `formRoles` and `sourceAliases` on the extending-forms read. +- [x] 4. Unit tests: the role is accepted, forwards nothing, never reaches a property, and every role the 2.79.0 consumer reads is accepted. +- [x] 5. Publish `allowedValues` for `inputControl` and `matchType` from the list the save path enforces, with a test that asks the validator. diff --git a/openspec/changes/flow-run-summary/proposal.md b/openspec/changes/flow-run-summary/proposal.md new file mode 100644 index 0000000000..093edf96ee --- /dev/null +++ b/openspec/changes/flow-run-summary/proposal.md @@ -0,0 +1,31 @@ +--- +kind: code +--- + +# Proposal: flow-run-summary + +## Summary + +A flow list shows, per flow, how its last run ended and how many runs it had +this week. Today nothing answers that without paging through every run. This +change adds `GET /api/flow-runs/summary?flowIds=a,b&since=`, which +returns per flow the newest visible run (uuid, status, error, created, updated) +and the number of visible runs created since the moment, and lets the run +history read narrow to several flows (`flowIds`) and a start moment (`since`) +so a "runs this week" page can list exactly the runs the summary counted. + +## Why + +The shared flows index in `@conduction/nextcloud-vue` (board PqFlows, used by +every app's Flows page) draws "Laatste uitvoering" and "Uitvoeringen deze +week" columns, a "Met fouten" filter and a "Uitvoeringen deze week" tab. The +run history read returns at most 200 rows with no time filter, so a client +deriving those numbers either undercounts or loads every run log. + +## Scope + +- `FlowRunMapper::summarizeByFlow()`, `countSinceByFlow()`, + `latestRunsByFlow()`; `findAllRuns()` gains `since` and `flowIds`. +- `FlowRunController::summary()` and the `since`/`flowIds` parameters on + `index()`, scoped exactly like `index()`. +- Route `flowRun#summary`, declared above the `{uuid}` route. diff --git a/openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md b/openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md new file mode 100644 index 0000000000..5e3720a4a2 --- /dev/null +++ b/openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md @@ -0,0 +1,43 @@ +## Purpose + +Per-flow run figures for a flow list, read in the datastore under the same +visibility rule as the run history. + +## ADDED Requirements + +### Requirement: The summary returns the newest run and a count per requested flow + +`GET /api/flow-runs/summary` SHALL accept `flowIds` (comma separated or an +array, at most 200 honoured) and an optional `since` (ISO 8601, default seven +days ago). It SHALL return `{results: {flowId: {lastRun, runsSince}}, since}` +with an entry for EVERY requested flow, `lastRun: null` and `runsSince: 0` +for a flow without visible runs. `lastRun` SHALL carry only `uuid`, `status`, +`error`, `created` and `updated`, never the log or marking. + +#### Scenario: a flow that never ran is still answered +- GIVEN flows A and B, where only A has runs +- WHEN the summary is read for A and B +- THEN B is present with `lastRun: null` and `runsSince: 0` + +### Requirement: The summary is scoped like the run history + +For a non-admin caller the summary SHALL count and return only runs the caller +triggered or runs of flows the caller owns, the same predicate the run history +applies. A caller without a session SHALL receive an empty result and no query +SHALL be issued. + +#### Scenario: no session reads nothing +- GIVEN a request without a user session +- WHEN the summary is read +- THEN the results are empty and the mapper is not called + +### Requirement: The run history narrows to several flows and a start moment + +`GET /api/flow-runs` SHALL accept `flowIds` (any of these flows) and `since` +(created at or after). An unreadable `since` SHALL be refused with 400 on both +reads rather than ignored. + +#### Scenario: an unreadable since is refused +- GIVEN `since=not-a-date` +- WHEN the summary or the history is read +- THEN the response is 400 naming the parameter diff --git a/openspec/changes/flow-run-summary/tasks.md b/openspec/changes/flow-run-summary/tasks.md new file mode 100644 index 0000000000..0a1198414e --- /dev/null +++ b/openspec/changes/flow-run-summary/tasks.md @@ -0,0 +1,7 @@ +# Tasks: flow-run-summary + +- [x] Mapper: `summarizeByFlow`, `countSinceByFlow`, `latestRunsByFlow`; `findAllRuns` takes `since` and `flowIds` +- [x] Controller: `summary()`, `since`/`flowIds` on `index()`, 400 on an unreadable `since` +- [x] Route `flowRun#summary` above `{uuid}` +- [x] Unit tests for the controller and the mapper's assembly +- [ ] nc-vue: the flows index source reads the summary (separate PR in nextcloud-vue) diff --git a/openspec/changes/form-destination-validator/.openspec.yaml b/openspec/changes/form-destination-validator/.openspec.yaml new file mode 100644 index 0000000000..53f62d7060 --- /dev/null +++ b/openspec/changes/form-destination-validator/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/form-destination-validator/proposal.md b/openspec/changes/form-destination-validator/proposal.md new file mode 100644 index 0000000000..c9d96ffe89 --- /dev/null +++ b/openspec/changes/form-destination-validator/proposal.md @@ -0,0 +1,33 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: form-destination-validator + +OpenRegister's half of decision 179 (Ruben, 10 October 2026): "We dont intake to an intake, we intake into a case, or ticket or something else." Cross-app change: `hydra/openspec/changes/form-submits-into-its-destination-object`, architecture in hydra ADR-117. Ruben's answers: decisions 180 (drafts) and 181. Every other app change in that chain waits on this one. + +## Why + +A form today is checked against its own field list. The destination schema judges the data later, in a background job, with nobody left to tell. OpenRegister already refuses a bad field at save time for flow task forms (`TaskFormReader::validate`), but nothing does the same for a form that creates an object. And `ObjectsController::create` validates only when a schema has hard validation on, then answers a bare 400 string, while update answers per-property errors. + +## What changes + +- **`FormDestinationValidator`**: judges a form's field-to-property mapping against a destination schema and returns findings (`required-unmapped`, `property-unknown`, `type-mismatch`, `format-mismatch`, `enum-unconstrained`, `enum-value-unknown`, `constraint-looser`, `fixed-value-invalid`, `destination-not-public`, `destination-is-staging`). Built by extending `TaskFormReader::validate`, not beside it. +- **`FormSubmitService`** and `POST /api/forms/{formId}/submit`: validates the payload with the destination's full validator whatever its hard-validation flag, creates the object or objects in one request, and returns `reference`, `receivedAt` and every property marked `x-openregister.confirmation: true`. A refusal is 422 with findings. An `Idempotency-Key` repeats the first answer. +- **All or nothing** for several writes: validate all, then write; on a later refusal, delete the earlier writes of that submit and audit it. +- **Upload tokens**: `POST /api/forms/{formId}/uploads` holds file bytes only, expires after 24 hours, purge counted. +- **Schema save checks dependent forms**: a schema change that invalidates a published form is refused or unpublishes the form, per the owning app's setting. +- **Create errors match update errors**: `ObjectsController::create` returns the per-property shape on a validation failure. +- **Three schema markers**: `x-openregister.confirmation` (returned to the submitter), `x-openregister.serverSet` (filled by a listener, so not reported as unmapped) and `x-openregister.reference` (the property returned as `reference`). Two schema configuration keys survive a save: `staging` and `additionalProperties`. +- **Explicit lifecycle status `draft`** (decision 180): object metadata gains a stored `@self.status` with `draft` and `active`. A draft may miss required properties and is never type-invalid. Moving to `active` runs full validation and is the moment of receipt. Objects without the field keep today's date-deduced status. +- **`or-form-and-journey-registry` amended**: `journeyRun` is replaced by draft destination objects; a step commits all or nothing. +- **Refuse from the first release** (decision 181): no report-only mode. + +## Out of scope + +Authoring screens (buildiq), the public host (portaliq), the case fields (dossiq). + +## Rollback + +The submit service, the routes and the `draft` status are additive; callers switch to them in their own changes. The validator refuses from the first release (decision 181, Q9), so rolling back the check means reverting this change. The create error body changes from a bare string to the update shape (same status). diff --git a/openspec/changes/form-destination-validator/screens.md b/openspec/changes/form-destination-validator/screens.md new file mode 100644 index 0000000000..4e5bba93b2 --- /dev/null +++ b/openspec/changes/form-destination-validator/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: a validator, a submit service and two schema markers inside OpenRegister; the findings appear on buildiq's designer (BqPaginaOntwerperFouten) and on portaliq's forms, which carry their own boards. diff --git a/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md b/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md new file mode 100644 index 0000000000..641ede2505 --- /dev/null +++ b/openspec/changes/form-destination-validator/specs/form-and-journey-registry/spec.md @@ -0,0 +1,22 @@ +# form-and-journey-registry Delta: form-destination-validator + +**Status**: draft +**Scope**: replaces the staging clauses of "The run API MUST stage answers and commit only at declared steps" in the open change `or-form-and-journey-registry`, per hydra ADR-117. Task 5.1 edits that change to match. + +## ADDED Requirements + +### Requirement: A saved journey MUST be its draft objects and MUST commit a step all or none + +There SHALL be no `journeyRun` object holding answers. A saved journey SHALL be its `writes[]` destination objects in status `draft` (decision 180). Objects SHALL leave `draft`, or be created, only when a step declaring `writes[]` is submitted, through `FormSubmitService`, all or none, with a later entry able to reference an earlier entry's id. + +#### Scenario: Advancing without a writes step activates nothing + +- **GIVEN** a journey advanced past two steps and saved, neither step declaring `writes[]` as submitted +- **WHEN** the target registers are queried +- **THEN** any object for this journey is in status `draft`, and no `journeyRun` object exists + +#### Scenario: A partial failure leaves nothing behind + +- **GIVEN** a step whose second write is refused after the first succeeded +- **WHEN** the step is submitted +- **THEN** the first object is deleted before the response, and the filer sees the second write's findings diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md new file mode 100644 index 0000000000..af1e91a99f --- /dev/null +++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md @@ -0,0 +1,188 @@ +# form-destination Delta: form-destination-validator + +**Status**: draft +**Scope**: OpenRegister validator, submit service and route, upload tokens, schema-save check. Canonical requirements: `hydra/openspec/changes/form-submits-into-its-destination-object/specs/form-destination/spec.md`. + +## ADDED Requirements + +### Requirement: OpenRegister MUST judge a form's mapping against its destination schema + +`FormDestinationValidator::validate(mapping, schema, options)` SHALL return one finding per problem, each `{ field?, property, code, message }`. The mapping is `{ fields: [ { field, property, type?, format?, options?, maxLength?, minLength?, maximum?, minimum?, pattern? } ], fixed: { property: value } }`. It SHALL report a required property (schema `required` list or property `required: true`) with no field, no fixed value, no `default`, no `computed` and no `x-openregister.serverSet` marker as `required-unmapped`; a field or fixed value into a property the schema lacks as `property-unknown`, unless the schema's configuration sets `additionalProperties: true` (OpenRegister schemas carry no top-level `additionalProperties`, so "forbids extras" would never hold); a field into a read-only property as `property-read-only`; a type or format that cannot produce the property's as `type-mismatch` or `format-mismatch`; a free-text field into an enum as `enum-unconstrained`; a choice option outside the enum as `enum-value-unknown`; a field bound looser than the property's (or missing where the property sets one) as `constraint-looser`; a fixed value the property refuses as `fixed-value-invalid`; a form for a `public` or `authenticated` audience into a schema whose authorization does not grant that audience create as `destination-not-public`; and a schema whose configuration sets `staging: true` as `destination-is-staging`. It SHALL extend `TaskFormReader::validate` so flow task forms and object forms are judged by one service. `POST /api/forms/validate` SHALL return `{ accepted, findings }` for an author; a mapping with findings is not accepted, from the first release (Q9, Ruben, 10 October 2026: no report-only release). + +#### Scenario: A required property with no source is reported + +- **GIVEN** schema `case` requiring `title` and `caseType` +- **AND** a mapping covering `title` only +- **WHEN** the validator runs +- **THEN** it returns one finding, property `caseType`, code `required-unmapped` + +#### Scenario: A property a listener fills is not reported + +- **GIVEN** schema `case` requiring `identifier`, marked `x-openregister.serverSet: true` +- **WHEN** a mapping without `identifier` is validated +- **THEN** no finding names `identifier` + +#### Scenario: A flow task form uses the same service + +- **GIVEN** a user-task step whose form names a read-only property +- **WHEN** the step is saved +- **THEN** the refusal comes from `FormDestinationValidator`, and `TaskFormReader` delegates to it + +### Requirement: A submit MUST create the destination in one request and return its reference + +`FormSubmitService::submit(destination, mapping, payload, subject, idempotencyKey, scope)` and `submitAll(writes, payload, subject, idempotencyKey, scope)` SHALL map the payload through the mapping (only mapped fields and fixed values reach the object; without a mapping the payload minus `_` and `@` keys is the object), validate it with the destination schema's full validator regardless of the schema's hard-validation flag (properties the server fills are excused from `required`), then create the object under the subject's RBAC; an anonymous submit is saved unowned. It SHALL return `reference` (the property marked `x-openregister.reference: true`, else the uuid), `id`, `receivedAt` (the object's created moment), a `confirmation` map of every property marked `x-openregister.confirmation: true`, read after every create listener ran, and `objects[]` listing each write. A refused payload SHALL answer 422 `{ message, findings }`; a subject not allowed to create 403; an unknown destination 404; an unreachable destination 503 "try again later" with nothing created (Q3). `POST /api/forms/{formId}/submit` SHALL wrap it for forms stored in OpenRegister, with `#[PublicPage]`, `#[AnonRateLimit]`, `#[UserRateLimit]` and `#[BruteForceProtection]` per ADR-082; an unknown, unpublished or destination-less form SHALL answer one identical 404 and register a brute-force attempt; a filled honeypot field `_hp` SHALL answer 202 and store nothing; a form whose `audience` is not `public` SHALL answer 401 to an anonymous visitor. A form stored in OpenRegister is any object whose body carries `status: published` (or `published: true`), `destination: { register, schema }` or `writes[]`, and optionally `mapping` and `audience`. + +#### Scenario: The response carries the listener-computed fields + +- **GIVEN** a destination whose `termStartsAt` is set by an `ObjectCreatingEvent` listener and marked `x-openregister.confirmation: true` +- **WHEN** a valid payload is submitted +- **THEN** the response is 201 and `confirmation.termStartsAt` holds the listener's value + +#### Scenario: A schema without hard validation is still validated + +- **GIVEN** a destination schema with hard validation off +- **WHEN** a payload missing a required property is submitted +- **THEN** the response is 422 with a `required` finding and nothing is created + +#### Scenario: A repeated idempotency key repeats the answer + +- **GIVEN** a submit with key `k1` that created object 7 +- **WHEN** the same key is submitted again within 24 hours +- **THEN** the response repeats object 7's reference and no object is created + +### Requirement: A submit writing several objects MUST commit all or none + +The service SHALL validate every write before the first. When a write is refused after earlier writes succeeded, it SHALL delete those earlier writes before responding, and SHALL audit both the create and the delete. + +#### Scenario: A refused second write deletes the first + +- **GIVEN** writes `organisation` then `contact`, where `contact` breaks a uniqueness rule +- **WHEN** submitted +- **THEN** the response is 422 naming the `contact` finding, and the organisation no longer exists + +### Requirement: Upload tokens MUST hold bytes only and expire + +`POST /api/forms/{formId}/uploads` SHALL accept one file and the destination `property` it is for, check it against that property's file rules (`maxSize` in bytes or `fileConfiguration.maxSize` in MB, `allowedTypes` or `fileConfiguration.allowedMimeTypes`, the same reader the save path uses), and return `{ token, expiresAt }`. A submit SHALL claim tokens by a payload value `{ "uploadToken": "" }` for the property the token was issued for, and only after its last write succeeded, so a refused submit leaves the files for the retry. Unclaimed tokens SHALL be deleted after 24 hours by an hourly background job that logs its count, a zero included. + +#### Scenario: An oversized file is refused at upload + +- **GIVEN** a destination file rule of 10 MB +- **WHEN** a 12 MB file is uploaded +- **THEN** the response is 422 and no token is issued + +### Requirement: Saving a schema MUST re-check the forms that submit into it + +When a schema's properties or required list are saved, OpenRegister SHALL dispatch `FormDestinationDependentsEvent` with the proposed definition; each owning app adds its published forms into that schema, and the validator SHALL run for each. The owning app's setting `formDestinationBreak` (`unpublish`, the default, or `refuse`) SHALL decide the outcome (Q7): any `refuse` blocks the save with 409 listing the forms and nothing is unpublished; otherwise, after the save, each broken form is unpublished through its owning app, its author is notified (`form_unpublished`), and the save response SHALL list every affected form with its outcome and findings under `affectedForms`. This applies from the first release (Q9). + +#### Scenario: A new required property unpublishes a form that lacks it + +- **GIVEN** a published form into `ticket` without `priority`, and the default setting +- **WHEN** `priority` becomes required +- **THEN** the form is unpublished, the response names it, and its author is notified + +### Requirement: A create refusal MUST use the per-property error shape + +`ObjectsController::create` SHALL answer a validation failure through the same handler update and patch use, so the body is the same `{ status, message, errors: [{ property, message }] }` and the status is the same (400 today; moving all three to 422 is question Q-openregister-F1). + +#### Scenario: Create and update refuse alike + +- **GIVEN** an object payload missing a required property +- **WHEN** it is sent to create and, for an existing object, to update +- **THEN** both answers carry the property in `errors[]` with the same status + +### Requirement: An object MUST be able to carry the explicit lifecycle status `draft` + +Object metadata SHALL carry a stored `@self.status` with at least the values `draft` and `active` (decision 180). An object without the field SHALL keep its status deduced from its dates, as today. Saving an object in `draft` SHALL run every schema rule except `required`. Moving an object from `draft` to `active` SHALL go through `FormSubmitService` or through the owner's activation (decision 216, change `draft-owner-activation-and-lifetime`), SHALL run full validation, and SHALL fire the create-time effects that count as receipt (reference, `receivedAt`, listeners marked as receipt listeners). A plain save SHALL NOT move an object out of `draft`. A form's draft save SHALL create or update its destination object in `draft`. + +#### Scenario: A draft without required data is saved +- **GIVEN** schema `case` requiring `description` +- **WHEN** a form saves a draft with no `description` +- **THEN** the object exists with `@self.status` `draft` and no `receivedAt` + +#### Scenario: A type-invalid draft is refused +- **GIVEN** a property `preferredDate` of format date-time +- **WHEN** a draft is saved with `preferredDate` holding a phone number +- **THEN** the save is refused with a `format-mismatch` finding and nothing is stored + +#### Scenario: Leaving draft runs full validation and stamps receipt +- **GIVEN** a draft that now holds every required property +- **WHEN** it is submitted +- **THEN** `@self.status` becomes `active`, `receivedAt` is set, and the response carries the reference and confirmation fields + +#### Scenario: An object without the field reads as today +- **GIVEN** an object created before this change, with no stored status +- **WHEN** it is read +- **THEN** its status is deduced from its dates exactly as before + +### Requirement: A draft MUST be able to be owned by a scope value + +A draft's owner SHALL be either a Nextcloud user or a scope value `:`, such as `portal:` for a resident signed in at a portal who has no Nextcloud account (decision 195). A scope is lower case letters, digits and hyphens; the whole value SHALL fit the owner column (64 characters). A Nextcloud user id cannot hold a colon, so a scope value never names a user. A draft owned by a scope value SHALL be found, read, saved and sent only by a caller asserting that same owner; every other caller, a signed-in user included, SHALL get the same 404 an unknown draft id gets. The owner assertion SHALL be the authorisation for every write of that draft, its start included: starting it SHALL NOT need the destination schema to allow public create, because the in-process caller vouches for its subject, as it does when it creates the object outright. Only a draft is authorised this way; a fresh submit without a draft SHALL stay under the destination's RBAC. An owner assertion SHALL be reachable in process only: no controller builds one or forwards an explicit owner to a save, so a request cannot authorise its own draft. The draft clause in the read RBAC and the draft lens on lists keep such a draft out of every list, as they keep any draft to its owner. Generic by construction (decision 182): the owner value carries no app or procedure logic. + +#### Scenario: A portal subject saves, reads and sends its own draft +- **GIVEN** a caller asserting owner `portal:subject-1` +- **WHEN** it saves a draft, reads it back by id, finds it by destination, and sends it +- **THEN** the draft is stored with owner `portal:subject-1` and status `draft`, each read answers its data, and the send moves it to `active` + +#### Scenario: Anyone else gets 404 +- **GIVEN** a draft owned by `portal:subject-1` +- **WHEN** a caller asserting `portal:subject-2`, or a signed-in user, reads, saves or sends it by id +- **THEN** each answers 404 and the draft is unchanged + +#### Scenario: A scope-owned draft starts where the public may not create +- **GIVEN** a destination schema that does not let the public create +- **WHEN** an in-process caller asserting owner `portal:subject-1` saves a first draft, and later sends it +- **THEN** the draft is stored with that owner and the send moves it to `active` +- **AND** an anonymous submit without a draft into the same destination still answers 403 + +#### Scenario: A request cannot name the owner +- **WHEN** a draft save or a submit arrives over HTTP with `_owner` or `@self.owner` in its body +- **THEN** the service is handed the session user only, and neither key reaches the payload + +#### Scenario: A malformed scope value is refused +- **WHEN** an owner is built from an empty reference, a scope with upper case or a colon, or a value longer than 64 characters +- **THEN** it is refused before anything is read or written + +### Requirement: A draft MUST be able to be discarded by its owner + +The owner of a draft SHALL be able to discard it (decisions 180 and 195). The discard SHALL admit only an object in status `draft` whose stored owner is exactly the asserted owner; a sent object, someone else's draft, an unknown id and an unknown destination SHALL all answer "not found" and remove nothing. A discarded draft SHALL be deleted permanently: it holds unsent answers and nothing refers to it. Generic by construction (decision 182): the caller names the destination and the owner, OpenRegister knows no app or procedure. + +#### Scenario: The owner discards its draft +- **GIVEN** a draft owned by `portal:subject-1` +- **WHEN** a caller asserting `portal:subject-1` discards it +- **THEN** the object is gone, not soft-deleted + +#### Scenario: Anything else is left alone +- **GIVEN** a draft owned by `portal:subject-2` and an active object owned by `portal:subject-1` +- **WHEN** a caller asserting `portal:subject-1` discards either +- **THEN** both answer "not found" and both objects remain + +### Requirement: A list MUST hide drafts unless asked + +An object list or search SHALL leave out objects in status `draft` by default (decision 201), whoever asks and by whichever path: an administrator and an app's service listing with RBAC off get the same work list. The caller's own drafts SHALL stay in the list. The query parameter `_drafts` SHALL change it, following `_archived`: absent or false is the work list, `_drafts=true` is drafts alone, `_drafts=any` is drafts beside the rest, `_drafts=none` leaves out every draft including the caller's own (decision 216), and an unrecognised value SHALL read as the work list. A query that filters on the stored status (`@self[status]`) without the parameter has named its statuses, so that filter SHALL decide. Counts and facets SHALL follow the same lens as the rows. The lens is a default on what a list shows and SHALL NOT widen what a caller may read: asking for drafts shows the drafts the read RBAC already lets that caller reach. Reading one object by id is not a list and is unchanged. Generic by construction (decision 182). + +#### Scenario: A work list leaves drafts out +- **GIVEN** schema `case` with one active object and one draft owned by somebody else +- **WHEN** an app's service lists `case` with RBAC off, or an administrator lists it +- **THEN** the list, its total and its facets hold the active object only + +#### Scenario: The owner still sees their own drafts +- **GIVEN** a draft owned by the signed-in caller +- **WHEN** the caller lists the schema without `_drafts` +- **THEN** their draft is in the list beside the objects that are not drafts + +#### Scenario: The filter shows them +- **WHEN** the list is asked with `_drafts=true`, with `_drafts=any`, or with `@self[status]=draft` +- **THEN** it answers drafts alone, drafts beside the rest, and the drafts the status filter names, each within what the caller may read + +#### Scenario: A typo does not widen the list +- **WHEN** the list is asked with `_drafts=al` +- **THEN** it answers the work list without drafts + +### Requirement: The form destination check MUST refuse from the first release + +Form save, journey save and schema save SHALL refuse on a finding from the first release that ships the validator. There SHALL be no report-only mode (decision 181). + +#### Scenario: A form with one finding is not saved +- **GIVEN** a form with one `required-unmapped` finding +- **WHEN** it is saved +- **THEN** the save is refused and the finding is returned diff --git a/openspec/changes/form-destination-validator/tasks.md b/openspec/changes/form-destination-validator/tasks.md new file mode 100644 index 0000000000..b68a2209e2 --- /dev/null +++ b/openspec/changes/form-destination-validator/tasks.md @@ -0,0 +1,56 @@ +# Tasks: form-destination-validator + +## 1. Validator + +- [x] 1.1 `lib/Service/Form/FormDestinationValidator.php` with the finding codes; `TaskFormReader::validate` delegates to it + - Spec ref: specs/form-destination/spec.md, "OpenRegister MUST judge a form's mapping against its destination schema" + - Test: `tests/Unit/Service/Form/FormDestinationValidatorTest.php`, one case per code plus a zero-findings control +- [x] 1.2 Read `x-openregister.serverSet` and `x-openregister.confirmation` in the schema property dialect; document both (plus `x-openregister.reference`) in `docs/features/form-destination.md`; the hydra ADR-011 table row is owed in hydra#748 +- [x] 1.3 `POST /api/forms/validate` (authenticated, body: mapping and destination) so buildiq and portaliq can show findings while authoring; refuses from the first release (Q9 answer: no report mode). `FormsController::validate`, `tests/Unit/Controller/FormsControllerTest.php` + +## 2. Submit + +- [x] 2.1 `lib/Service/Form/FormSubmitService.php`: full validation regardless of hard-validation flag, create under the subject's RBAC, confirmation map read after listeners + - Test: unit test with a fake creating listener; a control proving the field is absent when the marker is absent +- [x] 2.2 All-or-none for several writes, with audited compensation +- [x] 2.3 Idempotency store (24 h) keyed by form and key +- [x] 2.4 `POST /api/forms/{formId}/submit` route, `#[PublicPage]`, ADR-082 attributes and `registerAttempt`; routes.php entry +- [x] 2.5 `ObjectsController::create` answers in the update error shape, through the same handler and status update uses (400; 422 for all three is Q-openregister-F1) + +## 2b. Lifecycle status `draft` (decision 180) + +- [x] 2b.1 Stored `@self.status` in object metadata (`draft`, `active`); objects without it keep the date-deduced status + - Spec ref: specs/form-destination/spec.md, "An object MUST be able to carry the explicit lifecycle status `draft`" + - Test: unit tests for each scenario, including the legacy read control +- [x] 2b.2 Draft validation skips `required` only; the draft-to-active move goes through `FormSubmitService` with full validation and receipt effects +- [x] 2b.4 A draft may be owned by a scope value `:` such as `portal:` (decision 195); only a caller asserting that owner finds, reads, saves or sends it, anyone else gets 404 + - Spec ref: specs/form-destination/spec.md, "A draft MUST be able to be owned by a scope value" + - Test: DraftOwnerTest, FormSubmitServiceTest (portal owner cases), SaveObjectSystemOwnerTest (explicit owner) +- [x] 2b.5 The owner may discard its draft; only its own object in `draft` is removed, permanently, anything else is "not found" + - Spec ref: specs/form-destination/spec.md, "A draft MUST be able to be discarded by its owner" + - Test: DraftDiscardTest +- [x] 2b.6 Starting a scope-owned draft is authorised by the owner assertion, not by the destination's public create rule; a fresh submit stays under RBAC; no controller can assert an owner + - Spec ref: specs/form-destination/spec.md, "A draft MUST be able to be owned by a scope value" (scenarios "A scope-owned draft starts where the public may not create", "A request cannot name the owner") + - Test: FormSubmitServiceTest (scope-owned draft and its control), FormsControllerTest (a request cannot name the owner, no controller asserts one) +- [x] 2b.7 Lists and searches hide drafts by default, keep the caller's own in view, and show them with `_drafts=true` / `_drafts=any` or a `@self[status]` filter (decision 201) + - Spec ref: specs/form-destination/spec.md, "A list MUST hide drafts unless asked" + - Test: DraftListLensTest, MagicSearchHandlerDraftLensTest (union path, single-table path, reserved parameter), DraftFinderTest (the finder asks for drafts) +- [ ] 2b.8 Live: on an instance, a draft is absent from `GET /api/objects/{register}/{schema}` for an administrator, present with `_drafts=any`, and its total and facets agree (live pass, decision 139) +- [ ] 2b.3 Answered by Ruben (decision 201), the rest not built here: a draft expires after the owning app's retention (30 days by default); since decision 216 the app declares that as `draftLifetimeDays` on its schema and OpenRegister's one cleanup job enforces it (change `draft-owner-activation-and-lifetime`); a draft gets its case number only when it leaves draft. The list half is 2b.7. Anonymous drafts stay open (Q-openregister-F2) + +## 3. Uploads + +- [x] 3.1 Upload token endpoint and claim in submit +- [x] 3.2 Purge job (`TimedJob`, hourly), logs the count + +## 4. Schema save + +- [x] 4.1 On schema save, re-validate dependent published forms; apply `formDestinationBreak`; list affected forms in the response; notify authors + +## 5. Journey registry + +- [x] 5.1 Apply the MODIFIED requirement to `or-form-and-journey-registry` (`journeyRun` replaced by draft destination objects; commit through `FormSubmitService`); its remaining `journeyRun` text is marked superseded + +## 6. Verification + +- [ ] 6.1 `composer check:strict`, `npm run lint`, `openspec validate form-destination-validator --strict` diff --git a/openspec/changes/graduated-rate-calculation/proposal.md b/openspec/changes/graduated-rate-calculation/proposal.md new file mode 100644 index 0000000000..55a33f61ba --- /dev/null +++ b/openspec/changes/graduated-rate-calculation/proposal.md @@ -0,0 +1,25 @@ +--- +kind: code +--- + +# Proposal: graduated-rate-calculation + +## Why + +ADR-118 (hydra, decisions 182, 184, 185) moves procedures out of app code and into configuration. Capability C16 (money calculation and payment) needs an amount that accrues over time on a rate that steps up, stops at a cap, and stays current without app code. dossiq computes its Awb 4:17 penalty in a hand-written daily loop today (`DwangsomCalculationService`). The same shape is tiered pricing, a tax bracket table, or a late fee that rises per week. + +OpenRegister's calculation engine already has the rest: materialised calculations, `diffDays`, `now` and the temporal sweep that rewrites a `now`-dependent calculation every day. It lacks one thing: summing a quantity over rate bands. + +## What changes + +- Two operators in `CalculationEvaluator`, backed by a pure `RateSchedule` class: + - `graduated: [quantity, bands, cap?]` returns the amount the quantity accrues over the bands, capped when a cap is given. + - `bandRate: [unit, bands]` returns the rate of the band a unit falls in, counting from 1. +- A band list is `[{upTo, rate}, ..., {rate}]`. Only the last band may leave out `upTo`. A malformed schedule is refused with an `EvaluationException`, never read as zero. +- `CalculationAnnotationValidator` treats a `lit` operand as data. Before this change, a literal holding objects (a rate schedule) was walked as if it were an expression and refused as `calculation-malformed-expr`. +- Both operators are published through `OperatorCatalogue` automatically, because the catalogue reads `CalculationEvaluator::OPERATORS`. + +## Out of scope + +- Interest over a period (clawback with statutory interest). That follows when subsidie moves (lane R4). +- The dossiq configuration that uses these operators. That ships in its own dossiq PR. diff --git a/openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md b/openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md new file mode 100644 index 0000000000..8120aac504 --- /dev/null +++ b/openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md @@ -0,0 +1,44 @@ +# Computed Fields Specification (delta) + +## ADDED Requirements + +### Requirement: A quantity accrues over graduated rate bands (REQ-GRC-01) + +The calculation engine MUST provide a `graduated` operator taking `[quantity, bands, cap?]`. `bands` MUST resolve to a list of `{upTo, rate}` objects with rising `upTo`, where only the last band MAY leave out `upTo` and then covers every later unit. The result MUST be the sum, per band, of the units of the quantity that fall in that band times the band's rate, and MUST NOT exceed `cap` when a cap is given. Units past the last closed band of a schedule without an open band MUST NOT be charged. A quantity of zero or less MUST yield zero. A quantity or schedule that resolves to null MUST yield null. + +#### Scenario: A stepped daily fee +- **GIVEN** bands `[{upTo: 3, rate: 2}, {upTo: 8, rate: 3}, {rate: 5}]` +- **WHEN** `graduated` is evaluated for quantity 9 +- **THEN** the result MUST be 26 (3 x 2 + 5 x 3 + 1 x 5) + +#### Scenario: The cap holds +- **GIVEN** the same bands and cap 40 +- **WHEN** `graduated` is evaluated for quantity 13 +- **THEN** the result MUST be 40 + +### Requirement: The rate of the band a unit falls in (REQ-GRC-02) + +The calculation engine MUST provide a `bandRate` operator taking `[unit, bands]` that returns the rate of the band whose range contains the unit, counting units from 1, and null when no band covers the unit (a unit of zero or less, or a unit past a closed schedule). + +#### Scenario: The boundary unit belongs to the lower band +- **GIVEN** bands `[{upTo: 3, rate: 2}, {upTo: 8, rate: 3}, {rate: 5}]` +- **WHEN** `bandRate` is evaluated for units 3, 4 and 9 +- **THEN** the results MUST be 2, 3 and 5 + +### Requirement: A malformed schedule is refused (REQ-GRC-03) + +An empty schedule, a schedule that is not a list, a band without a numeric rate, an open band before the last position, or `upTo` bounds that do not rise MUST raise an evaluation error. A malformed schedule MUST NOT be read as an amount of zero. + +#### Scenario: Falling bounds +- **GIVEN** bands `[{upTo: 5, rate: 1}, {upTo: 3, rate: 2}]` +- **WHEN** `graduated` is evaluated +- **THEN** evaluation MUST fail with an evaluation error + +### Requirement: A literal operand is data (REQ-GRC-04) + +The calculation annotation validator MUST NOT walk into the operand of `lit`, so a literal list of objects such as a rate schedule is accepted on import. + +#### Scenario: A schedule written in the expression +- **GIVEN** a calculation `graduated: [{prop: hours}, {lit: [{upTo: 3, rate: 2}, {rate: 5}]}, 40]` +- **WHEN** the schema is validated +- **THEN** the validator MUST return no errors diff --git a/openspec/changes/graduated-rate-calculation/tasks.md b/openspec/changes/graduated-rate-calculation/tasks.md new file mode 100644 index 0000000000..488ee62398 --- /dev/null +++ b/openspec/changes/graduated-rate-calculation/tasks.md @@ -0,0 +1,8 @@ +# Tasks: graduated-rate-calculation + + + +- [x] 1.1 `RateSchedule` sums a quantity over rate bands with an optional cap, and returns the rate of the band a unit falls in (REQ-GRC-01, REQ-GRC-02) +- [x] 1.2 `graduated` and `bandRate` are dispatched by `CalculationEvaluator` and listed in `OPERATORS`, null-safe like the other arithmetic operators (REQ-GRC-01, REQ-GRC-02) +- [x] 1.3 A malformed schedule is refused, not read as zero (REQ-GRC-03) +- [x] 1.4 `CalculationAnnotationValidator` does not walk into a `lit` operand (REQ-GRC-04) diff --git a/openspec/changes/magic-table-metadata-self-healing/design.md b/openspec/changes/magic-table-metadata-self-healing/design.md new file mode 100644 index 0000000000..b8751fdbef --- /dev/null +++ b/openspec/changes/magic-table-metadata-self-healing/design.md @@ -0,0 +1,64 @@ +# Design: magic-table-metadata-self-healing + +## D-1 Where the check runs + +`MagicTableHandler::existsTableForRegisterSchema()`. Every path that queries a +magic table asks it first: the single-table list, the count, the facets, the +UNION search and count, and the sequential cross-table search. When the +answer is "exists", the handler calls `MagicMetadataColumnHealer::ensure()` +with the table name. + +It is not in `boot()` and it is not a sweep. ADR-106 forbids per-request work +that grows with the number of features; this check is per table, on first +use, and is answered from memory after that (D-2). ADR-106 rule 4 names a +cron job as the fallback for a repair step that may not have run; a cron job +would leave every list of a drifted table failing until it ran, which is the +failure this change removes. A cron job can still be added beside this +without changing it. + +## D-2 Cost + +1. Per process: a static memo, table name to the columns it still lacks. After + the first call for a table, `ensure()` is an array lookup. +2. Across requests: the distributed cache (`openregister_magic_metadata`) + holds "complete" for `HEALTHY_TTL` (one hour) under a key that includes a + version hash of the metadata column names and types. Declaring a new + metadata column changes the hash, so every table is checked again once. +3. Cold cache: one `information_schema.columns` query for that table. + +`MagicMapper::dropTable()` forgets the table, so a table recreated under the +same name is checked again. + +## D-3 Concurrency + +Two requests may add the same column. On PostgreSQL the statement is +`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so both succeed. Elsewhere a +failed ALTER is followed by a fresh read of the columns; a column that is now +present counts as added, no warning. + +On MySQL and MariaDB a DDL statement commits an open transaction. When a +transaction is open there, the healer does not alter the table in that +request; the search handler leaves the column out (D-4) and the next request +heals it. + +## D-4 Failure + +A column that cannot be added (no ALTER permission, a lock timeout) makes the +table "degraded" for `FAILURE_TTL` (15 minutes) in the distributed cache. The +warning is logged once when the table becomes degraded, not per request, and +names the remedy. While degraded, `missingFor()` answers the missing columns +and the search handler drops `_archived IS NULL` and the draft predicate for +that table (and answers no rows for the archived-only and drafts-only lenses). +That is exact: a table without `_archived` holds no archived row. A primary +key column cannot be retrofitted and is reported as failed. + +## D-5 One helper, three callers + +The request path, `openregister:tables:reconcile` and the +`AddObjectStateColumns` repair step all call the healer, through +`MagicMapper::inspectMetadataColumns()` and `healMetadataColumns()`. The +column list itself stays in `MagicMapper::getMetadataColumns()`, exposed as +`getMetadataColumnDefinitions()`. The command and the repair step walk the +physical tables (`MagicMapper::listMagicTableNames()`) rather than the +register to schema links, which is what made the earlier reconcile blind to +orphaned tables. diff --git a/openspec/changes/magic-table-metadata-self-healing/proposal.md b/openspec/changes/magic-table-metadata-self-healing/proposal.md new file mode 100644 index 0000000000..697a65fac8 --- /dev/null +++ b/openspec/changes/magic-table-metadata-self-healing/proposal.md @@ -0,0 +1,53 @@ +--- +kind: code +depends_on: [object-archive-state] +--- + +# Proposal: magic-table-metadata-self-healing + +## Summary + +A magic table that lacks a metadata column (`_archived`, `_frozen`, +`_status`, ...) heals itself the first time it is used, instead of failing +every list of it until an app upgrade runs a repair step. +`occ openregister:tables:reconcile` reports and repairs the same drift, and +the upgrade step, the command and the request path share one helper. + +- Ruben decided this on 14 October 2026: fix it in OpenRegister itself, so no + installation depends on an upgrade having run. +- Dependencies: `object-archive-state`, which introduced `_archived` and + `_frozen` and the `AddObjectStateColumns` repair step. + +## Why + +Measured on the development instance on 14 October 2026: + +- 38 of 1,712 magic tables lacked `_archived`, `_frozen` and `_status` + (6 also lacked `_quality`). +- Every object list puts `_archived IS NULL` in its WHERE, so each list of + those tables failed with `column t._archived does not exist`. Postgres + logged that on almost every request (a 2.6 GB container log). +- The columns arrived only through the post-migration repair step, which runs + only when the app version changes. A developer mount moved forward without + a version change, so it never ran. A table restored from a backup, or made + by an older code path, is in the same state on any installation. +- `occ openregister:tables:reconcile --apply` reported "No drift": it walked + only the schemas a register still lists, and 37 of the 38 tables were not + among them (14 had no register row, 15 no schema row). +- `occ maintenance:repair` took more than 15 minutes in another app's step + and held the instance in maintenance mode, so it is not a remedy. + +## What changes + +1. `MagicMetadataColumnHealer`: one class that finds and adds missing metadata + columns, with a per-process memo and a distributed-cache answer keyed by a + version hash of the metadata column list. +2. The table-existence check every list, count, facet and cross-table search + already makes calls the healer, so the first use of a drifted table heals it. +3. A table that cannot be healed is logged once and its lists leave the + missing column out of the archive and draft predicates, which is exact + because a table without the column holds no archived or draft row. +4. `occ openregister:tables:reconcile` reports and, with `--apply`, adds + missing metadata columns on every physical magic table. +5. `AddObjectStateColumns` walks every physical magic table through the same + healer. diff --git a/openspec/changes/magic-table-metadata-self-healing/specs/object-lifecycle/spec.md b/openspec/changes/magic-table-metadata-self-healing/specs/object-lifecycle/spec.md new file mode 100644 index 0000000000..95f5c85010 --- /dev/null +++ b/openspec/changes/magic-table-metadata-self-healing/specs/object-lifecycle/spec.md @@ -0,0 +1,84 @@ +# object-lifecycle + +## ADDED Requirements + +### Requirement: A magic table missing metadata columns heals itself on first use (REQ-MTH-001) + +When a request first uses an existing magic table, OpenRegister SHALL check +that the table carries every metadata column (`_archived`, `_frozen`, +`_status` and the rest of the declared list) and SHALL add the ones it lacks +before the table is queried. The check SHALL NOT depend on an app upgrade or +a repair step having run. After the first use the answer SHALL come from a +per-process memo, and across requests from the distributed cache under a key +that changes when the metadata column list changes, so a complete table costs +no schema query per request. Two requests adding the same column at once +SHALL both succeed. + +#### Scenario: a list of a table without `_archived` succeeds + +- **GIVEN** a magic table that has every metadata column except `_archived`, `_frozen` and `_status` +- **WHEN** a user lists the objects of its schema +- **THEN** the three columns are added to the table +- **AND** the list answers with the table's objects instead of an SQL error +- @e2e exclude {needs a table altered underneath the app; covered by the SQLite unit test through existsTableForRegisterSchema()} + +#### Scenario: a complete table is not inspected on every request + +- **GIVEN** a table that was found complete +- **WHEN** it is used again in the same process, or in another request within the cache lifetime +- **THEN** no information_schema query is made for it +- @e2e exclude {query counts are not observable from a browser; covered by unit tests} + +#### Scenario: two requests add the same column + +- **GIVEN** two requests that both find `_archived` missing +- **WHEN** both try to add it +- **THEN** neither fails and no warning is logged +- @e2e exclude {a race cannot be staged from a browser; covered by unit tests} + +### Requirement: A table that cannot be healed degrades quietly (REQ-MTH-002) + +When a missing metadata column cannot be added, OpenRegister SHALL log one +warning naming the table and the remedy, SHALL NOT log it again on every +request while the table stays degraded, and SHALL leave the missing column +out of the archive and draft predicates for that table. A table without +`_archived` holds no archived row, so the default list SHALL show all its +rows and the archived-only lens SHALL show none. + +#### Scenario: no permission to alter + +- **GIVEN** a table without `_archived` and a database user that may not alter it +- **WHEN** its objects are listed three times +- **THEN** one warning is logged +- **AND** each list answers with the table's rows +- @e2e exclude {needs a database user without ALTER; covered by unit tests} + +### Requirement: Reconcile reports and repairs missing metadata columns (REQ-MTH-003) + +`occ openregister:tables:reconcile` SHALL report every physical magic table +that lacks metadata columns, including tables whose schema no register lists, +and with `--apply` SHALL add them, without maintenance mode. `--register` +SHALL limit both passes to that register's tables. A metadata column SHALL be +reported once, not again by the property pass. + +#### Scenario: an orphaned table is reported and repaired + +- **GIVEN** a magic table lacking `_archived` whose schema no register lists +- **WHEN** an administrator runs the command without `--apply` +- **THEN** the output names the table and `_archived` and the exit code is 0 +- **AND** with `--apply` the column is added and the summary counts the table as repaired +- @e2e exclude {occ command; covered by unit tests} + +### Requirement: The upgrade step and reconcile share the healer (REQ-MTH-004) + +The `AddObjectStateColumns` repair step, the reconcile command and the request +path SHALL decide which columns a table lacks, and add them, through the same +helper, so the three cannot disagree. The repair step SHALL walk every physical +magic table and SHALL NOT abort the upgrade when one table cannot be altered. + +#### Scenario: the upgrade step heals an orphaned table + +- **GIVEN** a magic table lacking `_archived` whose schema no register lists +- **WHEN** the app is upgraded +- **THEN** the repair step adds the column +- @e2e exclude {repair step runs on upgrade; covered by unit tests} diff --git a/openspec/changes/magic-table-metadata-self-healing/tasks.md b/openspec/changes/magic-table-metadata-self-healing/tasks.md new file mode 100644 index 0000000000..135c10b34e --- /dev/null +++ b/openspec/changes/magic-table-metadata-self-healing/tasks.md @@ -0,0 +1,8 @@ +# Tasks: magic-table-metadata-self-healing + +- [x] 1.1 `MagicMetadataColumnHealer` with `inspect()`, `heal()`, `ensure()`, `missingFor()` and `forget()` (D-2, D-3, D-4). +- [x] 1.2 `existsTableForRegisterSchema()` calls `ensure()` when a table exists (D-1). +- [x] 1.3 The search handler leaves a missing `_archived` or `_status` out of the lifecycle predicates on the QueryBuilder and the UNION paths (D-4). +- [x] 2.1 `openregister:tables:reconcile` reports and, with `--apply`, adds missing metadata columns on every physical magic table, including orphaned ones (D-5). +- [x] 3.1 `AddObjectStateColumns` walks every physical magic table through the healer (D-5). +- [x] 4.1 Unit tests against SQLite: first use heals and the archive predicate then runs; memo and distributed cache prevent a second inspection; a concurrent add is tolerated; a failure warns once and degrades; reconcile reports and applies; the repair step heals an orphaned table. diff --git a/openspec/changes/migration-pack-editor-ui/design.md b/openspec/changes/migration-pack-editor-ui/design.md new file mode 100644 index 0000000000..9d1693a4ee --- /dev/null +++ b/openspec/changes/migration-pack-editor-ui/design.md @@ -0,0 +1,216 @@ +# Design: migration-pack-editor-ui + +## D-1: the migration-pack store, not the Twig mapping and not register objects + +OpenRegister has three places a column mapping could live: + +| Store | Fits the packs | Cost | +| --- | --- | --- | +| `openregister_migration_packs` (`/api/migration-packs`) | Yes: the packs are written in this format, `MappingEngine::mapRow` runs them unchanged, `PackDefinitionValidator` checks every transform they use | A screen and four API fixes (this change) | +| Twig `Mapping` entity (`/api/mappings`) | No: lookup, required, date and join become templates | An engine rewrite in every app that imports with packs; errors lose their row and column | +| Register objects (as Integriq stores its mappings) | Partly: the generic object form cannot edit nested field mappings | A third store next to the one that exists, and a second validator in a save hook | + +The screen therefore edits migration packs. Integriq's mapping editor is the +pattern for its layout (a general card, a rule table, a test panel, a dialog +per rule); no Integriq code is imported and Integriq is not changed. + +## D-2: an ordered list of rows, and a round trip that loses nothing + +A pack's `fieldMappings` is an ordered list, and the order matters: + +- one column may feed several targets (stackiq's `Applicatiesoort` feeds + `applicationType` and `cloudDienstverleningsmodel`); +- several columns may feed one target, and the engine writes the rows in + order, so for a source row where both have a value the later row wins + (`Roepnaam` over `Nickname`). + +Integriq's column-mapping draft keys rows by source column and would merge the +first case away. The editor's draft (`src/views/migrationPack/packDraft.js`) +is built around the ordered list instead, with move up and move down, and a +hint under every target that more than one row writes. + +The draft keeps the stored document next to the fields it edits. The payload +is rebuilt in the stored key order: keys the editor does not model (a +top-level `x-stackiq`, a transform option a later version adds, an +`idStrategy` extra) are copied through, and a key the stored document did not +have is only written when the editor gave it a value. Two consequences are +deliberate: + +- A lookup's `default` is three-way, because the engine checks + `array_key_exists('default')`: no key reports an error for an unmapped + value, `null` leaves the field empty, any other value is stored. A text box + cannot tell `null` from an empty string, so the dialog offers the three as + a choice. +- A value that was not a string in the stored document (a lookup result + `["SaaS"]`, a number, `true`) is edited as JSON and saved with its type. + +PHP stores an empty object as `[]`; an unedited empty `defaults` or +`skipRows` is written back in the stored shape. + +The round trip is tested on the five TOPdesk packs stackiq ships and on +OpenRegister's own template pack: loaded and saved without an edit, each +comes back equal and in the same key order. + +stackiq ships five packs (module, manufacturer, municipality, usage and +business owner) but stores four of them here: its `SeedCmdbImportPacks` +repair step seeds the four sheet packs as built-in packs, and keeps the +municipality pack, which maps the import's own options rather than a sheet +column, in its shipped file only. A rig with stackiq therefore lists four +stackiq packs. The fixtures and the round-trip tests cover all five, because +an administrator can still import the municipality pack by hand. + +## D-3: the definition travels in an envelope; the route id wins + +Nextcloud's `Request::decodeContent()` merges the JSON body after the URL +parameters, so a body `id` replaces the route `{id}` and the dispatcher casts +the slug to an int. Create and update therefore accept +`{"definition": {...}}` and read the definition from it. When the body has no +`definition` object, the raw body is the definition as before, minus keys +starting with `_` and, on update, minus the route `id`. The editor always +sends the envelope. + +## D-4: the slug of a built-in pack is locked on the server + +An app finds the pack it seeded by slug. Locking the slug field in the editor +is not enough: the Advanced (JSON) view, a `PUT` from a script and an import +can all change `id`. The server refuses an update of a built-in pack whose +definition changes the slug with 422 `builtin-slug-locked`. The editor also +locks the field and refuses the change in the JSON view before it is sent. + +## D-5: preview is a read on the real engine + +`POST /api/migration-packs/preview` takes an unsaved definition and up to 20 +sample rows, validates the definition with `PackDefinitionValidator` (422 +`invalid-pack` with the errors in `details.errors`) and returns +`MappingEngine::mapRow` per row. Nothing is stored. A body without a +`definition` object or with `rows` that are not a list of objects answers 400 +`invalid-request`, more than 20 rows 400 `too-many-rows`, a definition and +rows over 1 MiB of JSON together 400 `invalid-request` (the row count alone +does not bound the work, since a row can have any number of columns), and an unexpected +failure 500 `migration-pack-error`, logged with the exception. The editor's test panel +calls it 400 ms after the last change and drops an answer to an older +request, so what the panel shows is what an import of that row does. It is +admin-only because only an administrator edits. + +## D-6: who sees what, and where the boundary is + +The access boundary is the server: every write route checks +`IGroupManager::isAdmin()` and now also requires the CSRF token +(`NoCSRFRequired` removed from create, update, destroy and import, and never +added to preview). `@nextcloud/axios` sends the token, so the screens need +nothing extra. Reads stay open to any signed-in user, as before, because the +import flow lets a register manager pick a pack. + +In the frontend, a manifest `permission: "admin"` does nothing in +OpenRegister: it passes no permissions list to `CnAppRoot` or `CnAppNav`, and +with an empty list every entry renders. The menu entry is therefore gated +with `visibleIf: {"user.isAdmin": true}`, which `CnAppNav` resolves against a +`runtime.user.isAdmin` that `MainMenu.vue` fills from `getCurrentUser()` (and +hides when the runtime block is absent). The pages themselves stay reachable +by URL for any signed-in user and render read-only for a non-admin: no +import, delete or save, the transform dialog without Apply, and no test panel. +The `permission` key is kept on the page and menu declarations as +documentation. + +## D-7: a log line per change, not an audit trail + +A pack is a row in its own table, not a register object, so it has no object +audit trail. Create, update, import and delete each write an info line +through the app logger naming the acting user id, the pack row id and the +pack slug; the import line also says whether it created a pack or replaced a +stored one. A refused or failed change writes no such line. That answers "who +changed the TOPdesk mapping" from the Nextcloud log without a new table, as +long as the log level is Info: at Nextcloud's default, Warning, the lines are +dropped. + +## D-8: a save names the version it was based on + +The store has no version column or ETag; `updatedAt` serves as the version. +The update envelope may carry `expectedUpdatedAt`, the `updatedAt` the caller +read, and the server refuses the update with 409 `pack-changed` when the +stored pack has another one. The editor always sends it and shows the refusal +with a reload. Because `updatedAt` has a resolution of one second, the editor +also reads the pack again just before the update and compares the definition, +which tells apart two saves within the same second. + +What is left: the server reads, compares and writes in three steps, not in +one conditional write, so two updates that reach it at the same moment can +both pass the comparison; and a save that lands within the same second as +the version the editor loaded, after that last read, is not told apart. Both +need two administrators saving the same pack within a second. A script that +leaves out `expectedUpdatedAt` replaces the pack, as before this change. + +## D-9: what other apps may rely on + +stackiq reads its packs from this store. The following are therefore a +contract for apps, changed only with notice in this spec: + +- `MigrationPackService::findByPackSlug(string $packSlug)`, throwing + `DoesNotExistException` when absent; +- `MigrationPack::getDefinitionArray()`, the stored definition as an array; +- `PackDefinitionValidator::validate(array)` and `assertValid(array)`; +- `MappingEngine::mapRow(array $pack, array $sourceRow, int $rowNumber)`. + +## D-10: two custom pages + +A migration pack is a foundation entity served by its own controller, like +sources and registers, so the built-in `index` and `detail` page types (which +load register objects) cannot render it, and no named entity source exists +for it. Both pages are `type: "custom"` and carry a `_note` saying so. This +grows the custom-page count by two (hydra gate-69's ratchet), knowingly. + +## D-11: version on save + +When the administrator saves from the form without changing the version, the +patch version goes up (`1.0.0` becomes `1.0.1`) and the footer says so before +the save. A version the administrator typed is kept. The JSON view saves the +definition exactly as written. + +## D-12: one error shape, codes the screens translate + +Every refusal the controller gives follows ADR-050: +`{"message": , "error": , "details"?: {...}}`. +The message is a fixed sentence, never `$e->getMessage()`, so no exception +text, path or SQL reaches a client. The codes are `invalid-pack` (422, the +validator's findings in `details.errors`, rows numbered from 1), +`builtin-slug-locked` (422), `pack-slug-exists` (422, a create or rename to +a slug another pack has), `pack-changed` (409, D-8), `pack-not-found` (404), +`invalid-request` and +`too-many-rows` (400), `not-authenticated` (401), `admin-required` (403) and +`migration-pack-error` (500, logged with the exception). The screens map the +code to their own translated text and list `details.errors`; they never parse +an English message. A taken slug is a 422 and not a 409, because the main +spec's scenario "Duplicate pack slug is rejected" fixes that status for API +clients; it has its own code, so a screen can say it in the user's language +instead of showing the validator's English sentence. A refusal Nextcloud gives before the controller runs (no +session, not an administrator, no request token) carries Nextcloud's body, +so a client reads the status there. + +## Verification + +- `tests/e2e/ci/migration-pack-pages.spec.ts` names every scenario of the + delta spec with an `@e2e` anchor and drives it through the browser or the + API of a running instance. CI runs it only on promotion pull requests and + `workflow_dispatch`, so on a pull request to `development` it proves + nothing until a run against a rig is linked (task 5.1). +- The log lines (D-7) have no browser test, because a browser cannot read the + Nextcloud log. `MigrationPacksControllerTest` proves them: a line per + create, update, import (with its outcome) and delete, and none on failure. +- The route-id fix (D-3) against Nextcloud's own `Request` is proven by + `MigrationPacksControllerTest::testUpdateReadsTheRouteIdFromARealNextcloudRequest`. + It skips when PHPUnit runs without an installed Nextcloud, as a local run + from a worktree does. It runs in the in-container step of + `api-test-coverage.yml` + (`OPENREGISTER_TEST_NC_ROOT=/var/www/html`), which does not list skipped + tests; `--filter RealNextcloudRequest --testdox` in that container shows it + ran. + +## Risks + +- **An edit that the importing app does not expect.** OpenRegister cannot + know an app's own rules (stackiq converts dates only for the columns its + profile names, for example). The editor says that apps can add rules of + their own; enforcing them is the app's job (stackiq validates the stored + pack before an import and fails closed). +- **Deleting a built-in pack.** The app that seeded it seeds it again, unedited, + on its next install or update. The delete dialog says so. diff --git a/openspec/changes/migration-pack-editor-ui/proposal.md b/openspec/changes/migration-pack-editor-ui/proposal.md new file mode 100644 index 0000000000..ffebb81a80 --- /dev/null +++ b/openspec/changes/migration-pack-editor-ui/proposal.md @@ -0,0 +1,147 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: migration-pack-editor-ui + +## Summary + +Give migration packs a screen. An administrator lists the packs, opens one, +and changes its column mappings: the source column, the target field, whether +the row is required, and the transform (trim, date, lookup table, yes/no +table, join, fixed value). The editor tests the pack against a sample row +before it is saved, and saving gives back exactly what was stored for every +part the editor does not show. + +The API gains what such a screen needs and lacked: an envelope body for +create and update, so a pack's own `id` slug can no longer replace the route's +row id; a server-side lock on the slug of a built-in pack; a preview endpoint +that maps sample rows without storing anything; CSRF on every write; one error +shape for every refusal; and a log line naming who created, changed, imported +or deleted a pack. + +## Why + +Packs are stored in OpenRegister (`openregister_migration_packs`, served by +`/api/migration-packs`) and run by its mapping engine, but there is no way to +see or change one except by exporting the JSON, editing it by hand and +importing it again. Apps now ship packs that a municipality is expected to +adjust. stackiq's TOPdesk CMDB import is the first (WOO-588): Gemeente +Rotterdam's administrator must be able to view and edit the column mappings +the import uses, without a developer and without hand-written JSON. + +The decision of 2026-10-12 puts all mapping in OpenRegister. Of the three +places a mapping could live there, the migration-pack store is the one the +packs are already written for: the import calls `MappingEngine::mapRow` with +them unchanged, and `PackDefinitionValidator` already checks every transform +they use. A Twig mapping would need an engine rewrite and loses the row and +column in every error; packs stored as register objects would duplicate a +store OpenRegister already has. See design.md D-1. + +Building the screen surfaced four defects the API had without one: + +- A `PUT /api/migration-packs/{id}` whose body carries the pack's own `id` + answers 404. Nextcloud merges the JSON body over the route parameters, so + the slug replaces the row id and the dispatcher casts it to 0. +- The slug of a built-in pack can be changed through update and import. An + app that finds its pack by slug then silently falls back to its shipped + file, and its next update seeds a second copy. +- The write routes skip the CSRF check (`NoCSRFRequired`). +- Nothing records who changed a pack, while a pack decides what an import + writes. + +## What changes + +- A **Migration packs** page and a pack **editor** page in OpenRegister, with + a menu entry next to Connections that only administrators see. +- The editor keeps the field mappings as an ordered list, keeps keys it does + not model, and treats a lookup's "other values" in the three ways the + engine does: an error, an empty field, or a stored value. +- `POST` and `PUT /api/migration-packs[/{id}]` accept `{"definition": {...}}`; + on update the route id always wins. The raw body keeps working. +- An update that changes the slug of a built-in pack answers 422 + `builtin-slug-locked`. +- `POST /api/migration-packs/preview` (admin): an unsaved definition and up to + 20 sample rows in, the mapped data and errors per row out. +- Create, update, delete, import and preview require an administrator and a + CSRF token. Create, update, import and delete each write an info log line + with the acting user, the pack slug and the pack row id; the line of an + import also says whether it created a pack or replaced a stored one. +- Every refusal the controller gives has one shape (ADR-050): + `{"message": , "error": , "details"?: {...}}`, + with codes `invalid-pack` (422, findings in `details.errors`), + `builtin-slug-locked` (422), `pack-slug-exists` (422), `pack-changed` + (409), `pack-not-found` (404), `invalid-request` and + `too-many-rows` (400), `not-authenticated` (401), `admin-required` (403) and + `migration-pack-error` (500, logged). The message never carries an + exception's text; the screens map the code to their own translated text. +- English and Dutch for every new string; the screens' strings in every + locale the frontend catalogue requires. + +## Impact + +- **stackiq** reads its TOPdesk packs from this store first and falls back to + its shipped files (its own change, `cmdb-import-mapping-view`). It depends + on `MigrationPackService::findByPackSlug()`, `MigrationPack::getDefinitionArray()`, + `PackDefinitionValidator` and `MappingEngine::mapRow()`; design.md D-9 + records that as a contract. +- **Integriq** is not touched. Its mapping editor is the pattern for this + screen's layout; no code is shared. +- **API clients** that create or update packs keep working with the raw body. + A client that posted to the write routes without a CSRF token, outside a + browser session, must send `OCS-APIRequest: true` or use app passwords with + basic auth, as for any other CSRF-protected Nextcloud route. +- **opencatalogi, softwarecatalog**: no change; they do not use packs. + +## Accessibility (WCAG 2.2 AA) + +The two pages, the transform dialog, the delete dialog and the sample-row +panel are new UI. They touch these success criteria; each names the Hydra +gate that catches a regression, or says that none does. + +| SC | Where | Caught by | +| --- | --- | --- | +| 1.3.1 Info and Relationships | List and mapping tables with ``; every section titled by its heading (`aria-labelledby`) | gate-43 (table-headers), gate-33 (axe-core) | +| 1.3.1 / 3.3.2 Labels or Instructions | Every input has a visible label or an `aria-label` naming its row (`Source column of row 3`); the sample-row inputs name their column | gate-40 (form-label-association) | +| 1.4.3 / 1.4.11 Contrast | Nextcloud theme variables only; the Built-in badge uses `--color-text-maxcontrast` | gate-33 (axe-core) | +| 2.1.1 Keyboard | Row move, remove, transform and delete are `NcButton`s; no click handler on a `div` or `span` | gate-32 (semantic-controls) | +| 2.4.3 Focus Order | No positive `tabindex`; the hidden file input has `tabindex="-1"` and the visible Import JSON button opens it | gate-36 (tabindex-positive), gate-37 (aria-hidden-focusable) | +| 3.3.1 Error Identification | A refused save lists each finding on its own line; the JSON view names the parse error and the locked slug | Playwright `migration-pack-pages.spec.ts`; no static gate | +| 3.3.4 Error Prevention | Delete asks for confirmation in an `NcDialog`, never `window.confirm`; a save that would overwrite someone else's is refused | gate-34 (window-confirm), Playwright | +| 4.1.2 Name, Role, Value | Icon-only buttons carry an `aria-label`; the Form / Advanced (JSON) switch sets `aria-pressed` | gate-39 (button-name), gate-37, gate-33 | +| 4.1.3 Status Messages | Save results and preview results sit in an `aria-live="polite"` region | gate-33 (axe-core); the announcement itself is not machine-checked | + +gate-33 only runs when a repository sets `enable-axe` on the shared quality +workflow. openregister has not, so for the rows that name it the check today +is review and the Playwright run, not the gate. + +The six criteria new in 2.2: + +- **2.4.11 Focus Not Obscured (Minimum).** The pages have no sticky header or + footer of their own; the dialogs are `NcDialog`, which keeps focus inside + the dialog. No Hydra gate measures it. +- **2.5.7 Dragging Movements.** Rows are reordered with Move up and Move down + buttons, never by dragging. n/a for a gate. +- **2.5.8 Target Size (Minimum).** The buttons are `NcButton`s at + Nextcloud's default clickable size, above the 24 px minimum. The native + Required checkbox in the mapping table is smaller, but it stands alone in + its cell, so the spacing exception applies. No Hydra gate measures it. +- **3.2.6 Consistent Help.** The pages offer no help mechanism of their own; + the explanatory notes sit in the same place on both pages. n/a. +- **3.3.7 Redundant Entry.** The sample-row test keeps the values typed for + each column while the pack is edited; nothing asks for a value twice. +- **3.3.8 Accessible Authentication (Minimum).** No authentication step is + added; Nextcloud's own sign-in applies. n/a. + +## Out of scope + +- Per-organisation packs. The store is instance-wide: one mapping per pack + slug per instance. A shared instance that imports for several + municipalities gets one mapping for all of them. +- A target-schema field picker. A pack does not say which schema it targets + (stackiq's targets span three schemas and a Contacts identity), so targets + stay free text. +- Marking app-owned packs in the `registers#import` pack picker. +- Shipped-version tracking ("is the stored pack still the shipped one?"): + that belongs to the app that seeds the pack. diff --git a/openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md b/openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md new file mode 100644 index 0000000000..6ec2feac5b --- /dev/null +++ b/openspec/changes/migration-pack-editor-ui/specs/migration-mapping-packs/spec.md @@ -0,0 +1,361 @@ +# migration-mapping-packs + +## ADDED Requirements + +### Requirement: Administrators MUST be able to list and edit migration packs in the OpenRegister UI + +The system SHALL offer a Migration packs page at `/apps/openregister/migration-packs` +listing every pack with its name, slug, source format, version, whether it is +built-in, and when it was last updated, with an action to open, export and +(for an administrator) delete each pack, and an action to import a pack from a +JSON file. A delete SHALL be confirmed first; for a built-in pack the +confirmation SHALL say that the app that installed it installs it again on its +next install or update. + +The system SHALL offer an editor at `/apps/openregister/migration-packs/{id}` +with: the general settings (name, description, version, source format, id +strategy, and the slug, read-only for a built-in pack); the field mappings as +an ordered table of source column, target field, required and a summary of the +transform, where rows can be added, removed and moved up or down; a dialog per +row for every transform type the validator accepts (trim; date with source and +stored format; lookup table and yes/no table with their values and a choice of +what happens to a value that is not in the table; join with its columns and +separator; fixed value); the pack defaults and the rows to skip; and an +Advanced (JSON) view of the whole definition. In a yes/no table the editor +SHALL take only yes or no as a value and as the value for other values, and +SHALL keep a stored 1 or 0 as it is; it SHALL refuse text such as `false`, +which the import casts to true. The server's validator does not check these +values, so a pack written through the API is not held to this. A save SHALL show the server's +refusal readably, one line per validation finding. When the version was not +changed, a save SHALL raise the patch version and SHALL say so before saving. +A save SHALL be refused, with a way to reload, when the pack was saved by +someone else after the editor loaded it. + +#### Scenario: An administrator changes a lookup value and saves + +- **GIVEN** an administrator and a stored copy of the usage pack stackiq ships, at version `1.0.0` +- **WHEN** they open it, change the stored value for `In voorraad` in the `Applicatie Status` lookup to `Acquisition`, and save +- **THEN** the stored pack has `Acquisition` for `In voorraad`, version `1.0.1`, and every other part as it was +- **AND** the editor shows that the pack was saved as version `1.0.1` + +#### Scenario: A row is moved and saved in the new order + +- **GIVEN** an administrator and a stored copy of the module pack stackiq ships, whose rows 4 and 5 both write `shortDescription`, from `Nickname` and from `Roepnaam` +- **WHEN** they open it +- **THEN** both rows carry a hint that rows 4 and 5 write the same field and the lower row with a value wins +- **WHEN** they move row 5 up and save +- **THEN** the stored `fieldMappings` list `Roepnaam` before `Nickname` + +#### Scenario: A validation refusal is shown per finding + +- **GIVEN** an administrator editing a pack +- **WHEN** they save and the server answers 422 `invalid-pack` with the findings `"name" is required` and `"version" must be a semver string` in `details.errors` +- **THEN** the editor shows the refusal with each finding on its own line +- **AND** the editor keeps the unsaved changes + +#### Scenario: A save over someone else's save is refused + +- **GIVEN** an administrator who opened a pack, and another administrator who saved it afterwards +- **WHEN** the first administrator saves +- **THEN** nothing is sent to the update route +- **AND** the editor says the pack was changed by someone else and offers to reload it + +#### Scenario: Deleting a built-in pack warns that it comes back + +- **GIVEN** an administrator and the built-in pack `zgw-zaken-json` that OpenRegister installs +- **WHEN** they choose Delete for it on the Migration packs page +- **THEN** the confirmation names the pack and says that the app installs it again, unedited, on its next install or update +- **AND** choosing Cancel leaves the pack stored + +#### Scenario: An administrator deletes a pack after confirming + +- **GIVEN** an administrator and a stored pack that no app installed +- **WHEN** they choose Delete for it on the Migration packs page and confirm +- **THEN** the pack is no longer listed and no longer stored + +#### Scenario: An administrator imports a pack from a JSON file + +- **GIVEN** an administrator on the Migration packs page +- **WHEN** they choose Import JSON and pick a pack definition file whose `id` is not stored yet +- **THEN** the page says the pack was imported and lists it +- **AND** the stored pack has the definition from the file + +### Requirement: The pack editor MUST save a definition without losing what it does not show + +The definition the editor builds from an unedited pack SHALL equal the +stored one, so an unedited pack shows no unsaved changes and an edit changes +only what was edited. The editor SHALL keep every top-level, field-mapping, +transform and id-strategy key it does not model, unchanged and in place. It +SHALL keep the field mappings as an ordered list: two rows with the same +source and two rows with the same target SHALL both be saved, in their order. +A lookup or yes/no table SHALL keep the three ways a value that is not in the +table is treated, as distinct stored forms: no `default` key (the row reports +an error), `default: null` (the field stays empty; a yes/no table stores +false, and the editor says so), and a `default` value. +A date format cleared in the editor SHALL be left out of the transform, so +the mapping engine's default applies, rather than stored as an empty string. +A value that is not a string in the stored definition SHALL be saved with its +JSON type. + +#### Scenario: The shipped TOPdesk packs survive an unedited save + +- **GIVEN** a stored copy of each of the five TOPdesk packs stackiq ships (module, manufacturer, municipality, usage and business owner; stackiq stores the four sheet packs and keeps the municipality pack, which maps import options, in its own files) +- **WHEN** an administrator opens it in the editor +- **THEN** the editor shows no unsaved changes +- **WHEN** the administrator changes only the name and saves +- **THEN** the stored definition equals the one before, key for key and in the same order, apart from the name and the raised version + +#### Scenario: An empty default stays null + +- **GIVEN** a stored copy of the module pack, whose `Applicatiesoort` lookup to `cloudDienstverleningsmodel` has `"default": null` +- **WHEN** an administrator saves it from the editor +- **THEN** that transform still has a `default` key whose value is `null`, not an empty string and not absent + +#### Scenario: A key the editor does not know is kept + +- **GIVEN** a pack with a top-level `x-stackiq` object and a transform carrying an option the editor does not show +- **WHEN** an administrator opens the transform, changes the pack name and saves +- **THEN** the transform dialog names the option it does not show and says it is kept +- **AND** `x-stackiq` and the transform option are saved unchanged + +#### Scenario: One column feeding two targets keeps both rows + +- **GIVEN** a stored copy of the module pack, where `Applicatiesoort` maps to `applicationType` and, in a later row, to `cloudDienstverleningsmodel` +- **WHEN** an administrator saves it from the editor +- **THEN** both rows are saved, in that order + +### Requirement: Pack changes MUST be limited to administrators and logged + +Creating, updating, deleting, importing and previewing a pack SHALL require a +Nextcloud administrator; the server SHALL answer 403 to any other signed-in +user, whatever the screen offered. The Migration packs menu entry SHALL only +be shown to an administrator. For any other signed-in user the pages SHALL be +read-only: no import, delete or save, and no sample-row test. Every create, +update, import and delete SHALL write an info line to the Nextcloud log with +the acting user id, the pack slug and the pack row id; the line of an import +SHALL also say whether it created a pack or replaced a stored one. A failed +change SHALL write no such line. The lines are info lines, so Nextcloud keeps +them only when its log level is Info or lower; at the default level, Warning, +they are dropped. + +#### Scenario: A non-admin does not see the menu entry + +- **GIVEN** a signed-in user who is not a Nextcloud administrator, and an administrator +- **WHEN** each of them opens OpenRegister +- **THEN** the administrator's navigation has a Migration packs entry +- **AND** the non-admin's navigation has none + +#### Scenario: A non-admin who opens the list can only read + +- **GIVEN** a signed-in non-admin +- **WHEN** they open `/apps/openregister/migration-packs` +- **THEN** the packs are listed with a note that only administrators can change packs +- **AND** there is no Import JSON and no Delete action + +#### Scenario: A non-admin who opens the editor by URL can only read + +- **GIVEN** a signed-in non-admin who opens the editor of a stored pack by its URL +- **WHEN** the page loads +- **THEN** the pack is shown with every field disabled and a note that only administrators can change packs +- **AND** there is no Save, Delete or Import action and no sample-row test + +#### Scenario: A non-admin's write is refused by the server + +- **GIVEN** a signed-in non-admin and a stored pack +- **WHEN** they call `PUT /api/migration-packs/{id}` for it directly +- **THEN** the server answers 403 and the pack is unchanged + +### Requirement: Create and update MUST accept a definition envelope and keep the route id + +`POST /api/migration-packs` and `PUT /api/migration-packs/{id}` SHALL accept a +body `{"definition": {...}}` and take the pack definition from it. On update +the route `{id}` SHALL always name the row to update; a body `id` is the pack +slug and SHALL never be taken as the row id. A body without a `definition` +object SHALL still be read as the definition itself, without keys starting +with `_` and, on update, without the route `id`. A definition the validator +refuses SHALL be answered with HTTP 422 `{"message": "The pack definition is +invalid", "error": "invalid-pack", "details": {"errors": [...]}}`, one entry +per finding with rows numbered from 1. An update of a row that does not exist +SHALL be answered with HTTP 404 and `error` `pack-not-found`. The update +envelope MAY carry `expectedUpdatedAt`, the `updatedAt` of the pack as the +caller read it; when the stored pack was saved at another time, the update +SHALL be answered with HTTP 409 and `error` `pack-changed`, and the pack SHALL +stay unchanged. An `expectedUpdatedAt` that is not a date-time in the form the +API answers `updatedAt` SHALL be answered with HTTP 400 `invalid-request`. +Without it, an update replaces the stored pack. The editor SHALL send it on +every save. + +#### Scenario: An envelope update keeps the route id and the slug + +- **GIVEN** an administrator and a stored pack with row id `{id}` and slug `e2e-pack` +- **WHEN** they send `PUT /api/migration-packs/{id}` with `{"definition": {"id": "e2e-pack", ...}}` +- **THEN** the answer is HTTP 200, not 404 +- **AND** row `{id}` is updated and keeps slug `e2e-pack` + +#### Scenario: A raw body still creates a pack + +- **GIVEN** an administrator +- **WHEN** they send `POST /api/migration-packs` with the pack document itself as the body +- **THEN** the pack is created with HTTP 201, as before this change + +#### Scenario: A definition the validator refuses is answered with its findings + +- **GIVEN** an administrator +- **WHEN** they send `POST /api/migration-packs` with `{"definition": {...}}` whose definition has no `name` +- **THEN** the server answers 422 with `error` `invalid-pack` and a finding about `name` in `details.errors` +- **AND** no pack is stored + +#### Scenario: An update of a pack that does not exist is answered 404 + +- **GIVEN** an administrator +- **WHEN** they send `PUT /api/migration-packs/{id}` for a row id that is not stored +- **THEN** the server answers 404 with `error` `pack-not-found` + +#### Scenario: An update based on an older read is refused + +- **GIVEN** an administrator and a stored pack +- **WHEN** they send `PUT /api/migration-packs/{id}` with `{"definition": {...}, "expectedUpdatedAt": ...}` naming an update time the pack no longer has +- **THEN** the server answers 409 with `error` `pack-changed` +- **AND** the pack is unchanged +- **AND** the same update naming the pack's current `updatedAt` is answered 200 + +### Requirement: The slug of a built-in pack MUST NOT change + +The system SHALL refuse an update of a built-in pack whose definition has a +different `id` than the stored slug with HTTP 422 and the body +`{"message": "...", "error": "builtin-slug-locked"}`, and the pack SHALL stay +unchanged. The editor SHALL show the slug of a built-in pack read-only and +SHALL refuse a changed `id` in the Advanced (JSON) view before sending it. +The lock SHALL outlive a delete: when a built-in pack is deleted, a pack that +is later created, imported or renamed under its slug SHALL be built-in, so its +slug stays locked. Because that pack is then stored under the slug, the app +that installed the pack does not install the shipped one again. + +#### Scenario: Renaming a built-in pack through the API is refused + +- **GIVEN** an administrator and the built-in pack `zgw-zaken-json` +- **WHEN** they send an update whose definition has `"id": "my-zgw"` +- **THEN** the server answers 422 with `error` `builtin-slug-locked` +- **AND** the pack keeps the slug `zgw-zaken-json` + +#### Scenario: Renaming a built-in pack in the JSON view is stopped in the editor + +- **GIVEN** an administrator in the Advanced (JSON) view of the built-in pack `zgw-zaken-json` +- **WHEN** they change `id` and try to go back to the form or save +- **THEN** the editor says the slug of a built-in pack cannot change and names the stored slug +- **AND** nothing is sent + +#### Scenario: A deleted built-in pack imported again keeps its lock + +- **GIVEN** the built-in pack `zgw-zaken-json`, deleted by an administrator +- **WHEN** an administrator imports a JSON file whose `id` is `zgw-zaken-json` +- **THEN** the stored pack is built-in +- **AND** an update whose definition has `"id": "my-zgw"` is refused with `error` `builtin-slug-locked` + +### Requirement: A pack MUST be testable against sample rows without saving it + +`POST /api/migration-packs/preview` SHALL, for administrators, take +`{"definition": {...}, "rows": [{"": ""}, ...]}` with at most 20 +rows, map every row with the mapping engine an import uses, and answer HTTP 200 +`{"rows": [{"rowNumber": 1, "data": {...}, "errors": [...]}]}`. It SHALL store +nothing. A body without a `definition` object, or whose `rows` are not a list +of objects, SHALL be answered with HTTP 400 and `error` `invalid-request`; +more than 20 rows with HTTP 400 and `error` `too-many-rows`; a definition and +rows that together take more than 1 MiB (1,048,576 bytes) as JSON with HTTP +400 and `error` `invalid-request`, because one row can carry any number of +columns. An invalid +definition SHALL be answered with HTTP 422 +`{"message": "...", "error": "invalid-pack", "details": {"errors": [...]}}`. +An unexpected failure SHALL be answered with HTTP 500 and `error` +`migration-pack-error`, and logged with the exception. +The editor SHALL offer a sample-row test that asks a value for every source +column the pack reads, runs on the definition as edited (saved or not), and +shows the fields the import would write and the row's errors. + +#### Scenario: A sample row shows what the import would write + +- **GIVEN** an administrator editing a stored copy of the module pack, who changed the target of row 1 to `title` without saving +- **WHEN** they enter ` Zaaksysteem ` for `Applicatie Naam`, `A-1` for `APPID` and `Cloud` for `Applicatiesoort` +- **THEN** the test shows `title` as `"Zaaksysteem"` and `cloudDienstverleningsmodel` as `null` + +#### Scenario: A sample row with a missing required column shows the error + +- **GIVEN** an administrator editing a stored copy of the module pack, whose `APPID` mapping is required +- **WHEN** the sample row has a value for `Applicatie Naam` and leaves `APPID` empty +- **THEN** the test lists a problem for source `APPID`, target `externalNumber` + +#### Scenario: An invalid definition is refused with its findings + +- **GIVEN** an administrator +- **WHEN** they send a preview whose definition has no `name` +- **THEN** the server answers 422 with `error` `invalid-pack` and the finding in `details.errors` + +#### Scenario: Too many sample rows are refused + +- **GIVEN** an administrator and a valid definition +- **WHEN** they send a preview with 21 rows +- **THEN** the server answers 400 with `error` `too-many-rows` and maps nothing + +#### Scenario: A malformed preview request is refused + +- **GIVEN** an administrator +- **WHEN** they send a preview without a `definition` object, or with `rows` that are not a list of objects +- **THEN** the server answers 400 with `error` `invalid-request` and maps nothing + +#### Scenario: An oversized preview request is refused + +- **GIVEN** an administrator and a valid definition +- **WHEN** they send a preview with 20 rows that take more than 1 MiB as JSON together +- **THEN** the server answers 400 with `error` `invalid-request` and maps nothing + +#### Scenario: A non-admin cannot run a preview + +- **GIVEN** a signed-in non-admin and a valid definition +- **WHEN** they send a preview +- **THEN** the server answers 403 and maps nothing + +### Requirement: Migration pack writes MUST require a CSRF token + +The system SHALL CSRF-protect the create, update, delete, import and preview +routes of `/api/migration-packs`: a request from a browser +session without a valid request token SHALL be refused by Nextcloud before the +controller runs. The read routes (list, show, export) SHALL stay as they are. +The editor SHALL send every write through a client that adds the request +token. + +#### Scenario: A cross-site write is refused + +- **GIVEN** an administrator signed in to Nextcloud in a browser, and a stored pack +- **WHEN** the browser sends `DELETE /apps/openregister/api/migration-packs/{id}` with the session cookie but without the request token +- **THEN** Nextcloud refuses the request and the pack is not deleted + +#### Scenario: The editor's own writes pass + +- **GIVEN** an administrator signed in to Nextcloud in a browser, in the editor +- **WHEN** they save a pack +- **THEN** the request carries the request token and is accepted + +## MODIFIED Requirements + +### Requirement: Migration packs MUST be manageable via admin-gated REST CRUD with JSON file import/export +The system SHALL expose `/api/migration-packs` REST routes: `index`/`show`/`export` available to any authenticated user (packs are shared instance assets the import flow must browse), `create`/`update`/`destroy`/`import`/`preview` restricted to Nextcloud administrators (HTTP 403 otherwise; HTTP 401 for anonymous callers on all routes). The write routes (`create`, `update`, `destroy`, `import`, `preview`) SHALL require Nextcloud's CSRF request token; a client outside a browser session sends `OCS-APIRequest: true` or authenticates with an app password. `create` and `update` SHALL take the definition from a `{"definition": {...}}` envelope or, without one, from the raw body; on `update` the route `{id}` SHALL name the row. `export` SHALL download the pack's definition as a standalone JSON file named `.json`; `import` SHALL accept an uploaded pack JSON file and upsert by the document's own `id` (create when absent, update the existing row when present), so packs round-trip between instances. A pack that `create`, `import` or a rename stores under the slug of a deleted built-in pack SHALL be built-in, and the slug of a built-in pack SHALL NOT change through `update` or `import`. Every error answer the controller gives SHALL be `{"message": , "error": , "details"?: {...}}`, where the message never carries an exception's text: 422 `invalid-pack` with the validator's findings in `details.errors`, 422 `builtin-slug-locked`, 422 `pack-slug-exists` for a create or rename to a slug another pack has, 409 `pack-changed` for an update whose `expectedUpdatedAt` the pack no longer has, 404 `pack-not-found`, 400 `invalid-request` or `too-many-rows`, 401 `not-authenticated`, 403 `admin-required`, and 500 `migration-pack-error` for an unexpected failure, which is logged with the exception. A refusal Nextcloud gives before the controller runs (not signed in, not an administrator, no request token) carries Nextcloud's own body. + +#### Scenario: Non-admin cannot mutate packs +- **GIVEN** an authenticated non-admin user +- **WHEN** they call `POST /api/migration-packs`, `PUT /api/migration-packs/{id}`, `DELETE /api/migration-packs/{id}`, `POST /api/migration-packs/import` or `POST /api/migration-packs/preview` +- **THEN** each call returns HTTP 403 and no pack is created, changed or deleted + +#### Scenario: Any authenticated user can browse and export packs +- **GIVEN** an authenticated non-admin user +- **WHEN** they call `GET /api/migration-packs` and `GET /api/migration-packs/{id}/export` +- **THEN** the list returns HTTP 200 and the export downloads the pack definition as JSON + +#### Scenario: Pack JSON export/import round-trips between instances +- **GIVEN** a pack exported as `.json` +- **WHEN** an admin uploads that file, with a changed description, to `POST /api/migration-packs/import` +- **THEN** the existing row with that slug is updated instead of erroring or creating a second pack + +#### Scenario: Anonymous callers are rejected everywhere +- **GIVEN** a caller without a session, app password or basic auth +- **WHEN** they call any `/api/migration-packs` route +- **THEN** the system returns HTTP 401 diff --git a/openspec/changes/migration-pack-editor-ui/tasks.md b/openspec/changes/migration-pack-editor-ui/tasks.md new file mode 100644 index 0000000000..d200c49733 --- /dev/null +++ b/openspec/changes/migration-pack-editor-ui/tasks.md @@ -0,0 +1,153 @@ +# Tasks: migration-pack-editor-ui + +## Where this change stands + +The API half (envelope body, slug lock, preview, CSRF, log lines, the one +error shape of D-12, the save precondition of D-8), the screens, the draft +model, their PHPUnit and Jest tests, the translations of the screens' strings +and this change are written on one branch; a box is ticked when its tests +pass. The Playwright file passes on a running instance (5.1); what is open +is 5.2. The tests find the pages by their English names, so `beforeAll` +stops with the `occ` command to run when a test account is set to another +language. CI runs the file only on promotion pull requests and +`workflow_dispatch`. + +## 1. API + +- [x] 1.1 Create and update read `{"definition": {...}}`, keep the raw body, and let the route id win on update (D-3). + - spec_ref: `migration-mapping-packs#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id` + - files: `lib/Controller/MigrationPacksController.php`, `tests/Unit/Controller/MigrationPacksControllerTest.php` + - acceptance_criteria: + - GIVEN pack row `{id}` WHEN an administrator sends `PUT /api/migration-packs/{id}` with `{"definition": {"id": "", ...}}` THEN row `{id}` is updated with HTTP 200 and keeps its slug + - GIVEN an administrator WHEN they `POST` the pack document itself THEN it is created with HTTP 201 + - `testUpdateReadsTheRouteIdFromARealNextcloudRequest` proves the route id against Nextcloud's own `Request`. It skips without an installed Nextcloud (any local run from a worktree) and runs in the in-container step of `api-test-coverage.yml`, which does not list skipped tests: show it ran with `--filter RealNextcloudRequest --testdox` in that container +- [x] 1.2 Refuse a slug change on a built-in pack with 422 `builtin-slug-locked`, also after the pack was deleted and created or imported again under its slug (D-4). + - spec_ref: `migration-mapping-packs#requirement-the-slug-of-a-built-in-pack-must-not-change` + - files: `lib/Service/MigrationPackService.php`, `lib/Controller/MigrationPacksController.php`, `tests/Unit/Service/MigrationPackServiceTest.php` + - acceptance_criteria: + - GIVEN the built-in pack `zgw-zaken-json` WHEN an update renames it THEN the answer is 422 `builtin-slug-locked` and the slug is unchanged + - GIVEN a deleted built-in pack WHEN a JSON file with its slug is imported THEN the stored pack is built-in and its slug locked +- [x] 1.3 `POST /api/migration-packs/preview`: admin, at most 20 rows, `MappingEngine::mapRow` per row, nothing stored (D-5). + - spec_ref: `migration-mapping-packs#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it` + - files: `appinfo/routes.php`, `lib/Controller/MigrationPacksController.php`, `lib/Service/MigrationPackService.php` + - acceptance_criteria: + - GIVEN a valid definition and up to 20 rows WHEN an administrator previews THEN each row comes back with `rowNumber` from 1, `data` and `errors` + - 21 rows answer 400 `too-many-rows`; a missing `definition`, `rows` that are not a list of objects, or more than 1 MiB of definition and rows together answer 400 `invalid-request`; an invalid definition answers 422 `invalid-pack` with `details.errors`; a non-admin gets 403 +- [x] 1.4 Remove `NoCSRFRequired` from create, update, destroy and import; preview without it (D-6). + - spec_ref: `migration-mapping-packs#requirement-migration-pack-writes-must-require-a-csrf-token` + - files: `lib/Controller/MigrationPacksController.php` + - acceptance_criteria: + - GIVEN a browser session WHEN a write is sent without the request token THEN Nextcloud refuses it before the controller runs + - `testWriteMethodsRequireCsrfAndAdmin` covers all five write methods +- [x] 1.5 An info log line on every create, update, import and delete, with the acting user id, the pack slug and the pack row id; the import line says whether it created or replaced a pack; no line on a failed change (D-7). + - spec_ref: `migration-mapping-packs#requirement-pack-changes-must-be-limited-to-administrators-and-logged` + - files: `lib/Controller/MigrationPacksController.php`, `tests/Unit/Controller/MigrationPacksControllerTest.php` + - acceptance_criteria: + - GIVEN administrator `ronald` WHEN he creates, updates, imports or deletes a pack THEN the log has an info line naming `ronald`, the slug and the row id + - GIVEN a stored pack WHEN a file with its slug is imported THEN the line says the import replaced it + - A browser cannot read the Nextcloud log, so these have PHPUnit tests (`testCreateLogsTheActorAndSlug`, `testUpdateLogsTheActorAndSlug`, `testImportReplacingAStoredPackLogsTheActorSlugAndOutcome`, `testImportOfANewSlugLogsThatItCreatedAPack`, `testDestroyActsOnTheRouteIdAndLogsTheActorAndSlug`, and the `...FailureWritesNoAuditLine` tests) and no e2e scenario +- [x] 1.6 One error shape for every refusal of the controller (D-12, ADR-050): `{message, error, details?}`, the message a fixed IL10N sentence and never an exception's text, codes as listed in D-12. + - spec_ref: `migration-mapping-packs#requirement-migration-packs-must-be-manageable-via-admin-gated-rest-crud-with-json-file-importexport` + - files: `lib/Controller/MigrationPacksController.php`, `lib/Service/MigrationPack/`, `tests/Unit/Controller/MigrationPacksControllerTest.php` + - acceptance_criteria: + - GIVEN an invalid definition WHEN it is created, updated, imported or previewed THEN the answer is 422 `invalid-pack` with the findings in `details.errors`, rows numbered from 1 + - GIVEN an update of a row that does not exist THEN the answer is 404 `pack-not-found` + - GIVEN an unexpected exception THEN the answer is 500 `migration-pack-error` without its text, and the exception is logged + - Proven by `testEveryWriteAnswersAnInvalidPackInTheSharedShape`, `testAnUnknownRowIdAnswersPackNotFound`, `testAnUnexpectedFailureIsLoggedAndAnsweredWithoutItsText` and `testARefusedCallerGetsAMessageAndACode` +- [x] 1.7 An update envelope may name the `updatedAt` it was based on (`expectedUpdatedAt`); the server refuses the update with 409 `pack-changed` when the pack was saved since, and the editor sends it on every save (D-8). + - spec_ref: `migration-mapping-packs#requirement-create-and-update-must-accept-a-definition-envelope-and-keep-the-route-id` + - files: `lib/Service/MigrationPackService.php`, `lib/Service/MigrationPack/MigrationPackRequestReader.php`, `lib/Service/MigrationPack/MigrationPackErrorResponder.php`, `src/services/migrationPacks.js`, `src/views/migrationPack/MigrationPackDetail.vue` + - acceptance_criteria: + - GIVEN a stored pack WHEN an update names an `expectedUpdatedAt` the pack no longer has THEN the answer is 409 `pack-changed` and the pack is unchanged (`testUpdateRefusesAPackSavedAfterTheCallerReadIt`, `testUpdateBasedOnAnOutdatedReadAnswersPackChanged`) + - GIVEN an `expectedUpdatedAt` that is not a date-time as the API answers `updatedAt` THEN the answer is 400 `invalid-request` + - GIVEN the editor WHEN it saves THEN the update names the `updatedAt` it loaded, and a 409 `pack-changed` is shown with a reload + +## 2. Draft model + +- [x] 2.1 `packDraft.js`: ordered rows, stored key order, unknown keys kept, three-way lookup default, JSON-typed values, empty `[]` kept (D-2). + - spec_ref: `migration-mapping-packs#requirement-the-pack-editor-must-save-a-definition-without-losing-what-it-does-not-show` + - files: `src/views/migrationPack/packDraft.js` + - acceptance_criteria: + - GIVEN a stored definition WHEN the draft is built and turned back into a payload THEN it equals the stored one, key for key and in order +- [x] 2.2 Round-trip tests on the five stackiq TOPdesk packs and the seeded template pack; tests for duplicates, order, the three defaults, unknown keys, validation and summaries. + - spec_ref: `migration-mapping-packs#requirement-the-pack-editor-must-save-a-definition-without-losing-what-it-does-not-show` + - files: `src/views/migrationPack/packDraft.spec.js`, `tests/fixtures/migration-packs/` + - acceptance_criteria: + - GIVEN each of the five stackiq packs (four of which stackiq stores, see D-2) and `zgw-zaken-json` WHEN loaded and saved without an edit THEN each comes back equal and in the same key order + - GIVEN the module pack WHEN saved THEN the `"default": null` of its `cloudDienstverleningsmodel` lookup is still `null`, and both `Applicatiesoort` rows are kept in order + +## 3. Screens + +- [x] 3.1 Migration packs list: name, slug, source format, version, built-in, updated; open, export, delete with confirmation (built-in warning), import JSON. + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `src/views/migrationPack/MigrationPacksIndex.vue`, `src/dialogs/migrationPack/DeleteMigrationPackDialog.vue`, `src/services/migrationPacks.js` + - acceptance_criteria: + - GIVEN the built-in pack WHEN an administrator chooses Delete THEN the confirmation says the app installs it again, unedited + - GIVEN a JSON file WHEN an administrator imports it THEN the page says so and lists the pack +- [x] 3.2 Editor: general card (slug read-only when built-in), ordered mapping table with add, remove and move, defaults, rows to skip, Advanced (JSON) view, save with `{definition}`, patch version raised, readable 422, refusal when someone else saved first (D-8, D-11). + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `src/views/migrationPack/MigrationPackDetail.vue`, `src/components/migrationPack/KeyValueRows.vue` + - acceptance_criteria: + - GIVEN the usage pack at `1.0.0` WHEN a lookup value is changed and saved THEN the editor says first that the version goes to `1.0.1`, then that it saved `1.0.1` + - GIVEN a pack someone else saved after it was opened WHEN the administrator saves THEN nothing is sent and a reload is offered +- [x] 3.3 Transform dialog for every validator type, the three-way choice for other values, unknown keys and types named and kept. + - spec_ref: `migration-mapping-packs#requirement-the-pack-editor-must-save-a-definition-without-losing-what-it-does-not-show` + - files: `src/dialogs/migrationPack/TransformDialog.vue` + - acceptance_criteria: + - GIVEN a transform with an option the editor does not show WHEN its dialog opens THEN the dialog names the option and says it is kept +- [x] 3.4 Sample-row test over the preview endpoint, 400 ms after the last change, older answers dropped. + - spec_ref: `migration-mapping-packs#requirement-a-pack-must-be-testable-against-sample-rows-without-saving-it` + - files: `src/components/migrationPack/PackPreviewPanel.vue` + - acceptance_criteria: + - GIVEN an unsaved edit WHEN a sample row is tested THEN the result follows the edit + - GIVEN a required column left empty THEN the row's problem names its source and target +- [x] 3.5 Pages `migration-packs` and `migration-pack-detail` in the manifest and registry; menu entry next to Connections, gated on `user.isAdmin` that `MainMenu.vue` puts in the manifest runtime; pages read-only for a non-admin (D-6, D-10). + - spec_ref: `migration-mapping-packs#requirement-pack-changes-must-be-limited-to-administrators-and-logged` + - files: `src/manifest.json`, `src/menu-layout.json`, `src/registry.js`, `src/icons.js`, `src/navigation/MainMenu.vue` + - acceptance_criteria: + - GIVEN a non-admin WHEN they open OpenRegister THEN there is no Migration packs entry; an administrator has one + - GIVEN a non-admin WHEN they open a pack by URL THEN every field is disabled and there is no Save, Import, Delete or sample-row test +- [x] 3.6 Jest tests for the screens: manifest and menu gating, list load, delete, import, save envelope and version, conflict, 422, JSON view and slug lock, preview request, debounce and ordering. + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `src/views/migrationPack/MigrationPacks.spec.js` + - acceptance_criteria: + - `npm test -- src/views/migrationPack` passes +- [x] 3.7 The screens read the error code of D-12, show their own translated text for it, list `details.errors` one per line, and never parse an English server message. + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `src/services/migrationPacks.js`, `src/views/migrationPack/`, `src/components/migrationPack/PackPreviewPanel.vue` + - acceptance_criteria: + - GIVEN a save answered 422 `invalid-pack` with two findings THEN the editor shows each finding on its own line and keeps the unsaved changes + - Proven by the Jest cases "read the slug, never the wording, of an error" and "shows each finding of a refused save with its table row number" + +## 4. Translations and docs + +- [x] 4.1 English and Dutch for every new string. + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `l10n/en.js`, `l10n/nl.js`, `l10n/nl.json` (the manifest titles and menu label, which the manifest l10n gate reads there, and the controller's IL10N messages) + - acceptance_criteria: + - `npm run test:l10n` reports no missing key from these screens +- [x] 4.2 The screens' strings in the other required locales, so `npm run test:l10n:parity` passes. The controller's IL10N messages are in `nl.json` only, as for the rest of the backend set; any other locale shows them in English. + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `l10n/*.js` + - acceptance_criteria: + - `npm run test:l10n:parity` prints OK +- [x] 4.3 User documentation of the screens. + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `docs/features/migration-packs.md` + - acceptance_criteria: + - The page says who can do what, how to change, test and save a pack, what a built-in pack is, and where the log lines go + +## 5. Verification + +- [x] 5.1 Playwright: every scenario of the delta spec, anchored with `@e2e migration-mapping-packs::` (`tests/e2e/ci/migration-pack-pages.spec.ts`). 38 of 38 tests passed on 2026-10-13 against Nextcloud 35.0.1 with this branch (8ef92f0c27) built and installed. + - spec_ref: `migration-mapping-packs#requirement-administrators-must-be-able-to-list-and-edit-migration-packs-in-the-openregister-ui` + - files: `tests/e2e/ci/migration-pack-pages.spec.ts` + - acceptance_criteria: + - An administrator edits a lookup value and saves; deletes after confirming; imports a JSON file; tests a sample row + - A non-admin sees no menu entry, a read-only list and a read-only editor, and is refused 403 by the API + - A browser write without the request token is refused; the editor's own save carries it +- [ ] 5.2 On a rig with stackiq: the four packs stackiq stores (module, manufacturer, usage, business owner; the municipality pack stays in stackiq's files, see D-2) are listed, an edited lookup value shows up in the next CMDB import, and an unedited save leaves the stored definition byte-equal. Open: the import half has not been run. + - spec_ref: `migration-mapping-packs#requirement-the-pack-editor-must-save-a-definition-without-losing-what-it-does-not-show` + - acceptance_criteria: + - GIVEN a rig with stackiq WHEN the Migration packs page opens THEN the four stackiq packs are listed as built-in + - GIVEN an edited lookup value WHEN the CMDB import runs THEN the imported object carries the edited value diff --git a/openspec/changes/nested-property-filters/proposal.md b/openspec/changes/nested-property-filters/proposal.md new file mode 100644 index 0000000000..5b1eea5de3 --- /dev/null +++ b/openspec/changes/nested-property-filters/proposal.md @@ -0,0 +1,51 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: nested-property-filters + +## Why + +A filter cannot name a field inside an object property. shillinq keeps an +invoice's reminder state in an object property, `ARInvoice.dunning` +(`currentStage`, `nextDunningDate`, ...), and its receivables board counts the +invoices in a reminder round with `?dunning.currentStage[isnull]=false`. That +count is always 0. + +Measured on 2026-10-16 on the dev instance (PostgreSQL, openregister +`development` at b08b9fe188), over three ARInvoice objects whose `dunning` is +empty, as user pieter: + +| query | total | +|---|---| +| `_limit=1` | 1 | +| `dunning.currentStage[isnull]=true` | 0 | +| `dunning.currentStage[isnull]=false` | 0 | + +The first filter should match the invoice and the second should not. Neither +does. + +## Cause, in the code + +PHP turns `dunning.currentStage` into `dunning_currentStage`, and +`SearchQueryHandler::buildSearchQuery()` splits that back into +`['dunning' => ['currentStage' => ...]]`. `MagicSearchHandler::applyObjectFilters()` +then reads the value of an object property as a list of UUIDs +(`applyJsonObjectFilter`). A PHP caller passing `['dunning.currentStage' => 2]` +gets an unknown property instead, which adds `1 = 0`. The raw UNION path +(`buildObjectFilterConditionsSql`) behaves the same way. + +## What Changes + +- `NestedPathFilter` takes the filters on a path inside an object property out of the filter map, in both spellings: a dotted key, or the nested map the request layer rebuilds. A path can be any depth. +- The path is read from the JSON column: `#>>` on PostgreSQL, `JSON_UNQUOTE(JSON_EXTRACT())` on MariaDB and MySQL. +- The comparison uses the text form: equality, a list, `in`, `notIn`, `ne`, `isnull` and `like`. `gt`, `gte`, `lt` and `lte` compare as numbers when the operand is numeric. +- Both the QueryBuilder path and the raw UNION path use it. +- A path segment that is not a plain name matches nothing and is reported as an ignored filter. An encrypted object property is refused, the same as its column filter. +- A filter on the object property itself keeps its current meaning: a UUID, a list, or an operator bag. + +## Impact + +- Affected code: `lib/Db/MagicMapper/NestedPathFilter.php` (new), `lib/Db/MagicMapper/MagicSearchHandler.php`. +- No schema, migration or API shape changes. A query that answered an empty page now answers the matching objects. diff --git a/openspec/changes/nested-property-filters/specs/zoeken-filteren/spec.md b/openspec/changes/nested-property-filters/specs/zoeken-filteren/spec.md new file mode 100644 index 0000000000..c22d8496f5 --- /dev/null +++ b/openspec/changes/nested-property-filters/specs/zoeken-filteren/spec.md @@ -0,0 +1,34 @@ +# zoeken-filteren + +## ADDED Requirements + +### Requirement: A filter can name a field inside an object property + +A property filter SHALL accept a path into an object-typed property, written +as a dotted key (`dunning.currentStage=2`) or as the nested map the request +layer rebuilds from it (`['dunning' => ['currentStage' => 2]]`). It SHALL +compare the value at that path on PostgreSQL, MySQL and MariaDB, for the list +and the count, with equality, a list of values, `in`, `notIn`, `ne`, `isnull`, +`like` and the comparisons `gt`, `gte`, `lt` and `lte`. The comparisons SHALL +be numeric when the operand is a number. A boolean value SHALL compare in its +JSON spelling. A path segment that is not a plain property name SHALL match +nothing. A path into an encrypted property SHALL be refused, the same as a +filter on that property. A filter on the object property itself SHALL keep +its meaning. + +#### Scenario: invoices in a reminder round + +- **GIVEN** an ARInvoice schema whose object property `dunning` holds `currentStage`, two invoices at stage 1 and 2, and one without a stage +- **WHEN** a client lists `ARInvoice?dunning.currentStage[isnull]=false` +- **THEN** the two invoices with a stage are returned, and the total is 2 + +#### Scenario: the same filter from PHP + +- **GIVEN** the same invoices +- **WHEN** a PHP caller searches with `['dunning.currentStage' => 2]` +- **THEN** the invoice at stage 2 is returned + +#### Scenario: a path segment that is not a name + +- **WHEN** a client filters on `dunning.current'Stage=1` +- **THEN** nothing matches and the filter is reported as ignored diff --git a/openspec/changes/nested-property-filters/tasks.md b/openspec/changes/nested-property-filters/tasks.md new file mode 100644 index 0000000000..cd208d82ed --- /dev/null +++ b/openspec/changes/nested-property-filters/tasks.md @@ -0,0 +1,6 @@ +# Tasks: nested-property-filters + +## 1. Filter + +- [x] 1.1 `NestedPathFilter`: split the path filters out of a filter map in both spellings, read the path per platform, write the conditions per operator. Verify: PHPUnit `MagicSearchHandlerNestedPathFilterTest`. +- [x] 1.2 Apply them on the QueryBuilder path (`applyObjectFilters`) and the raw UNION path (`buildObjectFilterConditionsSql`), refusing an encrypted root. Verify: the same test, both paths; a live list on PostgreSQL with `dunning.currentStage[isnull]=true|false`. diff --git a/openspec/changes/object-archive-state/screens.md b/openspec/changes/object-archive-state/screens.md new file mode 100644 index 0000000000..3ce6df1eaf --- /dev/null +++ b/openspec/changes/object-archive-state/screens.md @@ -0,0 +1 @@ +- No screen: the archive and freeze are object states set through the API; the file half (REQ-OAS-007) is a refusal on the files API and on the Files/WebDAV door, which the files tab shows as the API's error text. diff --git a/openspec/changes/object-archive-state/tasks.md b/openspec/changes/object-archive-state/tasks.md index 59e0612c9f..f61fbf7a70 100644 --- a/openspec/changes/object-archive-state/tasks.md +++ b/openspec/changes/object-archive-state/tasks.md @@ -65,11 +65,11 @@ ## Woo programme amendment: file writes honour the freeze (REQ-OAS-007, supports 5.18 and 19.15) -- [ ] W.1 Add `lib/Service/Object/FileWriteGuard.php` (resolves the owning object, throws `ObjectStateWriteException::frozen()` or `::archived()`); call it from every write action in `FilesController` (`create`, `save`, `createMultipart`, `update`, `delete`, `rename`, `move`, `batch`, `lock`, `unlock`) and from the `FileService` write methods those actions use. Verify: `tests/Unit/Service/Object/FileWriteGuardTest.php::testEveryFilesControllerWriteActionCallsTheGuard` enumerates the controller's public methods by reflection and fails on a write action without the call; `testAFrozenObjectRefusesAnUpload` through `FilesController::create()` with a real `ObjectEntity` carrying the marker (fails today: the upload succeeds); `testAnUnfrozenObjectAcceptsFileWritesAgain`; `testReadsStayAllowed`. -- [ ] W.2 Add `lib/Listener/FrozenNodeWriteListener.php` on `BeforeNodeWrittenEvent`, `BeforeNodeDeletedEvent`, `BeforeNodeRenamedEvent` and `BeforeNodeCreatedEvent`, registered in `lib/AppInfo/Application.php`, calling `abortOperation()` for a node in a frozen or archived object's folder and for an unresolvable owner inside the register tree. Verify: `tests/Unit/Listener/FrozenNodeWriteListenerTest.php::testAWriteIntoAFrozenObjectFolderIsAborted` and `testAnUnresolvableOwnerInTheRegisterTreeIsRefused`, constructing the real event classes; `testANodeOutsideTheRegisterTreeIsIgnored`. -- [ ] W.3 Map the exception to 409 with `{error, state, by, at, reason}` in the files controller responses. Verify: `tests/Unit/Controller/FilesFrozenResponseTest.php`. -- [ ] W.4 Through the callers: a Newman sequence in `tests/newman/` freezes an object, gets 409 on `POST .../files` and a failed WebDAV `PUT` into its folder, unfreezes, and gets 200; `tests/e2e/ci/object-archive-state.spec.ts` gains a case that freezes an object and sees the upload refused with the reason in the files tab. -- [ ] W.5 Contract for the consumers: freeze through `POST /api/objects/{register}/{schema}/{id}/freeze` with `{reason, state?}`, the 409 body above on a file write. Verify: `tests/Contract/FrozenFileWriteContractTest.php` pins both; `opencatalogi/publication-withdrawal-aftercare` and `dossiq/woo-delivered-set-is-a-record` carry their consumer tests. Every consumer requires OpenRegister, so there is no absent-app path. +- [x] W.1 (`lib/Service/Object/FileWriteGuard.php`, called on the change path of `FilesController::ensureObjectAccess()`, so every write action goes through it; `FileService` writes reach the guard through `FrozenNodeWriteListener`, because they go through the Nextcloud node API; `tests/Unit/Service/Object/FileWriteGuardTest.php`) Add `lib/Service/Object/FileWriteGuard.php` (resolves the owning object, throws `ObjectStateWriteException::frozen()` or `::archived()`); call it from every write action in `FilesController` (`create`, `save`, `createMultipart`, `update`, `delete`, `rename`, `move`, `batch`, `lock`, `unlock`) and from the `FileService` write methods those actions use. Verify: `tests/Unit/Service/Object/FileWriteGuardTest.php::testEveryFilesControllerWriteActionCallsTheGuard` enumerates the controller's public methods by reflection and fails on a write action without the call; `testAFrozenObjectRefusesAnUpload` through `FilesController::create()` with a real `ObjectEntity` carrying the marker (fails today: the upload succeeds); `testAnUnfrozenObjectAcceptsFileWritesAgain`; `testReadsStayAllowed`. +- [x] W.2 (`lib/Listener/FrozenNodeWriteListener.php`; write and create throw a `HintException`, because Nextcloud's hook emitter swallows every other exception there; delete and rename use `abortOperation()`. "Unresolvable" means the owner lookup FAILED; "no such object" is the folder of an object still being created and is let through; `tests/Unit/Listener/FrozenNodeWriteListenerTest.php`) Add `lib/Listener/FrozenNodeWriteListener.php` on `BeforeNodeWrittenEvent`, `BeforeNodeDeletedEvent`, `BeforeNodeRenamedEvent` and `BeforeNodeCreatedEvent`, registered in `lib/AppInfo/Application.php`, calling `abortOperation()` for a node in a frozen or archived object's folder and for an unresolvable owner inside the register tree. Verify: `tests/Unit/Listener/FrozenNodeWriteListenerTest.php::testAWriteIntoAFrozenObjectFolderIsAborted` and `testAnUnresolvableOwnerInTheRegisterTreeIsRefused`, constructing the real event classes; `testANodeOutsideTheRegisterTreeIsIgnored`. +- [x] W.3 (`ObjectStateWriteException::toResponseBody()`, `FilesController::stateRefused()`; `tests/Unit/Controller/FilesFrozenResponseTest.php`) Map the exception to 409 with `{error, state, by, at, reason}` in the files controller responses. Verify: `tests/Unit/Controller/FilesFrozenResponseTest.php`. +- [ ] W.4 (written, live pass, decision 139: `tests/newman/openregister-frozen-files.postman_collection.json` (not registered in run-all.sh until its first live run; the WebDAV PUT is left out because no API user reaches the openregister home over WebDAV without a share) and the new case in `tests/e2e/ci/object-archive-state.spec.ts`) Through the callers: a Newman sequence in `tests/newman/` freezes an object, gets 409 on `POST .../files` and a failed WebDAV `PUT` into its folder, unfreezes, and gets 200; `tests/e2e/ci/object-archive-state.spec.ts` gains a case that freezes an object and sees the upload refused with the reason in the files tab. +- [x] W.5 (`tests/Unit/Contract/FrozenFileWriteContractTest.php`, in the unit suite so it runs on every PR; the freeze route now passes `state` through) Contract for the consumers: freeze through `POST /api/objects/{register}/{schema}/{id}/freeze` with `{reason, state?}`, the 409 body above on a file write. Verify: `tests/Contract/FrozenFileWriteContractTest.php` pins both; `opencatalogi/publication-withdrawal-aftercare` and `dossiq/woo-delivered-set-is-a-record` carry their consumer tests. Every consumer requires OpenRegister, so there is no absent-app path. ## V. Verification and done diff --git a/openspec/changes/object-tree-read/design.md b/openspec/changes/object-tree-read/design.md new file mode 100644 index 0000000000..048d323663 --- /dev/null +++ b/openspec/changes/object-tree-read/design.md @@ -0,0 +1,54 @@ +# Design: object-tree-read + +Read at openregister development b9e5d8df4. + +## D-1: no second declaration + +The parent property comes from `x-openregister-hierarchy.parent` when present, else from +`x-openregister-party.parentProperty`. Both already exist and both mean "this property +holds the uuid of the parent object in this schema". The hierarchy one also sends +grants down, which an organisation group must not do (a person allowed to read the +holding is not thereby allowed to read every subsidiary), so the party declaration is +how an organisation tree is declared. The depth bound is the declaration's `maxDepth`, +held under `HierarchyGrantExpander::DEPTH_CEILING`. + +## D-2: one query per level + +Parent and child are rows of the same magic table, so a level is +`HierarchyDescender::childrenOf()` (one chunked `IN` query). Names and requested fields +come from `MagicMapper::findObjectsByUuidsInRegisterSchema()`, one query per level. + +## D-3: RBAC per node, pruning the subtree + +Every level is filtered by `MagicMapper::filterReadableUuids()`, the same access filter +the single-object read applies. A node the caller may not read is left out and the walk +does not continue below it, so the answer never shows a level number that skips a +hidden organisation. Walking up stops at the first unreadable ancestor for the same +reason. In-process callers that scope their own reads (portaliq reads with +`_rbac: false` everywhere and applies the portal subject's scope itself) pass +`_rbac: false`. The API route always applies RBAC. + +## D-4: bounds and an answer that says it is partial + +Three bounds: the declared `maxDepth`, the requested `depth`, and the instance node cap +(`RelationGraphService::maximumNodes()`, setting `relationGraphMaxNodes`). The answer +carries `truncated` and `truncatedBy` (`depth`, `cap`, or `unreadable` when a level +could not be read). `depth` is only reported when there really is more below the last +level, so a consumer can refuse a partial tree, which is what portaliq does. + +## D-5: cycles + +A uuid already in the answer is never added again, in either direction, so a cycle in +the data ends the walk. Writes are already guarded by `PartyTreeGuardListener`; the read +does not trust that. + +## Answer shape + +``` +{ root, register, schema, parentProperty, direction, depth, requestedDepth, maxDepth, + nodes: [{ uuid, name, level, parent, properties: {: value} }], + truncated, truncatedBy } +``` + +Ancestors have negative levels (nearest is -1) and come first, the root is level 0, +descendants follow breadth first. diff --git a/openspec/changes/object-tree-read/proposal.md b/openspec/changes/object-tree-read/proposal.md new file mode 100644 index 0000000000..3e30b3a3b3 --- /dev/null +++ b/openspec/changes/object-tree-read/proposal.md @@ -0,0 +1,79 @@ +--- +kind: code +--- + +# Proposal: object-tree-read + +## Summary + +One generic read answers "which objects sit below (or above) this one" along the parent +property a schema already declares, with a level per object, a depth bound, cycle +protection, a node cap, and the caller's RBAC on every node. An organisation's group +(parent company and subsidiaries) is the first consumer: portaliq's "Reikt tot" on the +mandates screen and its case list under a mandate that reaches down (decision 250). + +## Why + +Decision 250 (Ruben, 15 Oct): an organisation's parent/subsidiary tree is a generic +OpenRegister relation between organisation objects, named by register, schema and field, +and every app reads it the same way. + +What exists, measured on development b9e5d8df4: + +- **Where organisations live as objects.** OpenRegister's own `Organisation` entity + is the tenancy concept, not a party. The party objects with a KvK identity are + dossiq's `kvkCompany` (register `dossiq`, `lib/Settings/register.d/25-brp-kvk.json`, + identity `kvkNumber`, name `tradeName`, kept current from KvK by integriq's + `KvkMutatieProvider`). pipelinq's `client` carries no KvK number; dossiq's + `partnerOrganization` is a chain partner keyed by OIN; OpenRegister's + `kvk_register.json` is a mock Handelsregister. portaliq has no organisation schema: + `PortalPartyTreeResolver` reads `portaliq/organisation.parent`, which does not exist. + No schema in the fleet records an organisation's parent today. +- **How a parent is declared.** Two declarations already exist and both name a + property that points at the same schema: + - `x-openregister-hierarchy.parent` (`HierarchyGrantExpander::declarationFor()`), + which also makes grants on a parent reach its children; + - `x-openregister-party.parentProperty` (`PartyDefinition::parentProperty()`), the + party model's organisation parent, already guarded against cycles and depth on + write by `PartyTreeGuardListener` / `PartyService::assertParentAllowed()`. +- **What reads them.** `HierarchyDescender::childrenOf()` reads one level of children + from the magic table; `ancestorsOf()` walks up, but only for the hierarchy + annotation. `RelationGraphService::graph()` walks every relation, typed, without + levels along one property. Nothing returns the descendants of an object with their + level, and nothing reads the party parent at all. + +## What changes + +- `ObjectTreeService::tree()` walks descendants, ancestors or both of one object along + the declared parent property, reusing `HierarchyDescender::childrenOf()` for each + level and `MagicMapper::filterReadableUuids()` for read access. +- `GET /api/objects/{register}/{schema}/{id}/tree?direction=&depth=&fields=` exposes it, + next to the existing `graph` route, resolving the root through `ObjectReadAccess`. +- No new annotation. An organisation tree is declared with + `x-openregister-party.parentProperty` (no access is inherited); a tree that should + also carry access uses `x-openregister-hierarchy` (which wins when both are present). + +## Consumers + +- portaliq: "Reikt tot" on the Machtigingen board, and mandated cases that reach down + (`PortalPartyTreeResolver`). +- dossiq: `kvkCompany` gains the parent property and the party declaration (its own + register change, named in tasks). +- Any app with a self-referencing schema (departments, product groups) gets the same + read; `records-tree-view` stays the one-level browse for lists. + +## Impact + +- Additive: one service, one route. A schema without a declared parent answers with the + root alone. +- Size: S. + +## Out of scope + +- Filling the relation from KvK. The KvK APIs integriq reads (Zoeken, Basisprofiel, + Vestigingsprofiel, Naamgeving, Mutatieservice) carry no group relations; + "vestigingen" are branches of one registration, not subsidiaries. KvK's + "Handelsregister Concernrelaties" (enig aandeelhouder, aansprakelijke, bestuurder) + is an ordered product, not an API. So the parent is recorded by hand (or by a later + source) and this change says so rather than faking a feed. +- Trees across schemas. diff --git a/openspec/changes/object-tree-read/specs/referential-integrity/spec.md b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md new file mode 100644 index 0000000000..5f7988fbce --- /dev/null +++ b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md @@ -0,0 +1,57 @@ +# referential-integrity + +## ADDED Requirements + +### Requirement: An object's tree along its declared parent property can be read (REQ-OTR-001) + +OpenRegister SHALL return the descendants, the ancestors, or both of one object along +the parent property its schema declares (`x-openregister-hierarchy.parent`, else +`x-openregister-party.parentProperty`), each with its level, its parent and its name. +The walk MUST stop at the declared `maxDepth`, at the requested depth and at the +instance node cap, MUST never visit an object twice, and MUST say in `truncatedBy` +which bound stopped it when more objects exist past it. A schema without a declared +parent SHALL answer with the root alone. + +#### Scenario: a holding's subsidiaries by level + +- **GIVEN** an organisation schema declaring `x-openregister-party` with `parentProperty` `parentOrganisation` +- **AND** a holding with two subsidiaries, one of which has a subsidiary of its own +- **WHEN** a client reads the tree of the holding +- **THEN** it gets four nodes with levels 0, 1, 1 and 2, each with its name and parent +- @e2e exclude {API read, covered by ObjectTreeServiceTest with a real schema fragment} + +#### Scenario: a cycle in the data ends the walk + +- **GIVEN** three organisations whose parents form a cycle +- **WHEN** a client reads the tree of one of them +- **THEN** each organisation appears once and the answer returns +- @e2e exclude {cycle guard, covered by ObjectTreeServiceTest} + +#### Scenario: a depth below the tree's height is reported + +- **GIVEN** the holding of the first scenario +- **WHEN** a client reads its tree with depth 1 +- **THEN** it gets the holding and its two subsidiaries +- **AND** `truncated` is true with `truncatedBy` `depth` +- @e2e exclude {bound logic, covered by ObjectTreeServiceTest} + +### Requirement: The tree read honours the caller's read access (REQ-OTR-002) + +Every node of the tree SHALL pass the caller's read access. A node the caller may not +read MUST be left out together with everything reached only through it, and the walk up +MUST stop at the first ancestor the caller may not read. A root the caller may not read +SHALL be answered with 404 before anything is walked. + +#### Scenario: a hidden subsidiary is not returned + +- **GIVEN** the holding of REQ-OTR-001 and a user who may read the holding, the second subsidiary and the grandchild, but not the first subsidiary +- **WHEN** that user reads the holding's tree +- **THEN** only the holding and the second subsidiary are returned +- @e2e exclude {RBAC pruning, covered by ObjectTreeServiceTest; a live check is task 3.1} + +#### Scenario: an unreadable root is not found + +- **GIVEN** an organisation the user may not read +- **WHEN** the user requests its tree +- **THEN** the answer is 404 and no level is read +- @e2e exclude {covered by ObjectRelationsControllerTreeTest} diff --git a/openspec/changes/object-tree-read/tasks.md b/openspec/changes/object-tree-read/tasks.md new file mode 100644 index 0000000000..8a45fd415f --- /dev/null +++ b/openspec/changes/object-tree-read/tasks.md @@ -0,0 +1,15 @@ +# Tasks: object-tree-read + +## 1. Backend + +- [x] 1.1 `ObjectTreeService::tree()` and `declarationFor()`. Verify: `tests/Unit/Service/Relation/ObjectTreeServiceTest.php` (real organisation fragment, cycle, depth bound, node cap, RBAC pruning down and up, hierarchy declaration). +- [x] 1.2 `GET /api/objects/{register}/{schema}/{id}/tree` on `ObjectRelationsController`. Verify: `tests/Unit/Controller/ObjectRelationsControllerTreeTest.php` (404 before any walk, 400 on an unknown direction, query reaches the walk). + +## 2. Consumers (other repositories) + +- [ ] 2.1 dossiq: `kvkCompany` gains `parentCompany` (uuid of a `kvkCompany`) and `x-openregister-party` `{kind: organisation, nameProperty: tradeName, parentProperty: parentCompany}`. +- [ ] 2.2 portaliq: `PortalPartyTreeResolver` reads this tree from the configured party schema; "Reikt tot" lists it. + +## 3. Live + +- [ ] 3.1 On a live instance: a holding with two subsidiaries and one below them; the tree route returns four nodes with levels 0, 1, 1, 2; a user without read on one subsidiary gets three. diff --git a/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md b/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md index f2ed2c46bf..64ad7e5962 100644 --- a/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md +++ b/openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md @@ -23,6 +23,15 @@ refused (404), even when the flow and node id otherwise resolve. - **THEN** only that node runs, against the given subject, and a `FlowRun` record is created scoped to it +#### Scenario: The caller's config reaches the node + +- **GIVEN** a published node whose authored config is + `{templateId: "authored", folder: "Besluiten"}` +- **WHEN** the caller runs it directly with `config: {templateId: "picked"}` +- **THEN** the node executes with `{templateId: "picked", folder: "Besluiten"}`: + caller keys win, authored keys the caller did not send stay, and the + published version pin does not drop the caller's config + ### Requirement: Direct invocation is authorized against the subject object The endpoint MUST NOT authorize solely on the flow named-rights matrix diff --git a/openspec/changes/or-flow-run-node/tasks.md b/openspec/changes/or-flow-run-node/tasks.md index 4d396d15c6..68f5cc4f9d 100644 --- a/openspec/changes/or-flow-run-node/tasks.md +++ b/openspec/changes/or-flow-run-node/tasks.md @@ -45,6 +45,14 @@ Implementation below. field unchanged; a node with no `IFlowNodeConfigForm` returns a body with NO `configForm` key (existing "absent, not empty" convention from `FlowNodeRegistry::palette()`, not a new failure mode). +- [x] 6. The caller's `config` reaches the node. `run()` built a merged step + but `executeNode()` re-pins the PUBLISHED graph, which replaced it, so a + picker's choice was dropped. `executeNode()` now takes `config` and overlays + it on the pinned step (caller keys win); the controller passes it. + - unit tests: `FlowRunServiceExecuteNodeTest::testTheCallersConfigOverlaysThePublishedStep`, + `testExecuteNodeRunsWithThePublishedConfigByDefault`; + `FlowNodeRunControllerTest::testAnAuthorizedCallerRunsTheNodeAndReceivesTheRun` + asserts the config is passed. - [ ] 4. `composer check:strict` exits 0. - [x] 5. `@spec openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md` on every changed/added method. diff --git a/openspec/changes/or-form-and-journey-registry/proposal.md b/openspec/changes/or-form-and-journey-registry/proposal.md index 84c76a64fa..10f59c2f08 100644 --- a/openspec/changes/or-form-and-journey-registry/proposal.md +++ b/openspec/changes/or-form-and-journey-registry/proposal.md @@ -48,10 +48,13 @@ the two can drift, they will. `next` rules using `$defs.visibleWhen` verbatim, `writes[]` per step, and `access` (`anonymous` | `authenticated` | `minTrust`). -**A `journeyRun` stages everything.** Answers accumulate there; objects are -written only at steps declaring `writes[]`. This preserves the property the -React wizards have by accident — an abandoned registration leaves no -half-built organisation — while adding the resumability they lack. +**There is no `journeyRun`** (amended by `form-destination-validator`, +decisions 179 and 180, ADR-117). A saved journey is its destination objects in +lifecycle status `draft`, owned by the filer. Objects leave draft only at steps +declaring `writes[]`, through `FormSubmitService`, all or none, so an abandoned +registration still leaves no active half-built organisation. The `journeyRun` +schema, its resume API and its retention below are superseded and are rewritten +when this change is built. **Retention ships with the schema, not after it.** A `journeyRun` holds names, addresses, e-mail, phone numbers and uploads before any of it is a record. diff --git a/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md b/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md index 1d6d30306c..05b02bb56a 100644 --- a/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md +++ b/openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md @@ -64,18 +64,24 @@ canonical `$defs.visibleWhen`; optional per-step `writes[]` of - **WHEN** it is written - **THEN** the write is rejected — access is never inferred -### Requirement: The run API MUST stage answers and commit only at declared steps +### Requirement: A saved journey MUST be its draft objects and MUST commit a step all or none -The API SHALL expose start, answer, resume and submit operations over a -`journeyRun`. Answers SHALL be persisted to the run. Objects SHALL be created -or updated only at a step declaring `writes[]`, in declared order, with a later -entry able to reference an earlier entry's id. +Amended by `form-destination-validator` (decision 179, ADR-117; decision 180). +There SHALL be no `journeyRun` object holding answers. A saved journey SHALL be +its `writes[]` destination objects in lifecycle status `draft`, owned by the +filer. Objects SHALL leave `draft`, or be created, only when a step declaring +`writes[]` is submitted, through `FormSubmitService`, all or none, in declared +order, with a later entry able to reference an earlier entry's id. Where this +change still names `journeyRun` (declaration, resume, retention), that text is +superseded by decision 180 and is rewritten when this change is built. -#### Scenario: Advancing without a writes step creates nothing +#### Scenario: Advancing without a writes step activates nothing -- **GIVEN** a run advanced past two steps, neither declaring `writes[]` +- **GIVEN** a journey advanced past two steps and saved, neither step declaring + `writes[]` as submitted - **WHEN** the target registers are queried -- **THEN** no object has been created +- **THEN** any object for this journey is in status `draft`, and no + `journeyRun` object exists #### Scenario: A dependent write receives the preceding write's id @@ -84,13 +90,12 @@ entry able to reference an earlier entry's id. - **WHEN** the step commits - **THEN** the contact carries the organisation's id -#### Scenario: A partial failure is recorded and is not duplicated on retry +#### Scenario: A partial failure leaves nothing behind -- **GIVEN** a step whose second write fails validation after the first - succeeded -- **WHEN** the step is re-submitted -- **THEN** the failure is recorded on the run, and the first object is updated - rather than created a second time +- **GIVEN** a step whose second write is refused after the first succeeded +- **WHEN** the step is submitted +- **THEN** the first object is deleted before the response, and the filer sees + the second write's findings #### Scenario: An answer for a field the current step does not declare is refused diff --git a/openspec/changes/or-form-and-journey-registry/tasks.md b/openspec/changes/or-form-and-journey-registry/tasks.md index d15fdc62ce..12ba505394 100644 --- a/openspec/changes/or-form-and-journey-registry/tasks.md +++ b/openspec/changes/or-form-and-journey-registry/tasks.md @@ -16,13 +16,13 @@ - [ ] Implement - [ ] Test -### Task 2: Journey run service — staged answers, committed writes -- **spec_ref**: `openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md#requirement-the-run-api-must-stage-answers-and-commit-only-at-declared-steps` +### Task 2: Journey run service: committed writes through FormSubmitService +- **spec_ref**: `openspec/changes/or-form-and-journey-registry/specs/form-and-journey-registry/spec.md#requirement-a-journey-run-must-store-no-written-ids-and-must-commit-a-step-all-or-none` - **files**: `lib/Service/JourneyRunService.php`, `tests/Unit/Service/JourneyRunServiceTest.php` - **acceptance_criteria**: - - Answers persist to the `journeyRun`; no target-register object exists until a step declaring `writes[]` commits — proven by querying the target register mid-run - - Writes execute in declared order; a later entry resolves a preceding entry's id - - A mid-`writes[]` failure records the failure AND the already-written id; re-submitting UPDATES that object rather than creating a second + - No target-register object exists until a step declaring `writes[]` commits, proven by querying the target register mid-run; there is no `journeyRun`: a saved journey is its destination objects in status `draft` (decisions 179, 180) + - Writes go through `FormSubmitService::submitAll` (form-destination-validator), in declared order; a later entry resolves a preceding entry's id with `{ "$write": "" }` + - A mid-`writes[]` refusal deletes the earlier writes of that step before the response (all or none) - An answer for a field outside the current step is refused — the client does not select its own scope - [ ] Implement - [ ] Test diff --git a/openspec/changes/records-saved-templates/tasks.md b/openspec/changes/records-saved-templates/tasks.md index bb00526054..46aef8395c 100644 --- a/openspec/changes/records-saved-templates/tasks.md +++ b/openspec/changes/records-saved-templates/tasks.md @@ -3,7 +3,9 @@ ## 1. Store - [ ] 1.1 `RecordTemplate` entity, mapper with reach listing, migration for `openregister_record_templates`. Verify: mapper tests for owner, group-shared and public templates on PostgreSQL and MariaDB. + - built (11 Oct, build/dep-dossiq-record-templates): `lib/Db/RecordTemplate.php`, `lib/Db/RecordTemplateMapper.php` (`findCandidates`: own, public or shared rows, by register/schema), `lib/Migration/Version1Date20261011090000.php` (`template_values`, as VALUES is an SQL keyword). The reach decision (owner, group share, public, plus create rights) is in `lib/Service/RecordTemplate/RecordTemplateService.php`, unit-tested in `tests/Unit/Service/RecordTemplate/RecordTemplateServiceTest.php`; entity, mapper table and migration in `tests/Unit/Db/RecordTemplateTest.php` and `tests/Unit/Migration/Version1Date20261011090000Test.php`. Open: the DB-backed mapper run on PostgreSQL and MariaDB (CI matrix / live pass, decision 139). - [ ] 1.2 `/api/record-templates` controller and routes beside `/api/views`, with owner-or-admin update and delete, and use limited to users who may create in the schema. Verify: `tests/Api/RecordTemplatesTest` including a user without create rights who does not see the template. + - built (11 Oct): `lib/Controller/RecordTemplatesController.php`, six routes after `views#destroy`; `tests/Unit/Controller/RecordTemplatesControllerTest.php` (401, filters, 201/204, 400 with findings, 403, 404, 500 without the message); the read-only member case through the service in `RecordTemplateServiceTest::testAMemberWithoutCreateRightsIsNotOfferedTheTemplate`. Open: `tests/Api/RecordTemplatesTest` against an instance (live pass, decision 139). ## 2. Interface diff --git a/openspec/changes/reimport-real-register-configuration/.openspec.yaml b/openspec/changes/reimport-real-register-configuration/.openspec.yaml new file mode 100644 index 0000000000..49ea85b3f2 --- /dev/null +++ b/openspec/changes/reimport-real-register-configuration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-14 diff --git a/openspec/changes/reimport-real-register-configuration/design.md b/openspec/changes/reimport-real-register-configuration/design.md new file mode 100644 index 0000000000..22b98463a5 --- /dev/null +++ b/openspec/changes/reimport-real-register-configuration/design.md @@ -0,0 +1,67 @@ +## Context + +Three paths import an app's register configuration today: + +- The app's `InitializeSettings` repair step calls its SettingsService with `force: false` on install and upgrade. +- The app's admin "reload" action calls the same service, forced. +- `occ openregister:descriptors:list --import` called `ConfigurationService::importFromApp()` directly, on the first file in `lib/Settings` declaring the slug. + +The fleet's SettingsServices differ. Measured on the development branches on 14 Oct 2026: + +| Forced entry point | Apps | +|---|---| +| `loadConfigurationForced()` | shillinq, humaniq | +| `reloadConfiguration()` | decidiq | +| `loadConfiguration(bool $force)` | portaliq, dossiq, hermiq, stackiq, every AppHost app | +| `loadSettings(bool $force)` | pipelinq | +| none | opencatalogi, integriq, thematiq, launchpad, keepiq, versioniq | + +The fragment merge also differs. pipelinq replaces lists except two additive paths. OpenRegister's `RegisterDocumentLoader` concatenates lists. Some apps re-apply instance configuration after the import: pipelinq re-grants its CRM groups, portaliq its page-editor groups. + +## Decisions + +### D-1. The app's own path first + +The re-import calls the app's SettingsService when it has one of the four forced shapes. A method counts only when its first parameter is named `force`, or when it takes no required argument and is one of the two forced names. shillinq's `loadConfiguration()` takes no argument and is not forced, so matching the name alone would run an import the version gate skips. + +Without such a method, OpenRegister imports `_register.json` plus `register.d` through `RegisterDocumentLoader`, forced. The output then says the app's own post-import steps did not run. + +Alternative rejected: a new interface every app implements. That is the clean end state, but it needs a change in 21 apps before the command works anywhere. + +### D-2. Counts from the database + +The import's own return value differs per app and lists what it processed, not what changed. The service fingerprints each declared register and its schemas before and after: version, title, description, properties, required, authorization, configuration. It reports created, updated and unchanged. + +### D-3. An authorization block settles whole + +`GuardedDescriptorMerge` compares flattened paths. `authorization.read` and `authorization.export` were separate parts, so one import could take one from the app and keep the other. On portaliq that produced staff-only `read` with `export` open to every signed-in account. + +Each block (the schema's `authorization`, and each `properties..authorization`) now takes one state for all its keys: + +- Only the app changed it: the app's block, whole. +- Only the instance changed it: the instance's block, whole. +- Both, or each changed a different key: the instance's block, whole, reported as one conflict on the block root. + +The flattener is unchanged, so the reset of one key (`authorization.read`, spec `shipped-baseline-reset-is-reachable`) still works. + +### D-4. The administrator's re-import is the decision; the unattended upgrade is not + +REQ-LCA-003 says a part changed on both sides is not applied without an explicit decision, recorded with its actor. That is a documented reason to merge rather than to make the file authoritative everywhere. So: + +- An administrator's re-import (occ, the admin panel, an example-set install) runs under `ShippedAuthorizationDecision`, a shared service injected into `GuardedDescriptorMerge`. While a decision is in effect, the merge adds every authorization block root to the decisions. Each block replaced over a local change goes to the audit trail as `configuration.conflict.decided`, with `via` naming who asked. +- An unattended `occ upgrade` keeps the instance's block and reports the conflict, as REQ-LCA-003 requires. + +The decision travels as a scope, not a parameter, because the app's SettingsService calls `importFromApp()` with a signature OpenRegister does not own. + +**Proposed, not built: authorization follows the app on upgrade too.** An access policy is the app's security contract. An app that closes a register to its staff group (portaliq 0.97.0) does so to fix a leak, and a stale local value keeps the leak open until somebody runs the re-import. The proposal is to amend REQ-LCA-003: on upgrade the shipped authorization block replaces a locally changed one, the replaced block is recorded on the audit trail, and an instance that needs wider access says so through a grant layer that survives upgrades rather than by editing the schema. This needs Ruben's decision, because it removes a guarantee a municipality may rely on. + +## Findings while tracing + +- **The baseline is keyed by schema slug only** (`ShippedBaselineStore::schemaSubject()` returns `schema:`). Two apps that ship a schema with the same slug share one baseline. Not changed here. +- **Where portaliq's `export: [authenticated]` came from is not established.** No version of `portaliq_register.json` ever shipped it: the history is no authorization, then `read` only (0.96.0), then `read` and `export` for staff (0.97.0). OpenRegister code does not derive `export` from `read`. The stored baseline for `schema:portalAccount` on the demo instance has no `export` key, so the guard read it as a local addition. +- **A mock writes the real register's version.** A mock declares the real register's slug, so `--mock` sets that register's version to the mock's. The help text says so. + +## Risks + +- An app's forced entry point can do more than import, such as pipelinq's access sync. That is the point of D-1, but it means the command runs app code. +- A municipality's local authorization change is replaced when an administrator runs the re-import. The command lists every replaced block and the audit trail records each one. diff --git a/openspec/changes/reimport-real-register-configuration/proposal.md b/openspec/changes/reimport-real-register-configuration/proposal.md new file mode 100644 index 0000000000..b2b4e8e6bf --- /dev/null +++ b/openspec/changes/reimport-real-register-configuration/proposal.md @@ -0,0 +1,43 @@ +--- +kind: code +--- + +## Why + +An administrator could not re-import an app's real register configuration on demand. Ruben decided on 14 Oct 2026 that this must work. + +Three findings on the Zuiddrecht demo instance led here. + +1. `occ openregister:descriptors:list --app=portaliq --import=portaliq` imported the app's sample data, not its configuration. It reported "164 of 186 demo object(s) imported" and set the register to the mock's version 0.27.0. The schema authorization stayed as it was. `RegisterDescriptorService::descriptorFor()` took the first file in glob order that declared the slug. `portaliq_mock_register.json` sorts before `portaliq_register.json`. +2. The real import runs only from each app's `InitializeSettings` repair step, on install and upgrade. A register change without an app-version bump never reaches an existing instance. That happened twice in one week: portaliq #1549 and pipelinq #2471. +3. After the real import ran, portaliq's schemas had `read: [portaliq-medewerkers]` from the file but kept `export: [authenticated]` from the instance. The shipped-baseline guard merges per action key. It took `read` from the app and kept `export` as a local change. The result is a policy nobody wrote: an account that may not read a record may still export it. + +## What changes + +- `--import` re-imports the app's real configuration, forced. It runs through the app's own SettingsService when that has a forced entry point, so the app's fragment merge and post-import steps run as on upgrade. Otherwise OpenRegister imports `_register.json` with its `register.d` fragments itself, and says the app's own steps did not run. +- An app without one base document, such as OpenRegister, re-imports the non-mock descriptor that declares the slug. +- `--import --mock` imports the sample descriptor. Only a file marked `x-openregister.type: mock` qualifies. +- The real re-import reports registers and schemas created, updated and unchanged, read from the database before and after. +- An authorization block (a schema's, or a property's) settles whole on import. It is never assembled from both sides. +- The administrator's re-import is the explicit decision REQ-LCA-003 asks for. Each schema's authorization becomes the file's. Every block replaced over a local change is counted, listed and written to the audit trail with who asked. + +Not in scope: making an unattended upgrade replace a locally changed authorization block. That changes REQ-LCA-003. See design.md, decision D-4. + +## Capabilities + +### Modified capabilities + +- `register-descriptor-admin`: the re-import imports the real configuration, `--mock` imports the sample, and the outcome is counted in registers and schemas. +- `schema-import`: an authorization block settles whole, and an administrator's re-import takes the shipped block. + +## Impact + +- `lib/Service/RegisterDescriptorService.php`: `reimport()` imports the real configuration. `reimportMock()` keeps the sample import. +- New `lib/Service/AppRegisterImportRunner.php`: finds and runs the app's forced entry point, or imports the files itself. +- New `lib/Service/ShippedBaseline/ShippedAuthorizationDecision.php`: carries the administrator's decision to the guard for the length of one import. It is one shared service per request, registered in `Application`. +- New `lib/Service/CountedRegisterImport.php`: runs a re-import under the decision and counts registers and schemas before and after. +- New `lib/Service/ShippedBaseline/AuthorizationBlocks.php`: finds, settles and reports the authorization blocks for `GuardedDescriptorMerge`. +- `lib/Service/ShippedBaseline/GuardedDescriptorMerge.php`, `ShippedConfigurationGuard.php`: the block rule and the decision. +- `lib/Command/DescriptorListCommand.php`: `--mock`, the help text, the counts. +- `lib/Controller/RegisterDescriptorController.php`, `lib/Service/ExampleSet/ExampleSetRegisters.php`: pass who asked. +- Fleet guard for finding 2: gate-120 `register-version-needs-app-version` in `conduction/hydra-gates`. diff --git a/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md b/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md new file mode 100644 index 0000000000..1f598f4373 --- /dev/null +++ b/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md @@ -0,0 +1,86 @@ +## Purpose + +An administrator re-imports an app's real register configuration on demand, and sees what changed. + +@e2e exclude occ command and backend import service, covered by PHPUnit through the real command, service, runner and guard + +## ADDED Requirements + +### Requirement: Re-import imports the app's real register configuration (REQ-RDA-010) + +`occ openregister:descriptors:list --app= --import=` SHALL import the app's real register configuration, forced. That is `lib/Settings/_register.json` with its `lib/Settings/register.d/*.json` fragments, the same configuration an install or upgrade imports. It SHALL run through the app's own SettingsService when that has a forced entry point. Otherwise OpenRegister SHALL import those files itself and SHALL say the app's own post-import steps did not run. A mock descriptor SHALL NOT be imported by `--import` alone. + +#### Scenario: The app ships both a mock and a real descriptor for the same register + +- **GIVEN** portaliq ships `portaliq_mock_register.json` (version 0.27.0, demo objects) and `portaliq_register.json` (version 0.97.0), both declaring register `portaliq` +- **WHEN** an administrator runs `occ openregister:descriptors:list --app=portaliq --import=portaliq` +- **THEN** the configuration of version 0.97.0 is imported under configuration id `portaliq` +- **AND** no demo object from the mock is imported + +#### Scenario: Fragments are part of the configuration + +- **GIVEN** an app ships `register.d/10-extra.json` adding a schema +- **WHEN** the administrator re-imports the app's register +- **THEN** the imported configuration contains that schema +- **AND** the imported version carries the fragment signature + +#### Scenario: The app's own forced entry point runs + +- **GIVEN** the app's SettingsService has `loadSettings(bool $force)` +- **WHEN** the administrator re-imports the app's register +- **THEN** `loadSettings(force: true)` runs once +- **AND** OpenRegister does not import the files a second time +- **AND** the output names the method that ran + +#### Scenario: An app without a forced entry point + +- **GIVEN** the app's SettingsService has no forced entry point +- **WHEN** the administrator re-imports the app's register +- **THEN** OpenRegister imports `_register.json` with its fragments, forced +- **AND** the output says the app's own post-import steps did not run + +#### Scenario: An app without one base document + +- **GIVEN** an app ships no `_register.json` and one non-mock descriptor declares the slug +- **WHEN** the administrator re-imports that slug +- **THEN** that descriptor is imported, forced +- **AND** a mock descriptor declaring the same slug is not + +#### Scenario: A slug the configuration does not declare + +- **WHEN** the administrator re-imports a slug no non-mock descriptor of the app declares +- **THEN** the command exits 1 +- **AND** the message lists the registers the configuration declares and points to `--mock` + +### Requirement: Sample data is imported only when asked for (REQ-RDA-011) + +`--import= --mock` SHALL import the app's descriptor marked `x-openregister.type: mock` that declares the slug, under configuration id `.mock`, and SHALL report the demo objects imported. The help text SHALL say that `--import` imports the real configuration and that `--mock` imports sample data. + +#### Scenario: The mock import never picks the real descriptor + +- **GIVEN** the real descriptor's file name sorts before the mock's +- **WHEN** the administrator runs `--import= --mock` +- **THEN** the mock descriptor is imported +- **AND** the output counts the demo objects imported and skipped + +#### Scenario: The help says which is which + +- **WHEN** the administrator reads `occ openregister:descriptors:list --help` +- **THEN** `--import` is described as the real register configuration including `register.d` +- **AND** `--mock` is described as sample data + +### Requirement: The re-import reports what changed (REQ-RDA-012) + +The real re-import SHALL report registers and schemas as created, updated and unchanged, measured from the database before and after the import. It SHALL report how many authorization blocks it took from the file over a change made on the instance, and name each. + +#### Scenario: Counts come from the database + +- **GIVEN** register `flows` holds schemas 1 and 2 before the import +- **WHEN** the import changes schema 1, leaves schema 2, adds schema 3 and raises the register version +- **THEN** the output reads registers 1 updated, 0 created, 0 unchanged +- **AND** schemas 1 updated, 1 created, 1 unchanged + +#### Scenario: Replaced authorization is listed + +- **WHEN** the re-import takes `portalAccount`'s authorization from the file over a local change +- **THEN** the output counts 1 replaced block and names `portalAccount: authorization` diff --git a/openspec/changes/reimport-real-register-configuration/specs/schema-import/spec.md b/openspec/changes/reimport-real-register-configuration/specs/schema-import/spec.md new file mode 100644 index 0000000000..1088de0016 --- /dev/null +++ b/openspec/changes/reimport-real-register-configuration/specs/schema-import/spec.md @@ -0,0 +1,48 @@ +## Purpose + +An import never writes an access policy that neither the app nor the instance wrote. + +@e2e exclude backend merge and guard, covered by PHPUnit + +## ADDED Requirements + +### Requirement: An authorization block settles whole (REQ-LCA-005) + +On import, a schema's `authorization` block and each property's `authorization` block SHALL take one side for all their keys. When only the app changed the block, the app's block SHALL be written whole. When only the instance changed it, the instance's block SHALL stay whole. When both changed it, or each changed a different key, the instance's block SHALL stay whole and one conflict SHALL be reported on the block's root path. + +#### Scenario: The app closes read and export; the instance had added export + +- **GIVEN** the baseline is `authorization: {read: [authenticated]}` +- **AND** the instance runs `{read: [authenticated], export: [authenticated]}` +- **WHEN** an unattended upgrade ships `{read: [portaliq-medewerkers], export: [portaliq-medewerkers]}` +- **THEN** the schema keeps `{read: [authenticated], export: [authenticated]}` +- **AND** one conflict is reported, on `authorization` +- **AND** the schema is never written with `read` from the app and `export` from the instance + +#### Scenario: Only the app changed the block + +- **GIVEN** the instance runs the baseline block `{read: [authenticated], export: [authenticated]}` +- **WHEN** the app ships `{read: [staff]}` +- **THEN** the schema's block is `{read: [staff]}` + +#### Scenario: A property's authorization is its own block + +- **WHEN** both sides changed `properties.bsn.authorization` +- **THEN** one conflict is reported on `properties.bsn.authorization` +- **AND** the property's other keys merge as before + +### Requirement: An administrator's re-import takes the shipped authorization (REQ-LCA-006) + +An import an administrator asked for (the occ re-import, the register descriptors panel, an example-set install) SHALL count as the explicit decision of REQ-LCA-003 for every authorization block. The shipped block SHALL be written whole. Each block replaced over a local change SHALL be recorded on the audit trail as `configuration.conflict.decided`, with who asked. + +#### Scenario: The administrator re-imports portaliq + +- **GIVEN** the conflict of REQ-LCA-005's first scenario +- **WHEN** an administrator runs `occ openregister:descriptors:list --app=portaliq --import=portaliq` +- **THEN** `portalAccount` gets `{read: [portaliq-medewerkers], export: [portaliq-medewerkers]}` +- **AND** the audit trail records `configuration.conflict.decided` for `authorization` with `via: occ openregister:descriptors:list --import` + +#### Scenario: The decision ends with the import + +- **WHEN** the re-import finishes, or throws +- **THEN** a later unattended import keeps local authorization again diff --git a/openspec/changes/reimport-real-register-configuration/tasks.md b/openspec/changes/reimport-real-register-configuration/tasks.md new file mode 100644 index 0000000000..f0eb3a9436 --- /dev/null +++ b/openspec/changes/reimport-real-register-configuration/tasks.md @@ -0,0 +1,26 @@ +## 1. Re-import the real configuration + +- [x] 1.1 `RegisterDescriptorService::reimport()` imports the app's real configuration (base file plus `register.d`), forced, through the app's own forced entry point or OpenRegister's loader. +- [x] 1.2 An app without one base document re-imports the non-mock descriptor that declares the slug. +- [x] 1.3 `reimportMock()` imports only a descriptor marked `type: mock`. +- [x] 1.4 The result counts registers and schemas created, updated and unchanged, read from the database before and after. + +## 2. The command + +- [x] 2.1 `--import` runs the real re-import; `--import --mock` the sample. The help text says which is which. +- [x] 2.2 The output names the path it took, the counts, and every authorization block replaced over a local change. On the fallback path it says the app's own post-import steps did not run. + +## 3. Authorization + +- [x] 3.1 `GuardedDescriptorMerge` settles each authorization block whole, and reports a conflict once, on the block root. +- [x] 3.2 `ShippedAuthorizationDecision` carries the administrator's decision to the guard; each replaced block is recorded on the audit trail with who asked. +- [ ] 3.3 Decide D-4's proposal: does the shipped authorization block also replace a local one on an unattended upgrade? + +## 4. Tests + +- [x] 4.1 Unit tests through the real service, runner, guard, merge and command. The merge tests and a probe of the old `reimport()` fail on the old code. +- [ ] 4.2 Live check on a test instance: re-import portaliq and read back `authorization.export` on `portalAccount`. + +## 5. Fleet guard + +- [x] 5.1 gate-120 `register-version-needs-app-version` in `conduction/hydra-gates`: a change to a register document's `info.version` without a change to `appinfo/info.xml` `` fails. diff --git a/openspec/changes/reviewer-owns-their-decisions/proposal.md b/openspec/changes/reviewer-owns-their-decisions/proposal.md index 4db8840a1b..d0a794228e 100644 --- a/openspec/changes/reviewer-owns-their-decisions/proposal.md +++ b/openspec/changes/reviewer-owns-their-decisions/proposal.md @@ -1,6 +1,6 @@ --- kind: code -depends_on: [redaction-release-safeguards, access-owner-and-condition-scopes] +depends_on: [access-owner-and-condition-scopes] --- # Proposal: reviewer-owns-their-decisions @@ -11,7 +11,7 @@ A reviewer edits only their own redaction decisions and comments, and can be bar - Rows: 12.28 (not statutory). The grant on `wooAssessment` is not in this change: it belongs to whichever app ships that schema (D1 moved the Woo request to dossiq), and is open as a follow-up. - Wave 1, size M. -- Dependencies: `openregister/redaction-release-safeguards` (https://github.com/ConductionNL/openregister/issues/4392) for `decidedBy` and `decidedAt`, and `openregister/access-owner-and-condition-scopes` (open, 0 of 5 tasks, no issue yet) for `@creator`. +- Dependencies: `openregister/access-owner-and-condition-scopes` (open, 0 of 5 tasks, no issue yet) for `@creator` (section 3 only). The former dependency on `redaction-release-safeguards` for `decidedBy`/`decidedAt` is gone: decision 197 puts them on the decision object. - Decision: D1 decides who ships the assessment schema; no decision bears on this change directly. - Build rules: openspec/woo-build-rules.md @@ -23,12 +23,26 @@ A four-eyes review is only four eyes if the second reviewer cannot quietly overw ## What changes -- Decision ownership. A relation decided by a person (`decidedBy`, added by `redaction-release-safeguards`) can then be changed only by that person, by a member of a declared supervisor group, or by an administrator. An undecided relation can be decided by anyone who may write its subject, as today. A refused change answers 403 naming who decided and when. -- The decision note. `EntityRelation` gains `decisionNote`, a reviewer's comment on the decision, owned the same way as the decision. -- Hiding. When hiding is on, every endpoint that returns entity relations returns another reviewer's decided relation as `decision: "hidden"`, without `decidedBy`, `decisionNote` or `bases`, unless the caller is that reviewer, a supervisor or an administrator. The relation itself stays listed, so the reviewer knows there is something to decide. -- Configuration. Per schema through `x-openregister-review: {"ownership": "decider", "supervisors": [""], "hideOthers": true}`, and an instance default in the file settings (`anonymisation.review.ownership` `open` or `decider`, `anonymisation.review.supervisors`, `anonymisation.review.hideOthers`) for files that belong to no schema. The default stays `open`, today's behaviour. +Decision 197 (Ruben, 11 Oct 2026): "A decision is its own object that can be linked by other objects." The earlier draft put `decidedBy`, `decidedAt` and `decisionNote` on the entity relation. That is replaced. + +- The decision object. A new OpenRegister table `openregister_decisions` holds one decision per row: `uuid`, `subjectType` and `subjectId` (what was decided on), `verdict`, `decidedBy`, `decidedAt`, `note` and `supersedes`. A decision is never edited. A change records a new decision that names the one it supersedes, so who decided is a fact, not a field someone else can overwrite. `DecisionService` records and reads them for any subject type, so other apps can link decisions from their own objects. +- The link. An entity relation gains `decision_uuid` and serialises it as `decision`. `PATCH /api/entity-relations/{id}` records a decision object (`entity-relation`, the relation id, `redact` or `release`, the caller, and `decisionNote` from the body) and links it, in one transaction with the audited write of `bases` and `skipAnonymization`. The response carries the relation plus `decisionRecord`. +- Decision ownership. A relation whose current decision was taken by someone else can then be changed only by that person, by a member of a declared supervisor group, or by an administrator. An undecided relation can be decided by anyone who may write its subject, as today. A refused change answers 403 `{error: "decision-owned", decision, decidedBy, decidedAt}`, without the verdict or the note. +- The app that owns the subject decides who supervises. Before someone other than the decider changes a decider-owned decision, OpenRegister dispatches `DecisionChangeRequestedEvent` with the decision, the caller, the policy and the subject (register, schema, object uuid, file id). A listener may allow or refuse; a listener that fails refuses. This is how dossiq's case reviewer access answers per case, without OpenRegister knowing about cases. +- Hiding. When hiding is on, every endpoint that returns entity relations returns another reviewer's decided relation as `decision: "hidden"`, without the decision record or `bases`, unless the caller is that reviewer, a supervisor or an administrator. The relation itself stays listed, so the reviewer knows there is something to decide. +- Configuration. Per schema through `x-openregister-review: {"ownership": "decider", "supervisors": [""], "hideOthers": true}`, refused at save when the ownership is neither `open` nor `decider`. A relation on a file reads the schema of the object the file belongs to. For files that belong to no schema, the instance default sits in the file settings as `anonymisationReview` with the same shape. The default stays `open`, today's behaviour. - The assessment record. A Woo assessment is an OpenRegister object, so its half is an authorization block the owning app ships: `update: ["@creator", ""]` and, to hide, `read: ["@creator", ""]`, enforced by `access-owner-and-condition-scopes` (open, 0 of 5). This change adds a test that such a block on a fixture `wooAssessment`-shaped schema refuses a second reviewer's edit, so the owning app can rely on it. +### Why OpenRegister's own decision object, not decidiq's Decision + +decidiq's `Decision` already carries `subjectRegister`, `subjectSchema` and `subjectId`, so reuse was checked first. It does not fit a reviewer verdict on a redaction: + +- OpenRegister cannot depend on decidiq. Redaction review runs on any OpenRegister instance, and a duck-typed call to an app that is not installed would turn ownership into a silent no-op. +- The subject is an entity relation, a row in `openregister_entity_relations` with an integer id, not an object with a register, schema and uuid. decidiq's back-reference cannot name it. +- decidiq's Decision is a governance decision: `title`, `text` and `decisionType` are required, it has a voting lifecycle, and creating one notifies `decidesk-members` (`decisionRecorded`). A document review takes hundreds of verdicts per document; each would become a governance decision and a notification. + +decidiq's Decision stays the object for governance decisions. A later change can let decidiq link an OpenRegister decision when a governance decision rests on a review. + ## What does not change - Who may write the relation's subject at all: `actorCanWriteRelationSubject()` stays the first check. @@ -36,7 +50,7 @@ A four-eyes review is only four eyes if the second reviewer cannot quietly overw ## Dependencies and absent apps -- `redaction-release-safeguards` (wave 1) adds `decidedBy` and `decidedAt`; this change orders after it in the same wave. +- `redaction-release-safeguards` (wave 1) was to add `decidedBy` and `decidedAt` to the relation. Decision 197 moves both onto the decision object, so this change no longer waits on it for them. - `access-owner-and-condition-scopes` (open, 0 of 5) supplies `@creator` for the assessment half. - The plan names "the wooAssessment grant change in opencatalogi/woo-request-scoped-access". That change does not exist on opencatalogi `development` (checked 2026-10-05), and decision D1 moved the Woo request to dossiq. The grant change therefore belongs to whichever app ships the `wooAssessment` schema; the PR body names the authorization block above for that lane. No app is called at runtime, so there is no absent-app path. diff --git a/openspec/changes/reviewer-owns-their-decisions/screens.md b/openspec/changes/reviewer-owns-their-decisions/screens.md new file mode 100644 index 0000000000..13649f2740 --- /dev/null +++ b/openspec/changes/reviewer-owns-their-decisions/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: the decision object, the ownership rule and the review declaration are API and storage inside OpenRegister; the reviewer-facing states ("decided by another reviewer") appear on the consuming app's review screen, which carries its own board (dossiq DqBeoordelingAfgeschermd, design backlog). diff --git a/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md b/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md new file mode 100644 index 0000000000..cb45ee9c41 --- /dev/null +++ b/openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md @@ -0,0 +1,68 @@ +--- +status: proposed +--- + +# decisions + +## ADDED Requirements + +### Requirement: A decision is its own object (REQ-DEC-001) + +OpenRegister SHALL store a decision as its own object in `openregister_decisions`, with `uuid`, `subjectType`, `subjectId`, `verdict`, `decidedBy`, `decidedAt`, `note` and `supersedes`. A decision SHALL NOT be edited after it is stored: changing a verdict, its grounds or its note SHALL record a new decision whose `supersedes` names the previous one, and the subject SHALL link the new one. A subject links its current decision by uuid; an entity relation does so through `decision_uuid`, serialised as `decision`. Every decision on a subject SHALL be readable in order through `DecisionService::history()`. A decision without a subject, a verdict or a decider, or with a note longer than 4,000 characters, SHALL be refused. + +#### Scenario: a reviewer's redaction is recorded as a decision object + + +- **GIVEN** an undecided entity relation 7 +- **WHEN** `j.devries` sends `PATCH /api/entity-relations/7` with `bases: ["5.1.2e"]` and `decisionNote: "Private person."` +- **THEN** a decision is stored with subject `entity-relation` 7, verdict `redact`, decided by `j.devries`, the moment and the note, the relation's `decision` names its uuid, and the response carries it as `decisionRecord` + +#### Scenario: a change supersedes, it does not overwrite + + +- **GIVEN** that decision +- **WHEN** a coordinator changes the relation to release +- **THEN** a second decision is stored, decided by the coordinator, verdict `release`, superseding the first, and the first is unchanged + +#### Scenario: repeating a decision records nothing + + +- **GIVEN** a decided relation +- **WHEN** its decider sends the same bases again +- **THEN** no new decision is recorded + +### Requirement: The app that owns the subject may answer who supervises (REQ-DEC-002) + +Before anyone other than the decider or an administrator changes a decider-owned decision, OpenRegister SHALL dispatch `DecisionChangeRequestedEvent` carrying the decision, the caller, the policy, the subject (subject type and id and, when known, register, schema, object uuid and file id) and OpenRegister's own answer from the policy's supervisor groups. A listener MAY allow or refuse. A listener that throws SHALL refuse. The event SHALL NOT be dispatched under `open` ownership, for the decider, or for an administrator. + +#### Scenario: a case's reviewer access answers for its case + + +- **GIVEN** a decider-owned decision on a document of a case whose case type names `woo-coordinator` as supervisor group, and an app listening for the event +- **WHEN** a member of that group, who is in no group the schema names, changes the decision +- **THEN** the listener allows it and the change is stored + +#### Scenario: a failing listener refuses + + +- **GIVEN** a listener that throws +- **WHEN** a supervisor changes another reviewer's decision +- **THEN** the change is refused with `decision-owned` + +### Requirement: The app that owns the subject may show a hidden decision (REQ-DEC-003) + +Before a decision is hidden from a caller under `hideOthers` (REQ-ROD-002), OpenRegister SHALL dispatch `DecisionVisibilityRequestedEvent` carrying the decision (null when its record is gone), the caller, the policy and the subject (as in REQ-DEC-002). A listener MAY allow, and the decision is then shown to that caller. Nothing else SHALL make a hidden decision visible: without an allow it stays hidden, and a listener that throws SHALL leave it hidden. The event SHALL NOT be dispatched when nothing would be hidden (no decision, `hideOthers` off, the caller is the decider, an administrator or in a supervisor group of the policy) or when nobody is signed in. + +#### Scenario: a case-type supervisor sees hidden decisions on their cases + + +- **GIVEN** `hideOthers` on and a decision by `j.devries` on a document of a case whose case type names `woo-coordinator` as supervisor group, which the schema does not name +- **WHEN** a member of `woo-coordinator` reads the document's detections +- **THEN** the app that owns the case allows it and the decision, its ground and its verdict are shown + +#### Scenario: a failing listener keeps the decision hidden + + +- **GIVEN** a listener that throws +- **WHEN** a reviewer reads another reviewer's decision under `hideOthers` +- **THEN** the relation shows `decision: "hidden"` without its ground or verdict diff --git a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md index 395ae26dc8..ee1b6279b5 100644 --- a/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md +++ b/openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md @@ -8,9 +8,10 @@ status: proposed ### Requirement: A reviewer's decision is changed only by that reviewer or a supervisor (REQ-ROD-001) -When ownership is `decider` for the relation's schema (from `x-openregister-review`) or, for a relation with no schema, from the instance default, `PATCH /api/entity-relations/{id}` on a relation whose `decidedBy` is set SHALL be refused with 403 `{error: "decision-owned", decidedBy, decidedAt}` unless the caller is `decidedBy`, a member of a configured supervisor group, or an administrator. A relation with `decidedBy` null SHALL be decided by any caller who passes the existing subject write check. `decisionNote` SHALL follow the same rule. When ownership is `open` behaviour SHALL be as today. +When ownership is `decider` for the relation's schema (from `x-openregister-review`, read from the relation's own schema or, for a relation on a file, from the schema of the object the file belongs to) or, for a relation with no schema, from the instance default (`anonymisationReview` in the file settings), `PATCH /api/entity-relations/{id}` on a relation whose current decision object (REQ-DEC-001) was taken by someone else SHALL be refused with 403 `{error: "decision-owned", decision, decidedBy, decidedAt}` unless the caller is the decider, a member of a configured supervisor group, an administrator, or allowed by the app that owns the subject (REQ-DEC-002). The refusal SHALL NOT carry the verdict or the note, and under `hideOthers` (REQ-ROD-002) it SHALL carry neither `decidedBy` nor `decidedAt`: it says only that the decision belongs to another reviewer. A relation without a decision SHALL be decided by any caller who passes the existing subject write check. The decision note (`decisionNote` in the body, stored as the decision's `note`) SHALL follow the same rule. When ownership is `open` behaviour SHALL be as today. A declaration whose ownership is neither `open` nor `decider`, whose supervisors are not a list of group ids, or whose `hideOthers` is not a boolean SHALL be refused at schema save with 422; an ownership that is unreadable at run time SHALL be read as `decider`. #### Scenario: a second reviewer cannot overwrite the first + - **GIVEN** a schema with ownership `decider` and supervisors `woo-coordinator`, and a name `j.devries` decided to redact under `5.1.2e` - **WHEN** reviewer `p.bakker`, who may write the document, changes it to release - **THEN** the change is refused, naming `j.devries` and the moment of the decision, and the relation is unchanged @@ -20,18 +21,18 @@ When ownership is `decider` for the relation's schema (from `x-openregister-revi - **GIVEN** the same decision - **WHEN** a member of `woo-coordinator` changes it -- **THEN** the change is stored with the coordinator as `decidedBy` and the audit row names both +- **THEN** a new decision is stored with the coordinator as `decidedBy`, superseding the first, and the audit row of the relation records the change #### Scenario: an undecided occurrence is open to any reviewer - **GIVEN** a relation with no decision - **WHEN** `p.bakker` decides it -- **THEN** the decision is stored with `p.bakker` as `decidedBy` +- **THEN** a decision object is stored with `p.bakker` as `decidedBy` and linked from the relation ### Requirement: A schema can hide one reviewer's decisions from another (REQ-ROD-002) -When `hideOthers` is on, every endpoint that returns entity relations (`gdprEntities#index`, `gdprEntities#show`, the file entity listings and the review leaf's reads) SHALL return a relation decided by someone other than the caller with `decision: "hidden"` and without `decidedBy`, `decidedAt`, `decisionNote` and `bases`, unless the caller is a supervisor or an administrator. The relation SHALL still be listed with its type and position. +When `hideOthers` is on, every endpoint that returns entity relations (`gdprEntities#index`, `gdprEntities#show`, the file entity listings and the review leaf's reads) SHALL return a relation decided by someone other than the caller with `decision: "hidden"`, without its decision record (`decidedBy`, `decidedAt`, `note`) and without `bases`, unless the caller is a supervisor or an administrator. The relation SHALL still be listed with its type and position. #### Scenario: a blind second review - **GIVEN** `hideOthers` on and ten occurrences, four decided by `j.devries` @@ -45,6 +46,13 @@ When `hideOthers` is on, every endpoint that returns entity relations (`gdprEnti - **WHEN** a reviewer reads relations through any endpoint that returns them - **THEN** no response carries another reviewer's `decidedBy`, `decisionNote` or `bases` +#### Scenario: a refused change under hideOthers names no one + + +- **GIVEN** ownership `decider` with `hideOthers` on, and `j.devries` decided an occurrence +- **WHEN** `p.bakker` tries to change that decision +- **THEN** the answer is 403 `{error: "decision-owned", decision}` without `decidedBy` or `decidedAt` + ### Requirement: An assessment record can be limited to its author and a supervisor (REQ-ROD-003) An object schema whose authorization block holds `update: ["@creator", ""]` SHALL refuse an update by a user who is neither the object's creator nor a member of the group, and `read: ["@creator", ""]` SHALL keep the object out of that user's lists and reads, as `access-owner-and-condition-scopes` specifies. diff --git a/openspec/changes/reviewer-owns-their-decisions/tasks.md b/openspec/changes/reviewer-owns-their-decisions/tasks.md index 2dd722a6ed..64096c2732 100644 --- a/openspec/changes/reviewer-owns-their-decisions/tasks.md +++ b/openspec/changes/reviewer-owns-their-decisions/tasks.md @@ -2,13 +2,18 @@ ## 1. Ownership (REQ-ROD-001) -- [ ] 1.1 Accept `x-openregister-review` in `Schema::ANNOTATION_VOCABULARY` (validated: `ownership` `open` or `decider`, `supervisors` a list of existing group ids, `hideOthers` boolean) and the instance defaults in `FileSettingsHandler`. Verify: `tests/Unit/Db/SchemaReviewAnnotationTest.php::testAnUnknownOwnershipIsRefused` (fails today: the key is dropped). -- [ ] 1.2 Migration adding `decision_note` to `openregister_entity_relations`; the ownership rule in `EntityRelationsController::update()` after `actorCanWriteRelationSubject()`. Verify: `tests/Unit/Controller/DecisionOwnershipTest.php::testASecondReviewerIsRefused`, `testASupervisorMayChangeAnotherReviewersDecision`, `testAnAdministratorMay`, `testAnUndecidedRelationIsOpen`, `testOpenOwnershipKeepsTodaysBehaviour`, `testTheNoteFollowsTheDecision`, with real `EntityRelation` entities and `environmentAwareDouble` for `IGroupManager`. -- [ ] 1.3 Through the route: a Newman sequence in `tests/newman/` with two reviewer users, one deciding and the other getting 403 on the same relation. +- [x] 1.1 Accept `x-openregister-review` in `Schema::ANNOTATION_VOCABULARY` (validated: `ownership` `open` or `decider`, `supervisors` a list of group ids, `hideOthers` boolean) and the instance default in `FileSettingsHandler`. Verify: `tests/Unit/Db/SchemaReviewAnnotationTest.php::testAnUnknownOwnershipIsRefused` (fails today: the key is dropped). + - done: `lib/Service/Decision/ReviewAnnotationValidator.php` + `ReviewDeclarationException` (422 from SchemasController create/update/upload), hooked in `SchemaMapper::cleanObject()`; `lib/Service/Decision/ReviewPolicy.php` (unreadable ownership fails closed to decider); instance default `anonymisationReview` in `FileSettingsHandler::updateFileSettingsOnly()`. Tests: `SchemaReviewAnnotationTest` (5, red before the change: 5 errors), `ReviewPolicyTest` (3), `FileSettingsHandlerTest::testTheReviewDefaultIsKeptAndChecked`. Group existence is not checked at save: an app's repair step may create its groups after its schemas, and a missing group grants nobody anything. +- [x] 1.2 Decision 197: the decision is its own object, not columns on the relation. Migration `Version1Date20261011140000` creates `openregister_decisions` and adds `decision_uuid` to `openregister_entity_relations`; the ownership rule runs in `EntityRelationsController::update()` after `actorCanWriteRelationSubject()`. Verify: `tests/Unit/Controller/DecisionOwnershipTest.php::testASecondReviewerIsRefused`, `testASupervisorMayChangeAnotherReviewersDecision`, `testAnAdministratorMay`, `testAnUndecidedRelationIsOpen`, `testOpenOwnershipKeepsTodaysBehaviour`, `testTheNoteFollowsTheDecision`, with real `EntityRelation` entities. + - done: `lib/Db/Decision.php`, `lib/Db/DecisionMapper.php`, `lib/Service/Decision/{DecisionService,DecisionOwnership,ReviewPolicyResolver,RelationDecisionService,DecisionOwnedException}.php`, `EntityRelationMapper::linkDecision()`, `EntityRelation::$decisionUuid` (serialised `decision`). Tests: the six named (real controller, real services, in-memory stores), `Service/Decision/{DecisionOwnershipTest,DecisionServiceTest,ReviewPolicyResolverTest}`, `Db/DecisionMapperTest`, `Migration/Version1Date20261011140000Test`, `EntityRelationMapperUpdateDecisionMetadataTest::testLinkDecisionStoresTheUuid`, `EntityRelationsControllerTest` (now through the decision path). +- [x] 1.4 The app that owns the subject answers who supervises (REQ-DEC-002): `lib/Event/DecisionChangeRequestedEvent.php`, dispatched by `DecisionOwnership` with the subject (register, schema, object uuid, file id; a relation on a file resolves its object through `FolderManagementHandler::getObjectForFile`). A failing listener refuses. + - done: `Service/Decision/DecisionOwnershipTest::testAListenerMayAllow`, `testAListenerMayRefuse`, `testAFailingListenerRefuses`. Consumer: dossiq woo-request-scoped-access 5.2 (case reviewer access). +- [ ] 1.3 Through the route: a Newman sequence in `tests/newman/` with two reviewer users, one deciding and the other getting 403 `decision-owned` on the same relation, and the decision object read back. (live pass, decision 139) ## 2. Hiding (REQ-ROD-002) -- [ ] 2.1 A `DecisionVisibility` filter applied in every controller that serialises `EntityRelation`. Verify: `tests/Unit/Service/Anonymisation/HiddenDecisionTest.php::testAnotherReviewersDecisionIsHidden`, `testTheSupervisorSeesIt`, `testEveryRelationEndpointMasks` (reflection over controllers returning relations, fails on one without the filter). +- [x] 2.1 A `DecisionVisibility` filter applied in every controller that serialises `EntityRelation`; a hidden relation carries `decision: "hidden"` and no decision record. Done: `lib/Service/Decision/DecisionVisibility.php` (masks `bases`, `skipAnonymization` and an embedded `decisionRecord`, since `skipAnonymization` is the verdict itself), applied in `GdprEntitiesController::show` and the `EntityRelationsController::update` answer. Verify: `tests/Unit/Service/Anonymisation/HiddenDecisionTest.php::testAnotherReviewersDecisionIsHidden`, `testTheSupervisorSeesIt`, `testEveryRelationEndpointMasks` (reflection over controllers returning relations, fails on one without the filter). +- [x] 2.3 The app that owns the subject may show a hidden decision (REQ-DEC-003): `lib/Event/DecisionVisibilityRequestedEvent.php` (allow only), dispatched by `DecisionVisibility::isHidden()` only when the decision would otherwise be hidden from a signed-in caller; a failing listener keeps it hidden. Under `hideOthers` the 403 `decision-owned` refusal names neither `decidedBy` nor `decidedAt`. Verify: `HiddenDecisionTest::testTheOwningAppMayShowAHiddenDecision`, `testAFailingListenerKeepsItHidden`, `testTheQuestionIsAskedOnlyWhenHidden`; `Service/Decision/DecisionOwnershipTest::testUnderHideOthersTheRefusalNamesNoDecider` and the controller-level test of the same name. Consumer: dossiq `CaseDecisionAccess` (case-type supervisors). - [ ] 2.2 The review UI in OpenRegister shows "decided by another reviewer" without the decision. Verify: `tests/e2e/ci/blind-second-review.spec.ts` seeds four decisions by one user, signs in as another and sees ten detections, four marked decided without their ground. ## 3. Assessment records (REQ-ROD-003) diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/design.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/design.md new file mode 100644 index 0000000000..e3b9d136ba --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/design.md @@ -0,0 +1,22 @@ +# Design: roles-block-that-resolves-to-nothing-grants-nothing + +## D-1. Closed, not absent + +The expansion's empty result meant two different things: "the author wrote no +rules" and "the author wrote rules nobody can resolve". Only the first is the +default-open case. The second now comes back as the four canonical verbs with +nobody in them, which every evaluator already reads as a refusal: the single +object path (`hasGroupPermission()`, `empty($authorization[$action])`) and the +list path (`MagicRbacHandler::applyRbacFilters()`, the `1 = 0` deny). + +## D-2. Why the four canonical verbs and not a marker key + +A marker key would be a key nobody else knows, and an unknown key is the kind +of thing a later strip or merge drops in silence. Four empty lists are the +grammar's own "grant to nobody", already used by `stripMcpScope()`. + +## D-3. Object level follows + +An object's own `_authorization` holding unresolvable roles also comes back +closed, and replaces the schema's verbs for that object. That narrows rather +than widens, which is the direction an ambiguous rule should fail in. diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/proposal.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/proposal.md new file mode 100644 index 0000000000..f08c5f2ca6 --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/proposal.md @@ -0,0 +1,39 @@ +# Proposal: roles-block-that-resolves-to-nothing-grants-nothing + +## Why + +Found live on 2026-10-11 on the dossiq review instance. dossiq's `case` schema +declared an `authorization` block holding `roles` only, written so the +notification dispatcher and the transition guard can find who holds a role. The +dossiq register declares no role definitions, so `PermissionHandler::expandRoles()` +logged "register has no role definitions", dropped the `roles` key and returned +`[]`. `hasGroupPermission()` reads `[]` as "no authorization configured", which is +default-open: a user in no group at all could read, change and delete every case. + +It is the same mistake `stripMcpScope()` already names and fixed for a block +holding only `mcp` entries: an author who writes rules has opted the schema into +authorization, and reading an unresolvable block as absent turns a restriction +into a grant, silently. + +## What changes + +- `expandRoles()` answers a block that grants nobody (`read`, `create`, + `update` and `delete` each `[]`) when the expansion leaves nothing: the + register declares no role definitions, or every role name is unknown. The + administrator and owner bypasses still apply. +- A block that keeps any other key after expansion is unchanged, because it is + already non-empty and so already refuses an unlisted verb. + +## Merge order + +dossiq first (or together): ConductionNL/dossiq `case-access-by-handling-team` +gives the case schema its own read and update rules. Merged alone, this change +closes dossiq's case schema to everybody but administrators and owners. +Other fleet apps whose schemas carry `roles` only are listed in the PR body. + +## Impact + +- `lib/Service/Object/PermissionHandler.php` +- `tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php` (the two + tests that asserted the open reading) +- `tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php` (new) diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md new file mode 100644 index 0000000000..77b93a0955 --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: A roles block that resolves to nothing grants nothing + +When an authorization block declares `roles` and none of them can be expanded, +because the parent register declares no role definitions or every role name is +unknown, the expanded block SHALL grant no verb to anybody: `read`, `create`, +`update` and `delete` SHALL each be an empty list. It SHALL NOT be read as an +absent block, which is open to every signed in account. The administrator and +object owner bypasses SHALL still apply, and a block that keeps any other rule +after expansion SHALL be evaluated as written. + +#### Scenario: A schema with roles only and a register without role definitions +@e2e exclude An engine verdict with no surface of its own; asserted through hasPermission() in tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php. +- **GIVEN** a schema whose authorization is `{"roles": {"behandelaar": ["behandelaars"]}}` +- **AND** a parent register that declares no role definitions +- **WHEN** a signed in user in no group asks to read, update or delete an object of it +- **THEN** each SHALL be refused +- **AND** an administrator SHALL still be allowed + +#### Scenario: A schema whose role names are all unknown +@e2e exclude Same engine verdict; asserted in tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php. +- **GIVEN** a register declaring the role `viewer` +- **AND** a schema whose authorization assigns only the unknown role `ghost` +- **WHEN** the block is expanded +- **THEN** every canonical verb SHALL be an empty list diff --git a/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/tasks.md b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/tasks.md new file mode 100644 index 0000000000..70f0beba25 --- /dev/null +++ b/openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/tasks.md @@ -0,0 +1,9 @@ +# Tasks: roles-block-that-resolves-to-nothing-grants-nothing + +- [x] 1.1 `PermissionHandler::expandRoles()` grants nothing when the expansion is empty. + - `tests/Unit/Service/Object/PermissionHandlerRoleHierarchyTest.php` +- [x] 1.2 Assert through `hasPermission()` and `ObjectFileAccess::mayUpdate()` that a + roles-only block closes the schema, and that a handling team rule keyed on a + field list and an assignee rule admit exactly their members. + - `tests/Unit/Service/Object/PermissionHandlerHandlingTeamRuleTest.php` +- [x] 1.3 `openspec validate roles-block-that-resolves-to-nothing-grants-nothing --strict`. diff --git a/openspec/changes/search-deep-link-names-object-properties/proposal.md b/openspec/changes/search-deep-link-names-object-properties/proposal.md new file mode 100644 index 0000000000..2d148e2042 --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/proposal.md @@ -0,0 +1,20 @@ +# Proposal: search-deep-link-names-object-properties + +## Summary + +A deep-link template can name any top-level key of the object data, but the unified search formatter only ever handed the registry `@self` plus the uuid, register and schema. So a template like `/apps/dossiq/cases/{case}`, which sends a hit on a case-owned record (a document, a contact moment, a decision) to the case it belongs to, opened with a literal `{case}` in it. + +The formatter now also passes the object's own scalar properties, URL-encoded, with `@self` and the resolved ids merged over them. A link whose placeholder the object cannot fill falls back to OpenRegister's own page instead of a broken URL. + +## Why + +Dossiq's `unified-search-opens-every-hit-in-dossiq` tasks 2.1 and 2.2: 47 case-owned schemas stay searchable but open OpenRegister's raw object page until this lands (decision 156: build the dependency in the other repo). + +## What changes + +- `ObjectSearchResultFormatter::format()` builds the link through `resolveObjectUrl()`, which merges `ownLinkProperties()` (declared, scalar, not `@self`, not `_`-prefixed, URL-encoded) under `@self` and the ids. +- An unfilled `{placeholder}` in the resolved link falls back to `openregister.objects.show`. + +## Out of scope + +`DeepLinkRegistration::resolveUrl()` is unchanged. Notification and smart-picker links build their own object data and are not touched. diff --git a/openspec/changes/search-deep-link-names-object-properties/screens.md b/openspec/changes/search-deep-link-names-object-properties/screens.md new file mode 100644 index 0000000000..4984a7354c --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/screens.md @@ -0,0 +1 @@ +- No screen: server-side link building for Nextcloud's unified search; the search box itself is Nextcloud's. diff --git a/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md b/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md new file mode 100644 index 0000000000..68918778b2 --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/specs/deep-link-registry/spec.md @@ -0,0 +1,27 @@ +# deep-link-registry Specification + +## ADDED Requirements + +### Requirement: A search hit's deep link can name the object's own properties + +When the unified search formats a hit, the object data handed to the deep-link registry MUST include the object's own declared scalar properties, URL-encoded, so a template can name one (for example `/apps/dossiq/cases/{case}`). `@self` metadata and the resolved `uuid`, `register` and `schema` MUST win over an own property of the same name. Keys starting with `_` (attached by the pipeline, not declared by the schema) and non-scalar values MUST NOT be passed. When the resolved link still contains an unfilled `{placeholder}`, the hit MUST link to OpenRegister's own object page instead. + +#### Scenario: A case-owned record opens its case +- **GIVEN** an app registered `/apps/dossiq/cases/{case}` for schema `caseDocument` +- **WHEN** a search hit is a `caseDocument` whose `case` is `c-1` +- **THEN** the hit's URL is `/apps/dossiq/cases/c-1` + +#### Scenario: An own property cannot spoof the uuid +- **GIVEN** an object with an own property `uuid: "spoofed"` and `@self.id: "u1"` +- **WHEN** the hit is formatted +- **THEN** `{uuid}` resolves to `u1` + +#### Scenario: A value cannot walk out of the path +- **GIVEN** an object whose `case` is `../settings` +- **WHEN** the hit is formatted +- **THEN** `{case}` resolves to `..%2Fsettings` + +#### Scenario: An unfilled placeholder falls back +- **GIVEN** the `{case}` template and an object with no `case` +- **WHEN** the hit is formatted +- **THEN** the hit's URL is OpenRegister's `openregister.objects.show` route for the object diff --git a/openspec/changes/search-deep-link-names-object-properties/tasks.md b/openspec/changes/search-deep-link-names-object-properties/tasks.md new file mode 100644 index 0000000000..bfe06e4c76 --- /dev/null +++ b/openspec/changes/search-deep-link-names-object-properties/tasks.md @@ -0,0 +1,5 @@ +# Tasks: search-deep-link-names-object-properties + +- [x] 1. `lib/Service/Search/ObjectSearchResultFormatter.php`: pass the object's own scalar properties (declared, URL-encoded) to the deep-link registry under `@self` and the ids; fall back to `openregister.objects.show` when a placeholder stays unfilled. + - unit tests in `tests/Unit/Service/Search/ObjectSearchResultFormatterTest.php`: `testADeepLinkTemplateCanNameAnOwnProperty`, `testMetadataWinsOverAnOwnPropertyOfTheSameName`, `testOwnPropertiesAreEncodedAndOnlyDeclaredScalarsPass`, `testAnUnfilledPlaceholderFallsBackToTheOpenRegisterRoute`. +- [ ] 2. Live: with dossiq's `{case}` deep links registered, search a seeded `caseDocument` title in the Nextcloud header; the hit opens `/apps/dossiq/cases/` (live pass, decision 139). diff --git a/openspec/changes/seed-objects-follow-the-shipped-version/proposal.md b/openspec/changes/seed-objects-follow-the-shipped-version/proposal.md new file mode 100644 index 0000000000..e03c3c72b2 --- /dev/null +++ b/openspec/changes/seed-objects-follow-the-shipped-version/proposal.md @@ -0,0 +1,47 @@ +# Seed objects follow the shipped version + +## What was wrong + +An app ships seed objects in its register configuration, each with a stable +`@self.slug` and a `@self.version`. The importer updates a stored seed when the +shipped version is higher. On the Zuiddrecht demo instance (14 Oct) dossiq's Woo +case type, shipped at 1.4.7 with a handling team and a flow, never arrived on an +upgraded instance, while fresh installs were fine. + +Three things in OpenRegister let it stay lost: + +1. **The refusal was swallowed and the import was remembered as done.** dossiq's + save listener refused the update (it judged the case type by a flow that was + not written yet). The per-object catch logged one warning, and the import + still stored its content hash. Every later upgrade fast-skipped on that hash, + so the seed was never tried again. Only a forced re-import retried it. +2. **Order inside one import was fatal.** A seed listed before a row it depends + on failed once and was never retried in the same run. +3. **An update dropped the shipped version.** The save bumped the stored patch + (`0.0.4`) instead of taking `1.4.7`, so the next import read the seed as + outdated again and re-applied it. + +A fourth defect sat on the same path: when `searchObjects` answered with arrays, +`jsonSerialize()` was called on an array, and every existing seed failed. + +## What changes + +- A listed object that fails is retried once, after every other listed object + has been written. Only the second failure is recorded. +- The import result names every object it could not write in + `failed.objects`, with the slug, schema, reason and whether a listener + refused it. +- When a listener refused a listed object, the content hash is not stored, so + the next run imports again. A failure the file causes (schema validation) does + not withhold the hash: the same content would fail the same way on every + repair, and dossiq alone ships about forty such rows today. +- An update that carries a usable `@self.version` higher than the stored one + takes that version. Any other save bumps the patch as before. + +## What counts as a seed + +An object listed in a configuration (`components.objects` or top-level +`objects`) with a `@self.slug`. It is found again by register, schema and slug. +Objects the configuration does not list are never read or written by the +import, so instance data stays untouched. Decision of 14 Oct: the shipped file +wins for configuration, so a higher shipped version replaces the stored values. diff --git a/openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md b/openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md new file mode 100644 index 0000000000..4dc5520ebb --- /dev/null +++ b/openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md @@ -0,0 +1,58 @@ +# Data import and export + +## ADDED Requirements + +### Requirement: A stored seed object follows a higher shipped version @e2e exclude backend import path run by repair steps and occ, no UI: covered by PHPUnit + +A seed object is an object listed in a configuration with a `@self.slug`. The +import SHALL find it by register, schema and slug. When the stored object +exists and the shipped `@self.version` is higher, the import SHALL update the +stored object to the shipped values. At the same or a lower version it SHALL +leave the object alone and count it as unchanged. Objects the configuration does +not list SHALL NOT be read or written. + +The update SHALL store the shipped version on the object, so the next import +reads the seed as current. Any other update SHALL bump the patch version. + +#### Scenario: An upgraded instance receives the shipped case type + +- **GIVEN** an instance holding the seed `woo-verzoek` at version `1`, without a handling team +- **WHEN** the app's configuration ships it at `1.4.7` with `handling.defaultGroup: behandelaars` +- **THEN** the stored object is updated to the shipped values +- **AND** its `@self.version` is `1.4.7` + +#### Scenario: A seed at the shipped version is left alone + +- **GIVEN** an instance holding the seed at the shipped version +- **WHEN** the configuration is imported +- **THEN** the object is not saved and is counted under `unchanged.objects` + +### Requirement: A failed seed object is retried, named, and not forgotten @e2e exclude backend import path run by repair steps and occ, no UI: covered by PHPUnit + +A listed object whose save fails SHALL be tried once more after every other +listed object of the import has been written. An object that fails again SHALL +be named in the result's `failed.objects` with its slug, register, schema, the +reason, and whether a save listener refused it. + +When a save listener refused a listed object, the import SHALL NOT store its +content hash, so the next run imports again instead of fast-skipping. A failure +caused by the file itself SHALL NOT withhold the hash. + +#### Scenario: A seed listed before the row it depends on + +- **GIVEN** a case type listed before the flow it names, and a listener that refuses the case type while that flow is missing +- **WHEN** the configuration is imported +- **THEN** the flow is written, the case type is retried and saved +- **AND** `failed.objects` is empty and the content hash is stored + +#### Scenario: A listener keeps refusing + +- **WHEN** a listener refuses a listed object on both attempts +- **THEN** `failed.objects` names it with `refusedByListener: true` +- **AND** the content hash is stored empty, so the next run imports again + +#### Scenario: The file itself is invalid + +- **WHEN** a listed object fails schema validation on both attempts +- **THEN** `failed.objects` names it with `refusedByListener: false` +- **AND** the content hash is stored diff --git a/openspec/changes/seed-objects-follow-the-shipped-version/tasks.md b/openspec/changes/seed-objects-follow-the-shipped-version/tasks.md new file mode 100644 index 0000000000..ebd130d3f4 --- /dev/null +++ b/openspec/changes/seed-objects-follow-the-shipped-version/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Retry a failed listed object once after the rest of the list (`ImportHandler::importListedObject()`). +- [x] Name failed objects in `failed.objects` (`ImportHandler::recordFailedObject()`). +- [x] Withhold the content hash when a listener refused a listed object. +- [x] Take a higher carried `@self.version` on update (`ObjectVersionHandler::advanceVersion()`, `SaveObject::prepareObjectForUpdate()`). +- [x] Read an array search result without calling `jsonSerialize()` on it. +- [x] Unit tests through `importFromJson()`: `ImportHandlerSeedObjectUpgradeTest`, `ObjectVersionHandlerTest`. diff --git a/openspec/changes/share-links-use-the-request-host/proposal.md b/openspec/changes/share-links-use-the-request-host/proposal.md new file mode 100644 index 0000000000..fa3160c3d4 --- /dev/null +++ b/openspec/changes/share-links-use-the-request-host/proposal.md @@ -0,0 +1,56 @@ +--- +kind: code +--- + +# Proposal: share-links-use-the-request-host + +## Summary + +A public document link points at the host the visitor used. Today every +download on the Zuiddrecht portal points at `http://nextcloud:8080/index.php/s//download`, +a Docker service name no browser can resolve, while the same file answers on +`http://localhost:8080`. The cause is a substitution in `FileService` and +`FileSharingHandler`: the base URL had `localhost` replaced by +`trusted_domains[1]`. On an instance that answers on several hosts +(cloud.zuiddrecht.nl, zuiddrecht.nl) no single trusted domain is right. + +## Why + +The substitution came in with commit 312b59d68 (December 2024, "Use trusted +domains when running cronjobs"). From cron or occ there is no request, the +request host falls back to `localhost`, and links built there pointed at +localhost. Picking the second trusted domain was a guess at the public host. +It fixed one instance and broke every other: the list says which hosts are +allowed, not which one a visitor reaches, and its order means nothing. It also +fired during web requests, so a visitor on localhost got the service name. + +Nextcloud already has the right answer for both cases. During a request +`IURLGenerator::getAbsoluteURL()` uses the request host and honours +`overwritehost`, `overwriteprotocol` and trusted proxies. Without one the +administrator declares the address in `overwrite.cli.url`. + +## What changes + +- New `ShareUrlBuilder` builds every absolute file address: the public share + link, the authenticated preview and the authenticated download. +- During a request it uses `getAbsoluteURL()`; without one (cli, phpdbg) it + uses `overwrite.cli.url`, falling back to the generator's base when that is + empty. It never reads `trusted_domains`. +- `FileService::getShareLink`, `FileSharingHandler::getShareLink` and the three + URL helpers in `FileMapper` use it. The two `getCurrentDomain()` methods go. + +## Links already stored + +File properties store file ids, and `FileMapper` builds `accessUrl` and +`downloadUrl` from the share token at read time, so listed and rendered files +pick up the fix with no migration. Two places keep an absolute link: + +- the object `image` column, set from a file's `downloadUrl` on save + (`SaveObject`) and reset on render when the schema configures + `objectImageField` (`RenderObject`); a re-save refreshes it; +- opencatalogi's legacy attachment upload (`FileService::addFileInfoToData`) + copies the link into attachment data, but that path has no caller on + `development`. + +Reading the token and building the link when serving stays the rule; this +change keeps it and adds no new stored link. diff --git a/openspec/changes/share-links-use-the-request-host/specs/file-actions/spec.md b/openspec/changes/share-links-use-the-request-host/specs/file-actions/spec.md new file mode 100644 index 0000000000..15def9c335 --- /dev/null +++ b/openspec/changes/share-links-use-the-request-host/specs/file-actions/spec.md @@ -0,0 +1,42 @@ +# file-actions + +## ADDED Requirements + +### Requirement: A file link points at the host the visitor used + +Every absolute file address Open Register hands out (the public share link, +its `/download` form, and the authenticated preview and download addresses) +SHALL be built for the host of the current request, through Nextcloud's URL +generator, so `overwritehost`, `overwriteprotocol` and trusted proxies apply. +When there is no request (cron, occ) the address SHALL be built from +`overwrite.cli.url`, and only when that is empty from the URL generator's own +base. The address SHALL NOT take its host from an entry of `trusted_domains`, +and the order of that list SHALL NOT change any address. + +#### Scenario: a visitor on localhost gets a localhost link + +- **GIVEN** an instance whose `trusted_domains` are `localhost` and `nextcloud` +- **WHEN** a visitor on `http://localhost:8080` asks for the share link of token `abc123` +- **THEN** the link is `http://localhost:8080/index.php/s/abc123` +- @e2e exclude {host resolution is unit-tested in tests/Unit/Service/File/ShareUrlBuilderTest.php; e2e runs on one host} + +#### Scenario: a visitor on the public host gets that host + +- **GIVEN** an instance that answers on `zuiddrecht.nl` and `cloud.zuiddrecht.nl`, with `overwrite.cli.url` set to `https://cloud.zuiddrecht.nl` +- **WHEN** a visitor on `https://zuiddrecht.nl` asks for the share link of token `abc123` +- **THEN** the link is `https://zuiddrecht.nl/index.php/s/abc123` +- @e2e exclude {host resolution is unit-tested in tests/Unit/Service/File/ShareUrlBuilderTest.php; e2e runs on one host} + +#### Scenario: without a request the link uses the CLI URL + +- **GIVEN** `overwrite.cli.url` is `https://cloud.zuiddrecht.nl/` +- **WHEN** a cron job or occ command builds the share link, preview address or download address of a file +- **THEN** each address starts with `https://cloud.zuiddrecht.nl/` +- @e2e exclude {no browser takes part in a cron run; unit-tested in tests/Unit/Service/File/ShareUrlBuilderTest.php} + +#### Scenario: the order of trusted domains is irrelevant + +- **GIVEN** any order of `trusted_domains`, including an empty list or a single entry +- **WHEN** a visitor on `http://localhost:8080` asks for the share link of token `abc123` +- **THEN** the link is `http://localhost:8080/index.php/s/abc123` every time +- @e2e exclude {configuration permutations are unit-tested in tests/Unit/Service/File/ShareUrlBuilderTest.php} diff --git a/openspec/changes/share-links-use-the-request-host/tasks.md b/openspec/changes/share-links-use-the-request-host/tasks.md new file mode 100644 index 0000000000..850ba9fdda --- /dev/null +++ b/openspec/changes/share-links-use-the-request-host/tasks.md @@ -0,0 +1,14 @@ +# Tasks: share-links-use-the-request-host + +## 1. Builder + +- [x] 1.1 `lib/Service/File/ShareUrlBuilder.php`: `shareLink(token)` and `absolute(path)`; request host during a request, `overwrite.cli.url` without one, never `trusted_domains`. Verify: `tests/Unit/Service/File/ShareUrlBuilderTest.php`. + +## 2. Callers + +- [x] 2.1 `FileService::getShareLink` and `FileSharingHandler::getShareLink` use the builder; both `getCurrentDomain()` methods removed. Verify: the handler scenarios in `ShareUrlBuilderTest`. +- [x] 2.2 `FileMapper::generateShareUrl`, `generateAuthenticatedAccessUrl` and `generateAuthenticatedDownloadUrl` use the builder. Verify: `testFileMapperLinksFollowTheSameRule`. + +## 3. Proof + +- [x] 3.1 The scenarios run against the old code fail: a localhost request gets `http://nextcloud:8080`, cron gets `http://nextcloud`, and the host moves with the order of `trusted_domains`. diff --git a/openspec/changes/special-right-to-manage-grants/proposal.md b/openspec/changes/special-right-to-manage-grants/proposal.md new file mode 100644 index 0000000000..34c66d89dd --- /dev/null +++ b/openspec/changes/special-right-to-manage-grants/proposal.md @@ -0,0 +1,76 @@ +--- +kind: code +--- + +# Proposal: special-right-to-manage-grants + +## Summary + +A group can hold the right to manage the grants on records it does not own: +list them, add one, take one away, and name the record's owning group. The +right is a special right on the organisation, next to `object_publish`, +`agent_use`, `dashboard_view` and `llm_use`. It is called `grant_manage`. + +It exists for a service identity that keeps grants in step with a rule. dossiq's +background service account reconciles who may read a case's private records on +upgrade and after a case type changes. Until now only an administrator, the +record's owner or its owning group could write a grant, so that walk borrowed an +administrator's account (decision 222 ended that, decision 234 chose a special +right as the way). + +## What a special right was before this change + +Read from the code on `development` at c021ddb06: + +- Declared in `Organisation::getDefaultAuthorization()` (`lib/Db/Organisation.php`), + as four top-level keys of the organisation's `authorization` block, each a + list of group ids. +- Assigned in the organisation editor's "Special Rights" tab + (`src/modals/organisation/EditOrganisation.vue`), which sends the whole block + to `PUT /api/organisations/{uuid}`. `OrganisationController::update()` lets + any member of the organisation do that (`hasAccessToOrganisation()`). +- Stored in the organisation row's `authorization` JSON column. +- Checked nowhere. `docs/Features/organisations.md` shows a + `hasSpecialRight()` call that does not exist in `lib/`, and no code path reads + one of the four keys to decide anything. + +So this change adds the first special right that is enforced, and the reader +the other four never had. + +## What changes + +- `OrganisationSpecialRights` (new) answers whether a caller holds a special + right on one record. It reads the organisation the record belongs to. +- `ObjectSharingService::listGrants()`, `grant()` and `revoke()` and + `ObjectOwnershipService::setOwnerGroup()` admit a holder of `grant_manage` + beside the owner, the owning group and an administrator. + `ObjectScopeResolver::admitsUnconditionally()` is not changed, because it also + decides who reads and changes the record. +- An entry may be narrowed to a register and to schemas. This is new: the four + existing rights are a flat list of groups for the whole organisation. A flat + group id keeps that meaning. +- A holder admitted by the special right alone may not grant to itself or to a + group it is in, and may not name one of its own groups as the owning group. +- `PUT /api/organisations/{uuid}` refuses a change to `grant_manage` from + anybody but an administrator or the organisation's owner. +- `OrganisationSpecialRights::give()` lets code shipped with an app give its + own service group the right when the app is installed. + +## What does not change + +- The right opens neither a read nor a write of the record's contents. +- The record's scope, a public link and an e-mail invitation stay with the + owner and administrators. +- "Assign a record to somebody else" stays administrator only. +- The four existing special rights stay unenforced. Any member can still + rewrite the rest of the organisation's authorization block, including the + create, read, update and delete lists that ARE enforced + (`MultiTenancyTrait::hasRbacPermission()`). That is older than this change + and is reported in the pull request, not fixed here. + +## Not verified + +No live instance was used. Core's share manager runs its own checks when a +share is created by an account that does not own the folder; the unit tests +double the share manager, so whether core accepts the service account as the +sharer is a live check for the consuming app. diff --git a/openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md b/openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md new file mode 100644 index 0000000000..d24a34a541 --- /dev/null +++ b/openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md @@ -0,0 +1,83 @@ +# object-ownership + +## ADDED Requirements + +### Requirement: A group can hold the special right to manage a record's grants without owning the record (REQ-OWN-007) + +An organisation's authorization block SHALL carry a special right `grant_manage` +beside its other special rights. A caller in a group that holds it on a record +MUST be able to list the record's grants, add a grant, revoke a grant and name +or remove the record's owning group, on a record the caller does not own. + +The right MUST be read from the organisation the record belongs to. A record +that names no organisation MUST be read against the instance's default +organisation. + +An entry under the right is either a group id, which holds the right on every +record of the organisation, or an object naming a `group` and narrowing it with +a `register` and a list of `schemas`, each named by slug, uuid or id. A narrowed +entry MUST hold only on records of the register and schemas it names. + +The right MUST NOT open anything else. A holder MUST NOT thereby read or change +the record's contents, change its scope, create a public link or send an e-mail +invitation. A holder admitted by the special right alone MUST NOT add a grant +for its own account or for a group it is in, and MUST NOT name a group it is in +as the owning group. + +Reading the right MUST fail closed: an organisation, register or schema that +cannot be loaded, a value that is not a list, an entry of neither shape, and the +wildcard `*` admit nobody. + +#### Scenario: A service account writes a grant on a record it does not own +- **GIVEN** a private record owned by a handler, of schema `private-note` in register `records` +- **AND** the record's organisation lists `{"group": "records-service", "register": "records", "schemas": ["private-note"]}` under `grant_manage` +- **WHEN** a caller in group `records-service` adds a read grant for user `reviewer` +- **THEN** the grant is stored, with the caller as the one who shared + +#### Scenario: The right does not reach a schema it does not name +- **GIVEN** the same entry +- **WHEN** the caller adds a grant on a record of schema `case` +- **THEN** the request is refused + +#### Scenario: A caller without the right is refused +- **GIVEN** a caller who is not the owner, not in the owning group, not an administrator and in no group that holds `grant_manage` +- **WHEN** the caller lists, adds or revokes a grant through the sharing API +- **THEN** each answer is HTTP 403 and nothing is written + +#### Scenario: A holder cannot let itself in +- **WHEN** a holder adds a grant for its own account, or for a group it is in, or names a group it is in as the owning group +- **THEN** the request is refused and nothing is written + +#### Scenario: A holder is not admitted to the record +- **GIVEN** a holder with no grant on the record and no rule that admits it +- **WHEN** the holder reads or changes the record +- **THEN** the record's own rules decide, and the special right is not consulted + +#### Scenario: An owner and an administrator are unaffected +- **WHEN** the record's owner or an administrator manages its grants +- **THEN** it works as before, including a grant to themselves + +### Requirement: Only an administrator or the organisation's owner decides who holds `grant_manage` (REQ-OWN-008) + +A change to the list under `grant_manage` through the organisation API MUST be +refused with HTTP 403 unless the caller is an administrator or the owner of the +organisation. Replacing the authorization block with one that leaves the key +out counts as a change. A member MUST still be able to change the rest of the +block when the list under `grant_manage` is sent unchanged. + +Code shipped with an app MAY give a group the right in process. One entry is +kept per group: giving it again replaces the group's entry. + +#### Scenario: A member adds their own group +- **GIVEN** a member of the organisation who is neither its owner nor an administrator +- **WHEN** they send an authorization block that adds a group to `grant_manage` +- **THEN** the answer is HTTP 403 and the stored list is unchanged + +#### Scenario: A member drops the key +- **WHEN** the same member sends an authorization block without `grant_manage` while the stored list is not empty +- **THEN** the answer is HTTP 403 and the stored list is unchanged + +#### Scenario: An app narrows what its service group holds +- **GIVEN** an organisation where group `records-service` holds the right on schema `old` +- **WHEN** the app gives the group the right on schemas `private-note` and `review` +- **THEN** the group has one entry, naming those two schemas diff --git a/openspec/changes/special-right-to-manage-grants/tasks.md b/openspec/changes/special-right-to-manage-grants/tasks.md new file mode 100644 index 0000000000..8fae31b30a --- /dev/null +++ b/openspec/changes/special-right-to-manage-grants/tasks.md @@ -0,0 +1,27 @@ +# Tasks: special-right-to-manage-grants + +## 1. The right +- [x] 1.1 `OrganisationSpecialRights::holdsOn()` reads a special right from the record's organisation, fail closed +- [x] 1.2 An entry is a group id, or a group narrowed to a register and schemas +- [x] 1.3 `Organisation::getDefaultAuthorization()` declares `grant_manage` with nobody holding it +- [x] 1.4 `OrganisationSpecialRights::give()` and `giveInEveryOrganisation()` for code shipped with an app + +## 2. Where it is checked +- [x] 2.1 `ObjectSharingService::listGrants()`, `grant()`, `revoke()` +- [x] 2.2 `ObjectOwnershipService::setOwnerGroup()` +- [x] 2.3 A holder may not grant to itself or name its own group as the owner +- [x] 2.4 The scope, a link and an invitation stay owner and administrator only + +## 3. Who assigns it +- [x] 3.1 `OrganisationController::update()` refuses a change to `grant_manage` from a member who is not the owner or an administrator + +## 4. Tests +- [x] 4.1 A holder writes, lists and revokes a grant on a record it does not own +- [x] 4.2 A non-holder is refused with 403 through the sharing controller +- [x] 4.3 A holder is not admitted to the record itself, and the read side does not know the right +- [x] 4.4 An owner and an administrator behave as before +- [x] 4.5 A member may not change who holds the right + +## 5. Live +- [ ] 5.1 A grant written by a service account that holds the right, on a running instance (the consuming app's live pass) +- [ ] 5.2 `grant_manage` in the organisation editor's Special Rights tab (it is assignable through the API and `give()` only) diff --git a/openspec/changes/stored-decision-tables/proposal.md b/openspec/changes/stored-decision-tables/proposal.md new file mode 100644 index 0000000000..b7954b14d7 --- /dev/null +++ b/openspec/changes/stored-decision-tables/proposal.md @@ -0,0 +1,21 @@ +--- +kind: code +--- + +# Proposal: stored-decision-tables + +## Why + +Decision 186 (Ruben, 10 Oct, answering Q-dossiq-L6-4): a named, reusable decision table lives in OpenRegister as a generic stored capability, and a flow references a table by id. Until now OpenRegister's decision-table flow step carried the table inline, and OpenRegister had no store for a named table. dossiq kept its own (`decisionTable` schema, CRUD, five routes, an admin tab), and the VTH enforcement recommendation evaluated a dossiq-stored table. Under ADR-118 (decisions 182, 184, 185) that stack is procedure logic on generic plumbing, and it moves onto this capability (capability C15). + +## What changes + +- A core register `decision-tables` with schema `decision-table` (`key`, `name`, `description`, `hitPolicy`, `inputs`, `outputs`, `rules`, `owner`), shipped as `lib/Settings/decision_table_register.json` and imported by the repair step `ImportDecisionTableRegister`. +- `DecisionTableValidationListener` refuses, at save, a stored table the evaluator cannot execute (same `DecisionTableValidator` the inline step uses) and a second table under a key another table carries. +- `DecisionTableStore` resolves a table by `key` or uuid, re-validates it on read, and evaluates it for an app that decides outside a flow (`evaluate(reference, inputs)`). An unknown reference is refused, never answered with an empty table. +- The flow step `openregister.decision-table` takes `tableRef` as an alternative to an inline `table`. Exactly one of the two. A `tableRef` that names no executable table refuses the flow at save and fails the step at run. + +## Out of scope + +- An authoring screen for stored tables. The generic object views of the register serve until the decision-table editor is pointed at the register (follow-up). +- dossiq's move onto this capability. That ships in dossiq: its `decisionTable` rows move here by a repair step, and its translator and LHS lookup read from here. diff --git a/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md b/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md new file mode 100644 index 0000000000..bcac30147c --- /dev/null +++ b/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md @@ -0,0 +1,44 @@ +# Flow Decision Tables Specification (delta) + +## ADDED Requirements + +### Requirement: Named decision tables are stored in OpenRegister (REQ-SDT-01) + +OpenRegister MUST ship a core register `decision-tables` with a schema `decision-table` carrying `key`, `name`, `hitPolicy`, `inputs`, `outputs` and `rules`, imported by a repair step, so any app can author a table once and any flow can use it. + +#### Scenario: The register exists after an upgrade +- **GIVEN** an instance upgraded to this version +- **WHEN** the repair steps have run +- **THEN** the register `decision-tables` with schema `decision-table` MUST exist + +### Requirement: A stored table the evaluator cannot run is refused at save (REQ-SDT-02) + +Saving a `decision-table` object MUST be refused with error code `decision-table-invalid` when `DecisionTableValidator` reports a problem, or when another table already carries the same `key`. Objects of other schemas MUST NOT be affected. + +#### Scenario: A rule with too few input entries +- **GIVEN** a table whose rule has fewer input entries than the table has inputs +- **WHEN** it is saved +- **THEN** the save MUST be refused naming the problem + +#### Scenario: A duplicate key +- **GIVEN** a stored table with key `parcel-carrier` +- **WHEN** a second table with key `parcel-carrier` is saved +- **THEN** the save MUST be refused naming the first table + +### Requirement: A table is resolved by key or id (REQ-SDT-03) + +`DecisionTableStore::find()` MUST return the table whose `key` equals the reference, else the table whose uuid equals it. A reference that matches no table MUST raise an error, never return an empty table. A stored table the evaluator cannot run MUST raise an error on read. `evaluate()` MUST run the resolved table through `DecisionTableEvaluator`. + +#### Scenario: Unknown reference +- **WHEN** `find('nope')` is called and no table carries `nope` +- **THEN** it MUST raise an error naming `nope` + +### Requirement: The flow step uses a stored table by reference (REQ-SDT-04) + +The `openregister.decision-table` step MUST accept `tableRef` as an alternative to `table`. A configuration with both, or neither, MUST be refused at save. A `tableRef` that names no executable table MUST be refused at save and MUST fail the step at run. The evaluation record MUST carry the stored table's key. + +#### Scenario: A flow decides with a stored table +- **GIVEN** a stored table with key `parcel-carrier` +- **AND** a step configured with `tableRef: parcel-carrier` +- **WHEN** the step runs on an item +- **THEN** the item MUST carry the table's outcome, as with an inline table diff --git a/openspec/changes/stored-decision-tables/tasks.md b/openspec/changes/stored-decision-tables/tasks.md new file mode 100644 index 0000000000..784fd1b30f --- /dev/null +++ b/openspec/changes/stored-decision-tables/tasks.md @@ -0,0 +1,10 @@ +# Tasks: stored-decision-tables + + + +- [x] 1.1 Ship the `decision-tables` register and its import repair step (REQ-SDT-01) +- [x] 1.2 Refuse a stored table the evaluator cannot run, and a duplicate key, at save (REQ-SDT-02) +- [x] 1.3 `DecisionTableStore` resolves by key or uuid and refuses an unknown reference (REQ-SDT-03) +- [x] 1.4 The decision-table step accepts `tableRef`, exactly one of `table` and `tableRef` (REQ-SDT-04) +- [ ] 1.5 Live: import the register on a running instance, store a table, run a flow step by `tableRef` (owed to the live pass) +- [ ] 1.6 Point the decision-table editor at the register (follow-up) diff --git a/openspec/changes/underscore-properties-and-config-paths/proposal.md b/openspec/changes/underscore-properties-and-config-paths/proposal.md new file mode 100644 index 0000000000..ec46536a4a --- /dev/null +++ b/openspec/changes/underscore-properties-and-config-paths/proposal.md @@ -0,0 +1,22 @@ +--- +kind: code +--- +# Proposal: underscore-properties-and-config-paths + +## Summary + +The Zuiddrecht rehearsal (R2, 15 October 2026) logged two OpenRegister faults that each cost real data, not only log lines. + +1. **A property whose name starts with an underscore broke every read of its row.** shillinq declares `_meta` on `RetentionRule`, `BbvAccountMapping` and `InnovatieboxTariff`. Its magic-table column is `_meta`, and the readers took every `_` column for object metadata: `MagicStatisticsHandler` called `ObjectEntity::setMeta()`, which throws, and dropped the whole row (16,038 log errors in one install, those objects unreadable through that path); `MagicSearchHandler` and `MagicMapper::rowToObjectEntity()` silently dropped the property; the bulk writer never wrote it. +2. **A dot-notation `objectDescriptionField` refused the schema.** opencatalogi's `publiccode` declares `description.nl.longDescription | description.en.longDescription`. The reader walks such paths (`MetadataHydrationHandler::getValueFromPath()`, and `Schema` documents the fields as dot-notation paths), but the save-time check compared the whole path with the top-level keys, so the schema was rejected on every import (`PARTIAL IMPORT`). + +## What changes + +- `MagicMetadataColumn` decides whether a `_` column is metadata or a schema property: a property the schema declares is a property, unless ObjectEntity has an attribute of that name. Both row readers, `rowToObjectEntity()` and the bulk writer use it or the same rule. +- A metadata column ObjectEntity has no attribute for is skipped and logged at debug once per field, instead of failing the row. A snake_case metadata column (`_schema_version`) reaches its camelCase attribute. +- `SchemaMapper::validateConfigField()` accepts a dot-notation path whose first segment is a property, alone or in a fallback list. + +## Out of scope + +- Renaming shillinq's `_meta` properties. +- Validating the segments after the first against nested `properties`: the reader does not need them declared. diff --git a/openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md b/openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md new file mode 100644 index 0000000000..564ccfb801 --- /dev/null +++ b/openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md @@ -0,0 +1,19 @@ +# magic-table-reads + +## ADDED Requirements + +### Requirement: A property whose name starts with an underscore is read as a property (REQ-MTR-001) + +A magic-table column whose name starts with `_` SHALL be read as the schema property it holds when the schema declares that property, unless ObjectEntity has an attribute of the same name, in which case it SHALL be read as that metadata. Every reader of a magic-table row SHALL apply this rule, and the bulk writer SHALL write such a property. A metadata column ObjectEntity has no attribute for SHALL be skipped without failing the row, and SHALL be logged at debug level at most once per field per process. + +#### Scenario: shillinq's `_meta` property survives a read +- **GIVEN** a schema with properties `code` and `_meta` (an object), and a stored row with `_meta` = `{"source":"seeded"}` +- **WHEN** the row is converted to an ObjectEntity +- **THEN** the entity SHALL carry `_meta` = `{"source":"seeded"}` in its object data, and no error SHALL be logged +- @e2e exclude {row conversion has no screen of its own; covered by MagicStatisticsHandlerTest} + +#### Scenario: an unknown metadata column does not fail the row +- **GIVEN** a row with a `_meta` column that is no property of the schema, and `_schema_version` = `1.2.0` +- **WHEN** the row is converted +- **THEN** the entity SHALL be returned with its uuid and `schemaVersion` `1.2.0`, and `_meta` SHALL be skipped +- @e2e exclude {row conversion has no screen of its own; covered by MagicStatisticsHandlerTest} diff --git a/openspec/changes/underscore-properties-and-config-paths/specs/schema-configuration-paths/spec.md b/openspec/changes/underscore-properties-and-config-paths/specs/schema-configuration-paths/spec.md new file mode 100644 index 0000000000..5cdbc1d5cd --- /dev/null +++ b/openspec/changes/underscore-properties-and-config-paths/specs/schema-configuration-paths/spec.md @@ -0,0 +1,19 @@ +# schema-configuration-paths + +## ADDED Requirements + +### Requirement: A name or description field may be a dot-notation path (REQ-SCP-001) + +Saving a schema SHALL accept an `objectNameField` or `objectDescriptionField` that is a dot-notation path whose first segment is a property of the schema, alone or as one entry of a `|` fallback list, because the metadata reader walks such paths. A plain name, or a path whose first segment is no property, SHALL still be refused. + +#### Scenario: opencatalogi's publiccode schema imports +- **GIVEN** a schema with an object property `description` and `objectDescriptionField` `description.nl.longDescription | description.en.longDescription` +- **WHEN** the schema is saved or imported +- **THEN** it SHALL be stored, and the import SHALL NOT report it as a rejected schema +- @e2e exclude {save-time validation; covered by SchemaMapperConfigurationPathTest} + +#### Scenario: a path into a property the schema does not have is refused +- **GIVEN** a schema without a property `summary` +- **WHEN** it is saved with `objectDescriptionField` `summary.nl.longDescription` +- **THEN** it SHALL be refused naming the field +- @e2e exclude {save-time validation; covered by SchemaMapperConfigurationPathTest} diff --git a/openspec/changes/underscore-properties-and-config-paths/tasks.md b/openspec/changes/underscore-properties-and-config-paths/tasks.md new file mode 100644 index 0000000000..278d8490cf --- /dev/null +++ b/openspec/changes/underscore-properties-and-config-paths/tasks.md @@ -0,0 +1,17 @@ +# Tasks: underscore-properties-and-config-paths + +## 1. Underscore properties + +- [x] 1.1 `MagicMetadataColumn::field()` and `::attribute()` +- [x] 1.2 `MagicStatisticsHandler::convertRowToObjectEntity()` reads a `_` property as a property and skips an unknown metadata column once, at debug +- [x] 1.3 `MagicSearchHandler::convertRowToObjectEntity()` and `MagicMapper::rowToObjectEntity()` read a `_` property +- [x] 1.4 `MagicBulkHandler::prepareObjectsForDynamicTable()` writes a `_` property the schema declares + +## 2. Configuration paths + +- [x] 2.1 `SchemaMapper::isPropertyPath()`, used for single names and fallback lists + +## 3. Tests + +- [x] 3.1 `MagicMetadataColumnTest`, `MagicStatisticsHandlerTest` (underscore property, unknown metadata column as control) +- [x] 3.2 `SchemaMapperConfigurationPathTest` (paths accepted, unknown first segment refused as control) diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 3bfb79fccc..6615baf922 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -8771,8 +8771,9 @@ "built": { "state": "built", "owner": "ConductionNL/openregister", - "evidence": "Code carrying @spec openspec/specs/flow-cases: lib/Controller/CaseController.php, lib/Db/CaseItem.php, lib/Db/CaseItemAudit.php, lib/Db/CaseItemAuditMapper.php.", - "spec": "openspec/specs/flow-cases" + "evidence": "Code carrying @spec openspec/specs/flow-cases: lib/Controller/CaseController.php, lib/Db/CaseItem.php, lib/Db/CaseItemAudit.php, lib/Db/CaseItemAuditMapper.php. One engine (one-engine-bpmn-and-cmmn): lib/Service/Case/CasePlanEnsurer.php, lib/Service/Case/CasePlanItemTimers.php, lib/Service/Case/Nodes/CaseOpenNode.php, lib/Service/Case/Nodes/CaseAdvanceNode.php.", + "spec": "openspec/specs/flow-cases", + "change": "2026-10-10-one-engine-bpmn-and-cmmn" }, "reachedOn": "API only", "provider": "openregister", diff --git a/openspec/specs/ai-mcp/screens.md b/openspec/specs/ai-mcp/screens.md new file mode 100644 index 0000000000..2fa77648e2 --- /dev/null +++ b/openspec/specs/ai-mcp/screens.md @@ -0,0 +1 @@ +- No screen: a PHP attribute parameter read by the MCP tool scanner; it changes tool descriptors, not a page. diff --git a/openspec/specs/ai-mcp/spec.md b/openspec/specs/ai-mcp/spec.md index 17441d2f57..c5d5934445 100644 --- a/openspec/specs/ai-mcp/spec.md +++ b/openspec/specs/ai-mcp/spec.md @@ -666,6 +666,60 @@ invocation behavior. - **THEN** the invocation MUST still fail with the service method's authorization error - **AND** the `readOnlyHint` value MUST NOT bypass, weaken, or otherwise affect that outcome +### Requirement: REQ-ATTR-006 — A curated attribute tool declares its reach + +`#[McpTool]` MUST accept an optional `reach` parameter whose value, when set, is one of `ToolReachResolver::ORDER` (`self`, `user`, `instance`, `external`). `AttributeToolScanner` MUST reject a declared `reach` outside that vocabulary at scan time (logged, the offending tool skipped, sibling tools unaffected), and MUST forward a valid declared `reach` into the descriptor under `ToolReachResolver::REACH_KEY` only when the author set it, never inferred. `AttributeToolProvider::getTools()` MUST carry the key through unchanged, so the value reaches `ToolReachResolver::resolve()` on both the JSON-RPC and the chat/facade surface. An undeclared `reach` on a two-segment curated id MUST still resolve to `external` (agent-capability-reach), and a declared `reach` MUST NOT remove a tool from the gated set that `ToolGrantResolver::isWriteOrDestructive()` already gates. + +#### Scenario: A declared reach appears in the descriptor +- **GIVEN** app `dossiq` exposes `#[McpTool(readOnlyHint: true, scope: 'read', reach: 'user')] getWorkload(string $userId)` +- **WHEN** `AttributeToolScanner` builds the descriptor +- **THEN** the descriptor MUST include `reach: 'user'` +@e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: An undeclared reach stays omitted and fails closed +- **GIVEN** a method `#[McpTool(scope: 'update')] reassignCase(string $id)` on app `dossiq` +- **WHEN** the descriptor is built and its reach resolved +- **THEN** the descriptor MUST NOT carry a `reach` key +- **AND** `ToolReachResolver::resolve('dossiq.reassignCase', $descriptor)` MUST return `external` +@e2e exclude Fail-closed default reachable only by constructing a descriptor; asserted by unit tests. + +#### Scenario: An unrecognised reach is rejected at scan time +- **GIVEN** a method `#[McpTool(reach: 'everyone')]` +- **WHEN** `AttributeToolScanner` scans the declaring class +- **THEN** no tool MUST be registered for that method +- **AND** a warning MUST be logged naming the invalid `reach` +@e2e exclude Malformed-attribute path with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: A declared reach arrives at the resolver on both surfaces +- **GIVEN** the `getWorkload` descriptor is registered via `AttributeToolProvider` +- **WHEN** it is read from `McpToolsService::listTools()` and from `ToolRegistryFacade::listTools()` +- **THEN** `ToolReachResolver::resolve()` MUST return `user` for both entries +- **AND** `ToolGrantResolver::requiresGrant()` MUST return false for a tool that is also read-only +@e2e exclude Wiring assertion through the real registry, bridge and facade chain; asserted by AttributeToolDualSurfaceTest. + +### Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations + +`#[McpTool]` MUST accept an optional `annotations` map whose keys are non-empty strings and whose values are scalars. `AttributeToolScanner` MUST reject a malformed map at scan time (logged, the offending tool skipped, sibling tools unaffected) and MUST forward a non-empty map into the descriptor under `annotations`; an empty map MUST NOT be forwarded. `AttributeToolProvider::getTools()` and `McpProviderBridge::getFunctions()` MUST carry the key through unchanged, so a consumer reads the same map on the JSON-RPC surface and on `ToolRegistryFacade::listTools()`. OpenRegister MUST NOT interpret any mark: a mark is a claim, and grant resolution, reach and RBAC MUST NOT change because of one. + +#### Scenario: A declared mark appears on both surfaces +- **GIVEN** app `dossiq` exposes `#[McpTool(scope: 'create', action: 'create', annotations: ['citizenIntake' => true])] fileCase(string $title)` +- **WHEN** the descriptor is read from `McpToolsService::listTools()` and from `ToolRegistryFacade::listTools()` +- **THEN** both entries MUST carry `annotations: {citizenIntake: true}`, `scope: 'create'` and `action: 'create'` +@e2e exclude Wiring assertion through the real registry, bridge and facade chain; asserted by AttributeToolDualSurfaceTest. + +#### Scenario: No declaration, no key +- **GIVEN** a method `#[McpTool(scope: 'update')] reassignCase(string $id)` +- **WHEN** its descriptor is built +- **THEN** the descriptor MUST NOT carry an `annotations` key +@e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest. + +#### Scenario: A malformed map is rejected at scan time +- **GIVEN** a method `#[McpTool(annotations: ['citizenIntake' => ['nested' => true]])]` +- **WHEN** `AttributeToolScanner` scans the declaring class +- **THEN** no tool MUST be registered for that method +- **AND** a warning MUST be logged naming `annotations` +@e2e exclude Malformed-attribute path with no UI surface; asserted by AttributeToolScannerTest. + ### Requirement: REQ-006 — ToolRegistryFacade is the public read/invoke surface for cross-app tool-loop consumers `OCA\OpenRegister\Service\Mcp\ToolRegistryFacade` MUST be OpenRegister's diff --git a/openspec/specs/archival-annotation-vocabulary/spec.md b/openspec/specs/archival-annotation-vocabulary/spec.md index e80e3d3ffb..c055bf3273 100644 --- a/openspec/specs/archival-annotation-vocabulary/spec.md +++ b/openspec/specs/archival-annotation-vocabulary/spec.md @@ -56,12 +56,18 @@ The platform SHALL ship an `OCA\OpenRegister\Service\Archival\ArchivalAnnotation #### Scenario: Unknown key at the top level is reported, not rejected - @e2e exclude schema-save validation — covered by PHPUnit -- **GIVEN** a schema declares `x-openregister-archival = { retention: { default: "P7Y" }, category: "Archiefwet 1995 selectielijst", action: "destroy" }` +- **GIVEN** a schema declares `x-openregister-archival = { retention: { default: "P7Y" }, aggregatieniveau: "Dossier" }` - **WHEN** the schema is saved - **THEN** the save SHALL succeed - **AND** `SchemaMapper` SHALL log one warning naming the schema and every ignored key - **AND** the import of a register declaring that schema SHALL link it, not drop it +#### Scenario: A key the archive reader reads is not unknown +- @e2e exclude schema-save validation — covered by PHPUnit +- **GIVEN** a schema declares `x-openregister-archival = { retention: { default: "P7Y" }, category: "1.1", categoryProperty: "selectielijstCategorie", action: "bewaren" }` +- **WHEN** the schema is saved +- **THEN** no `archival-unknown-key` finding SHALL be made, because `ArchivalDeclarationReader` and `ClassificationOverride` read all three keys + #### Scenario: Well-formed annotation passes - **GIVEN** a schema declares `x-openregister-archival.retention = { default: "P30D", rules: [{ condition: "statusCode < 400", retention: "PT1H", reason: "successful integrations" }] }` - **WHEN** the schema is saved diff --git a/openspec/specs/audit-trail-immutable/spec.md b/openspec/specs/audit-trail-immutable/spec.md index 39035d1d1a..379c0747a6 100644 --- a/openspec/specs/audit-trail-immutable/spec.md +++ b/openspec/specs/audit-trail-immutable/spec.md @@ -273,6 +273,88 @@ object, and the admin-only index remains the only surface that carries them. - **THEN** the row carries its action, actor and changes, and carries no `session`, `request` or `ipAddress` - @e2e exclude {asserted in tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php} +### Requirement: The history of one object is readable by whoever may read that object + +The per-object history, `GET /api/objects/{register}/{schema}/{id}/audit-trails`, +SHALL be readable by a non-admin caller if and only if that caller may read the +object itself (decision 14 Oct 2026). Readability SHALL be decided by the read +path of the object GET, `ObjectService::find()` with RBAC and multitenancy on, +so that a grant, a schema rule, a register rule, a draft, a deleted object and +an organisation boundary mean here what they mean on the object. When that read +is refused the history SHALL answer 404, the answer the object GET gives, so the +route does not confirm that an object exists to somebody who may not see it. +The doelbinding guard of the object GET SHALL apply to the history as well. An +anonymous caller SHALL receive 401. An administrator SHALL read the history as +before. The instance-wide and register-wide listings (`GET /api/audit-trails`, +`GET /api/audit-trails/{id}`, `GET /api/audit-trails/statistics`, +`GET /api/audit-trails/export`) SHALL stay admin-only. + +#### Scenario: a reader of the object gets its history + +- **GIVEN** a case handler who may read a case object, and audit entries on that object +- **WHEN** they request the object's audit trail +- **THEN** the response is 200 with the object's entries +- **AND** the entries are read by the uuid of the object the read check resolved +- @e2e exclude {asserted through the real controller in tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php; a live run needs a non-admin seed user on the dev stack} + +#### Scenario: a caller who may not read the object gets 404 + +- **GIVEN** a non-admin user for whom the object read is refused +- **WHEN** they request the object's audit trail +- **THEN** the response is 404 and no audit entry is read +- @e2e exclude {asserted in tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php} + +#### Scenario: an administrator reads the history as before + +- **GIVEN** an administrator +- **WHEN** they request an object's audit trail +- **THEN** the entries are returned unfiltered, with their session, request and IP address, and the reader path is not used +- @e2e exclude {asserted in tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php} + +#### Scenario: the instance-wide listings stay admin-only + +- **GIVEN** a non-admin user +- **WHEN** they request the instance-wide audit trail, one entry by id, the statistics or the export +- **THEN** the response is 403 +- @e2e exclude {asserted in tests/Unit/Controller/AuditTrailControllerTest.php and by the @NoAdminRequired reflection check in tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php} + +### Requirement: An object's history withholds from its reader what the object withholds + +When a non-admin reads an object's history, every entry SHALL be stripped of +`session`, `request` and `ipAddress`, and its `changed` payload SHALL NOT carry +the old or new value of a property the caller may not see on the object. A +property is withheld when the render path withholds it from this caller: +`writeOnly` (top-level or a declared nested path), a property `authorization.read` +rule the caller does not satisfy, or a field hidden by the object's lifecycle +state. The `@self` copies of a withheld property (its keys in the relations +mirror, and the name, description, summary or image field the schema copies +from it) SHALL be withheld with it. A free-text search SHALL match the payload +the reader is given, and a reader SHALL NOT filter or sort on `changed`, +`session`, `request` or `ip_address`, so that neither can tell them whether a +withheld property ever held a value. + +#### Scenario: a property hidden from the reader is absent from the history + +- **GIVEN** a schema whose `interneAantekening` property may be read only by the group `redactie`, and an update entry that changed `interneAantekening` and `titel` +- **WHEN** a reader of the object outside `redactie` requests its history +- **THEN** the entry's `changed` carries `titel` and does not carry `interneAantekening` +- **AND** the relations mirror under `@self` carries no key of `interneAantekening` +- @e2e exclude {asserted with the real PropertyRbacHandler in tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php} + +#### Scenario: a write-only value never reaches the reader + +- **GIVEN** a schema with a `writeOnly` property `apiToken`, and an entry that changed it +- **WHEN** a reader of the object requests its history +- **THEN** the entry's `changed` does not carry `apiToken` +- @e2e exclude {asserted in tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php} + +#### Scenario: a search cannot find a withheld value + +- **GIVEN** an entry whose only match for a search term is the old value of a property hidden from the reader +- **WHEN** the reader searches the object's history for that term +- **THEN** the entry is not returned and the total is 0 +- @e2e exclude {asserted in tests/Unit/Controller/AuditTrailObjectHistoryForReadersTest.php} + ### Requirement: An app counts and lists the audit actions it writes under its own prefix An app that writes its own audit rows, such as portaliq's proof records (`portaliq.login`, `portaliq.download`), SHALL be able to count them per action without loading the rows, and an administrator SHALL be able to list every action of one prefix at once. `AuditTrailMapper::countByActionPrefix($prefix)` MUST answer the lifetime row count per full action for the actions that start with the prefix, in one grouped query. The list filter `action=.*` MUST answer every row whose action starts with the prefix. The prefix MUST match literally: `_` and `%` are not wildcards. An exact `action` filter MUST keep filtering exactly. Source: DECISIONS row 5 (portaliq audit trail move). diff --git a/openspec/specs/credential-broker/spec.md b/openspec/specs/credential-broker/spec.md index 868e2367fd..0ba1c2b6b7 100644 --- a/openspec/specs/credential-broker/spec.md +++ b/openspec/specs/credential-broker/spec.md @@ -520,3 +520,37 @@ throw); the app's OR app-key onboarding and the broker SHALL continue unchanged. - **WHEN** the onboarding hook runs while Doriath is absent, disabled, or its `ApplicationService` is unloadable - **THEN** it logs/outputs a warning and completes without error - **AND** the app's OpenRegister app-key onboarding and the credential broker continue to operate unchanged + +### Requirement: GitHub code search allow-rule + +The provider catalogue's `github` entry SHALL permit `GET /search/code` so a +caller can find files by name across every repository the token can see. The +rule SHALL match the path exactly (after the broker strips the query string), +SHALL be GET only, and SHALL NOT grant any other search endpoint or any sub-path +of `/search/code`. The per-file read stays covered by the existing +`GET /repos/*` rule; no additional contents rule is added. + +#### Scenario: Code search with a query and a page is allowed + +- **WHEN** a brokered call for a `github` credential requests + `GET /search/code?q=filename%3Apubliccode.yml+path%3A%2F&per_page=100&page=3` +- **THEN** the query string is stripped for rule matching and the + `GET /search/code` rule matches +- **AND** the outbound URL is + `https://api.github.com/search/code?q=filename%3Apubliccode.yml+path%3A%2F&per_page=100&page=3` + with the broker-injected `Authorization` header + +#### Scenario: The per-file contents read stays allowed + +- **WHEN** a brokered call requests + `GET /repos/ConductionNL/opencatalogi/contents/publiccode.yml?ref=main` +- **THEN** the existing `GET /repos/*` rule matches and the call proceeds + host-locked to `api.github.com` + +#### Scenario: Writes and other searches stay refused + +- **WHEN** a brokered call requests `POST`, `PUT` or `DELETE` on `/search/code`, + or `GET /search/commits`, `GET /search/users`, `GET /search/code/extra` or + `GET /search/codes` +- **THEN** the broker refuses it with the static 403 (`Request not permitted`; + the reason is logged, never returned) and makes no outbound call diff --git a/openspec/specs/external-recipient-opt-out/screens.md b/openspec/specs/external-recipient-opt-out/screens.md new file mode 100644 index 0000000000..996228844a --- /dev/null +++ b/openspec/specs/external-recipient-opt-out/screens.md @@ -0,0 +1 @@ +- No screen: opt-out questions and unsubscribe links on outbound mail, decided on the server. diff --git a/openspec/specs/external-recipient-opt-out/spec.md b/openspec/specs/external-recipient-opt-out/spec.md index a0139da851..be422dc136 100644 --- a/openspec/specs/external-recipient-opt-out/spec.md +++ b/openspec/specs/external-recipient-opt-out/spec.md @@ -122,3 +122,28 @@ A `parties` notification MUST send the rule's resolved `message` as the mail bod - **WHEN** the rule fires for a party - **THEN** the mail's body starts with the subject - @e2e exclude notification dispatcher path, covered by PHPUnit + +### Requirement: An email recipient reads its address through a reference (REQ-ERO-007) + +An `x-openregister-notifications` rule MAY address `{kind: "email", field: ""}`. The path's last segment names the address; every segment before it names a reference that OpenRegister MUST follow to the referenced object, at most three references deep. OpenRegister MUST read the referenced object as the system, so the rule reaches the address even when the person whose save fired the rule may not read that object, and MUST refuse a referenced object that belongs to another organisation than the triggering object (outcome `refused-other-tenant`). A value that is not an e-mail address MUST NOT be mailed (`not-an-address`); a missing reference or object MUST be named (`no-reference`, `reference-not-found`). A resolved address MUST be put to integriq like a party address (REQ-ERO-003) and carry the unsubscribe link (REQ-ERO-004). Every entry MUST leave a history row with its outcome, recorded against the path and not the address. The validator MUST refuse an `email` entry without a `field` (`notification-recipient-email-field-missing`), whose first segment is not a declared property (`notification-recipient-field-unknown`), that follows more than three references (`notification-recipient-email-path-too-deep`), or on a rule without the `email` channel (`notification-recipient-email-needs-email-channel`). + +#### Scenario: A supplier message mails the supplier's contact address + +- **GIVEN** a `supplierMessage` schema with the rule `{kind: email, field: supplierRef.contactEmail}` on the `email` channel +- **AND** a message whose `supplierRef` names a supplier of the same organisation with `contactEmail: info@bouw.example` +- **WHEN** the message is created +- **THEN** `info@bouw.example` gets the rule's subject and message, with integriq's unsubscribe link +- @e2e exclude notification dispatcher path, covered by PHPUnit `ReferencedAddressNotifierTest` and `AnnotationNotificationDispatcherTest::testAnEmailRuleIsSentThroughTheReferencedAddressNotifier` + +#### Scenario: A reference into another tenant is refused + +- **GIVEN** the same rule and a message whose `supplierRef` names a supplier of another organisation +- **WHEN** the message is created +- **THEN** nothing is mailed and the history row reads `refused-other-tenant` +- @e2e exclude tenant boundary in the dispatcher, covered by PHPUnit `ReferencedAddressNotifierTest::testAnUnresolvableAddressIsNamedAndNothingIsSent` + +#### Scenario: An email entry on a rule without the email channel is refused at save + +- **WHEN** a schema is saved with an `email` recipient on a rule whose channels are `["nc-notification"]` +- **THEN** the save is refused with code `notification-recipient-email-needs-email-channel` +- @e2e exclude schema validator, covered by PHPUnit `NotificationAnnotationValidatorTest::testAnEmailRecipientNeedsADeclaredFieldAndTheEmailChannel` diff --git a/openspec/specs/flow-cases/spec.md b/openspec/specs/flow-cases/spec.md index a83a2fa1a4..3ab781e194 100644 --- a/openspec/specs/flow-cases/spec.md +++ b/openspec/specs/flow-cases/spec.md @@ -448,3 +448,147 @@ reachable only via a BPMN document. - **THEN** no such endpoint MUST exist - @e2e exclude covered by the route inventory test asserting the absence of CMMN endpoints + +### Requirement: One engine carries both model types on a shared core + +The system SHALL run BPMN processes and CMMN case plans as two model types of +one engine. Each model type SHALL own only the shape of its runtime state and +the algorithm that advances it. Human work, deadlines, conditions, events, +the subject anchor and authorization SHALL come from the shared core: the +task service, the timer service with its working calendar, the event catalog +with the flow expression evaluator, the anchor triple, and fail-closed +authorization. A plan item's lifecycle states SHALL be the task's lifecycle +states, declared once. + +#### Scenario: Plan items and tasks share one list of states + +- **GIVEN** the task lifecycle's states and terminal states +- **WHEN** the plan item's states and terminal states are read +- **THEN** they MUST be the same lists, by reference to one declaration +- @e2e exclude a declaration invariant; asserted by a unit test + +### Requirement: A process task starts a flow + +A plan item of type `processTask` SHALL be realised by a flow run on the +plan's subject when it becomes active, and the run's terminal state SHALL +drive the item exactly as it does for a stage realised by a run. A process +task SHALL name its flow in the plan settings; a definition with a process +task that names no flow SHALL be refused at create time. A process task SHALL +follow the work-item lifecycle and SHALL NOT have children. + +#### Scenario: A process task queues its flow + +- **GIVEN** a plan whose process task `advies` names flow `advice-flow` +- **WHEN** `advies` becomes active +- **THEN** a run of `advice-flow` MUST be queued on the plan's subject +- **AND** the item MUST record the run as its realisation +- @e2e exclude realisation is asserted by unit tests over the real realisation service + +#### Scenario: A process task without a flow is refused + +- **GIVEN** a definition with a process task whose key has no flow in the plan settings +- **WHEN** the plan is created +- **THEN** creation MUST be refused naming the item, and nothing MUST be written +- @e2e exclude save-time validation; asserted by unit tests + +### Requirement: A flow step opens or advances a case + +The flow engine SHALL offer a node that creates a case plan on the run's +subject from a definition, and a node that transitions a plan item of the +run's subject by its key. Both SHALL act as the run's attributed identity and +SHALL go through the same service verbs, validation and authorization as any +other caller. Opening a plan on an object that already has one SHALL be +reported on the node's output, not raised. A refused transition SHALL fail +the step with the engine's message. + +#### Scenario: A flow reaches a milestone + +- **GIVEN** a case plan on object O with milestone `besluit-genomen` in `available` +- **WHEN** a run on O executes `case-advance` with item `besluit-genomen` and `to: completed` +- **THEN** the milestone MUST be completed with the run's identity in the audit +- @e2e exclude node behaviour; asserted by unit tests over the real plan service + +#### Scenario: A refused advance fails the step + +- **GIVEN** a milestone already `completed` +- **WHEN** `case-advance` asks to complete it again +- **THEN** the step MUST fail with the message naming item, type, from-state and to-state +- @e2e exclude asserted by unit tests + +### Requirement: Plan-item deadlines run on the shared clock + +A plan item with a due date or an expiry SHALL arm a timer on the shared +timer service, subject type `case-item`, when it becomes active, and every +open timer of an item SHALL be cancelled when the item reaches a terminal +state. An enforcing expiry SHALL terminate the item. The case layer SHALL NOT +keep a clock of its own. A failure to arm SHALL be logged and SHALL NOT +block the transition. + +#### Scenario: An active item with a due date gets a timer + +- **GIVEN** a human item with `dueAt` set +- **WHEN** it becomes active +- **THEN** a `due` timer with subject type `case-item` and the item's uuid MUST be armed + +#### Scenario: Completing an item cancels its timers + +- **GIVEN** an active item with an armed timer +- **WHEN** the item is completed +- **THEN** its timer MUST be cancelled + +- @e2e exclude timer wiring; asserted by unit tests over the real timer service + +### Requirement: In-process callers act as a named app + +The case layer SHALL offer in-process verbs to create a plan, read a plan and +ensure items as a named app, for code that runs without a user session +(listeners, commands, repair steps). The audit SHALL record the actor as +`system:`. These verbs SHALL NOT be reachable over HTTP. The user verbs +SHALL keep refusing a missing identity. + +#### Scenario: An app's listener creates a plan + +- **GIVEN** an object without a plan +- **WHEN** app `dossiq` creates a plan for it in-process as the system +- **THEN** the plan MUST exist and its creation audit MUST name `system:dossiq` + +#### Scenario: No route reaches a system verb + +- **GIVEN** the controllers of the app +- **WHEN** they are searched for the system verbs +- **THEN** none MUST reference them + +- @e2e exclude no HTTP surface by design; asserted by unit and structural tests + +### Requirement: Plan items can be ensured convergently with their recorded states + +The case layer SHALL offer an ensure verb that, keyed on the object and the +item key, creates only the items an object does not have yet, with the state +each carries, and leaves existing items untouched. It SHALL append audit +entries only for items it created in that call, and SHALL import supplied +history for those items as audit entries flagged as imported, with their +original moments. It SHALL validate every item and state before writing +anything, SHALL create no realisation for imported items, and SHALL run no +cascade. Running it twice with the same input SHALL change nothing the +second time. + +#### Scenario: A half-written plan resumes without duplicates + +- **GIVEN** an object holding items `intake` and `beoordeling` and an ensure call naming `intake`, `beoordeling` and `besluit` +- **WHEN** the call runs +- **THEN** only `besluit` MUST be created, with its recorded state +- **AND** `intake` and `beoordeling` MUST keep their states and gain no audit entry + +#### Scenario: A second run appends nothing + +- **GIVEN** an ensure call that has already run +- **WHEN** it runs again with the same items and history +- **THEN** no row and no audit entry MUST be added + +#### Scenario: An illegal recorded state is refused before writing + +- **GIVEN** an ensure call with a milestone carrying state `active` +- **WHEN** the call runs +- **THEN** it MUST be refused naming the item and state, and nothing MUST be written + +- @e2e exclude occ-level behaviour with no browser surface; asserted by unit tests diff --git a/openspec/specs/flow-user-task-node/spec.md b/openspec/specs/flow-user-task-node/spec.md index bfabdf421d..05b5400162 100644 --- a/openspec/specs/flow-user-task-node/spec.md +++ b/openspec/specs/flow-user-task-node/spec.md @@ -69,7 +69,11 @@ context's signal slot as an answer: a signal is one slot per run, consumed by the walk it wakes, and a flow with two user-task nodes would otherwise have the second read an answer given to the first. -Completing the task SHALL make the run due. A completion that is delivered +Completing the task SHALL make the run due. The wake SHALL carry the task's +outcome bag (the same bag the node writes onto the items) in the run's signal +slot, never an empty payload: the slot is still not an answer the node reads, +but an empty wake overwrote whatever a leaf app had signalled about the same +completion and left the run's context saying nothing about it. A completion that is delivered while the run is still mid-walk, or whose delivery fails, SHALL NOT strand the run: the node SHALL suspend with a heartbeat `resumeAt` so a lost wake costs one heartbeat interval rather than the flow. The node SHALL NOT @@ -112,6 +116,12 @@ task and the run's suspension unchanged. - **THEN** it MUST NOT be null - @e2e exclude covered by a unit test asserting the thrown suspension +#### Scenario: The wake carries the outcome bag +- @e2e exclude bridge payload, covered by PHPUnit (FlowTaskBridgeTest) +- **GIVEN** a run suspended on a user-task node +- **WHEN** its task is completed with outcome `approved` by `alice` +- **THEN** the run's signal slot SHALL hold the task's outcome bag, with `outcome: approved` and `completedBy: alice` + ### Requirement: The outcome is written onto every item, not only onto the run When the node continues, it SHALL write the task's completion result onto diff --git a/openspec/specs/integration-person-lookup/screens.md b/openspec/specs/integration-person-lookup/screens.md new file mode 100644 index 0000000000..4c06e3fd2f --- /dev/null +++ b/openspec/specs/integration-person-lookup/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: the BRP person lookup and the residents count are provider methods and an API; the consuming apps (portaliq, pipelinq) own the screens that show them. diff --git a/openspec/specs/integration-person-lookup/spec.md b/openspec/specs/integration-person-lookup/spec.md index ffcb36f2ba..119fb55abb 100644 --- a/openspec/specs/integration-person-lookup/spec.md +++ b/openspec/specs/integration-person-lookup/spec.md @@ -157,3 +157,55 @@ that both transports are configured on the OpenConnector source. - WHEN the provider or controller writes a log line - THEN the log line contains neither the raw BSN nor the request/response body - @e2e exclude Backend privacy invariant — verified by code review + PHPUnit (logger mocked, asserted not to receive the BSN), not a browser flow. + +### Requirement: BRP count of residents at an address object + +`BrpPersonProvider::countResidentsAtAddressObject(string $addressObjectId)` SHALL answer how many people the BRP registers at an address object, as a number only. It SHALL ask Haal Centraal `/personen` with a `ZoekMetAdresseerbaarObjectIdentificatie` body carrying the 16-digit `adresseerbaarObjectIdentificatie` and a `fields` list of one field, and answer `{ total, meta }` where `meta` is the Wet-BRP audit metadata `{ correlationId, durationMs, status }`. + +The provider SHALL ask only when the `brp-haalcentraal` source carries `configuration.residentCount` set to the boolean `true`. Otherwise it SHALL answer `{ unavailable: true, cause: 'resident-count-not-enabled' }` without calling the registry. + +The call SHALL reach the connector with `withholdBodies`, so the call log keeps no request or response body. A connector that cannot withhold bodies SHALL NOT be called, and the answer is `{ unavailable: true, cause: 'upstream-service-down' }`. + +The answer SHALL never hold a name, a BSN, a person object or the address object id, and the provider SHALL never log the address object id. A malformed id SHALL answer `{ unavailable: true, cause: 'invalid-identifier' }` without a call. A missing or unreachable source SHALL degrade to `{ unavailable: true, cause }` as `lookupByBsn` does. + +#### Scenario: the count of residents at an address object + +- GIVEN the `brp-haalcentraal` source carries `configuration.residentCount: true` +- AND the BRP registers three people at address object `0518010000123456` +- WHEN portaliq asks `countResidentsAtAddressObject('0518010000123456')` +- THEN the provider posts `type='ZoekMetAdresseerbaarObjectIdentificatie'`, the id in `adresseerbaarObjectIdentificatie` and one field, with `withholdBodies` +- AND it answers `{ total: 3, meta }` and nothing about the three people +- @e2e exclude Backend provider method, verified by PHPUnit (router mocked); portaliq's Mijn gegevens is the live pass. + +#### Scenario: the setting is off + +- GIVEN the source carries no `configuration.residentCount`, or a value other than `true` +- WHEN the count is asked +- THEN the answer is `{ unavailable: true, cause: 'resident-count-not-enabled' }` and the registry is not called +- @e2e exclude Backend refusal path, verified by PHPUnit. + +#### Scenario: no number and no address in the answer or the log + +- GIVEN any count, answered or degraded +- WHEN the provider answers and logs +- THEN the answer holds only `total` and `meta`, or `unavailable` and `cause` +- AND no log line holds the address object id or a burgerservicenummer +- @e2e exclude Backend privacy invariant, verified by PHPUnit (logger asserted). + +### Requirement: a call may be recorded without its bodies + +`ExternalIntegrationRouter` SHALL honour a `withholdBodies: true` call option by passing `withholdBodies: true` to the connector's `CallService::call`, and SHALL never pass that option on as a request option. When the connector's `call` has no `withholdBodies` parameter, the router SHALL NOT call it and SHALL fail with cause `upstream-service-down`. `sourceConfiguration(provider)` SHALL answer the provider's source `configuration` array and fail like `call()` when the connector or the source is missing. + +#### Scenario: the connector records the call without bodies + +- GIVEN integriq's CallService, whose `call` takes `withholdBodies` +- WHEN a provider calls with `withholdBodies: true` +- THEN the CallService receives `withholdBodies: true` and the request options do not carry it +- @e2e exclude Backend transport option, verified by PHPUnit (CallService stand-in). + +#### Scenario: an older connector is not called + +- GIVEN a CallService whose `call` has no `withholdBodies` parameter +- WHEN a provider calls with `withholdBodies: true` +- THEN the CallService is not called and the router fails with `upstream-service-down` +- @e2e exclude Backend refusal path, verified by PHPUnit. diff --git a/openspec/specs/referential-integrity/spec.md b/openspec/specs/referential-integrity/spec.md index 33b88723cb..bd4076669f 100644 --- a/openspec/specs/referential-integrity/spec.md +++ b/openspec/specs/referential-integrity/spec.md @@ -414,6 +414,26 @@ Referential integrity operations MUST bypass RBAC and multi-tenancy filters when - **THEN** `filterByRbac()` MUST check schema authorization for each result - **AND** only the 3 accessible orders MUST be returned +### Requirement 16: A reference that cites a row by a key property MUST be able to refuse its delete +A schema property whose values cite rows of another schema by a key property (a code, not a uuid) MUST be able to declare `referenceSchema` (the cited schema, on the property or on `items`), `referenceKey` (the cited schema's property, e.g. `code`) and `onDelete: RESTRICT`. Deleting a cited row MUST then be refused with the citing rows as blockers, exactly as for a uuid reference. `referenceSchema` MUST NOT engage the `$ref` relation behaviour (rendering, `_extend`, uuid validation). A key reference MUST be honoured only with `RESTRICT`: saving a schema that declares `referenceKey` with any other action MUST be refused, and such a relation that reaches the relation index anyway MUST be left out with a warning. `onDelete` MUST be accepted on a property with `referenceSchema` as it is on one with `$ref`. The check MUST fail closed: when the citing rows cannot be read, the delete MUST be refused with a blocker whose `reason` is `unverifiable`; only a citing table that does not exist yet MUST count as "nothing cites it". A row without a value for the key MUST be deletable. This is the generic reference guard (capability C25 of hydra's `procedures-are-configuration`), so an app needs no delete listener of its own. + +#### Scenario: A cited code refuses the delete +- **GIVEN** schema `note` with property `labels` = `{type: array, items: {type: string}, referenceSchema: label, referenceKey: code, onDelete: RESTRICT}` +- **AND** a note whose `labels` holds `A1` +- **WHEN** the `label` row with `code` `A1` is deleted +- **THEN** the delete MUST be refused with `DELETION_BLOCKED`, naming the note as blocker +- **AND** the citing table MUST be searched for `A1`, not for the label's uuid + +#### Scenario: An unreadable citation refuses the delete +- **GIVEN** the same schemas, and the citing table exists but cannot be read +- **WHEN** the `label` row is deleted +- **THEN** the delete MUST be refused with a blocker whose `reason` is `unverifiable` + +#### Scenario: A key reference does not cascade +- **GIVEN** a key reference declared with `onDelete: CASCADE` +- **WHEN** the relation index is built +- **THEN** the relation MUST be left out and a warning logged + ## Current Implementation Status **Substantially implemented.** Core referential integrity logic exists: diff --git a/openspec/specs/unified-search-provider/spec.md b/openspec/specs/unified-search-provider/spec.md index 55bf4e66d4..521b485fc2 100644 --- a/openspec/specs/unified-search-provider/spec.md +++ b/openspec/specs/unified-search-provider/spec.md @@ -191,6 +191,29 @@ second declaration mechanism. - WHEN the entry is built - THEN its URL is the `openregister.objects.show` route for that register, schema, and UUID +### Requirement: A deep-link template may name an object property + +The data handed to `DeepLinkRegistryService::resolveUrl()` SHALL carry the +object's own top-level scalar properties beside the `@self` metadata, so a +template such as `/apps/dossiq/cases/{case}` is filled. Each property value +SHALL be URL-encoded, because it is record data and must not change which page +the link opens. On a name clash the metadata (`uuid`, `register`, `schema` and +the `@self` keys) SHALL win. A property whose value is not scalar SHALL NOT be +offered as a link value. + +#### Scenario: A template names a property of the object +- @e2e exclude formatter data assembly, covered by PHPUnit (ObjectSearchResultFormatterTest) +- GIVEN dossiq registered `urlTemplate: '/apps/dossiq/cases/{case}'` +- AND a matched object carries `case: "c-42"` +- WHEN the entry is built +- THEN its URL is `/apps/dossiq/cases/c-42` + +#### Scenario: A property cannot override the metadata +- @e2e exclude formatter data assembly, covered by PHPUnit (ObjectSearchResultFormatterTest) +- GIVEN a matched object carries a property named `uuid` with another value +- WHEN the entry is built +- THEN `{uuid}` is filled from the object's `@self.id` + ### Requirement: Result sublines MUST contain an excerpt of the matched content When the search was term-driven, the subline MUST end with an excerpt: diff --git a/package-lock.json b/package-lock.json index 14b033dcd5..ba85898979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,10 +33,10 @@ "vue-codemirror6": "^1.7.0", "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", - "vue-material-design-icons": "^5.2.0", + "vue-material-design-icons": "^5.4.0", "vue-router": "^5.3.1", "vue3-apexcharts": "~1.8.0", - "zod": "^4.6.5" + "zod": "^4.6.7" }, "devDependencies": { "@babel/core": "^7.23.9", @@ -51,11 +51,11 @@ "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^7.0.1", "@pinia/testing": "^1.0.3", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "@stoplight/spectral-cli": "^6.17.0", "@types/jest": "^29.5.12", "@types/node": "^26.6.4", - "@typescript-eslint/parser": "^8.68.0", + "@typescript-eslint/parser": "^8.71.2", "@vue/test-utils": "^2.5.0", "@vue/vue3-jest": "^29.2.6", "axe-core": "^4.10.0", @@ -5985,13 +5985,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -7563,16 +7563,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", - "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.68.0", - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/typescript-estree": "8.68.0", - "@typescript-eslint/visitor-keys": "8.68.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -7588,14 +7588,14 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", - "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.68.0", - "@typescript-eslint/types": "^8.68.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -7610,14 +7610,14 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", - "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/visitor-keys": "8.68.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7628,9 +7628,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", - "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "devOptional": true, "license": "MIT", "engines": { @@ -7645,9 +7645,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", - "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "devOptional": true, "license": "MIT", "engines": { @@ -7659,16 +7659,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", - "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.68.0", - "@typescript-eslint/tsconfig-utils": "8.68.0", - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/visitor-keys": "8.68.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -7687,13 +7687,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", - "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -19469,13 +19469,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -19485,9 +19485,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -24714,9 +24714,9 @@ } }, "node_modules/vue-material-design-icons": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.3.1.tgz", - "integrity": "sha512-6UNEyhlTzlCeT8ZeX5WbpUGFTTPSbOoTQeoASTv7X4Ylh0pe8vltj+36VMK56KM0gG8EQVoMK/Qw/6evalg8lA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.4.0.tgz", + "integrity": "sha512-NqKTWn+4cnWDiD2P6xeKqaLg1iGajl0VVUcMbcv5aDRoXFPu70hqN55sBSrGKatkeP5M1aDZ/YCiCgBkr9qh1A==", "license": "MIT" }, "node_modules/vue-resize": { @@ -25706,9 +25706,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index a5d6dfa852..4ef2b09457 100644 --- a/package.json +++ b/package.json @@ -87,10 +87,10 @@ "vue-codemirror6": "^1.7.0", "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", - "vue-material-design-icons": "^5.2.0", + "vue-material-design-icons": "^5.4.0", "vue-router": "^5.3.1", "vue3-apexcharts": "~1.8.0", - "zod": "^4.6.5" + "zod": "^4.6.7" }, "devDependencies": { "@babel/core": "^7.23.9", @@ -105,11 +105,11 @@ "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/webpack-vue-config": "^7.0.1", "@pinia/testing": "^1.0.3", - "@playwright/test": "^1.63.0", + "@playwright/test": "^1.64.0", "@stoplight/spectral-cli": "^6.17.0", "@types/jest": "^29.5.12", "@types/node": "^26.6.4", - "@typescript-eslint/parser": "^8.68.0", + "@typescript-eslint/parser": "^8.71.2", "@vue/test-utils": "^2.5.0", "@vue/vue3-jest": "^29.2.6", "axe-core": "^4.10.0", diff --git a/src/App.vue b/src/App.vue index 36125b081c..f29f0e648a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,6 @@ + + diff --git a/src/dialogs/migrationPack/DeleteMigrationPackDialog.vue b/src/dialogs/migrationPack/DeleteMigrationPackDialog.vue new file mode 100644 index 0000000000..60c87eec86 --- /dev/null +++ b/src/dialogs/migrationPack/DeleteMigrationPackDialog.vue @@ -0,0 +1,78 @@ + + + + diff --git a/src/dialogs/migrationPack/TransformDialog.vue b/src/dialogs/migrationPack/TransformDialog.vue new file mode 100644 index 0000000000..b45dd9e9d4 --- /dev/null +++ b/src/dialogs/migrationPack/TransformDialog.vue @@ -0,0 +1,603 @@ + + + + + + diff --git a/src/dialogs/migrationPack/UnsavedChangesDialog.vue b/src/dialogs/migrationPack/UnsavedChangesDialog.vue new file mode 100644 index 0000000000..66fb06e2fa --- /dev/null +++ b/src/dialogs/migrationPack/UnsavedChangesDialog.vue @@ -0,0 +1,61 @@ + + + + diff --git a/src/icons.js b/src/icons.js index ec5b217b33..a50e2ceea9 100644 --- a/src/icons.js +++ b/src/icons.js @@ -47,6 +47,7 @@ import ShieldAccountOutline from 'vue-material-design-icons/ShieldAccountOutline import ShieldLockOutline from 'vue-material-design-icons/ShieldLockOutline.vue' import SitemapOutline from 'vue-material-design-icons/SitemapOutline.vue' import StoreOutline from 'vue-material-design-icons/StoreOutline.vue' +import SwapHorizontal from 'vue-material-design-icons/SwapHorizontal.vue' import TextBoxOutline from 'vue-material-design-icons/TextBoxOutline.vue' import TrayFull from 'vue-material-design-icons/TrayFull.vue' import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue' @@ -88,6 +89,7 @@ export default { ShieldLockOutline, SitemapOutline, StoreOutline, + SwapHorizontal, TextBoxOutline, TrayFull, ViewDashboardOutline, diff --git a/src/manifest.json b/src/manifest.json index 127525c13d..1c7cd25c06 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -339,6 +339,24 @@ "component": "IntegrationsView", "_note": "Standalone integration-registry surface for the per-leaf screenshot harness; bypasses object-detail so sub-resource plugins do not race the render." }, + { + "id": "migration-packs", + "route": "/migration-packs", + "type": "custom", + "title": "Migration packs", + "component": "MigrationPacksIndex", + "permission": "admin", + "_note": "The column mappings an import runs (migration-pack-editor-ui). A migration pack is an OpenRegister foundation entity served by its own controller (/api/migration-packs), not a register-stored object, so the built-in index renderer (useObjectStore by register+schema slug) cannot resolve it. A named entity source (config.entitySource, as the flows page uses) would close that, but in nextcloud-vue 2.72.0 the sources are a fixed registry inside the library (indexSources.js: flows, tasks) that an app cannot add to; a migrationPacks source there is what would let this page become type:index. `permission` is documentation only: OpenRegister passes no permissions list to CnAppRoot, so the page itself shows read-only for a non-admin and the menu entry is gated on runtime.user.isAdmin." + }, + { + "id": "migration-pack-detail", + "route": "/migration-packs/:id", + "type": "custom", + "title": "Migration pack", + "component": "MigrationPackDetail", + "permission": "admin", + "_note": "The editor of one migration pack (migration-pack-editor-ui): an ordered field-mapping table with per-row transform dialogs, an Advanced (JSON) view and a sample-row test over /api/migration-packs/preview. Not a register object, so the built-in detail renderer (CnDetailPage, which loads one object by register+schema) has nothing to load and has no named-source mode in nextcloud-vue 2.72.0, and its ordered fieldMappings with per-row transforms are not expressible as a schema form." + }, { "id": "connections", "route": "/settings/connections", @@ -570,6 +588,17 @@ "visibleIf": { "appInstalled": "integriq" } + }, + { + "id": "MigrationPacks", + "label": "Migration packs", + "icon": "SwapHorizontal", + "route": "migration-packs", + "order": 97, + "permission": "admin", + "visibleIf": { + "user.isAdmin": true + } } ] }, diff --git a/src/menu-layout.json b/src/menu-layout.json index 0bf6bb658e..2860abab13 100644 --- a/src/menu-layout.json +++ b/src/menu-layout.json @@ -33,6 +33,7 @@ "Endpoints", "Webhooks", "Connections", + "MigrationPacks", "Configurations" ] } diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue index 5ee31c75f8..1f2f2b5b5b 100644 --- a/src/navigation/MainMenu.vue +++ b/src/navigation/MainMenu.vue @@ -1,5 +1,5 @@