diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2506817ed1..a083cb2f71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Added
+- **New API surface in this release.** `POST /api/forms/{formId}/submit`, `.../uploads` and `.../draft` and `POST /api/forms/validate` (forms, #4563); `POST /api/objects/{register}/{schema}/{id}/activate` (make a draft definitive, #4600); `GET /api/objects/{register}/{schema}/{id}/tree` (the tree along a schema's parent property, #4656); `/api/record-templates` (CRUD, #4595); `POST /api/migration-packs/preview` (#4602); and the occ commands `openregister:example-set:list`, `:install` and `:remove` (#4603). Each is described in its openspec change.
- **Schema-scoped Smart Picker / unified-search provider base classes (`schema-scoped-smart-picker`).** Two new reusable abstract classes let a consuming app expose one (register, schema) pair as its own discoverable entry in Nextcloud's Smart Picker "Select provider" list, instead of every object funnelling through the single generic "Register Objects" entry: `AbstractSchemaReferenceProvider` (`lib/AppHost/Reference/AbstractSchemaReferenceProvider.php`, extends `ADiscoverableReferenceProvider` + `ISearchableReferenceProvider`) and `AbstractSchemaSearchProvider` (`lib/AppHost/Search/AbstractSchemaSearchProvider.php`, implements `OCP\Search\IProvider`). A subclass implements only `getRegisterSlug()`/`getSchemaSlug()` — the provider id (`openregister-ref-{registerSlug}-{schemaSlug}` / `openregister_objects_{registerSlug}_{schemaSlug}`), title, and icon are computed by `final` base-class methods from the schema's own live metadata, never chosen by the app, so ids can never collide. `matchReference()`/`resolveReference()` reuse the existing URL-parsing and rich-preview logic via the new shared `ObjectPreviewFormatter` (`lib/Service/Reference/ObjectPreviewFormatter.php`) and reject any URL outside the configured schema; `search()` delegates to `ObjectService::searchObjectsPaginated(_rbac: true, _multitenancy: true)` scoped to the configured schema via the new shared `ObjectSearchResultFormatter` (`lib/Service/Search/ObjectSearchResultFormatter.php`) — both existing generic providers (`ObjectsProvider`/`ObjectReferenceProvider`) were refactored to delegate to these same shared services, behavior-preserving, same provider ids. A new `Schema::$smartPickerEnabled` flag (default `false`, migration `Version1Date20260817120000`, `SchemaMapper::findSmartPickerEnabledIds()`, mirroring the existing `searchable` flag) gates whether a schema-scoped provider is functionally active — disabling it makes `matchReference()`/`resolveReference()`/`search()` return nothing, but cannot remove the provider's entry from the picker list, since Nextcloud resolves that list from boot-time class registration, not a runtime flag. Also closes two previously-documented-but-unimplemented `mail-smart-picker` gaps: `ObjectService::saveObject()` now invalidates the Smart Picker reference cache for every canonical URL shape via `IReferenceManager::invalidateCache()`, and `ObjectReferenceProvider` now implements `OCP\Collaboration\Reference\IPublicReferenceProvider` so publicly-readable objects get rich previews for anonymous viewers. No consuming-app subclass ships in this change — Pipelinq's `LeadReferenceProvider`/`LeadSearchProvider` is a follow-up. (`schema-scoped-smart-picker`)
- **Context Chat content provider — register objects indexed into the Nextcloud Assistant RAG pipeline (`context-chat-provider`).** OpenRegister now implements `OCP\ContextChat\IContentProvider` (`lib/ContextChat/ContentProvider.php`), registered via `ContentProviderRegisterEvent` (`lib/ContextChat/ContentProviderRegistrationListener.php`) and softly gated on `IContentManager::isContextChatAvailable()` — a standalone instance without the `context_chat` app installed is entirely unaffected (`OCP\ContextChat\*` are core NC 32+ interfaces that always resolve via DI; only the actual submission/registration calls no-op when the app is absent). A new per-schema opt-in, `configuration['x-openregister-contextchat']` (default OFF), added to `Schema::setConfiguration()`'s annotation allow-list plus an `isContextChatIndexingEnabled()` accessor, gates which schemas' objects are ever submitted. `lib/Listener/ContextChatSubmissionListener.php` submits/removes content on the existing `ObjectCreatedEvent`/`ObjectUpdatedEvent`/`ObjectDeletedEvent` listeners (fail-soft — a Context Chat outage never blocks an object write); the publication predicate is implemented as "the `public` group is granted `read` under the schema's resolved authorization" — the living RBAC-`$now` equivalent of the removed `@self.published`/`@self.depublished` metadata (see `deprecate-published-metadata`). `getItemUrl()` reuses `DeepLinkRegistryService::resolveUrl()` with the `openregister.objects.show` fallback, the same mechanism `ObjectsProvider` already uses for unified search. New `occ openregister:contextchat:reindex` command (optional `--register`/`--schema` scoping) and `ContentProvider::triggerInitialImport()` share one batched backfill path (`lib/Command/ContextChatReindexCommand.php`). (`context-chat-provider`)
- **Field-level object encryption at rest (`field-level-encryption`).** Schema property flag `x-openregister-encrypted: true` encrypts flagged object field values at rest, transparently, for every app built on OpenRegister — reusing `OCP\Security\ICrypto` (the same primitive `TenantKeyService`/`SourcesController`/`WorkflowEngineRegistry` already use for credentials, no new crypto). New `lib/Service/FieldEncryptionHandler.php` encrypts on save (`SaveObject::prepareObjectData()`, last step before persistence) and decrypts on read (`RenderObject::renderEntity()`/`redactWriteOnlyFromRows()`) — decryption runs strictly *after* the existing `writeOnly`/property-authorization redaction and only touches properties still present, so an unauthorized caller never receives ciphertext or plaintext, only the same absence every other redacted field gets. Stored values use a versioned envelope (`openregister:enc:v1:`) so mixed plaintext/ciphertext during rollout is unambiguous. An encrypted property gets no dedicated magic-table column (`MagicMapper::buildTableColumnsFromSchema()`), is excluded from full-text search and facets, and filtering on it is rejected with HTTP 400 (`EncryptedFieldFilterException` via new `EncryptedFieldFilterMiddleware`) rather than silently returning zero rows. New `occ openregister:encrypt-field --property=` command migrates existing plaintext values idempotently, with `--dry-run`/`--register`/`--schema` scoping. Decryption failure never fails silently — a structured `@openregister_decryption_error` marker + ERROR log by default, or a thrown `FieldDecryptionException` for strict callers (the migration command). No behaviour change for any schema that does not use the new flag. (`field-level-object-encryption`)
@@ -37,6 +38,18 @@
- **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.
+- **Upgrade note: a roles block that resolves to nothing now grants nothing (`roles-block-that-resolves-to-nothing-grants-nothing`, #4589).** A schema or register `authorization` that carries a `roles` entry opts it into authorization. When none of the roles can be expanded (the register declares no role definitions, or every role name is unknown) the block used to count as "no authorization configured" and every signed-in account could read, create, change and delete. It now grants `read`, `create`, `update` and `delete` to nobody; the owner and administrators still pass, and the generated OAS describes the schema the same way. **This can take access away on upgrade**: before upgrading, check every schema and register that has a `roles` block and make sure each role name is defined in the register's `roles`. A denial names the missing role in the log (`Unknown role name referenced in authorization`).
+- **Upgrade note: the files of a frozen or archived object cannot be changed any more, also through Nextcloud Files and WebDAV (`object-archive-state`, REQ-OAS-007, #4549).** Creating, writing, renaming, moving and deleting a file under such an object's folder is refused, as the files API already did: through WebDAV and the Files app (also by a person who reaches the folder through a share), through the object-folder endpoints (`POST/PUT/DELETE .../folder`, `POST .../folder/upload`, which answer 409 with the state, who, when and why) and through the in-process file service, where a delete or move that Nextcloud aborted is reported as failed instead of done. Clients and flows that attach files to archived objects start getting errors; unfreeze or restore the object first. Reading, downloading and previewing are unchanged.
+- **Personal settings moved into the in-app user settings dialog (#4587).** OpenRegister no longer shows a section in Nextcloud's personal settings (`OpenRegisterPersonal` and its script are removed). Connected accounts, notifications and the other per-person settings are in the user settings dialog of the OpenRegister app; the "Reconnect your account" notification and the admin hint point there.
+- **The audit trail of an object is readable by anyone who may read the object (`audit-trail-immutable`, #4614).** `GET /api/objects/{register}/{schema}/{id}/audit-trails` used to need an administrator; a person who may read the object now reads its history (`GET .../logs` answers non-administrators with the same redacted page), without the session, request and IP address of each entry, without the values of properties that person may not see (property-level rules, write-only properties), without the identity of the object whose delete cascaded into it, and subject to the doelbinding (purpose) rule that the object read runs. This is an exposure change administrators should know about: the history of an object is now as visible as the object.
+- **Drafts, forms and tasks: the draft rule now holds on the bulk paths, and a form cannot choose the identity of what it writes.** A draft of somebody else stays hidden from bulk delete, bulk save and the readable audit trail (`/api/audit-trails/readable`), not only from the single-object read and write; a form payload cannot name the uuid of the object it creates, so a draft save cannot reach another draft. A blocked task (`blockedBy` names an open task) also waits out of sight in the portal: it is not listed or counted, and cannot be read or completed by its uuid until the blocker closes. `blockedBy` accepts only a task the caller may read.
+- **Upgrade note: an email notification to a referenced address follows only a reference that names its schema (`external-recipient-opt-out`, REQ-ERO-007).** Each hop of the recipient reference must be an object of the schema the reference property's `$ref` points at. A rule whose reference property declares no `$ref` (a plain uuid string) now resolves to `reference-not-found` and sends nothing; give the property a `$ref` to keep such a rule working.
+- **Object tree and relation graph: the doelbinding rule and property rules apply (`object-tree-read`, #4656).** `GET /api/objects/{register}/{schema}/{id}/tree`, `.../graph` and `.../graph/export` refuse a read that names no purpose where the schema asks for one (403, as the object read). The `fields` the tree copies onto each node are read through the same redaction as every other read: write-only properties, property-level `authorization.read` and encrypted values are never returned raw. A level wider than the instance node cap is no longer read in full (`truncatedBy: cap`), a failing access check answers `truncatedBy: unreadable`, and the topmost ancestor no longer names a parent the caller may not read. The tree route is limited to 60 requests a minute per user.
+- **Bulk writes no longer let a schema property named like a metadata column replace it (#4661).** A schema property whose column is a metadata column (`_owner`, `_organisation`, `_authorization`, `_status`, `_deleted`, ...; also `_Owner` or `__owner_`) is not written by the bulk or the single-object writer: the server-stamped value stays. Such a property reads back empty; rename it (a property that merely starts with an underscore, such as `_meta`, is unaffected).
+- **Grants and the owning group leave audit-trail rows (`special-right-to-manage-grants`, #4601).** Adding or revoking a grant on a record, and changing its owning group, write `grant_added`, `grant_revoked` and `owner_group_changed` entries naming who acted, the principal and whether the caller was admitted as owner or as holder of the special right `grant_manage`; a change of the list that holds the right is written as a `settings.updated` row. A holder of `grant_manage` is also refused a grant to a remote user or remote group, which cannot be proved not to be itself.
+- **A schema written by the configuration import, the import endpoint or the AI and MCP tools re-checks its published forms (`form-destination-validator`).** Until now only the schema API did. A write that changes `properties` or `required` unpublishes the forms it breaks and notifies their authors; an owning app's `formDestinationBreak: refuse` cannot refuse a write that has no response, so these forms are unpublished and the log says so.
+- **Upgrade note: the BRP resident count needs an integriq whose `CallService::call` has the `withholdBodies` parameter (#4657).** That parameter exists on integriq `development` only. Against a connector without it `countResidentsAtAddressObject()` makes no call and answers `{ unavailable: true, cause: "connector-cannot-withhold-bodies" }` (logged at info level, not as an outage); upgrade integriq together with OpenRegister to use the count.
+- **Deferred listener work fits the job limit of the running Nextcloud (`deferred-listener-batches-stay-under-the-job-limit`, #4616).** Chunks are cut to 4000 characters on Nextcloud 32 and 33 and 32000 from 34. An update notification whose previous data alone is larger than that is queued without it (conditioned `updated` rules and `calculatedChange` rules are skipped for that object, and the log names it). A webhook whose payload exceeds the limit is logged and not delivered.
- **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 9ed1aa7004..adb05bc456 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -182,6 +182,7 @@ Vrij en open source onder de EUPL-licentie.
OCA\OpenRegister\BackgroundJob\FlowRunWorkerOCA\OpenRegister\BackgroundJob\FlowScheduleWorkerOCA\OpenRegister\BackgroundJob\FlowTimerWorker
+ OCA\OpenRegister\BackgroundJob\CasePlanTimerRearmJobOCA\OpenRegister\BackgroundJob\HandoffQueueDrainJobOCA\OpenRegister\BackgroundJob\TemporalCalculationSweepJobOCA\OpenRegister\BackgroundJob\DsarDpiaDetectionJob
diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md
index c4869fa631..bf9242ea69 100644
--- a/docs/features/form-destination.md
+++ b/docs/features/form-destination.md
@@ -81,10 +81,16 @@ OpenRegister object (`status: published`, `destination` or `writes`,
| 401 | The form is for signed-in people |
| 403 | The subject may not create the destination |
| 404 | No such published form (also counted as a brute-force attempt) |
+| 409 | The `Idempotency-Key` is in use by a submit still running, or was used by another person or for another payload |
| 422 | The payload was refused: `{ message, findings }`, nothing was created |
| 503 | The destination could not be reached; the resident tries again later |
-A retried `Idempotency-Key` returns the first answer for 24 hours. When one
+A retried `Idempotency-Key` returns the first answer for 24 hours, to the same
+person with the same payload only; the key is reserved while its submit runs, so
+a parallel retry answers 409 instead of creating a second object. That
+parallel-retry protection relies on Nextcloud's file locking (`filelocking.enabled`,
+on by default); with file locking switched off the key is not reserved and only the
+sequential repeat is answered from the stored answer. When one
of several writes is refused, the earlier writes of that submit are deleted
before the answer, and the deletion is logged.
diff --git a/docs/federated-configuration.md b/docs/federated-configuration.md
index 504b3240ef..8a72f20c26 100644
--- a/docs/federated-configuration.md
+++ b/docs/federated-configuration.md
@@ -26,7 +26,7 @@ rules.
## Choose which GitHub credential the store uses
The store never assumes your only GitHub key is the one you meant to use. In the
-app's **Configuration store** settings pane (personal settings), pick which of
+app's **Configuration store** settings pane (user settings), pick which of
your GitHub credentials the store should publish and browse with. The choice is
saved as a per-user preference; the credential itself is custodied by the broker.
diff --git a/l10n/be.js b/l10n/be.js
index bb6a8a0854..5f316f797d 100644
--- a/l10n/be.js
+++ b/l10n/be.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} неудачно",
"0 = unlimited": "0 = неограниченно",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Паток запускае серыю крокаў, калі нешта адбываецца — аб'ект змяняецца, спрацоўвае расклад, або вы запускаеце яго самі. Гэты спіс паказвае патокі ўсіх праграм.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ва ўласных наладах; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ў наладах карыстальніка; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.",
"A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.",
"AI Assistant": "ИИ-помощник",
"AI Chat": "ИИ-чат",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Прысутнічае",
"Re-import": "Імпартаваць зноў",
"Reading descriptors …": "Чытанне дэскрыптараў …",
+ "Re-import {register}?": "Імпартаваць {register} зноў?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Паўторны імпарт {app} бярэ аўтарызацыю яго схем з файла, замяняючы любыя змены, зробленыя на гэтым серверы, і можа паўторна імпартаваць кожны рэестр праграмы, а не толькі {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Аўтарызацыя, узятая з файла замест лакальнай змены: {count}.",
"Register descriptors": "Дэскрыптары рэестраў",
"ships v{shipped}": "пастаўляе v{shipped}",
"State": "Стан",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Слупок ідэнтыфікатара",
"Import JSON": "Імпартаваць JSON",
"Imported \"{name}\".": "\"{name}\" імпартаваны.",
+ "Replaced \"{name}\".": "\"{name}\" заменены.",
"Imports find the pack by this slug.": "Імпарты знаходзяць пакет па гэтым слагу.",
"Join columns": "Аб'яднаць слупкі",
"Joined with {fields}, separated by \"{separator}\"": "Аб'яднана з {fields}, раздзяляльнік \"{separator}\"",
diff --git a/l10n/be.json b/l10n/be.json
index 85cf08dae2..0bc0dc18c7 100644
--- a/l10n/be.json
+++ b/l10n/be.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} неудачно",
"0 = unlimited": "0 = неограниченно",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Паток запускае серыю крокаў, калі нешта адбываецца — аб'ект змяняецца, спрацоўвае расклад, або вы запускаеце яго самі. Гэты спіс паказвае патокі ўсіх праграм.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ва ўласных наладах; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключоў VAPID наладжана. Карыстальнікі могуць уключыць апавяшчэнні браўзера ў наладах карыстальніка; яны дастаўляюцца нават тады, калі ўкладка браўзера закрыта.",
"A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.",
"AI Assistant": "ИИ-помощник",
"AI Chat": "ИИ-чат",
diff --git a/l10n/bg.js b/l10n/bg.js
index 96880d23aa..1eaa1e32cb 100644
--- a/l10n/bg.js
+++ b/l10n/bg.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} неуспешни",
"0 = unlimited": "0 = неограничено",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потокът изпълнява поредица от стъпки, когато нещо се случи — обект се променя, график се задейства или го стартирате сами. Този списък показва потоците на всички приложения.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от личните си настройки; те се доставят дори когато разделът на браузъра е затворен.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от потребителските си настройки; те се доставят дори когато разделът на браузъра е затворен.",
"A deactivation request is pending.": "Заявка за деактивиране е в очакване.",
"AI Assistant": "AI асистент",
"AI Chat": "AI чат",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Наличен",
"Re-import": "Импортирайте отново",
"Reading descriptors …": "Четене на дескриптори …",
+ "Re-import {register}?": "Да се импортира ли отново {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторното импортиране на {app} взема оторизацията на схемите му от файла, като заменя всички промени, направени в тази инсталация, и може да импортира отново всеки регистър на приложението, не само {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Оторизация, взета от файла вместо локална промяна: {count}.",
"Register descriptors": "Дескриптори на регистри",
"ships v{shipped}": "доставя v{shipped}",
"State": "Състояние",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Колона с идентификатор",
"Import JSON": "Импортиране на JSON",
"Imported \"{name}\".": "\"{name}\" е импортиран.",
+ "Replaced \"{name}\".": "\"{name}\" е заменен.",
"Imports find the pack by this slug.": "Импортиранията намират пакета по този slug.",
"Join columns": "Обединяване на колони",
"Joined with {fields}, separated by \"{separator}\"": "Обединено с {fields}, разделено с \"{separator}\"",
diff --git a/l10n/bg.json b/l10n/bg.json
index 0e0384068c..fdb318c262 100644
--- a/l10n/bg.json
+++ b/l10n/bg.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} неуспешни",
"0 = unlimited": "0 = неограничено",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потокът изпълнява поредица от стъпки, когато нещо се случи — обект се променя, график се задейства или го стартирате сами. Този списък показва потоците на всички приложения.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от личните си настройки; те се доставят дори когато разделът на браузъра е затворен.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Настроена е двойка ключове VAPID. Потребителите могат да включат известията в браузъра от потребителските си настройки; те се доставят дори когато разделът на браузъра е затворен.",
"A deactivation request is pending.": "Заявка за деактивиране е в очакване.",
"AI Assistant": "AI асистент",
"AI Chat": "AI чат",
diff --git a/l10n/bs.js b/l10n/bs.js
index 69d4e70a2e..587ff5d4c4 100644
--- a/l10n/bs.js
+++ b/l10n/bs.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} neuspješno",
"0 = unlimited": "0 = neograničeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promijeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u ličnim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.",
"A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI razgovor",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Postoji",
"Re-import": "Ponovo uvezi",
"Reading descriptors …": "Čitanje deskriptora …",
+ "Re-import {register}?": "Ponovo uvesti {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponovnim uvozom {app} ovlaštenja njegovih shema preuzimaju se iz datoteke, čime se zamjenjuju sve promjene napravljene na ovoj instanci, a mogu se ponovo uvesti i svi registri aplikacije, ne samo {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Ovlaštenja preuzeta iz datoteke umjesto lokalne promjene: {count}.",
"Register descriptors": "Deskriptori registara",
"ships v{shipped}": "isporučuje v{shipped}",
"State": "Stanje",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Kolona s id-om",
"Import JSON": "Uvezi JSON",
"Imported \"{name}\".": "\"{name}\" je uvezen.",
+ "Replaced \"{name}\".": "\"{name}\" je zamijenjen.",
"Imports find the pack by this slug.": "Uvozi pronalaze paket po ovom slugu.",
"Join columns": "Spoji kolone",
"Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, razdvojeno sa \"{separator}\"",
diff --git a/l10n/bs.json b/l10n/bs.json
index cad03268c4..36ef584d08 100644
--- a/l10n/bs.json
+++ b/l10n/bs.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} neuspješno",
"0 = unlimited": "0 = neograničeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promijeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u ličnim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je konfigurisan. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju čak i kada je kartica preglednika zatvorena.",
"A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI razgovor",
diff --git a/l10n/ca.js b/l10n/ca.js
index cd056d1a01..f2915d7e6a 100644
--- a/l10n/ca.js
+++ b/l10n/ca.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} fallits",
"0 = unlimited": "0 = il·limitat",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux executa una sèrie de passos quan passa alguna cosa — un objecte canvia, una programació es dispara o l'executeu vós mateix. Aquesta llista mostra els fluxos de cada aplicació.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració personal; s'entreguen fins i tot quan la pestanya del navegador està tancada.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració d'usuari; s'entreguen fins i tot quan la pestanya del navegador està tancada.",
"A deactivation request is pending.": "Hi ha una sol·licitud de desactivació pendent.",
"AI Assistant": "Assistent d'IA",
"AI Chat": "Xat d'IA",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Present",
"Re-import": "Reimporta",
"Reading descriptors …": "S’estan llegint els descriptors …",
+ "Re-import {register}?": "Reimportar {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "En reimportar {app}, l'autorització dels seus esquemes es pren del fitxer i substitueix qualsevol canvi fet en aquesta instància, i es poden reimportar tots els registres de l'aplicació, no només {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorització presa del fitxer sobre un canvi local: {count}.",
"Register descriptors": "Descriptors de registre",
"ships v{shipped}": "lliura v{shipped}",
"State": "Estat",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Columna d'id",
"Import JSON": "Importar JSON",
"Imported \"{name}\".": "S'ha importat \"{name}\".",
+ "Replaced \"{name}\".": "S'ha substituït \"{name}\".",
"Imports find the pack by this slug.": "Les importacions troben el paquet per aquest slug.",
"Join columns": "Unir columnes",
"Joined with {fields}, separated by \"{separator}\"": "Unit amb {fields}, separat per \"{separator}\"",
diff --git a/l10n/ca.json b/l10n/ca.json
index 815fbd889e..d36d735ace 100644
--- a/l10n/ca.json
+++ b/l10n/ca.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} fallits",
"0 = unlimited": "0 = il·limitat",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux executa una sèrie de passos quan passa alguna cosa — un objecte canvia, una programació es dispara o l'executeu vós mateix. Aquesta llista mostra els fluxos de cada aplicació.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració personal; s'entreguen fins i tot quan la pestanya del navegador està tancada.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hi ha un parell de claus VAPID configurat. Els usuaris poden activar les notificacions del navegador des de la configuració d'usuari; s'entreguen fins i tot quan la pestanya del navegador està tancada.",
"A deactivation request is pending.": "Hi ha una sol·licitud de desactivació pendent.",
"AI Assistant": "Assistent d'IA",
"AI Chat": "Xat d'IA",
diff --git a/l10n/cs.js b/l10n/cs.js
index 4c02e2f7d4..932f64016c 100644
--- a/l10n/cs.js
+++ b/l10n/cs.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} selhalo",
"0 = unlimited": "0 = neomezeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sérii kroků, když se něco stane — objekt se změní, spustí se plán, nebo jej spustíte sami. Tento seznam zobrazuje toky všech aplikací.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v osobním nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v uživatelském nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.",
"A deactivation request is pending.": "Žádost o deaktivaci čeká na vyřízení.",
"AI Assistant": "AI asistent",
"AI Chat": "AI chat",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Přítomen",
"Re-import": "Znovu importovat",
"Reading descriptors …": "Načítání deskriptorů …",
+ "Re-import {register}?": "Znovu importovat {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Opětovný import aplikace {app} převezme autorizaci jejích schémat ze souboru a nahradí všechny změny provedené v této instanci; může také znovu importovat všechny registry aplikace, nejen {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorizace převzatá ze souboru namísto místní změny: {count}.",
"Register descriptors": "Deskriptory registrů",
"ships v{shipped}": "dodává v{shipped}",
"State": "Stav",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Sloupec s ID",
"Import JSON": "Importovat JSON",
"Imported \"{name}\".": "Balíček \"{name}\" byl naimportován.",
+ "Replaced \"{name}\".": "Balíček \"{name}\" byl nahrazen.",
"Imports find the pack by this slug.": "Importy najdou balíček podle tohoto slugu.",
"Join columns": "Spojit sloupce",
"Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, odděleno \"{separator}\"",
diff --git a/l10n/cs.json b/l10n/cs.json
index 8672dcef7a..316fad442f 100644
--- a/l10n/cs.json
+++ b/l10n/cs.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} selhalo",
"0 = unlimited": "0 = neomezeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sérii kroků, když se něco stane — objekt se změní, spustí se plán, nebo jej spustíte sami. Tento seznam zobrazuje toky všech aplikací.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v osobním nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurován pár klíčů VAPID. Uživatelé si mohou v uživatelském nastavení zapnout oznámení v prohlížeči, která se doručují i při zavřené kartě prohlížeče.",
"A deactivation request is pending.": "Žádost o deaktivaci čeká na vyřízení.",
"AI Assistant": "AI asistent",
"AI Chat": "AI chat",
diff --git a/l10n/da.js b/l10n/da.js
index d3179bd246..a69e3fba56 100644
--- a/l10n/da.js
+++ b/l10n/da.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} mislykkedes",
"0 = unlimited": "0 = ubegrænset",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Et flow kører en række trin, når noget sker — et objekt ændres, en tidsplan udløses, eller du kører det selv. Denne liste viser alle appers flows.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Der er konfigureret et VAPID-nøglepar. Brugere kan tilmelde sig browsernotifikationer i deres brugerindstillinger, som leveres, selv når browserfanen er lukket.",
"A deactivation request is pending.": "En anmodning om deaktivering afventer.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-chat",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Til stede",
"Re-import": "Genimportér",
"Reading descriptors …": "Læser deskriptorer …",
+ "Re-import {register}?": "Genimportér {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Genimport af {app} henter autorisationen for dens skemaer fra filen og erstatter alle ændringer, der er foretaget på denne instans, og kan genimportere alle appens registre, ikke kun {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorisation hentet fra filen over en lokal ændring: {count}.",
"Register descriptors": "Register-deskriptorer",
"ships v{shipped}": "leverer v{shipped}",
"State": "Status",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Id-kolonne",
"Import JSON": "Importér JSON",
"Imported \"{name}\".": "\"{name}\" er importeret.",
+ "Replaced \"{name}\".": "\"{name}\" er erstattet.",
"Imports find the pack by this slug.": "Importer finder pakken via denne slug.",
"Join columns": "Sammenføj kolonner",
"Joined with {fields}, separated by \"{separator}\"": "Sammenføjet med {fields}, adskilt af \"{separator}\"",
diff --git a/l10n/da.json b/l10n/da.json
index 90478e08d1..f0a0344637 100644
--- a/l10n/da.json
+++ b/l10n/da.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} mislykkedes",
"0 = unlimited": "0 = ubegrænset",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Et flow kører en række trin, når noget sker — et objekt ændres, en tidsplan udløses, eller du kører det selv. Denne liste viser alle appers flows.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Der er konfigureret et VAPID-nøglepar. Brugere kan tilmelde sig browsernotifikationer i deres brugerindstillinger, som leveres, selv når browserfanen er lukket.",
"A deactivation request is pending.": "En anmodning om deaktivering afventer.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-chat",
diff --git a/l10n/de.js b/l10n/de.js
index d7d5dd17ba..99091f3f1f 100644
--- a/l10n/de.js
+++ b/l10n/de.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} fehlgeschlagen",
"0 = unlimited": "0 = unbegrenzt",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ein Flow führt eine Reihe von Schritten aus, wenn etwas passiert — ein Objekt ändert sich, ein Zeitplan wird ausgelöst oder Sie führen ihn selbst aus. Diese Liste zeigt die Flows jeder App.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren persönlichen Einstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren Benutzereinstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.",
"A deactivation request is pending.": "Eine Deaktivierungsanfrage ist ausstehend.",
"AI Assistant": "KI-Assistent",
"AI Chat": "KI-Chat",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Vorhanden",
"Re-import": "Erneut importieren",
"Reading descriptors …": "Deskriptoren werden gelesen …",
+ "Re-import {register}?": "{register} erneut importieren?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Beim erneuten Import von {app} wird die Autorisierung seiner Schemata aus der Datei übernommen und jede auf dieser Instanz vorgenommene Änderung ersetzt; dabei können auch alle Register der App erneut importiert werden, nicht nur {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorisierung aus der Datei über eine lokale Änderung hinweg übernommen: {count}.",
"Register descriptors": "Register-Deskriptoren",
"ships v{shipped}": "liefert v{shipped}",
"State": "Status",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "ID-Spalte",
"Import JSON": "JSON importieren",
"Imported \"{name}\".": "\"{name}\" importiert.",
+ "Replaced \"{name}\".": "\"{name}\" ersetzt.",
"Imports find the pack by this slug.": "Importe finden das Paket über diesen Slug.",
"Join columns": "Spalten verbinden",
"Joined with {fields}, separated by \"{separator}\"": "Verbunden mit {fields}, getrennt durch \"{separator}\"",
diff --git a/l10n/de.json b/l10n/de.json
index f9620c388e..6faeab6db4 100644
--- a/l10n/de.json
+++ b/l10n/de.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} fehlgeschlagen",
"0 = unlimited": "0 = unbegrenzt",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ein Flow führt eine Reihe von Schritten aus, wenn etwas passiert — ein Objekt ändert sich, ein Zeitplan wird ausgelöst oder Sie führen ihn selbst aus. Diese Liste zeigt die Flows jeder App.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren persönlichen Einstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ein VAPID-Schlüsselpaar ist konfiguriert. Benutzer können Browser-Benachrichtigungen in ihren Benutzereinstellungen aktivieren; diese werden auch dann zugestellt, wenn der Browser-Tab geschlossen ist.",
"A deactivation request is pending.": "Eine Deaktivierungsanfrage ist ausstehend.",
"AI Assistant": "KI-Assistent",
"AI Chat": "KI-Chat",
diff --git a/l10n/el.js b/l10n/el.js
index ee09a21146..1afb7fc563 100644
--- a/l10n/el.js
+++ b/l10n/el.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} απέτυχαν",
"0 = unlimited": "0 = απεριόριστο",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Μια ροή εκτελεί μια σειρά βημάτων όταν συμβεί κάτι — ένα αντικείμενο αλλάζει, ένα χρονοδιάγραμμα ενεργοποιείται ή την εκτελείτε εσείς οι ίδιοι. Αυτή η λίστα εμφανίζει τις ροές κάθε εφαρμογής.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Έχει ρυθμιστεί ένα ζεύγος κλειδιών VAPID. Οι χρήστες μπορούν να ενεργοποιήσουν τις ειδοποιήσεις του προγράμματος περιήγησης από τις ρυθμίσεις χρήστη τους· παραδίδονται ακόμη και όταν η καρτέλα του προγράμματος περιήγησης είναι κλειστή.",
"A deactivation request is pending.": "Εκκρεμεί ένα αίτημα απενεργοποίησης.",
"AI Assistant": "Βοηθός AI",
"AI Chat": "Συνομιλία AI",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Υπάρχει",
"Re-import": "Επανεισαγωγή",
"Reading descriptors …": "Ανάγνωση περιγραφέων …",
+ "Re-import {register}?": "Επανεισαγωγή του {register};",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Η επανεισαγωγή της εφαρμογής {app} παίρνει την εξουσιοδότηση των σχημάτων της από το αρχείο, αντικαθιστώντας κάθε αλλαγή που έγινε σε αυτήν την εγκατάσταση, και μπορεί να επανεισαγάγει όλα τα μητρώα της εφαρμογής, όχι μόνο το {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Εξουσιοδότηση που ελήφθη από το αρχείο πάνω από τοπική αλλαγή: {count}.",
"Register descriptors": "Περιγραφείς μητρώων",
"ships v{shipped}": "παρέχει v{shipped}",
"State": "Κατάσταση",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Στήλη id",
"Import JSON": "Εισαγωγή JSON",
"Imported \"{name}\".": "Το \"{name}\" εισήχθη.",
+ "Replaced \"{name}\".": "Το \"{name}\" αντικαταστάθηκε.",
"Imports find the pack by this slug.": "Οι εισαγωγές βρίσκουν το πακέτο μέσω αυτού του slug.",
"Join columns": "Συνένωση στηλών",
"Joined with {fields}, separated by \"{separator}\"": "Συνενωμένο με {fields}, με διαχωριστικό \"{separator}\"",
diff --git a/l10n/el.json b/l10n/el.json
index ad01251066..2934d1da20 100644
--- a/l10n/el.json
+++ b/l10n/el.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} απέτυχαν",
"0 = unlimited": "0 = απεριόριστο",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Μια ροή εκτελεί μια σειρά βημάτων όταν συμβεί κάτι — ένα αντικείμενο αλλάζει, ένα χρονοδιάγραμμα ενεργοποιείται ή την εκτελείτε εσείς οι ίδιοι. Αυτή η λίστα εμφανίζει τις ροές κάθε εφαρμογής.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Έχει ρυθμιστεί ένα ζεύγος κλειδιών VAPID. Οι χρήστες μπορούν να ενεργοποιήσουν τις ειδοποιήσεις του προγράμματος περιήγησης από τις ρυθμίσεις χρήστη τους· παραδίδονται ακόμη και όταν η καρτέλα του προγράμματος περιήγησης είναι κλειστή.",
"A deactivation request is pending.": "Εκκρεμεί ένα αίτημα απενεργοποίησης.",
"AI Assistant": "Βοηθός AI",
"AI Chat": "Συνομιλία AI",
diff --git a/l10n/en.js b/l10n/en.js
index f827348952..68360fd1ba 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1609,7 +1609,7 @@ OC.L10N.register(
"Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.": "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.",
"Browser Web Push (VAPID)": "Browser Web Push (VAPID)",
"Browser web push is configured": "Browser web push is configured",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.",
"VAPID public key": "VAPID public key",
"Browser web push is not configured": "Browser web push is not configured",
"No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:": "No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Present",
"Re-import": "Re-import",
"Reading descriptors …": "Reading descriptors …",
+ "Re-import {register}?": "Re-import {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Authorization taken from the file over a local change: {count}.",
"Register descriptors": "Register descriptors",
"ships v{shipped}": "ships v{shipped}",
"State": "State",
@@ -3405,6 +3408,7 @@ OC.L10N.register(
"Id column": "Id column",
"Import JSON": "Import JSON",
"Imported \"{name}\".": "Imported \"{name}\".",
+ "Replaced \"{name}\".": "Replaced \"{name}\".",
"Imports find the pack by this slug.": "Imports find the pack by this slug.",
"Join columns": "Join columns",
"Joined with {fields}, separated by \"{separator}\"": "Joined with {fields}, separated by \"{separator}\"",
diff --git a/l10n/en.json b/l10n/en.json
index ad83c873c7..49e0d5938b 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1605,7 +1605,7 @@
"Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.": "Real-time push notifications are active. Connected clients receive instant updates when objects are created, updated, or deleted.",
"Browser Web Push (VAPID)": "Browser Web Push (VAPID)",
"Browser web push is configured": "Browser web push is configured",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.",
"VAPID public key": "VAPID public key",
"Browser web push is not configured": "Browser web push is not configured",
"No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:": "No VAPID keypair is configured yet. Generate one with the following occ command to enable browser notifications:",
diff --git a/l10n/es.js b/l10n/es.js
index 38f1bb0cef..e56887ecad 100644
--- a/l10n/es.js
+++ b/l10n/es.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} fallidos",
"0 = unlimited": "0 = ilimitado",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flujo ejecuta una serie de pasos cuando ocurre algo — un objeto cambia, una programación se activa o usted mismo lo ejecuta. Esta lista muestra los flujos de cada aplicación.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes personales; se entregan incluso con la pestaña del navegador cerrada.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes de usuario; se entregan incluso con la pestaña del navegador cerrada.",
"A deactivation request is pending.": "Hay una solicitud de desactivación pendiente.",
"AI Assistant": "Asistente de IA",
"AI Chat": "Chat de IA",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Presente",
"Re-import": "Reimportar",
"Reading descriptors …": "Leyendo descriptores …",
+ "Re-import {register}?": "¿Reimportar {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Al reimportar {app}, la autorización de sus esquemas se toma del archivo y sustituye cualquier cambio hecho en esta instancia, y puede reimportarse cada registro de la aplicación, no solo {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorización tomada del archivo sobre un cambio local: {count}.",
"Register descriptors": "Descriptores de registro",
"ships v{shipped}": "entrega v{shipped}",
"State": "Estado",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Columna de id",
"Import JSON": "Importar JSON",
"Imported \"{name}\".": "Se ha importado \"{name}\".",
+ "Replaced \"{name}\".": "Se ha reemplazado \"{name}\".",
"Imports find the pack by this slug.": "Las importaciones encuentran el paquete por este slug.",
"Join columns": "Unir columnas",
"Joined with {fields}, separated by \"{separator}\"": "Unido con {fields}, separado por \"{separator}\"",
diff --git a/l10n/es.json b/l10n/es.json
index e50617a17e..c8b8933386 100644
--- a/l10n/es.json
+++ b/l10n/es.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} fallidos",
"0 = unlimited": "0 = ilimitado",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flujo ejecuta una serie de pasos cuando ocurre algo — un objeto cambia, una programación se activa o usted mismo lo ejecuta. Esta lista muestra los flujos de cada aplicación.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes personales; se entregan incluso con la pestaña del navegador cerrada.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Hay configurado un par de claves VAPID. Los usuarios pueden activar las notificaciones del navegador desde sus ajustes de usuario; se entregan incluso con la pestaña del navegador cerrada.",
"A deactivation request is pending.": "Hay una solicitud de desactivación pendiente.",
"AI Assistant": "Asistente de IA",
"AI Chat": "Chat de IA",
diff --git a/l10n/et.js b/l10n/et.js
index 84a153530c..abd01772c2 100644
--- a/l10n/et.js
+++ b/l10n/et.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} ebaõnnestus",
"0 = unlimited": "0 = piiramatu",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Voog käivitab rea samme, kui midagi juhtub — objekt muutub, ajakava rakendub või käivitad selle ise. See loend näitab iga rakenduse vooge.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma isiklikes sätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma kasutajasätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.",
"A deactivation request is pending.": "Deaktiveerimistaotlus on ootel.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-vestlus",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Olemas",
"Re-import": "Impordi uuesti",
"Reading descriptors …": "Kirjeldajate lugemine …",
+ "Re-import {register}?": "Kas importida {register} uuesti?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "{app} uuesti importimine võtab selle skeemide volitused failist, asendades kõik sellel instantsil tehtud muudatused, ning võib uuesti importida kõik rakenduse registrid, mitte ainult {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Failist üle võetud volitused kohaliku muudatuse asemel: {count}.",
"Register descriptors": "Registrikirjeldajad",
"ships v{shipped}": "tarnib v{shipped}",
"State": "Olek",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "ID-veerg",
"Import JSON": "Impordi JSON",
"Imported \"{name}\".": "„{name}“ on imporditud.",
+ "Replaced \"{name}\".": "„{name}“ on asendatud.",
"Imports find the pack by this slug.": "Impordid leiavad paketi selle identifikaatori järgi.",
"Join columns": "Ühenda veerud",
"Joined with {fields}, separated by \"{separator}\"": "Ühendatud väljadega {fields}, eraldajaks „{separator}“",
diff --git a/l10n/et.json b/l10n/et.json
index e09dafb8dd..22f5175d2a 100644
--- a/l10n/et.json
+++ b/l10n/et.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} ebaõnnestus",
"0 = unlimited": "0 = piiramatu",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Voog käivitab rea samme, kui midagi juhtub — objekt muutub, ajakava rakendub või käivitad selle ise. See loend näitab iga rakenduse vooge.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma isiklikes sätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID võtmepaar on seadistatud. Kasutajad saavad brauseriteavitused oma kasutajasätetes sisse lülitada; need toimetatakse kohale ka siis, kui brauseri kaart on suletud.",
"A deactivation request is pending.": "Deaktiveerimistaotlus on ootel.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-vestlus",
diff --git a/l10n/fi.js b/l10n/fi.js
index 6ee565dede..21999dfe79 100644
--- a/l10n/fi.js
+++ b/l10n/fi.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} epäonnistui",
"0 = unlimited": "0 = rajoittamaton",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Vuo suorittaa sarjan vaiheita, kun jotain tapahtuu — objekti muuttuu, aikataulu laukeaa tai suoritat sen itse. Tämä luettelo näyttää jokaisen sovelluksen vuot.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-avainpari on määritetty. Käyttäjät voivat ottaa selainilmoitukset käyttöön käyttäjäasetuksissaan, ja ne toimitetaan, vaikka selaimen välilehti olisi suljettu.",
"A deactivation request is pending.": "Deaktivointipyyntö on odottamassa.",
"AI Assistant": "Tekoälyavustaja",
"AI Chat": "Tekoälykeskustelu",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Olemassa",
"Re-import": "Tuo uudelleen",
"Reading descriptors …": "Luetaan kuvauksia …",
+ "Re-import {register}?": "Tuodaanko {register} uudelleen?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Kohteen {app} uudelleentuonti ottaa sen skeemojen valtuutukset tiedostosta ja korvaa kaikki tässä instanssissa tehdyt muutokset, ja se voi tuoda uudelleen sovelluksen kaikki rekisterit, ei vain rekisteriä {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Tiedostosta otetut valtuutukset paikallisen muutoksen sijaan: {count}.",
"Register descriptors": "Rekisterikuvaukset",
"ships v{shipped}": "toimittaa v{shipped}",
"State": "Tila",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Tunnussarake",
"Import JSON": "Tuo JSON",
"Imported \"{name}\".": "\"{name}\" tuotiin.",
+ "Replaced \"{name}\".": "\"{name}\" korvattiin.",
"Imports find the pack by this slug.": "Tuonnit löytävät paketin tämän slugin avulla.",
"Join columns": "Yhdistä sarakkeita",
"Joined with {fields}, separated by \"{separator}\"": "Yhdistetty sarakkeisiin {fields}, erottimena \"{separator}\"",
diff --git a/l10n/fi.json b/l10n/fi.json
index 25aff9ee61..fb3519bef4 100644
--- a/l10n/fi.json
+++ b/l10n/fi.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} epäonnistui",
"0 = unlimited": "0 = rajoittamaton",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Vuo suorittaa sarjan vaiheita, kun jotain tapahtuu — objekti muuttuu, aikataulu laukeaa tai suoritat sen itse. Tämä luettelo näyttää jokaisen sovelluksen vuot.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-avainpari on määritetty. Käyttäjät voivat ottaa selainilmoitukset käyttöön käyttäjäasetuksissaan, ja ne toimitetaan, vaikka selaimen välilehti olisi suljettu.",
"A deactivation request is pending.": "Deaktivointipyyntö on odottamassa.",
"AI Assistant": "Tekoälyavustaja",
"AI Chat": "Tekoälykeskustelu",
diff --git a/l10n/fr.js b/l10n/fr.js
index 4ce02c8562..f819484aba 100644
--- a/l10n/fr.js
+++ b/l10n/fr.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} échoué(s)",
"0 = unlimited": "0 = illimité",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux exécute une série d'étapes lorsqu'un événement se produit — un objet change, une planification se déclenche, ou vous l'exécutez vous-même. Cette liste affiche les flux de chaque application.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres personnels ; elles sont remises même lorsque l'onglet du navigateur est fermé.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres utilisateur ; elles sont remises même lorsque l'onglet du navigateur est fermé.",
"A deactivation request is pending.": "Une demande de désactivation est en attente.",
"AI Assistant": "Assistant IA",
"AI Chat": "Chat IA",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Présent",
"Re-import": "Réimporter",
"Reading descriptors …": "Lecture des descripteurs …",
+ "Re-import {register}?": "Réimporter {register} ?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "La réimportation de {app} reprend l'autorisation de ses schémas depuis le fichier, en remplaçant toute modification faite sur cette instance, et peut réimporter tous les registres de l'application, pas seulement {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorisation reprise du fichier à la place d'une modification locale : {count}.",
"Register descriptors": "Descripteurs de registre",
"ships v{shipped}": "fournit v{shipped}",
"State": "État",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Colonne d'identifiant",
"Import JSON": "Importer du JSON",
"Imported \"{name}\".": "« {name} » importé.",
+ "Replaced \"{name}\".": "« {name} » remplacé.",
"Imports find the pack by this slug.": "Les imports trouvent le pack grâce à ce slug.",
"Join columns": "Joindre des colonnes",
"Joined with {fields}, separated by \"{separator}\"": "Joint avec {fields}, séparé par « {separator} »",
diff --git a/l10n/fr.json b/l10n/fr.json
index 637fc471f3..f1ed31541c 100644
--- a/l10n/fr.json
+++ b/l10n/fr.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} échoué(s)",
"0 = unlimited": "0 = illimité",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux exécute une série d'étapes lorsqu'un événement se produit — un objet change, une planification se déclenche, ou vous l'exécutez vous-même. Cette liste affiche les flux de chaque application.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres personnels ; elles sont remises même lorsque l'onglet du navigateur est fermé.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Une paire de clés VAPID est configurée. Les utilisateurs peuvent activer les notifications du navigateur depuis leurs paramètres utilisateur ; elles sont remises même lorsque l'onglet du navigateur est fermé.",
"A deactivation request is pending.": "Une demande de désactivation est en attente.",
"AI Assistant": "Assistant IA",
"AI Chat": "Chat IA",
diff --git a/l10n/ga.js b/l10n/ga.js
index be77c4ad55..cfce8204bb 100644
--- a/l10n/ga.js
+++ b/l10n/ga.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", theip ar {failed}",
"0 = unlimited": "0 = gan teorainn",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ritheann sruth sraith céimeanna nuair a tharlaíonn rud éigin — athraíonn oibiacht, lasann sceideal, nó ritheann tú féin é. Taispeánann an liosta seo sruthanna gach aipe.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe pearsanta, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe úsáideora, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.",
"A deactivation request is pending.": "Tá iarratas díghníomhachtaithe ar feitheamh.",
"AI Assistant": "Cúntóir AI",
"AI Chat": "Comhrá AI",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "I láthair",
"Re-import": "Athiompórtáil",
"Reading descriptors …": "Tuairisceoirí á léamh …",
+ "Re-import {register}?": "An ndéanfar {register} a athiompórtáil?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Nuair a athiompórtáiltear {app}, tógtar údarú a scéimrí ón gcomhad, ag cur in ionad aon athraithe a rinneadh ar an gcás seo, agus is féidir gach clár den aip a athiompórtáil, ní hamháin {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Údarú a tógadh ón gcomhad in ionad athraithe áitiúil: {count}.",
"Register descriptors": "Tuairisceoirí clár",
"ships v{shipped}": "soláthraíonn sé v{shipped}",
"State": "Staid",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Colún id",
"Import JSON": "Iompórtáil JSON",
"Imported \"{name}\".": "Iompórtáladh \"{name}\".",
+ "Replaced \"{name}\".": "Athsholáthraíodh \"{name}\".",
"Imports find the pack by this slug.": "Aimsíonn iompórtálacha an pacáiste tríd an slug seo.",
"Join columns": "Cumaisc colúin",
"Joined with {fields}, separated by \"{separator}\"": "Cumascthe le {fields}, scartha le \"{separator}\"",
diff --git a/l10n/ga.json b/l10n/ga.json
index dfa5b4c675..840d5ff932 100644
--- a/l10n/ga.json
+++ b/l10n/ga.json
@@ -43,7 +43,7 @@
", {failed} failed": ", theip ar {failed}",
"0 = unlimited": "0 = gan teorainn",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ritheann sruth sraith céimeanna nuair a tharlaíonn rud éigin — athraíonn oibiacht, lasann sceideal, nó ritheann tú féin é. Taispeánann an liosta seo sruthanna gach aipe.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe pearsanta, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Tá péire eochracha VAPID cumraithe. Is féidir le húsáideoirí fógraí brabhsálaí a roghnú ina socruithe úsáideora, agus seachadtar iad fiú nuair a bhíonn cluaisín an bhrabhsálaí dúnta.",
"A deactivation request is pending.": "Tá iarratas díghníomhachtaithe ar feitheamh.",
"AI Assistant": "Cúntóir AI",
"AI Chat": "Comhrá AI",
diff --git a/l10n/hr.js b/l10n/hr.js
index e89bffbf04..b6a8e70cd8 100644
--- a/l10n/hr.js
+++ b/l10n/hr.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} neuspješno",
"0 = unlimited": "0 = neograničeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto dogodi — objekt se promijeni, raspored se aktivira ili ga sami pokrenete. Ovaj popis prikazuje tokove svih aplikacija.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u osobnim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.",
"A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI razgovor",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Postoji",
"Re-import": "Ponovno uvezi",
"Reading descriptors …": "Čitanje deskriptora …",
+ "Re-import {register}?": "Ponovno uvesti {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponovnim uvozom aplikacije {app} ovlaštenja njezinih shema preuzimaju se iz datoteke i zamjenjuju sve promjene napravljene na ovoj instanci, a mogu se ponovno uvesti i svi registri aplikacije, ne samo {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Ovlaštenja preuzeta iz datoteke umjesto lokalne promjene: {count}.",
"Register descriptors": "Deskriptori registara",
"ships v{shipped}": "isporučuje v{shipped}",
"State": "Stanje",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Stupac s id-om",
"Import JSON": "Uvezi JSON",
"Imported \"{name}\".": "\"{name}\" je uvezen.",
+ "Replaced \"{name}\".": "\"{name}\" je zamijenjen.",
"Imports find the pack by this slug.": "Uvozi pronalaze paket po ovom slugu.",
"Join columns": "Spoji stupce",
"Joined with {fields}, separated by \"{separator}\"": "Spojeno s {fields}, odvojeno s \"{separator}\"",
diff --git a/l10n/hr.json b/l10n/hr.json
index 941e70da55..1c46704b98 100644
--- a/l10n/hr.json
+++ b/l10n/hr.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} neuspješno",
"0 = unlimited": "0 = neograničeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto dogodi — objekt se promijeni, raspored se aktivira ili ga sami pokrenete. Ovaj popis prikazuje tokove svih aplikacija.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u osobnim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključeva VAPID je konfiguriran. Korisnici mogu u korisničkim postavkama uključiti obavijesti preglednika, koje se isporučuju i kada je kartica preglednika zatvorena.",
"A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI razgovor",
diff --git a/l10n/hu.js b/l10n/hu.js
index 4f7ae600f8..b9062ad342 100644
--- a/l10n/hu.js
+++ b/l10n/hu.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} sikertelen",
"0 = unlimited": "0 = korlátlan",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Egy folyamat lépések sorozatát futtatja, amikor történik valami — egy objektum megváltozik, egy ütemezés elindul, vagy Ön maga futtatja. Ez a lista minden alkalmazás folyamatait mutatja.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID-kulcspár be van állítva. A felhasználók a felhasználói beállításaikban kapcsolhatják be a böngészőértesítéseket, amelyek akkor is kézbesítésre kerülnek, ha a böngészőfül be van zárva.",
"A deactivation request is pending.": "Egy deaktiválási kérelem függőben van.",
"AI Assistant": "AI asszisztens",
"AI Chat": "AI csevegés",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Jelen van",
"Re-import": "Újraimportálás",
"Reading descriptors …": "Leírók olvasása …",
+ "Re-import {register}?": "Újraimportálja a(z) {register} nyilvántartást?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "A(z) {app} újraimportálása a sémái jogosultságait a fájlból veszi át, felülírva az ezen a példányon végzett összes módosítást, és az alkalmazás összes nyilvántartását újraimportálhatja, nem csak a(z) {register} nyilvántartást.",
+ "Authorization taken from the file over a local change: {count}.": "A fájlból átvett jogosultságok helyi módosítás helyett: {count}.",
"Register descriptors": "Regiszterleírók",
"ships v{shipped}": "szállítja: v{shipped}",
"State": "Állapot",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Azonosító oszlop",
"Import JSON": "JSON importálása",
"Imported \"{name}\".": "\"{name}\" importálva.",
+ "Replaced \"{name}\".": "\"{name}\" lecserélve.",
"Imports find the pack by this slug.": "Az importálások ezen a slugon keresztül találják meg a csomagot.",
"Join columns": "Oszlopok egyesítése",
"Joined with {fields}, separated by \"{separator}\"": "Egyesítve ezekkel: {fields}, elválasztó: \"{separator}\"",
diff --git a/l10n/hu.json b/l10n/hu.json
index fe68d07528..ae131fd360 100644
--- a/l10n/hu.json
+++ b/l10n/hu.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} sikertelen",
"0 = unlimited": "0 = korlátlan",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Egy folyamat lépések sorozatát futtatja, amikor történik valami — egy objektum megváltozik, egy ütemezés elindul, vagy Ön maga futtatja. Ez a lista minden alkalmazás folyamatait mutatja.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "A VAPID-kulcspár be van állítva. A felhasználók a felhasználói beállításaikban kapcsolhatják be a böngészőértesítéseket, amelyek akkor is kézbesítésre kerülnek, ha a böngészőfül be van zárva.",
"A deactivation request is pending.": "Egy deaktiválási kérelem függőben van.",
"AI Assistant": "AI asszisztens",
"AI Chat": "AI csevegés",
diff --git a/l10n/is.js b/l10n/is.js
index 8d99a0d3a5..5adae0738e 100644
--- a/l10n/is.js
+++ b/l10n/is.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} mishepnuð",
"0 = unlimited": "0 = ótakmarkað",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Flæði keyrir röð skrefa þegar eitthvað gerist — hlutur breytist, tímaáætlun fer í gang eða þú keyrir það sjálft. Þessi listi sýnir flæði allra forrita.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í persónulegum stillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í notandastillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.",
"A deactivation request is pending.": "Beiðni um óvirkjun er í bið.",
"AI Assistant": "AI aðstoðarmaður",
"AI Chat": "AI spjall",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Til staðar",
"Re-import": "Flytja inn aftur",
"Reading descriptors …": "Les lýsingar …",
+ "Re-import {register}?": "Flytja {register} inn aftur?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Þegar {app} er flutt inn aftur eru heimildir skemu þess teknar úr skránni og koma í stað allra breytinga sem gerðar voru á þessu tilviki, og það getur flutt inn aftur allar skrár forritsins, ekki bara {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Heimildir sem teknar voru úr skránni í stað staðbundinnar breytingar: {count}.",
"Register descriptors": "Skrárlýsingar",
"ships v{shipped}": "skilar v{shipped}",
"State": "Staða",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Auðkennisdálkur",
"Import JSON": "Flytja inn JSON",
"Imported \"{name}\".": "\"{name}\" var flutt inn.",
+ "Replaced \"{name}\".": "\"{name}\" var skipt út.",
"Imports find the pack by this slug.": "Innflutningar finna pakkann eftir þessu auðkenni.",
"Join columns": "Sameina dálka",
"Joined with {fields}, separated by \"{separator}\"": "Sameinað með {fields}, aðskilið með \"{separator}\"",
diff --git a/l10n/is.json b/l10n/is.json
index 0fc333f72f..1fc9fa6ce3 100644
--- a/l10n/is.json
+++ b/l10n/is.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} mishepnuð",
"0 = unlimited": "0 = ótakmarkað",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Flæði keyrir röð skrefa þegar eitthvað gerist — hlutur breytist, tímaáætlun fer í gang eða þú keyrir það sjálft. Þessi listi sýnir flæði allra forrita.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í persónulegum stillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID-lyklapar er stillt. Notendur geta virkjað tilkynningar í vafra í notandastillingum sínum og þær berast jafnvel þegar flipinn í vafranum er lokaður.",
"A deactivation request is pending.": "Beiðni um óvirkjun er í bið.",
"AI Assistant": "AI aðstoðarmaður",
"AI Chat": "AI spjall",
diff --git a/l10n/it.js b/l10n/it.js
index e8b4e2a4e6..c73fbb739d 100644
--- a/l10n/it.js
+++ b/l10n/it.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} non riusciti",
"0 = unlimited": "0 = illimitato",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flusso esegue una serie di passaggi quando accade qualcosa — un oggetto cambia, una pianificazione scatta oppure lo si esegue manualmente. Questo elenco mostra i flussi di ogni app.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni personali; vengono recapitate anche quando la scheda del browser è chiusa.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni utente; vengono recapitate anche quando la scheda del browser è chiusa.",
"A deactivation request is pending.": "È in sospeso una richiesta di disattivazione.",
"AI Assistant": "Assistente IA",
"AI Chat": "Chat IA",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Presente",
"Re-import": "Reimporta",
"Reading descriptors …": "Lettura dei descrittori …",
+ "Re-import {register}?": "Reimportare {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "La reimportazione di {app} acquisisce dal file l'autorizzazione dei suoi schemi, sostituendo qualsiasi modifica apportata su questa istanza, e può reimportare tutti i registri dell'app, non solo {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorizzazione acquisita dal file al posto di una modifica locale: {count}.",
"Register descriptors": "Descrittori di registro",
"ships v{shipped}": "fornisce v{shipped}",
"State": "Stato",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Colonna ID",
"Import JSON": "Importa JSON",
"Imported \"{name}\".": "\"{name}\" importato.",
+ "Replaced \"{name}\".": "\"{name}\" sostituito.",
"Imports find the pack by this slug.": "Le importazioni trovano il pacchetto tramite questo slug.",
"Join columns": "Unisci colonne",
"Joined with {fields}, separated by \"{separator}\"": "Unito con {fields}, separato da \"{separator}\"",
diff --git a/l10n/it.json b/l10n/it.json
index 08c819be46..6513598d0b 100644
--- a/l10n/it.json
+++ b/l10n/it.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} non riusciti",
"0 = unlimited": "0 = illimitato",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flusso esegue una serie di passaggi quando accade qualcosa — un oggetto cambia, una pianificazione scatta oppure lo si esegue manualmente. Questo elenco mostra i flussi di ogni app.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni personali; vengono recapitate anche quando la scheda del browser è chiusa.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "È configurata una coppia di chiavi VAPID. Gli utenti possono attivare le notifiche del browser dalle impostazioni utente; vengono recapitate anche quando la scheda del browser è chiusa.",
"A deactivation request is pending.": "È in sospeso una richiesta di disattivazione.",
"AI Assistant": "Assistente IA",
"AI Chat": "Chat IA",
diff --git a/l10n/lb.js b/l10n/lb.js
index 6bb48b1f6c..acc596d526 100644
--- a/l10n/lb.js
+++ b/l10n/lb.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} mislukt",
"0 = unlimited": "0 = onbeperkt",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "E Flux féiert eng Rei vu Schrëtt aus, wann eppes geschitt — en Objet ännert sech, en Zäitplang gëtt ausgeléist oder Dir féiert en selwer aus. Dës Lëscht weist d'Fluxe vun all App.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire perséinlechen Astellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire Benotzerastellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.",
"A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.",
"AI Assistant": "KI-Assistent",
"AI Chat": "KI-Chat",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Do",
"Re-import": "Nei importéieren",
"Reading descriptors …": "Deskriptore ginn gelies …",
+ "Re-import {register}?": "{register} nei importéieren?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Beim neien Import vun {app} gëtt d'Autorisatioun vu senge Schemaen aus der Datei iwwerholl a jiddwer Ännerung op dëser Instanz ersat, a kann all Register vun der App nei importéiert ginn, net nëmmen {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorisatioun aus der Datei amplaz vun enger lokaler Ännerung iwwerholl: {count}.",
"Register descriptors": "Register-Deskriptoren",
"ships v{shipped}": "liwwert v{shipped}",
"State": "Status",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "ID-Kolonn",
"Import JSON": "JSON importéieren",
"Imported \"{name}\".": "\"{name}\" gouf importéiert.",
+ "Replaced \"{name}\".": "\"{name}\" gouf ersat.",
"Imports find the pack by this slug.": "Importer fannen de Pak iwwer dëse Kuerznumm.",
"Join columns": "Kolonne verbannen",
"Joined with {fields}, separated by \"{separator}\"": "Verbonne mat {fields}, getrennt duerch \"{separator}\"",
diff --git a/l10n/lb.json b/l10n/lb.json
index 3257d19301..f8e1fd1c94 100644
--- a/l10n/lb.json
+++ b/l10n/lb.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} mislukt",
"0 = unlimited": "0 = onbeperkt",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "E Flux féiert eng Rei vu Schrëtt aus, wann eppes geschitt — en Objet ännert sech, en Zäitplang gëtt ausgeléist oder Dir féiert en selwer aus. Dës Lëscht weist d'Fluxe vun all App.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire perséinlechen Astellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "E VAPID-Schlësselpaar ass konfiguréiert. Benotzer kënnen a hire Benotzerastellunge Browser-Notifikatiounen aktivéieren, déi och geliwwert ginn, wann de Browser-Tab zou ass.",
"A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.",
"AI Assistant": "KI-Assistent",
"AI Chat": "KI-Chat",
diff --git a/l10n/lt.js b/l10n/lt.js
index 53d503f132..fa95ed416c 100644
--- a/l10n/lt.js
+++ b/l10n/lt.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", nepavyko: {failed}",
"0 = unlimited": "0 = neribota",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Srautas vykdo veiksmų seką, kai kas nors įvyksta — pasikeičia objektas, suveikia tvarkaraštis arba paleidžiate jį patys. Šiame sąraše rodomi visų programų srautai.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo asmeniniuose nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo naudotojo nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.",
"A deactivation request is pending.": "Laukiama deaktyvavimo užklausa.",
"AI Assistant": "DI asistentas",
"AI Chat": "DI pokalbis",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Yra",
"Re-import": "Importuoti iš naujo",
"Reading descriptors …": "Skaitomi deskriptoriai …",
+ "Re-import {register}?": "Importuoti {register} iš naujo?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Iš naujo importuojant {app}, jos schemų įgaliojimai paimami iš failo, pakeičiant visus šiame egzemplioriuje atliktus pakeitimus, ir gali būti iš naujo importuoti visi programos registrai, ne tik {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Įgaliojimai, paimti iš failo vietoj vietinio pakeitimo: {count}.",
"Register descriptors": "Registrų deskriptoriai",
"ships v{shipped}": "pateikia v{shipped}",
"State": "Būsena",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Id stulpelis",
"Import JSON": "Importuoti JSON",
"Imported \"{name}\".": "\"{name}\" importuotas.",
+ "Replaced \"{name}\".": "\"{name}\" pakeistas.",
"Imports find the pack by this slug.": "Importai randa paketą pagal šį trumpąjį pavadinimą.",
"Join columns": "Sujungti stulpelius",
"Joined with {fields}, separated by \"{separator}\"": "Sujungta su {fields}, atskirta \"{separator}\"",
diff --git a/l10n/lt.json b/l10n/lt.json
index 8dc7ac6498..cda729d6a5 100644
--- a/l10n/lt.json
+++ b/l10n/lt.json
@@ -43,7 +43,7 @@
", {failed} failed": ", nepavyko: {failed}",
"0 = unlimited": "0 = neribota",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Srautas vykdo veiksmų seką, kai kas nors įvyksta — pasikeičia objektas, suveikia tvarkaraštis arba paleidžiate jį patys. Šiame sąraše rodomi visų programų srautai.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo asmeniniuose nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID raktų pora sukonfigūruota. Naudotojai savo naudotojo nustatymuose gali įjungti naršyklės pranešimus, kurie pristatomi net ir tada, kai naršyklės kortelė uždaryta.",
"A deactivation request is pending.": "Laukiama deaktyvavimo užklausa.",
"AI Assistant": "DI asistentas",
"AI Chat": "DI pokalbis",
diff --git a/l10n/lv.js b/l10n/lv.js
index ff06fb429f..025806edd6 100644
--- a/l10n/lv.js
+++ b/l10n/lv.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} neizdevās",
"0 = unlimited": "0 = neierobežots",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Plūsma izpilda darbību virkni, kad kaut kas notiek — mainās objekts, nostrādā grafiks vai jūs to palaižat paši. Šis saraksts rāda visu lietotņu plūsmas.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos personīgajos iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos lietotāja iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.",
"A deactivation request is pending.": "Deaktivizācijas pieprasījums gaida apstrādi.",
"AI Assistant": "MI asistents",
"AI Chat": "MI tērzēšana",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Klātesošs",
"Re-import": "Importēt atkārtoti",
"Reading descriptors …": "Notiek deskriptoru lasīšana …",
+ "Re-import {register}?": "Vai importēt {register} atkārtoti?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Atkārtoti importējot {app}, tās shēmu autorizācija tiek ņemta no faila, aizstājot visas šajā instancē veiktās izmaiņas, un var tikt atkārtoti importēti visi lietotnes reģistri, ne tikai {register}.",
+ "Authorization taken from the file over a local change: {count}.": "No faila ņemta autorizācija vietējas izmaiņas vietā: {count}.",
"Register descriptors": "Reģistru deskriptori",
"ships v{shipped}": "piegādā v{shipped}",
"State": "Stāvoklis",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "ID kolonna",
"Import JSON": "Importēt JSON",
"Imported \"{name}\".": "Pakotne \"{name}\" ir importēta.",
+ "Replaced \"{name}\".": "Pakotne \"{name}\" ir aizstāta.",
"Imports find the pack by this slug.": "Importi atrod pakotni pēc šī identifikatora.",
"Join columns": "Apvienot kolonnas",
"Joined with {fields}, separated by \"{separator}\"": "Apvienots ar {fields}, atdalīts ar \"{separator}\"",
diff --git a/l10n/lv.json b/l10n/lv.json
index 26cfe9e189..bc1738852d 100644
--- a/l10n/lv.json
+++ b/l10n/lv.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} neizdevās",
"0 = unlimited": "0 = neierobežots",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Plūsma izpilda darbību virkni, kad kaut kas notiek — mainās objekts, nostrādā grafiks vai jūs to palaižat paši. Šis saraksts rāda visu lietotņu plūsmas.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos personīgajos iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "VAPID atslēgu pāris ir konfigurēts. Lietotāji savos lietotāja iestatījumos var ieslēgt pārlūka paziņojumus, kas tiek piegādāti arī tad, kad pārlūka cilne ir aizvērta.",
"A deactivation request is pending.": "Deaktivizācijas pieprasījums gaida apstrādi.",
"AI Assistant": "MI asistents",
"AI Chat": "MI tērzēšana",
diff --git a/l10n/mk.js b/l10n/mk.js
index 2abb5e6a7e..e70f095d39 100644
--- a/l10n/mk.js
+++ b/l10n/mk.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} неуспешни",
"0 = unlimited": "0 = неограничено",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Текот извршува низа чекори кога ќе се случи нешто — објект се менува, распоред се активира или сами го стартувате. Оваа листа ги прикажува тековите на сите апликации.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во личните поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во корисничките поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.",
"A deactivation request is pending.": "Барањето за деактивација е на чекање.",
"AI Assistant": "AI асистент",
"AI Chat": "AI разговор",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Постои",
"Re-import": "Увези повторно",
"Reading descriptors …": "Читање дескриптори …",
+ "Re-import {register}?": "Да се увезе ли повторно {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторното увезување на {app} ја презема авторизацијата на нејзините шеми од датотеката, заменувајќи ги сите промени направени на оваа инстанца, и може повторно да ги увезе сите регистри на апликацијата, не само {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Авторизација преземена од датотеката наместо локална промена: {count}.",
"Register descriptors": "Дескриптори на регистри",
"ships v{shipped}": "испорачува v{shipped}",
"State": "Состојба",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Колона со идентификатор",
"Import JSON": "Увези JSON",
"Imported \"{name}\".": "\"{name}\" е увезен.",
+ "Replaced \"{name}\".": "\"{name}\" е заменет.",
"Imports find the pack by this slug.": "Увозите го наоѓаат пакетот според овој slug.",
"Join columns": "Спој колони",
"Joined with {fields}, separated by \"{separator}\"": "Споено со {fields}, одделено со \"{separator}\"",
diff --git a/l10n/mk.json b/l10n/mk.json
index 9b0946193c..26a9f0efc5 100644
--- a/l10n/mk.json
+++ b/l10n/mk.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} неуспешни",
"0 = unlimited": "0 = неограничено",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Текот извршува низа чекори кога ќе се случи нешто — објект се менува, распоред се активира или сами го стартувате. Оваа листа ги прикажува тековите на сите апликации.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во личните поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Конфигуриран е пар VAPID клучеви. Корисниците можат во корисничките поставки да ги вклучат известувањата од прелистувачот, кои се испорачуваат дури и кога картичката на прелистувачот е затворена.",
"A deactivation request is pending.": "Барањето за деактивација е на чекање.",
"AI Assistant": "AI асистент",
"AI Chat": "AI разговор",
diff --git a/l10n/mt.js b/l10n/mt.js
index fa40858b53..1efbb110d0 100644
--- a/l10n/mt.js
+++ b/l10n/mt.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} fallew",
"0 = unlimited": "0 = bla limitu",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Fluss imexxi sensiela ta' passi meta jiġri xi ħaġa — oġġett jinbidel, skeda tiskatta, jew tmexxih int stess. Din il-lista turi l-flussi ta' kull app.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar personali tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar tal-utent tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.",
"A deactivation request is pending.": "Talba għad-diżattivazzjoni qiegħda pendenti.",
"AI Assistant": "Assistent tal-IA",
"AI Chat": "Chat tal-IA",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Preżenti",
"Re-import": "Erġa’ importa",
"Reading descriptors …": "Qed jinqraw id-deskritturi …",
+ "Re-import {register}?": "Terġa’ timporta {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Meta terġa’ timporta {app}, l-awtorizzazzjoni tal-iskemi tagħha tittieħed mill-fajl, tissostitwixxi kull bidla magħmula f’din l-istanza, u jistgħu jiġu importati mill-ġdid ir-reġistri kollha tal-applikazzjoni, mhux biss {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Awtorizzazzjoni meħuda mill-fajl minflok bidla lokali: {count}.",
"Register descriptors": "Deskritturi tar-reġistri",
"ships v{shipped}": "iwassal v{shipped}",
"State": "Stat",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Kolonna tal-ID",
"Import JSON": "Importa JSON",
"Imported \"{name}\".": "\"{name}\" ġie importat.",
+ "Replaced \"{name}\".": "\"{name}\" ġie sostitwit.",
"Imports find the pack by this slug.": "L-importazzjonijiet isibu l-pakkett b'dan is-slug.",
"Join columns": "Għaqqad il-kolonni",
"Joined with {fields}, separated by \"{separator}\"": "Magħqud ma' {fields}, separat b'\"{separator}\"",
diff --git a/l10n/mt.json b/l10n/mt.json
index 4b8b11a1f9..871d1736e3 100644
--- a/l10n/mt.json
+++ b/l10n/mt.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} fallew",
"0 = unlimited": "0 = bla limitu",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Fluss imexxi sensiela ta' passi meta jiġri xi ħaġa — oġġett jinbidel, skeda tiskatta, jew tmexxih int stess. Din il-lista turi l-flussi ta' kull app.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar personali tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ta' ċwievet VAPID huwa kkonfigurat. L-utenti jistgħu jattivaw in-notifiki tal-browser mill-issettjar tal-utent tagħhom, u dawn jiġu kkunsinnati anke meta t-tab tal-browser ikun magħluq.",
"A deactivation request is pending.": "Talba għad-diżattivazzjoni qiegħda pendenti.",
"AI Assistant": "Assistent tal-IA",
"AI Chat": "Chat tal-IA",
diff --git a/l10n/nb.js b/l10n/nb.js
index c5e2b445b5..c8122b7300 100644
--- a/l10n/nb.js
+++ b/l10n/nb.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} mislyktes",
"0 = unlimited": "0 = ubegrenset",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "En flyt kjører en rekke trinn når noe skjer — et objekt endres, en tidsplan utløses, eller du kjører den selv. Denne listen viser alle appers flyter.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Et VAPID-nøkkelpar er konfigurert. Brukere kan slå på nettleservarsler i brukerinnstillingene sine, og varslene leveres selv når nettleserfanen er lukket.",
"A deactivation request is pending.": "En deaktiveringsforespørsel venter.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-chat",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Finnes",
"Re-import": "Importer på nytt",
"Reading descriptors …": "Leser deskriptorer …",
+ "Re-import {register}?": "Importere {register} på nytt?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ny import av {app} henter autorisasjonen for skjemaene fra filen og erstatter alle endringer som er gjort på denne instansen, og kan importere alle appens registre på nytt, ikke bare {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorisasjon hentet fra filen over en lokal endring: {count}.",
"Register descriptors": "Registerdeskriptorer",
"ships v{shipped}": "leverer v{shipped}",
"State": "Status",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Id-kolonne",
"Import JSON": "Importer JSON",
"Imported \"{name}\".": "\"{name}\" er importert.",
+ "Replaced \"{name}\".": "\"{name}\" er erstattet.",
"Imports find the pack by this slug.": "Importer finner pakken via denne slugen.",
"Join columns": "Slå sammen kolonner",
"Joined with {fields}, separated by \"{separator}\"": "Slått sammen med {fields}, skilt med \"{separator}\"",
diff --git a/l10n/nb.json b/l10n/nb.json
index 75c292baec..fa889a0eef 100644
--- a/l10n/nb.json
+++ b/l10n/nb.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} mislyktes",
"0 = unlimited": "0 = ubegrenset",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "En flyt kjører en rekke trinn når noe skjer — et objekt endres, en tidsplan utløses, eller du kjører den selv. Denne listen viser alle appers flyter.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Et VAPID-nøkkelpar er konfigurert. Brukere kan slå på nettleservarsler i brukerinnstillingene sine, og varslene leveres selv når nettleserfanen er lukket.",
"A deactivation request is pending.": "En deaktiveringsforespørsel venter.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-chat",
diff --git a/l10n/nl.js b/l10n/nl.js
index 54ebbc735e..5aa41ca773 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -64,7 +64,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} mislukt",
"0 = unlimited": "0 = onbeperkt",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Een flow voert een reeks stappen uit wanneer er iets gebeurt — een object wijzigt, een planning wordt geactiveerd, of je voert hem zelf uit. Deze lijst toont de flows van elke app.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun persoonlijke instellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun gebruikersinstellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.",
"A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.",
"ADDRESS": "ADRES",
"AI Assistant": "AI-assistent",
@@ -2820,6 +2820,9 @@ OC.L10N.register(
"Present": "Aanwezig",
"Re-import": "Opnieuw importeren",
"Reading descriptors …": "Descriptors lezen …",
+ "Re-import {register}?": "{register} opnieuw importeren?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Bij het opnieuw importeren van {app} wordt de autorisatie van de schema's uit het bestand overgenomen, ook waar die op deze instantie is aangepast, en kunnen alle registers van de app opnieuw worden geïmporteerd, niet alleen {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorisatie uit het bestand overgenomen boven een lokale wijziging: {count}.",
"Register descriptors": "Register-descriptors",
"ships v{shipped}": "levert v{shipped}",
"State": "Status",
@@ -3467,6 +3470,7 @@ OC.L10N.register(
"Id column": "Id-kolom",
"Import JSON": "JSON importeren",
"Imported \"{name}\".": "\"{name}\" is geïmporteerd.",
+ "Replaced \"{name}\".": "\"{name}\" is vervangen.",
"Imports find the pack by this slug.": "Imports vinden het pakket via deze slug.",
"Join columns": "Kolommen samenvoegen",
"Joined with {fields}, separated by \"{separator}\"": "Samengevoegd met {fields}, gescheiden door \"{separator}\"",
diff --git a/l10n/nl.json b/l10n/nl.json
index f4e28ff085..9b8005ea48 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -60,7 +60,7 @@
", {failed} failed": ", {failed} mislukt",
"0 = unlimited": "0 = onbeperkt",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Een flow voert een reeks stappen uit wanneer er iets gebeurt — een object wijzigt, een planning wordt geactiveerd, of je voert hem zelf uit. Deze lijst toont de flows van elke app.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun persoonlijke instellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Er is een VAPID-sleutelpaar geconfigureerd. Gebruikers kunnen zich via hun gebruikersinstellingen aanmelden voor browsermeldingen, die ook worden bezorgd wanneer het browsertabblad gesloten is.",
"A deactivation request is pending.": "Er is een deactiveringsverzoek in behandeling.",
"ADDRESS": "ADRES",
"AI Assistant": "AI-assistent",
@@ -3440,6 +3440,33 @@
"The id of a built-in migration pack cannot be changed": "Het id van een ingebouwd migratiepakket kan niet worden gewijzigd",
"The request must contain a pack definition object": "Het verzoek moet een object met een pakketdefinitie bevatten",
"Sample rows must be a list of objects": "Voorbeeldrijen moeten een lijst van objecten zijn",
+ "\"%s\" is required and must be a non-empty string": "\"%s\" is verplicht en moet een niet-lege tekenreeks zijn",
+ "\"id\" must be a lowercase slug (letters, digits, hyphens), got \"%s\"": "\"id\" moet een slug in kleine letters zijn (letters, cijfers, koppeltekens), maar is \"%s\"",
+ "%1$s must be one of: %2$s (got %3$s)": "%1$s moet een van deze zijn: %2$s (gekregen: %3$s)",
+ "\"version\" must be a semver string (MAJOR.MINOR.PATCH), got %s": "\"version\" moet een semver-tekenreeks zijn (MAJOR.MINOR.PATCH), maar is %s",
+ "\"fieldMappings\" is required and must be a non-empty array": "\"fieldMappings\" is verplicht en moet een niet-lege lijst zijn",
+ "Mapping row %s must be an object": "Toewijzingsrij %s moet een object zijn",
+ "Mapping row %1$s: %2$s is required and must be a non-empty string": "Toewijzingsrij %1$s: %2$s is verplicht en moet een niet-lege tekenreeks zijn",
+ "Mapping row %s: required must be a boolean when present": "Toewijzingsrij %s: required moet een boolean zijn wanneer opgegeven",
+ "Mapping row %s: transform": "Toewijzingsrij %s: transform",
+ "%s must be an object with a \"type\" key": "%s moet een object met een \"type\"-sleutel zijn",
+ "%s must be a string when present": "%s moet een tekenreeks zijn wanneer opgegeven",
+ "%1$s is required and must be a non-empty object for a %2$s transform": "%1$s is verplicht en moet een niet-leeg object zijn voor een %2$s-omzetting",
+ "%1$s is required and must be a non-empty array for a %2$s transform": "%1$s is verplicht en moet een niet-lege lijst zijn voor een %2$s-omzetting",
+ "%s must be a non-empty string": "%s moet een niet-lege tekenreeks zijn",
+ "%1$s is required for a %2$s transform": "%1$s is verplicht voor een %2$s-omzetting",
+ "\"defaults\" must be an object of target-property => default value when present": "\"defaults\" moet een object van doeleigenschap => standaardwaarde zijn wanneer opgegeven",
+ "\"skipRows\" must be an array of row numbers when present": "\"skipRows\" moet een lijst met rijnummers zijn wanneer opgegeven",
+ "\"skipRows\" entries must be positive integers": "De waarden in \"skipRows\" moeten positieve gehele getallen zijn",
+ "\"idStrategy\" is required and must be an object with a \"type\" key": "\"idStrategy\" is verplicht en moet een object met een \"type\"-sleutel zijn",
+ "idStrategy.field is required and must be a non-empty string when idStrategy.type is \"sourceField\"": "idStrategy.field is verplicht en moet een niet-lege tekenreeks zijn wanneer idStrategy.type \"sourceField\" is",
+ "Required source field \"%s\" is missing or empty": "Verplicht bronveld \"%s\" ontbreekt of is leeg",
+ "Unknown transform type \"%s\"": "Onbekend omzettingstype \"%s\"",
+ "Could not parse date \"%1$s\" with format \"%2$s\"": "Kan de datum \"%1$s\" niet lezen met de notatie \"%2$s\"",
+ "Could not parse date \"%1$s\": %2$s": "Kan de datum \"%1$s\" niet lezen: %2$s",
+ "Value \"%s\" has no mapping and no default is configured": "Waarde \"%s\" heeft geen toewijzing en er is geen standaardwaarde ingesteld",
+ "An \"id\" in the request body that equals the row id in the URL cannot be told apart from it.": "Een \"id\" in de aanvraag die gelijk is aan de rij-id in de URL, is daar niet van te onderscheiden.",
+ "Send the pack as {\"definition\": {...}} to keep that id.": "Stuur het pakket als {\"definition\": {...}} om die id te behouden.",
"A preview takes at most %s sample rows": "Een voorvertoning neemt maximaal %s voorbeeldrijen aan",
"The pack definition is invalid": "De pakketdefinitie is ongeldig",
"Failed to preview migration pack": "De voorvertoning van het migratiepakket is mislukt",
diff --git a/l10n/pl.js b/l10n/pl.js
index f91f12fb6e..f186acc437 100644
--- a/l10n/pl.js
+++ b/l10n/pl.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", nieudane: {failed}",
"0 = unlimited": "0 = bez ograniczeń",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Przepływ wykonuje serię kroków, gdy coś się wydarzy — obiekt się zmienia, uruchamia się harmonogram lub uruchamiasz go samodzielnie. Ta lista pokazuje przepływy każdej aplikacji.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Skonfigurowano parę kluczy VAPID. Użytkownicy mogą włączyć powiadomienia przeglądarki w swoich ustawieniach użytkownika; są one dostarczane nawet wtedy, gdy karta przeglądarki jest zamknięta.",
"A deactivation request is pending.": "Oczekuje wniosek o dezaktywację.",
"AI Assistant": "Asystent AI",
"AI Chat": "Czat AI",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Obecny",
"Re-import": "Importuj ponownie",
"Reading descriptors …": "Odczytywanie deskryptorów …",
+ "Re-import {register}?": "Zaimportować ponownie {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponowny import aplikacji {app} przejmuje z pliku autoryzację jej schematów, zastępując wszelkie zmiany wprowadzone w tej instancji, i może ponownie zaimportować wszystkie rejestry aplikacji, nie tylko {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autoryzacja przejęta z pliku w miejsce zmiany lokalnej: {count}.",
"Register descriptors": "Deskryptory rejestrów",
"ships v{shipped}": "dostarcza v{shipped}",
"State": "Stan",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Kolumna identyfikatora",
"Import JSON": "Importuj JSON",
"Imported \"{name}\".": "Zaimportowano \"{name}\".",
+ "Replaced \"{name}\".": "Zastąpiono \"{name}\".",
"Imports find the pack by this slug.": "Importy znajdują pakiet po tym slugu.",
"Join columns": "Połącz kolumny",
"Joined with {fields}, separated by \"{separator}\"": "Połączone z {fields}, rozdzielone \"{separator}\"",
diff --git a/l10n/pl.json b/l10n/pl.json
index 038420280d..6845312565 100644
--- a/l10n/pl.json
+++ b/l10n/pl.json
@@ -43,7 +43,7 @@
", {failed} failed": ", nieudane: {failed}",
"0 = unlimited": "0 = bez ograniczeń",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Przepływ wykonuje serię kroków, gdy coś się wydarzy — obiekt się zmienia, uruchamia się harmonogram lub uruchamiasz go samodzielnie. Ta lista pokazuje przepływy każdej aplikacji.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Skonfigurowano parę kluczy VAPID. Użytkownicy mogą włączyć powiadomienia przeglądarki w swoich ustawieniach użytkownika; są one dostarczane nawet wtedy, gdy karta przeglądarki jest zamknięta.",
"A deactivation request is pending.": "Oczekuje wniosek o dezaktywację.",
"AI Assistant": "Asystent AI",
"AI Chat": "Czat AI",
diff --git a/l10n/pt.js b/l10n/pt.js
index 0a5bc565fd..70671f28f2 100644
--- a/l10n/pt.js
+++ b/l10n/pt.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} falharam",
"0 = unlimited": "0 = ilimitado",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Um fluxo executa uma série de passos quando algo acontece — um objeto muda, um agendamento dispara ou o próprio utilizador o executa. Esta lista mostra os fluxos de cada aplicação.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições pessoais; são entregues mesmo com o separador do navegador fechado.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições de utilizador; são entregues mesmo com o separador do navegador fechado.",
"A deactivation request is pending.": "Existe um pedido de desativação pendente.",
"AI Assistant": "Assistente de IA",
"AI Chat": "Conversa de IA",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Presente",
"Re-import": "Reimportar",
"Reading descriptors …": "A ler descritores …",
+ "Re-import {register}?": "Reimportar {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ao reimportar {app}, a autorização dos seus esquemas é obtida do ficheiro, substituindo qualquer alteração feita nesta instância, e pode reimportar todos os registos da aplicação, não apenas {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorização obtida do ficheiro em vez de uma alteração local: {count}.",
"Register descriptors": "Descritores de registo",
"ships v{shipped}": "fornece v{shipped}",
"State": "Estado",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Coluna de ID",
"Import JSON": "Importar JSON",
"Imported \"{name}\".": "\"{name}\" importado.",
+ "Replaced \"{name}\".": "\"{name}\" substituído.",
"Imports find the pack by this slug.": "As importações encontram o pacote por este slug.",
"Join columns": "Juntar colunas",
"Joined with {fields}, separated by \"{separator}\"": "Juntado com {fields}, separado por \"{separator}\"",
diff --git a/l10n/pt.json b/l10n/pt.json
index 630b4546c6..f1492a3459 100644
--- a/l10n/pt.json
+++ b/l10n/pt.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} falharam",
"0 = unlimited": "0 = ilimitado",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Um fluxo executa uma série de passos quando algo acontece — um objeto muda, um agendamento dispara ou o próprio utilizador o executa. Esta lista mostra os fluxos de cada aplicação.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições pessoais; são entregues mesmo com o separador do navegador fechado.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Está configurado um par de chaves VAPID. Os utilizadores podem ativar as notificações do navegador nas definições de utilizador; são entregues mesmo com o separador do navegador fechado.",
"A deactivation request is pending.": "Existe um pedido de desativação pendente.",
"AI Assistant": "Assistente de IA",
"AI Chat": "Conversa de IA",
diff --git a/l10n/rm.js b/l10n/rm.js
index db804b3995..f2743f52a5 100644
--- a/l10n/rm.js
+++ b/l10n/rm.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} falharam",
"0 = unlimited": "0 = ilimitado",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "In flux exequescha ina retscha da pass, cur ch'insatge capita — in object sa mida, in urari vegn activà u l'utilisader al exequescha sez. Questa glista mussa ils fluxs da mintga applicaziun.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters persunals, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters d'utilisader, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.",
"A deactivation request is pending.": "Existe um pedido de desativação pendente.",
"AI Assistant": "Assistent IA",
"AI Chat": "Chat IA",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Preschent",
"Re-import": "Importar danovamain",
"Reading descriptors …": "Leger ils descripturs …",
+ "Re-import {register}?": "Importar danovamain {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Cur ch'ins importescha danovamain {app}, vegn l'autorisaziun da ses schemas surpigliada da la datoteca e remplazza mintga midada fatga sin questa instanza, e tut ils registers da l'app pon vegnir importads danovamain, buca mo {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorisaziun surpigliada da la datoteca empè d'ina midada locala: {count}.",
"Register descriptors": "Descripturs da register",
"ships v{shipped}": "furnescha v{shipped}",
"State": "Status",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Colonna da l'id",
"Import JSON": "Importar JSON",
"Imported \"{name}\".": "\"{name}\" è vegnì importà.",
+ "Replaced \"{name}\".": "\"{name}\" è vegnì remplazzà.",
"Imports find the pack by this slug.": "Imports chattan il pachet tenor quest slug.",
"Join columns": "Cumbinar colonnas",
"Joined with {fields}, separated by \"{separator}\"": "Cumbinà cun {fields}, separà cun \"{separator}\"",
diff --git a/l10n/rm.json b/l10n/rm.json
index c6c5e41d7d..db72e154b5 100644
--- a/l10n/rm.json
+++ b/l10n/rm.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} falharam",
"0 = unlimited": "0 = ilimitado",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "In flux exequescha ina retscha da pass, cur ch'insatge capita — in object sa mida, in urari vegn activà u l'utilisader al exequescha sez. Questa glista mussa ils fluxs da mintga applicaziun.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters persunals, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "In pèr da clavs VAPID è configurà. Ils utilisaders pon s'annunziar per las communicaziuns dal navigatur en lur parameters d'utilisader, che vegnan furnidas era cur ch'il tab dal navigatur è serrà.",
"A deactivation request is pending.": "Existe um pedido de desativação pendente.",
"AI Assistant": "Assistent IA",
"AI Chat": "Chat IA",
diff --git a/l10n/ro.js b/l10n/ro.js
index 455a22a8bf..4a169eff1a 100644
--- a/l10n/ro.js
+++ b/l10n/ro.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} eșuate",
"0 = unlimited": "0 = nelimitat",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux rulează o serie de pași atunci când se întâmplă ceva — un obiect se modifică, o programare se declanșează sau îl rulați chiar dvs. Această listă afișează fluxurile fiecărei aplicații.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările personale; acestea sunt livrate chiar și când fila browserului este închisă.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările de utilizator; acestea sunt livrate chiar și când fila browserului este închisă.",
"A deactivation request is pending.": "O cerere de dezactivare este în așteptare.",
"AI Assistant": "Asistent AI",
"AI Chat": "Conversație AI",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Prezent",
"Re-import": "Reimportă",
"Reading descriptors …": "Se citesc descriptorii …",
+ "Re-import {register}?": "Reimportați {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "La reimportarea aplicației {app}, autorizarea schemelor sale este preluată din fișier, înlocuind orice modificare făcută pe această instanță, și pot fi reimportate toate registrele aplicației, nu doar {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorizare preluată din fișier peste o modificare locală: {count}.",
"Register descriptors": "Descriptori de registru",
"ships v{shipped}": "livrează v{shipped}",
"State": "Stare",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Coloană ID",
"Import JSON": "Importare JSON",
"Imported \"{name}\".": "\"{name}\" a fost importat.",
+ "Replaced \"{name}\".": "\"{name}\" a fost înlocuit.",
"Imports find the pack by this slug.": "Importurile găsesc pachetul după acest slug.",
"Join columns": "Concatenare coloane",
"Joined with {fields}, separated by \"{separator}\"": "Concatenat cu {fields}, separat prin \"{separator}\"",
diff --git a/l10n/ro.json b/l10n/ro.json
index 03c60cff7a..a2bdea669e 100644
--- a/l10n/ro.json
+++ b/l10n/ro.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} eșuate",
"0 = unlimited": "0 = nelimitat",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Un flux rulează o serie de pași atunci când se întâmplă ceva — un obiect se modifică, o programare se declanșează sau îl rulați chiar dvs. Această listă afișează fluxurile fiecărei aplicații.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările personale; acestea sunt livrate chiar și când fila browserului este închisă.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "O pereche de chei VAPID este configurată. Utilizatorii pot activa notificările din browser în setările de utilizator; acestea sunt livrate chiar și când fila browserului este închisă.",
"A deactivation request is pending.": "O cerere de dezactivare este în așteptare.",
"AI Assistant": "Asistent AI",
"AI Chat": "Conversație AI",
diff --git a/l10n/ru.js b/l10n/ru.js
index 6b429b77f7..912a91c876 100644
--- a/l10n/ru.js
+++ b/l10n/ru.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} неудачно",
"0 = unlimited": "0 = неограниченно",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Поток запускает серию шагов, когда что-то происходит — объект изменяется, срабатывает расписание или вы запускаете его сами. Этот список показывает потоки всех приложений.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключей VAPID настроена. Пользователи могут включить уведомления браузера в настройках пользователя; они доставляются, даже когда вкладка браузера закрыта.",
"A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.",
"AI Assistant": "ИИ-помощник",
"AI Chat": "ИИ-чат",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Присутствует",
"Re-import": "Импортировать снова",
"Reading descriptors …": "Чтение дескрипторов …",
+ "Re-import {register}?": "Импортировать {register} снова?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторный импорт {app} берёт авторизацию его схем из файла, заменяя любые изменения, сделанные на этом экземпляре, и может повторно импортировать все реестры приложения, а не только {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Авторизация, взятая из файла вместо локального изменения: {count}.",
"Register descriptors": "Дескрипторы реестров",
"ships v{shipped}": "поставляет v{shipped}",
"State": "Состояние",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Столбец id",
"Import JSON": "Импортировать JSON",
"Imported \"{name}\".": "\"{name}\" импортирован.",
+ "Replaced \"{name}\".": "\"{name}\" заменён.",
"Imports find the pack by this slug.": "Импорты находят пакет по этому slug.",
"Join columns": "Объединить столбцы",
"Joined with {fields}, separated by \"{separator}\"": "Объединено с {fields}, разделитель \"{separator}\"",
diff --git a/l10n/ru.json b/l10n/ru.json
index 8613070f5e..f006f8f2b5 100644
--- a/l10n/ru.json
+++ b/l10n/ru.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} неудачно",
"0 = unlimited": "0 = неограниченно",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Поток запускает серию шагов, когда что-то происходит — объект изменяется, срабатывает расписание или вы запускаете его сами. Этот список показывает потоки всех приложений.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пара ключей VAPID настроена. Пользователи могут включить уведомления браузера в настройках пользователя; они доставляются, даже когда вкладка браузера закрыта.",
"A deactivation request is pending.": "Запрос на деактивацию ожидает обработки.",
"AI Assistant": "ИИ-помощник",
"AI Chat": "ИИ-чат",
diff --git a/l10n/sk.js b/l10n/sk.js
index 07e6f584dd..52715e8446 100644
--- a/l10n/sk.js
+++ b/l10n/sk.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} zlyhalo",
"0 = unlimited": "0 = neobmedzené",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sériu krokov, keď sa niečo stane — objekt sa zmení, spustí sa plán, alebo ho spustíte sami. Tento zoznam zobrazuje toky všetkých aplikácií.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v osobných nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v používateľských nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.",
"A deactivation request is pending.": "Žiadosť o deaktiváciu čaká na vybavenie.",
"AI Assistant": "AI asistent",
"AI Chat": "AI chat",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Prítomný",
"Re-import": "Znovu importovať",
"Reading descriptors …": "Načítavanie deskriptorov …",
+ "Re-import {register}?": "Znovu importovať {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Opätovný import aplikácie {app} prevezme autorizáciu jej schém zo súboru a nahradí všetky zmeny vykonané v tejto inštancii; môže znova importovať aj všetky registre aplikácie, nielen {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorizácia prevzatá zo súboru namiesto lokálnej zmeny: {count}.",
"Register descriptors": "Deskriptory registrov",
"ships v{shipped}": "dodáva v{shipped}",
"State": "Stav",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Stĺpec s id",
"Import JSON": "Importovať JSON",
"Imported \"{name}\".": "\"{name}\" bol importovaný.",
+ "Replaced \"{name}\".": "\"{name}\" bol nahradený.",
"Imports find the pack by this slug.": "Importy nájdu balík podľa tohto slugu.",
"Join columns": "Spojiť stĺpce",
"Joined with {fields}, separated by \"{separator}\"": "Spojené s {fields}, oddelené \"{separator}\"",
diff --git a/l10n/sk.json b/l10n/sk.json
index 133465b455..e537e1c37b 100644
--- a/l10n/sk.json
+++ b/l10n/sk.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} zlyhalo",
"0 = unlimited": "0 = neobmedzené",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok spustí sériu krokov, keď sa niečo stane — objekt sa zmení, spustí sa plán, alebo ho spustíte sami. Tento zoznam zobrazuje toky všetkých aplikácií.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v osobných nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Je nakonfigurovaný pár kľúčov VAPID. Používatelia si môžu v používateľských nastaveniach zapnúť oznámenia prehliadača, ktoré sa doručujú aj pri zatvorenej karte prehliadača.",
"A deactivation request is pending.": "Žiadosť o deaktiváciu čaká na vybavenie.",
"AI Assistant": "AI asistent",
"AI Chat": "AI chat",
diff --git a/l10n/sl.js b/l10n/sl.js
index eb8f7422a5..9621080b56 100644
--- a/l10n/sl.js
+++ b/l10n/sl.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} neuspešno",
"0 = unlimited": "0 = neomejeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok izvede vrsto korakov, ko se nekaj zgodi — objekt se spremeni, urnik se sproži ali pa ga zaženete sami. Ta seznam prikazuje tokove vseh aplikacij.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v osebnih nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v uporabniških nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.",
"A deactivation request is pending.": "Zahteva za deaktivacijo je v čakanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI klepet",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Prisoten",
"Re-import": "Ponovno uvozi",
"Reading descriptors …": "Branje deskriptorjev …",
+ "Re-import {register}?": "Želite znova uvoziti {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ob ponovnem uvozu aplikacije {app} se avtorizacija njenih shem prevzame iz datoteke in nadomesti vse spremembe, narejene na tem primerku, ponovno pa se lahko uvozijo tudi vsi registri aplikacije, ne le {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Avtorizacija, prevzeta iz datoteke namesto lokalne spremembe: {count}.",
"Register descriptors": "Deskriptorji registrov",
"ships v{shipped}": "dobavlja v{shipped}",
"State": "Stanje",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Stolpec z ID",
"Import JSON": "Uvozi JSON",
"Imported \"{name}\".": "Paket \"{name}\" je uvožen.",
+ "Replaced \"{name}\".": "Paket \"{name}\" je zamenjan.",
"Imports find the pack by this slug.": "Uvozi najdejo paket po tej oznaki.",
"Join columns": "Združi stolpce",
"Joined with {fields}, separated by \"{separator}\"": "Združeno s {fields}, ločeno z \"{separator}\"",
diff --git a/l10n/sl.json b/l10n/sl.json
index 3e6a20e70e..e0b8771a4d 100644
--- a/l10n/sl.json
+++ b/l10n/sl.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} neuspešno",
"0 = unlimited": "0 = neomejeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok izvede vrsto korakov, ko se nekaj zgodi — objekt se spremeni, urnik se sproži ali pa ga zaženete sami. Ta seznam prikazuje tokove vseh aplikacij.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v osebnih nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par ključev VAPID je nastavljen. Uporabniki lahko v uporabniških nastavitvah vklopijo obvestila brskalnika, ki so dostavljena tudi, ko je zavihek brskalnika zaprt.",
"A deactivation request is pending.": "Zahteva za deaktivacijo je v čakanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI klepet",
diff --git a/l10n/sq.js b/l10n/sq.js
index 549980838a..9c58edad2d 100644
--- a/l10n/sq.js
+++ b/l10n/sq.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} dështoi",
"0 = unlimited": "0 = pa limit",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Një rrjedhë ekzekuton një varg hapash kur ndodh diçka — një objekt ndryshon, një planifikim aktivizohet ose e ekzekutoni vetë. Kjo listë shfaq rrjedhat e çdo aplikacioni.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e tyre personale dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e përdoruesit dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.",
"A deactivation request is pending.": "Një kërkesë çaktivizimi është në pritje.",
"AI Assistant": "Asistent AI",
"AI Chat": "Chat AI",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "I pranishëm",
"Re-import": "Riimporto",
"Reading descriptors …": "Po lexohen përshkruesit …",
+ "Re-import {register}?": "Ta riimportoni {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Riimportimi i {app} merr autorizimin e skemave të tij nga skedari, duke zëvendësuar çdo ndryshim të bërë në këtë instancë, dhe mund të riimportojë çdo regjistër të aplikacionit, jo vetëm {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Autorizim i marrë nga skedari në vend të një ndryshimi lokal: {count}.",
"Register descriptors": "Përshkrues regjistrash",
"ships v{shipped}": "jep v{shipped}",
"State": "Gjendja",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Kolona e identifikuesit",
"Import JSON": "Importo JSON",
"Imported \"{name}\".": "\"{name}\" u importua.",
+ "Replaced \"{name}\".": "\"{name}\" u zëvendësua.",
"Imports find the pack by this slug.": "Importet e gjejnë paketën sipas këtij emri të shkurtër.",
"Join columns": "Bashko kolonat",
"Joined with {fields}, separated by \"{separator}\"": "Bashkuar me {fields}, të ndara me \"{separator}\"",
diff --git a/l10n/sq.json b/l10n/sq.json
index 43ddc42c26..3ae9a1a10f 100644
--- a/l10n/sq.json
+++ b/l10n/sq.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} dështoi",
"0 = unlimited": "0 = pa limit",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Një rrjedhë ekzekuton një varg hapash kur ndodh diçka — një objekt ndryshon, një planifikim aktivizohet ose e ekzekutoni vetë. Kjo listë shfaq rrjedhat e çdo aplikacioni.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e tyre personale dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Është konfiguruar një çift çelësash VAPID. Përdoruesit mund të aktivizojnë njoftimet e shfletuesit nga cilësimet e përdoruesit dhe ato dorëzohen edhe kur skeda e shfletuesit është e mbyllur.",
"A deactivation request is pending.": "Një kërkesë çaktivizimi është në pritje.",
"AI Assistant": "Asistent AI",
"AI Chat": "Chat AI",
diff --git a/l10n/sr.js b/l10n/sr.js
index a6e3b8c40c..64629ac9cd 100644
--- a/l10n/sr.js
+++ b/l10n/sr.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} neuspješno",
"0 = unlimited": "0 = neograničeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u ličnim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u korisničkim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.",
"A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI razgovor",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Postoji",
"Re-import": "Ponovo uvezi",
"Reading descriptors …": "Čitanje deskriptora …",
+ "Re-import {register}?": "Ponovo uvesti {register}?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Ponovnim uvozom aplikacije {app} ovlaštenja njenih šema preuzimaju se iz datoteke i zamenjuju sve izmene napravljene na ovoj instanci, a mogu se ponovo uvesti i svi registri aplikacije, ne samo {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Ovlaštenja preuzeta iz datoteke umesto lokalne izmene: {count}.",
"Register descriptors": "Deskriptori registara",
"ships v{shipped}": "isporučuje v{shipped}",
"State": "Stanje",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Колона са ИД-ом",
"Import JSON": "Увези JSON",
"Imported \"{name}\".": "\"{name}\" је увезен.",
+ "Replaced \"{name}\".": "\"{name}\" je zamenjen.",
"Imports find the pack by this slug.": "Увози проналазе пакет по овом slug-у.",
"Join columns": "Спој колоне",
"Joined with {fields}, separated by \"{separator}\"": "Спојено са {fields}, раздвојено знаком \"{separator}\"",
diff --git a/l10n/sr.json b/l10n/sr.json
index f57c81b950..31c2bd4487 100644
--- a/l10n/sr.json
+++ b/l10n/sr.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} neuspješno",
"0 = unlimited": "0 = neograničeno",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Tok pokreće niz koraka kada se nešto desi — objekat se promeni, raspored se aktivira ili ga sami pokrenete. Ova lista prikazuje tokove svih aplikacija.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u ličnim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Par VAPID ključeva je podešen. Korisnici mogu u korisničkim podešavanjima da uključe obaveštenja pregledača, koja se isporučuju čak i kada je kartica pregledača zatvorena.",
"A deactivation request is pending.": "Zahtjev za deaktivaciju je na čekanju.",
"AI Assistant": "AI asistent",
"AI Chat": "AI razgovor",
diff --git a/l10n/sv.js b/l10n/sv.js
index 3bbb89ffac..31922eb161 100644
--- a/l10n/sv.js
+++ b/l10n/sv.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} misslyckades",
"0 = unlimited": "0 = obegränsat",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ett flöde kör en serie steg när något händer — ett objekt ändras, ett schema utlöses eller du kör det själv. Den här listan visar alla appars flöden.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ett VAPID-nyckelpar är konfigurerat. Användare kan aktivera webbläsaraviseringar i sina användarinställningar, och de levereras även när webbläsarfliken är stängd.",
"A deactivation request is pending.": "En avaktiveringsförfrågan väntar.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-chatt",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Finns",
"Re-import": "Importera igen",
"Reading descriptors …": "Läser deskriptorer …",
+ "Re-import {register}?": "Importera {register} igen?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Vid ny import av {app} hämtas auktoriseringen för dess scheman från filen och ersätter alla ändringar som gjorts på den här instansen, och alla appens register kan importeras igen, inte bara {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Auktorisering hämtad från filen över en lokal ändring: {count}.",
"Register descriptors": "Registerdeskriptorer",
"ships v{shipped}": "levererar v{shipped}",
"State": "Status",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Id-kolumn",
"Import JSON": "Importera JSON",
"Imported \"{name}\".": "\"{name}\" har importerats.",
+ "Replaced \"{name}\".": "\"{name}\" har ersatts.",
"Imports find the pack by this slug.": "Importer hittar paketet via denna slug.",
"Join columns": "Sammanfoga kolumner",
"Joined with {fields}, separated by \"{separator}\"": "Sammanfogad med {fields}, avgränsad med \"{separator}\"",
diff --git a/l10n/sv.json b/l10n/sv.json
index 782a24d55a..0ec0480896 100644
--- a/l10n/sv.json
+++ b/l10n/sv.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} misslyckades",
"0 = unlimited": "0 = obegränsat",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Ett flöde kör en serie steg när något händer — ett objekt ändras, ett schema utlöses eller du kör det själv. Den här listan visar alla appars flöden.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Ett VAPID-nyckelpar är konfigurerat. Användare kan aktivera webbläsaraviseringar i sina användarinställningar, och de levereras även när webbläsarfliken är stängd.",
"A deactivation request is pending.": "En avaktiveringsförfrågan väntar.",
"AI Assistant": "AI-assistent",
"AI Chat": "AI-chatt",
diff --git a/l10n/tr.js b/l10n/tr.js
index 52936493a3..7e14a93d2e 100644
--- a/l10n/tr.js
+++ b/l10n/tr.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} başarısız",
"0 = unlimited": "0 = sınırsız",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Bir akış, bir şey olduğunda — bir nesne değiştiğinde, bir zamanlama tetiklendiğinde veya siz kendiniz çalıştırdığınızda — bir dizi adımı çalıştırır. Bu liste her uygulamanın akışlarını gösterir.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kişisel ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kullanıcı ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.",
"A deactivation request is pending.": "Bir devre dışı bırakma isteği beklemede.",
"AI Assistant": "Yapay Zeka Asistanı",
"AI Chat": "Yapay Zeka Sohbeti",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Mevcut",
"Re-import": "Yeniden içe aktar",
"Reading descriptors …": "Tanımlayıcılar okunuyor …",
+ "Re-import {register}?": "{register} yeniden içe aktarılsın mı?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "{app} uygulamasını yeniden içe aktarmak, şemalarının yetkilendirmesini dosyadan alır ve bu örnekte yapılan tüm değişikliklerin yerini alır; yalnızca {register} değil, uygulamanın tüm kayıtlarını da yeniden içe aktarabilir.",
+ "Authorization taken from the file over a local change: {count}.": "Yerel bir değişikliğin yerine dosyadan alınan yetkilendirme: {count}.",
"Register descriptors": "Kayıt tanımlayıcıları",
"ships v{shipped}": "v{shipped} sunuyor",
"State": "Durum",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Kimlik sütunu",
"Import JSON": "JSON içe aktar",
"Imported \"{name}\".": "\"{name}\" içe aktarıldı.",
+ "Replaced \"{name}\".": "\"{name}\" değiştirildi.",
"Imports find the pack by this slug.": "İçe aktarımlar paketi bu slug ile bulur.",
"Join columns": "Sütunları birleştir",
"Joined with {fields}, separated by \"{separator}\"": "{fields} ile birleştirildi, \"{separator}\" ile ayrıldı",
diff --git a/l10n/tr.json b/l10n/tr.json
index 65513ec886..ee6bd6ec13 100644
--- a/l10n/tr.json
+++ b/l10n/tr.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} başarısız",
"0 = unlimited": "0 = sınırsız",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Bir akış, bir şey olduğunda — bir nesne değiştiğinde, bir zamanlama tetiklendiğinde veya siz kendiniz çalıştırdığınızda — bir dizi adımı çalıştırır. Bu liste her uygulamanın akışlarını gösterir.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kişisel ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Bir VAPID anahtar çifti yapılandırılmış. Kullanıcılar tarayıcı bildirimlerini kullanıcı ayarlarından açabilir; bu bildirimler tarayıcı sekmesi kapalıyken bile iletilir.",
"A deactivation request is pending.": "Bir devre dışı bırakma isteği beklemede.",
"AI Assistant": "Yapay Zeka Asistanı",
"AI Chat": "Yapay Zeka Sohbeti",
diff --git a/l10n/uk.js b/l10n/uk.js
index 01e6aeede1..32590a3f90 100644
--- a/l10n/uk.js
+++ b/l10n/uk.js
@@ -56,7 +56,7 @@ OC.L10N.register(
", {failed} failed": ", {failed} неудачно",
"0 = unlimited": "0 = неограниченно",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потік запускає серію кроків, коли щось відбувається — об'єкт змінюється, спрацьовує розклад або ви запускаєте його самі. Цей список показує потоки всіх застосунків.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пару ключів VAPID налаштовано. Користувачі можуть увімкнути сповіщення браузера в налаштуваннях користувача; їх доставляють, навіть коли вкладка браузера закрита.",
"A deactivation request is pending.": "Запит на деактивацію ожидает обробки.",
"AI Assistant": "ИИ-помощник",
"AI Chat": "ИИ-чат",
@@ -2762,6 +2762,9 @@ OC.L10N.register(
"Present": "Наявний",
"Re-import": "Імпортувати знову",
"Reading descriptors …": "Читання дескрипторів …",
+ "Re-import {register}?": "Імпортувати {register} знову?",
+ "Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.": "Повторний імпорт {app} бере авторизацію його схем із файлу, замінюючи будь-які зміни, внесені на цьому екземплярі, і може повторно імпортувати всі реєстри застосунку, а не лише {register}.",
+ "Authorization taken from the file over a local change: {count}.": "Авторизація, взята з файлу замість локальної зміни: {count}.",
"Register descriptors": "Дескриптори реєстрів",
"ships v{shipped}": "постачає v{shipped}",
"State": "Стан",
@@ -3407,6 +3410,7 @@ OC.L10N.register(
"Id column": "Стовпець ID",
"Import JSON": "Імпортувати JSON",
"Imported \"{name}\".": "\"{name}\" імпортовано.",
+ "Replaced \"{name}\".": "\"{name}\" замінено.",
"Imports find the pack by this slug.": "Імпорти знаходять пакет за цим slug.",
"Join columns": "Об'єднати стовпці",
"Joined with {fields}, separated by \"{separator}\"": "Об'єднано з {fields}, розділено \"{separator}\"",
diff --git a/l10n/uk.json b/l10n/uk.json
index a7f76d59c6..59498da918 100644
--- a/l10n/uk.json
+++ b/l10n/uk.json
@@ -43,7 +43,7 @@
", {failed} failed": ", {failed} неудачно",
"0 = unlimited": "0 = неограниченно",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Потік запускає серію кроків, коли щось відбувається — об'єкт змінюється, спрацьовує розклад або ви запускаєте його самі. Цей список показує потоки всіх застосунків.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Пару ключів VAPID налаштовано. Користувачі можуть увімкнути сповіщення браузера в налаштуваннях користувача; їх доставляють, навіть коли вкладка браузера закрита.",
"A deactivation request is pending.": "Запит на деактивацію ожидает обробки.",
"AI Assistant": "ИИ-помощник",
"AI Chat": "ИИ-чат",
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 79cf329169..005e590330 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -3372,6 +3372,11 @@ private function registerEventListeners(IRegistrationContext $context): void {
$context->registerEventListener(SchemaCreatedEvent::class, ApprovalChainAnnotationInstaller::class);
$context->registerEventListener(SchemaUpdatedEvent::class, ApprovalChainAnnotationInstaller::class);
+
+ // A schema written by anything but the schema API (the configuration
+ // importer, the import endpoint, the AI and MCP tools) re-checks the
+ // published forms that submit into it, as the API does itself.
+ $context->registerEventListener(SchemaUpdatedEvent::class, \OCA\OpenRegister\Listener\FormDestinationSchemaListener::class);
$context->registerEventListener(ObjectUpdatingEvent::class, ApprovalChainGateListener::class);
$context->registerEventListener(
\OCA\OpenRegister\Event\TaskSequenceCompletedEvent::class,
diff --git a/lib/BackgroundJob/CasePlanTimerRearmJob.php b/lib/BackgroundJob/CasePlanTimerRearmJob.php
new file mode 100644
index 0000000000..44cbf2d9b3
--- /dev/null
+++ b/lib/BackgroundJob/CasePlanTimerRearmJob.php
@@ -0,0 +1,103 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Cron
+ * @package OCA\OpenRegister\BackgroundJob
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\BackgroundJob;
+
+use DateTimeImmutable;
+use OCA\OpenRegister\Service\Case\CasePlanTimerRearm;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Runs one re-arm pass per tick.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess) DateTimeImmutable::createFromInterface
+ * is PHP's own conversion of the job clock.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+class CasePlanTimerRearmJob extends TimedJob {
+
+ /**
+ * The interval, the same as the timer sweep it backs up.
+ *
+ * @var int
+ */
+ public const INTERVAL_SECONDS = 300;
+
+ /**
+ * Constructor.
+ *
+ * @param ITimeFactory $time Job scheduling clock.
+ * @param CasePlanTimerRearm $rearm The pass.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private readonly CasePlanTimerRearm $rearm,
+ private readonly LoggerInterface $logger,
+ ) {
+ parent::__construct(time: $time);
+ $this->setInterval(seconds: self::INTERVAL_SECONDS);
+
+ }//end __construct()
+
+ /**
+ * Run one pass; a failing pass is logged, never thrown.
+ *
+ * @param mixed $argument The job argument (unused).
+ *
+ * @return void
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter) `$argument` is TimedJob's signature.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+ protected function run($argument): void {
+ try {
+ $result = $this->rearm->run(now: DateTimeImmutable::createFromInterface($this->time->getDateTime()));
+ if ($result !== ['armed' => 0, 'enforced' => 0, 'dropped' => 0, 'failed' => 0]) {
+ $this->logger->info(
+ message: sprintf(
+ '[CasePlanTimerRearmJob] Armed %d statutory timer(s), enforced %d expired term(s), dropped %d marker(s); %d still failing',
+ $result['armed'],
+ $result['enforced'],
+ $result['dropped'],
+ $result['failed']
+ ),
+ context: ['file' => __FILE__, 'line' => __LINE__] + $result
+ );
+ }
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: '[CasePlanTimerRearmJob] Re-arm pass failed: ' . $e->getMessage(),
+ context: ['file' => __FILE__, 'line' => __LINE__, 'exception' => $e]
+ );
+ }//end try
+
+ }//end run()
+}//end class
diff --git a/lib/BackgroundJob/DraftExpiryJob.php b/lib/BackgroundJob/DraftExpiryJob.php
index 9e7f6e04ce..8c95d2584f 100644
--- a/lib/BackgroundJob/DraftExpiryJob.php
+++ b/lib/BackgroundJob/DraftExpiryJob.php
@@ -112,17 +112,35 @@ protected function run(mixed $argument): void {
// One line per schema that held an expired draft, so an administrator
// reads from the log which schema lost how many (decision 228), and
// one line with the totals, a zero included.
+ //
+ // A sweep that removed or refused a draft is logged at WARNING: the
+ // default loglevel (2 = warning) would hide an INFO line, and the first
+ // run after the upgrade removes drafts that were kept until then, so an
+ // administrator would have no record of how many. A sweep that changed
+ // nothing keeps its counted zero at info.
foreach ($counts['perSchema'] as $row) {
- $this->logger->info(
- message: '[DraftExpiryJob] drafts past the lifetime of a schema',
- context: $row
- );
+ $this->report(message: '[DraftExpiryJob] drafts past the lifetime of a schema', counts: $row);
}
unset($counts['perSchema']);
- $this->logger->info(
- message: '[DraftExpiryJob] swept drafts past their schema\'s lifetime',
- context: $counts
- );
+ $this->report(message: '[DraftExpiryJob] swept drafts past their schema\'s lifetime', counts: $counts);
}//end run()
+
+ /**
+ * Log one line of counts: at warning when drafts were removed or refused, at info for a counted zero.
+ *
+ * @param string $message The line.
+ * @param array $counts The counts of one schema or of the whole sweep.
+ *
+ * @return void
+ */
+ private function report(string $message, array $counts): void {
+ if (((int)($counts['removed'] ?? 0) + (int)($counts['refused'] ?? 0)) > 0) {
+ $this->logger->warning(message: $message, context: $counts);
+
+ return;
+ }
+
+ $this->logger->info(message: $message, context: $counts);
+ }//end report()
}//end class
diff --git a/lib/Command/ExampleSetInstallCommand.php b/lib/Command/ExampleSetInstallCommand.php
index b9c1fa2670..be28991abe 100644
--- a/lib/Command/ExampleSetInstallCommand.php
+++ b/lib/Command/ExampleSetInstallCommand.php
@@ -83,7 +83,8 @@ protected function configure(): void {
name: 'check',
shortcut: null,
mode: InputOption::VALUE_NONE,
- description: 'Validate every object against its schema and write nothing'
+ description: 'Validate every object against its schema; writes no account, group or object '
+ .'(a missing required register is still imported, and a missing cast organisation created)'
)
->addOption(
name: 'adopt',
@@ -185,6 +186,9 @@ private function check(array $plan, array $adopt, OutputInterface $output): int
}
$output->writeln('Every object passes its schema. No account, group or object of the set was written.');
+ $output->writeln(
+ 'A required register the instance lacked was imported, and a cast organisation it lacked was created, because the check needs them.'
+ );
return self::SUCCESS;
}//end check()
@@ -227,7 +231,32 @@ private function printReport(array $report, OutputInterface $output): void {
}
}
- foreach ($report['types'] as $type => $counts) {
+ $this->printTypes(types: $report['types'], output: $output);
+
+ if ($report['files'] > 0) {
+ $output->writeln('Files attached: '.$report['files']);
+ }
+
+ $this->printOutstanding(report: $report, output: $output);
+
+ if ($report['passwords'] !== []) {
+ $output->writeln('New passwords, shown once and stored nowhere:');
+ foreach ($report['passwords'] as $uid => $password) {
+ $output->writeln(' '.$uid.': '.$password);
+ }
+ }
+ }//end printReport()
+
+ /**
+ * Print the counts per register and schema.
+ *
+ * @param array> $types Counts by ``.
+ * @param OutputInterface $output The command output.
+ *
+ * @return void
+ */
+ private function printTypes(array $types, OutputInterface $output): void {
+ foreach ($types as $type => $counts) {
$output->writeln(
sprintf(
'%s: %d declared, %d created, %d already there, %d found afterwards',
@@ -239,9 +268,19 @@ private function printReport(array $report, OutputInterface $output): void {
)
);
}
+ }//end printTypes()
- if ($report['files'] > 0) {
- $output->writeln('Files attached: '.$report['files']);
+ /**
+ * Print what was left as it is, and what is not written, not there or not kept.
+ *
+ * @param array $report The installer's report.
+ * @param OutputInterface $output The command output.
+ *
+ * @return void
+ */
+ private function printOutstanding(array $report, OutputInterface $output): void {
+ foreach ($report['left'] as $line) {
+ $output->writeln('Left as it is: '.$line);
}
$problems = ['Not written: ' => $report['dropped'], 'Not on the instance: ' => $report['missing'], 'Written but not kept: ' => $report['lost']];
@@ -250,14 +289,7 @@ private function printReport(array $report, OutputInterface $output): void {
$output->writeln(''.$words.$line.'');
}
}
-
- if ($report['passwords'] !== []) {
- $output->writeln('New passwords, shown once and stored nowhere:');
- foreach ($report['passwords'] as $uid => $password) {
- $output->writeln(' '.$uid.': '.$password);
- }
- }
- }//end printReport()
+ }//end printOutstanding()
/**
* The words for an outcome.
diff --git a/lib/Command/ExampleSetRemoveCommand.php b/lib/Command/ExampleSetRemoveCommand.php
index b3492829cb..cf655090dc 100644
--- a/lib/Command/ExampleSetRemoveCommand.php
+++ b/lib/Command/ExampleSetRemoveCommand.php
@@ -41,6 +41,13 @@
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007
*/
class ExampleSetRemoveCommand extends Command {
+ /**
+ * How a `left` line that is a sentence of its own begins; any other line is an adopted account.
+ *
+ * @var array
+ */
+ private const NOTES = ['app config ', 'user preference ', 'OpenRegister organisation ', 'change '];
+
/**
* Constructor.
*
@@ -90,7 +97,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
foreach ($report['left'] as $left) {
- if (str_starts_with($left, 'app config ') === true || str_starts_with($left, 'user preference ') === true) {
+ if ($this->isNote(left: $left) === true) {
$output->writeln('Left in place: '.$left.'.');
continue;
}
@@ -109,4 +116,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return self::SUCCESS;
}//end execute()
+
+ /**
+ * Whether a `left` line is a sentence of its own and not an adopted account's id.
+ *
+ * @param string $left The line.
+ *
+ * @return bool
+ */
+ private function isNote(string $left): bool {
+ foreach (self::NOTES as $prefix) {
+ if (str_starts_with($left, $prefix) === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isNote()
}//end class
diff --git a/lib/Controller/FormsController.php b/lib/Controller/FormsController.php
index 33b28f4f7d..1cba865995 100644
--- a/lib/Controller/FormsController.php
+++ b/lib/Controller/FormsController.php
@@ -167,8 +167,8 @@ public function submit(string $formId): JSONResponse {
try {
$form = $this->resolveForm(formId: $formId);
$subject = $this->userSession->getUser();
- if ($subject === null && $form['audience'] !== 'public') {
- return new JSONResponse(data: ['message' => $this->l10n->t('Please sign in to send this form.'), 'findings' => []], statusCode: 401);
+ if ($this->audienceRefuses(form: $form, subject: $subject) === true) {
+ return $this->signInRequired();
}
$key = trim($this->request->getHeader('Idempotency-Key'));
@@ -253,7 +253,8 @@ public function draft(string $formId): JSONResponse {
*
* @param string $formId The form object's uuid.
*
- * @return JSONResponse 201 `{ token, expiresAt }`; 404 or 422 with `{ message, findings }`.
+ * @return JSONResponse 201 `{ token, expiresAt }`; 401 for an anonymous visitor of a sign-in-only form;
+ * 404 or 422 with `{ message, findings }`.
*
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-upload-tokens-must-hold-bytes-only-and-expire
*/
@@ -271,6 +272,12 @@ public function upload(string $formId): JSONResponse {
try {
$form = $this->resolveForm(formId: $formId);
+ // The token is only redeemable by submit(), which asks the audience;
+ // asking it here too keeps a closed form from storing bytes for anyone.
+ if ($this->audienceRefuses(form: $form, subject: $this->userSession->getUser()) === true) {
+ return $this->signInRequired();
+ }
+
$rule = $this->ruleFor(writes: $form['writes'], property: $property);
$issued = $this->uploads->issue(formId: $formId, property: $property, rule: $rule, file: $file);
} catch (FormSubmitRefusedException $refused) {
@@ -280,6 +287,27 @@ public function upload(string $formId): JSONResponse {
return new JSONResponse(data: $issued, statusCode: 201);
}//end upload()
+ /**
+ * Whether the form's audience turns this visitor away: anonymous, and the form is not public.
+ *
+ * @param array{id: string, writes: array>, audience: string} $form The form.
+ * @param IUser|null $subject The signed-in subject, null when anonymous.
+ *
+ * @return bool True when the visitor must sign in first.
+ */
+ private function audienceRefuses(array $form, ?IUser $subject): bool {
+ return $subject === null && $form['audience'] !== 'public';
+ }//end audienceRefuses()
+
+ /**
+ * The 401 an anonymous visitor of a sign-in-only form gets.
+ *
+ * @return JSONResponse The refusal.
+ */
+ private function signInRequired(): JSONResponse {
+ return new JSONResponse(data: ['message' => $this->l10n->t('Please sign in to send this form.'), 'findings' => []], statusCode: 401);
+ }//end signInRequired()
+
/**
* One destination through FormSubmitService::submit(), a journey's writes through submitAll().
*
diff --git a/lib/Controller/MigrationPacksController.php b/lib/Controller/MigrationPacksController.php
index ec415f806e..a9073e26ba 100644
--- a/lib/Controller/MigrationPacksController.php
+++ b/lib/Controller/MigrationPacksController.php
@@ -247,7 +247,10 @@ public function export(int $id): JSONResponse|DataDownloadResponse {
* Import a migration pack definition from an uploaded JSON file. Admin only.
* Upserts by the document's own `id` — re-importing an updated pack
* updates the existing row. The log line says whether the import created
- * or replaced a pack, as reported by the write itself.
+ * or replaced a pack, as reported by the write itself, and so does the
+ * status: 201 for a pack created, 200 for one replaced, so the caller can
+ * tell an administrator that a stored pack (possibly edited on this
+ * instance) was overwritten.
*
* @return JSONResponse
*
@@ -274,8 +277,10 @@ public function import(): JSONResponse {
}
$outcome = 'replaced';
+ $status = 200;
if ($result->created === true) {
$outcome = 'created';
+ $status = 201;
}
$pack = $result->pack;
@@ -290,7 +295,7 @@ public function import(): JSONResponse {
]
);
- return new JSONResponse(data: $pack->jsonSerialize(), statusCode: 201);
+ return new JSONResponse(data: $pack->jsonSerialize(), statusCode: $status);
}//end import()
/**
diff --git a/lib/Controller/ObjectDraftController.php b/lib/Controller/ObjectDraftController.php
index a789640830..c0913ab6bb 100644
--- a/lib/Controller/ObjectDraftController.php
+++ b/lib/Controller/ObjectDraftController.php
@@ -179,7 +179,14 @@ public function activate(string $register, string $schema, string $id): JSONResp
);
}//end try
- return new JSONResponse(data: $activated->jsonSerialize());
+ // The saved entity goes through the render boundary the object API reads
+ // through: a `writeOnly` property is "may be sent, never returned", a
+ // property the caller's groups may not read is not theirs, and an
+ // encrypted one comes back decrypted for who may read it. The save
+ // renders its result already, but an automatic transition the save
+ // applied hands back an entity of its own, so it is rendered here once
+ // more: rendering is idempotent.
+ return new JSONResponse(data: $this->objects->renderEntity(entity: $activated, _rbac: true));
}//end activate()
/**
diff --git a/lib/Controller/ObjectFolderController.php b/lib/Controller/ObjectFolderController.php
index 6e9aa6e127..4b9714b2b7 100644
--- a/lib/Controller/ObjectFolderController.php
+++ b/lib/Controller/ObjectFolderController.php
@@ -25,10 +25,12 @@
use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Exception\ObjectFileAccessDeniedException;
+use OCA\OpenRegister\Exception\ObjectStateWriteException;
use OCA\OpenRegister\Service\File\ObjectFileAccess;
use OCA\OpenRegister\Service\File\ObjectFolderBrowser;
use OCA\OpenRegister\Service\File\ObjectFolderResponder;
use OCA\OpenRegister\Service\File\ObjectFolderUploads;
+use OCA\OpenRegister\Service\Object\FileWriteGuard;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
@@ -43,7 +45,8 @@
* person's Files app, so a reader has no WebDAV path to them. These endpoints
* are that path: every call checks the object first, as the signed-in person,
* exactly as the object file endpoints do. A listing needs read on the object
- * and answers 404 without it; a change needs update and answers 403 to a reader.
+ * and answers 404 without it; a change needs update and answers 403 to a reader,
+ * and 409 when the object is frozen or archived (REQ-OAS-007).
* No Nextcloud share is created, because a share on an object folder is an
* object grant that would outlive the rule that admitted the reader.
*
@@ -62,6 +65,7 @@ class ObjectFolderController extends Controller {
* @param ObjectFolderBrowser $browser The folder tree of one object.
* @param ObjectFolderUploads $uploads Stores a batch of uploads.
* @param ObjectFolderResponder $responder Turns outcomes and failures into responses.
+ * @param FileWriteGuard $fileWriteGuard Refuses a change to the files of a frozen or archived object.
*/
public function __construct(
string $appName,
@@ -70,6 +74,7 @@ public function __construct(
private readonly ObjectFolderBrowser $browser,
private readonly ObjectFolderUploads $uploads,
private readonly ObjectFolderResponder $responder,
+ private readonly FileWriteGuard $fileWriteGuard,
) {
parent::__construct(appName: $appName, request: $request);
}//end __construct()
@@ -246,9 +251,19 @@ private function ensureReadable(string $register, string $schema, string $id): O
* @return ObjectEntity The object.
*
* @throws ObjectFileAccessDeniedException With 404 without read, 403 with read but without update.
+ * @throws ObjectStateWriteException When the object is frozen or archived (answered 409, REQ-OAS-007).
+ *
+ * @spec openspec/changes/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007
*/
private function ensureChangeable(string $register, string $schema, string $id): ObjectEntity {
- return $this->objectFileAccess->changeable(register: $register, schema: $schema, id: $id);
+ $object = $this->objectFileAccess->changeable(register: $register, schema: $schema, id: $id);
+
+ // Asked after the access rule, so a caller without access still gets
+ // 404 or 403 and learns nothing of the state; the same order the files
+ // API uses (FilesController::ensureObjectAccess()).
+ $this->fileWriteGuard->assertWritable(object: $object);
+
+ return $object;
}//end ensureChangeable()
/**
diff --git a/lib/Controller/ObjectRelationsController.php b/lib/Controller/ObjectRelationsController.php
index 0942f0628b..b216bc7f09 100644
--- a/lib/Controller/ObjectRelationsController.php
+++ b/lib/Controller/ObjectRelationsController.php
@@ -35,6 +35,10 @@
use InvalidArgumentException;
use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Db\ObjectRelation;
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Service\Audit\PurposeGuard;
+use OCA\OpenRegister\Service\Audit\PurposeRefusedException;
use OCA\OpenRegister\Service\Export\ExportGate;
use OCA\OpenRegister\Service\Object\ObjectReadAccess;
use OCA\OpenRegister\Service\ObjectService;
@@ -45,6 +49,7 @@
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
@@ -54,6 +59,8 @@
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects) A REST surface over the
* relation row store, the graph walk and the object read it authorises against.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One action per route of the relation
+ * surface (rows, derive, graph, tree, export), each a short method that refuses first.
*
* @spec openspec/changes/relation-types-with-inverses/specs/referential-integrity/spec.md
*/
@@ -69,6 +76,10 @@ class ObjectRelationsController extends Controller {
* @param ExportGate $exportGate The export verb, checked before the graph leaves.
* @param ObjectReadAccess $access Resolves an object under the caller's own RBAC.
* @param ObjectTreeService $trees The bounded walk along a schema's parent property.
+ * @param PurposeGuard $purposeGuard The doelbinding guard the object read runs, asked before a tree or a graph is answered.
+ * @param RegisterMapper $registerMapper Resolves an object's register for that guard.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; each dependency is named above.
*
* @spec openspec/changes/relation-types-with-inverses/specs/referential-integrity/spec.md
*/
@@ -81,6 +92,8 @@ public function __construct(
private readonly ExportGate $exportGate,
private readonly ObjectReadAccess $access,
private readonly ObjectTreeService $trees,
+ private readonly PurposeGuard $purposeGuard,
+ private readonly RegisterMapper $registerMapper,
) {
parent::__construct(appName: $appName, request: $request);
}//end __construct()
@@ -477,6 +490,11 @@ public function graph(string $register, string $schema, string $id): JSONRespons
return $this->access->notReadable();
}
+ $refusal = $this->purposeRefusal(object: $object);
+ if ($refusal !== null) {
+ return $refusal;
+ }
+
return new JSONResponse(
data: $this->graphs->graph(
rootUuid: (string)$object->getUuid(),
@@ -503,12 +521,18 @@ public function graph(string $register, string $schema, string $id): JSONRespons
*/
#[NoAdminRequired]
#[NoCSRFRequired]
+ #[UserRateLimit(limit: 60, period: 60)]
public function tree(string $register, string $schema, string $id): JSONResponse {
$object = $this->access->readable(register: $register, schema: $schema, id: $id);
if ($object === null) {
return $this->access->notReadable();
}
+ $refusal = $this->purposeRefusal(object: $object);
+ if ($refusal !== null) {
+ return $refusal;
+ }
+
$direction = (string)$this->request->getParam('direction', ObjectTreeService::DIRECTION_DESCENDANTS);
$directions = [
ObjectTreeService::DIRECTION_DESCENDANTS,
@@ -563,6 +587,11 @@ public function exportGraph(string $register, string $schema, string $id): DataD
return $this->access->notReadable();
}
+ $purposeRefusal = $this->purposeRefusal(object: $object);
+ if ($purposeRefusal !== null) {
+ return $purposeRefusal;
+ }
+
// REQ-EXP-001: a relation graph as a CSV is the object's data leaving
// the instance, so it is an export and is checked against the export
// verb, not against the read the caller already passed above. A
@@ -601,6 +630,58 @@ public function exportGraph(string $register, string $schema, string $id): DataD
);
}//end exportGraph()
+ /**
+ * The refusal for a read that has to name a purpose and did not, or null.
+ *
+ * The doelbinding guard the object GET runs, asked after the read check so
+ * a refusal never confirms an object the caller may not see. The names and
+ * properties these routes answer for many objects at once are the same
+ * data the object GET would refuse, and the accepted purpose is what puts
+ * the read in the processing register. An object whose register or schema
+ * does not resolve is answered as not readable: the rule cannot be applied.
+ *
+ * @param ObjectEntity $object The object the route answers for, already read under the caller's RBAC.
+ *
+ * @return JSONResponse|null The refusal, or null when the read may go on.
+ *
+ * @spec openspec/changes/audit-trail-shipped-and-purpose-bound/specs/verwerkingsregister-api/spec.md
+ */
+ private function purposeRefusal(ObjectEntity $object): ?JSONResponse {
+ $schema = $this->access->schemaOf(object: $object);
+ $register = $this->registerOf(object: $object);
+ if ($schema === null || $register === null) {
+ return $this->access->notReadable();
+ }
+
+ try {
+ $this->purposeGuard->enforce(register: $register, schema: $schema);
+ } catch (PurposeRefusedException $refusal) {
+ return new JSONResponse(data: $refusal->toResponseBody(), statusCode: $refusal->getStatusCode());
+ }
+
+ return null;
+ }//end purposeRefusal()
+
+ /**
+ * The register an object belongs to, or null when it does not resolve.
+ *
+ * @param ObjectEntity $object The object.
+ *
+ * @return Register|null The register.
+ */
+ private function registerOf(ObjectEntity $object): ?Register {
+ $registerId = $object->getRegister();
+ if ($registerId === null || $registerId === '') {
+ return null;
+ }
+
+ try {
+ return $this->registerMapper->find($registerId, _rbac: false, _multitenancy: false);
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }//end registerOf()
+
/**
* The depth this request asks for.
*
diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php
index a9983c8c9c..9a7b8b425a 100644
--- a/lib/Controller/ObjectsController.php
+++ b/lib/Controller/ObjectsController.php
@@ -31,6 +31,8 @@
use DateTimeImmutable;
use OCA\OpenRegister\Controller\Trait\ResolvesRegisterAndSchemaTrait;
use OCA\OpenRegister\Db\AuditTrailMapper;
+use OCA\OpenRegister\Service\Audit\ObjectHistoryForReaders;
+use OCA\OpenRegister\Service\Audit\PurposeRefusedException;
use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Db\Register;
use OCA\OpenRegister\Db\RegisterMapper;
@@ -4782,6 +4784,14 @@ public function used(string $id, string $register, string $schema, ObjectService
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
*/
public function logs(string $id, string $register, string $schema, ObjectService $objectService): JSONResponse {
+ // Whoever may read the object reads its history, redacted like the
+ // audit-trails route: a non-admin is answered by ObjectHistoryForReaders
+ // (404 when the object GET would be, the doelbinding guard, and no
+ // `session`, `request`, `ipAddress` or property the caller may not see).
+ if ($this->isCurrentUserAdmin() === false) {
+ return $this->logsForReader(register: $register, schema: $schema, id: $id);
+ }
+
// Set the register and schema context first.
$objectService->setRegister(register: $register);
$objectService->setSchema(schema: $schema);
@@ -4870,6 +4880,51 @@ public function logs(string $id, string $register, string $schema, ObjectService
);
}//end logs()
+ /**
+ * One object's history for a non-admin, when they may read the object.
+ *
+ * @param string $register The register slug or identifier
+ * @param string $schema The schema slug or identifier
+ * @param string $id The object ID or UUID
+ *
+ * @return JSONResponse 200 with the redacted page, 404 when the object is not readable to this caller,
+ * or the doelbinding refusal.
+ *
+ * @spec openspec/specs/audit-trail-immutable/spec.md#requirement-the-history-of-one-object-is-readable-by-whoever-may-read-that-object
+ */
+ private function logsForReader(string $register, string $schema, string $id): JSONResponse {
+ $config = $this->getConfig(_register: $register, _schema: $schema);
+ $search = null;
+ if ($config['_search'] !== null) {
+ $search = (string)$config['_search'];
+ }
+
+ try {
+ $page = $this->container->get(ObjectHistoryForReaders::class)->page(
+ register: $register,
+ schema: $schema,
+ id: $id,
+ params: [
+ 'limit' => $config['limit'],
+ 'offset' => $config['offset'],
+ 'page' => $config['page'],
+ 'filters' => [],
+ 'sort' => [],
+ 'search' => $search,
+ ]
+ );
+ } catch (PurposeRefusedException $refusal) {
+ return new JSONResponse(data: $refusal->toResponseBody(), statusCode: $refusal->getStatusCode());
+ }
+
+ if ($page === null) {
+ // The answer the object GET gives a caller who may not read the object.
+ return new JSONResponse(data: ['message' => 'Object not found'], statusCode: 404);
+ }
+
+ return new JSONResponse(data: $page);
+ }//end logsForReader()
+
/**
* Lock an object
*
diff --git a/lib/Controller/OrganisationController.php b/lib/Controller/OrganisationController.php
index 2d70263c3b..001066476e 100644
--- a/lib/Controller/OrganisationController.php
+++ b/lib/Controller/OrganisationController.php
@@ -35,6 +35,7 @@
use OCA\OpenRegister\Db\OrganisationMapper;
use OCA\OpenRegister\Db\TenantUsageMapper;
use OCA\OpenRegister\Service\OrganisationService;
+use OCA\OpenRegister\Service\Rbac\GrantChangeAuditor;
use OCA\OpenRegister\Service\Rbac\OrganisationSpecialRights;
use OCA\OpenRegister\Service\TenantLifecycleService;
use OCP\AppFramework\Controller;
@@ -115,6 +116,13 @@ class OrganisationController extends Controller {
*/
private IGroupManager $groupManager;
+ /**
+ * Records a change of who holds the special right `grant_manage`.
+ *
+ * @var GrantChangeAuditor|null
+ */
+ private ?GrantChangeAuditor $grantAuditor;
+
/**
* OrganisationController constructor
*
@@ -127,6 +135,9 @@ class OrganisationController extends Controller {
* @param TenantUsageMapper $tenantUsageMapper Usage mapper
* @param IUserSession $userSession User session for authorization checks
* @param IGroupManager $groupManager Group manager for admin checks
+ * @param GrantChangeAuditor|null $grantAuditor Records a change of who holds the special right `grant_manage`. Null only in legacy fixtures.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; each dependency is named above.
*
* @spec openspec/specs/tenant-lifecycle/spec.md
*/
@@ -140,8 +151,10 @@ public function __construct(
TenantUsageMapper $tenantUsageMapper,
IUserSession $userSession,
IGroupManager $groupManager,
+ ?GrantChangeAuditor $grantAuditor = null,
) {
parent::__construct(appName: $appName, request: $request);
+ $this->grantAuditor = $grantAuditor;
$this->organisationService = $organisationService;
$this->organisationMapper = $organisationMapper;
$this->logger = $logger;
@@ -712,6 +725,7 @@ public function update(string $uuid): JSONResponse {
$organisation = $this->organisationMapper->findByUuid($uuid);
$data = $this->extractRequestData();
+ $grantManageBefore = $this->grantManageOf(organisation: $organisation);
// A member may edit the organisation; only its owner or an administrator
// may decide who manages the grants on its records.
@@ -744,12 +758,43 @@ public function update(string $uuid): JSONResponse {
return $parentUpdateResponse;
}
- return $this->saveAndReturnOrganisation(organisation: $organisation);
+ $response = $this->saveAndReturnOrganisation(organisation: $organisation);
+
+ // Who holds the right to decide who reads a record is the one change
+ // here that must be on the trail, whoever made it.
+ $grantManageAfter = $this->grantManageOf(organisation: $organisation);
+ if ($grantManageAfter !== $grantManageBefore) {
+ $this->grantAuditor?->recordSpecialRight(
+ organisationUuid: $uuid,
+ before: $grantManageBefore,
+ after: $grantManageAfter
+ );
+ }
+
+ return $response;
} catch (Exception $e) {
return $this->handleUpdateError(uuid: $uuid, exception: $e);
}//end try
}//end update()
+ /**
+ * The groups that hold the special right `grant_manage` on an organisation's records.
+ *
+ * @param object $organisation Organisation entity.
+ *
+ * @return array The stored list, empty when there is none.
+ *
+ * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ */
+ private function grantManageOf(object $organisation): array {
+ $list = ($organisation->getAuthorization()[OrganisationSpecialRights::GRANT_MANAGE] ?? []);
+ if (is_array($list) === false) {
+ return [];
+ }
+
+ return $list;
+ }//end grantManageOf()
+
/**
* Patch organisation details (alias for update)
*
diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php
index b49609331a..b27bf63636 100644
--- a/lib/Controller/SchemasController.php
+++ b/lib/Controller/SchemasController.php
@@ -1109,8 +1109,12 @@ public function update(int $id): JSONResponse {
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it
*/
private function assessDependentForms(Schema $existing, array $data): ?array {
+ if (isset($data['properties']) === false && isset($data['required']) === false) {
+ return null;
+ }
+
$check = $this->formDependentsCheck();
- if ($check === null || (isset($data['properties']) === false && isset($data['required']) === false)) {
+ if ($check === null) {
return null;
}
@@ -1129,12 +1133,21 @@ private function assessDependentForms(Schema $existing, array $data): ?array {
/**
* The dependent-form check, resolved lazily like the audit service.
*
+ * A check that cannot be built is logged at warning level: the save then
+ * goes ahead without judging the published forms, and that must leave a trace
+ * (an owning app's `refuse` setting is skipped with it).
+ *
* @return FormDependentsCheck|null The check, or null when it cannot be built.
*/
private function formDependentsCheck(): ?FormDependentsCheck {
try {
$check = $this->container->get(FormDependentsCheck::class);
- } catch (\Throwable) {
+ } catch (\Throwable $e) {
+ $this->logger->warning(
+ message: '[SchemasController] The dependent-form check could not be built; the schema save did not re-check the published forms',
+ context: ['error' => $e->getMessage()]
+ );
+
return null;
}
@@ -1142,6 +1155,11 @@ private function formDependentsCheck(): ?FormDependentsCheck {
return $check;
}
+ $this->logger->warning(
+ message: '[SchemasController] The dependent-form check could not be built; the schema save did not re-check the published forms',
+ context: ['error' => 'the container did not return a FormDependentsCheck']
+ );
+
return null;
}//end formDependentsCheck()
@@ -1279,6 +1297,13 @@ private function applyUpdate(int $id, array $data): JSONResponse {
// set to `refuse` blocks the save; otherwise the broken forms are
// unpublished after the save, and the response lists them.
$formAssessment = $this->assessDependentForms(existing: $existingSchema, data: $data);
+ if ($formAssessment !== null) {
+ // This request judges the forms itself (refusing or unpublishing
+ // after the write): the listener that covers every other schema
+ // writer must not do it a second time.
+ $this->formDependentsCheck()?->claim(schemaId: $id);
+ }
+
if ($formAssessment !== null && $formAssessment['refused'] === true) {
return new JSONResponse(
data: [
diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php
index c6b37cbd16..17e3a0d613 100644
--- a/lib/Db/EntityRelationMapper.php
+++ b/lib/Db/EntityRelationMapper.php
@@ -23,8 +23,10 @@
use DateTime;
use OCA\OpenRegister\Event\EntityRelationDecisionUpdatedEvent;
use OCA\OpenRegister\Exception\CustomValidationException;
+use OCA\OpenRegister\Exception\DecisionConflictException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\ICompositeExpression;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IDBConnection;
@@ -948,16 +950,60 @@ private function emitDecisionMetadataAuditEntry(
* `openregister_decisions` (decision 197); the relation only points at it.
* The audit trail of the verdict stays with `updateDecisionMetadata()`.
*
+ * With `$guarded`, the link is a compare-and-swap: it is written only while
+ * the stored link is still `$expectedUuid` (null: none), so two reviewers
+ * taking a first decision at once cannot both link theirs.
+ *
* @param EntityRelation $relation The relation.
* @param string $decisionUuid The decision's uuid.
+ * @param string|null $expectedUuid The link the caller read before deciding; only read when guarded.
+ * @param bool $guarded Whether to write only while the stored link is still `$expectedUuid`.
*
* @return EntityRelation The stored relation.
*
+ * @throws DecisionConflictException When guarded and the stored link changed in the meantime.
+ *
* @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-a-decision-is-its-own-object-req-dec-001
+ *
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The default keeps every unguarded caller unchanged; the guard is one
+ * extra statement, not a second behaviour.
*/
- public function linkDecision(EntityRelation $relation, string $decisionUuid): EntityRelation {
+ public function linkDecision(
+ EntityRelation $relation,
+ string $decisionUuid,
+ ?string $expectedUuid = null,
+ bool $guarded = false
+ ): EntityRelation {
+ if ($guarded === true) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update($this->getTableName())
+ ->set('decision_uuid', $qb->createNamedParameter($decisionUuid))
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($relation->getId(), IQueryBuilder::PARAM_INT)));
+ $qb->andWhere($this->storedLinkIs(qb: $qb, expectedUuid: $expectedUuid));
+
+ if ($qb->executeStatement() === 0) {
+ throw new DecisionConflictException(message: 'The relation was decided by somebody else in the meantime.');
+ }
+ }
+
$relation->setDecisionUuid($decisionUuid);
return $this->update(entity: $relation);
}//end linkDecision()
+
+ /**
+ * The condition "the stored decision link is still what the caller read".
+ *
+ * @param IQueryBuilder $qb The query builder the condition is for.
+ * @param string|null $expectedUuid The link the caller read; null or empty means none.
+ *
+ * @return ICompositeExpression|string The condition for `andWhere()`.
+ */
+ private function storedLinkIs(IQueryBuilder $qb, ?string $expectedUuid): ICompositeExpression|string {
+ if ($expectedUuid !== null && $expectedUuid !== '') {
+ return $qb->expr()->eq('decision_uuid', $qb->createNamedParameter($expectedUuid));
+ }
+
+ return $qb->expr()->orX($qb->expr()->isNull('decision_uuid'), $qb->expr()->eq('decision_uuid', $qb->createNamedParameter('')));
+ }//end storedLinkIs()
}//end class
diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php
index 2e802ec60a..4d5f739b69 100644
--- a/lib/Db/MagicMapper.php
+++ b/lib/Db/MagicMapper.php
@@ -555,6 +555,16 @@ private function getFullTableName(string $tableName): string {
* @return void
*/
private function initializeHandlers(): void {
+ // Built first: the RBAC handler asks it which metadata columns a
+ // degraded table still lacks, so the read predicate does not name a
+ // `_status` column the table could not be given.
+ $this->metadataColumnHealer = new MagicMetadataColumnHealer(
+ db: $this->db,
+ logger: $this->logger,
+ magicMapper: $this,
+ cache: $this->createMetadataColumnCache()
+ );
+
$this->rbacHandler = new MagicRbacHandler(
userSession: $this->userSession,
groupManager: $this->groupManager,
@@ -562,7 +572,8 @@ private function initializeHandlers(): void {
appConfig: $this->appConfig,
conditionMatcher: $this->container->get(\OCA\OpenRegister\Service\ConditionMatcher::class),
container: $this->container,
- logger: $this->logger
+ logger: $this->logger,
+ metadataColumns: $this->metadataColumnHealer
);
$this->organizationHandler = new MagicOrganizationHandler(
@@ -580,13 +591,6 @@ private function initializeHandlers(): void {
// Built before the table handler, which asks it on every first use
// of a table, and before the search handler, which asks it which
// metadata columns a degraded table still lacks.
- $this->metadataColumnHealer = new MagicMetadataColumnHealer(
- db: $this->db,
- logger: $this->logger,
- magicMapper: $this,
- cache: $this->createMetadataColumnCache()
- );
-
$this->tableHandler = new MagicTableHandler(
db: $this->db,
appConfig: $this->appConfig,
@@ -4686,7 +4690,25 @@ private function prepareObjectDataForTable(array $objectData, Register $register
$schemaProperties = $schema->getProperties();
if (is_array($schemaProperties) === true) {
+ $columnRoles = new MagicMetadataColumn();
foreach (array_keys($schemaProperties) as $propertyName) {
+ // A property whose column is a metadata column (`_owner`, `_authorization`,
+ // `_status`, ...) never writes it: the loop above stamped that column from
+ // server-side values, and the client's value must not replace them. The
+ // read side already treats such a column as metadata.
+ if (is_string($propertyName) === true && $columnRoles->shadowsMetadata(key: $propertyName) === true) {
+ $this->logger->warning(
+ message: '[MagicMapper] A schema property is named like a metadata column; its value is not written',
+ context: [
+ 'file' => __FILE__,
+ 'line' => __LINE__,
+ 'propertyName' => $propertyName,
+ 'schema' => $schema->getId(),
+ ]
+ );
+ continue;
+ }
+
// Use array_key_exists to distinguish between:.
// - Property exists with null value → include in prepared data (update DB to null).
// - Property doesn't exist at all → skip (don't change DB value).
@@ -6438,7 +6460,8 @@ public function findInRegisterSchemaTable(
schema: $schema,
_rbac: $_rbac,
_multitenancy: $_multitenancy,
- registerId: $register->getId()
+ registerId: $register->getId(),
+ tableName: $tableName
);
}
@@ -6795,7 +6818,8 @@ public function findAcrossAllMagicTables(
schema: $schema,
_rbac: $_rbac,
_multitenancy: $_multitenancy,
- registerId: $register?->getId()
+ registerId: $register?->getId(),
+ tableName: $tableName
);
$accessResult = $accessQb->executeQuery();
@@ -7671,7 +7695,8 @@ private function scopeDeletedScanToCaller(
schema: $schema,
_rbac: $_rbac,
_multitenancy: $_multitenancy,
- registerId: $table['registerId']
+ registerId: $table['registerId'],
+ tableName: self::TABLE_PREFIX . $table['registerId'] . '_' . $table['schemaId']
);
return true;
@@ -7926,16 +7951,23 @@ public function findAllInRegisterSchemaTable(
* the list and the raw query cannot drift apart. Columns are unqualified:
* the query must read the magic table without an alias.
*
- * @param Schema $schema The schema of the table being read.
- * @param string $action The action the rows are filtered for (default: 'read').
+ * @param Schema $schema The schema of the table being read.
+ * @param string $action The action the rows are filtered for (default: 'read').
+ * @param Register|null $register The register of the table, so a metadata column the table could not
+ * be given is left out of the predicate. Null keeps every predicate.
*
* @return string|null Null when no row restriction applies (an admin); otherwise
* a predicate safe to AND into a WHERE, `1 = 0` for deny-all.
*
* @spec openspec/specs/rbac-scopes/spec.md
*/
- public function rbacRowPredicateSql(Schema $schema, string $action = 'read'): ?string {
- return $this->rbacHandler->buildRbacRowPredicateSql(schema: $schema, action: $action);
+ public function rbacRowPredicateSql(Schema $schema, string $action = 'read', ?Register $register = null): ?string {
+ $tableName = null;
+ if ($register !== null) {
+ $tableName = $this->getTableNameForRegisterSchema(register: $register, schema: $schema);
+ }
+
+ return $this->rbacHandler->buildRbacRowPredicateSql(schema: $schema, action: $action, tableName: $tableName);
}//end rbacRowPredicateSql()
/**
@@ -10128,6 +10160,27 @@ public function findMultiple(array $ids): array {
* @spec openspec/specs/schema-driven-read-coercion/spec.md
*/
public function filterReadableUuids(int $registerId, int $schemaId, array $uuids): array {
+ return ($this->readableUuidsOrNull(registerId: $registerId, schemaId: $schemaId, uuids: $uuids) ?? []);
+ }//end filterReadableUuids()
+
+ /**
+ * The same filter as filterReadableUuids(), but a failure is told apart from "none".
+ *
+ * A caller that reports completeness (a tree that says which bound stopped
+ * it) must not read "read access could not be established" as "nothing is
+ * readable here": the first is an incomplete answer, the second is a
+ * complete one. Null means the check itself failed (and was logged);
+ * an empty array means it ran and the caller may read none of the uuids.
+ *
+ * @param int $registerId The register the table belongs to.
+ * @param int $schemaId The schema the table belongs to.
+ * @param array $uuids UUIDs to test, all expected in that table.
+ *
+ * @return array|null The readable subset, or null when read access could not be established.
+ *
+ * @spec openspec/specs/schema-driven-read-coercion/spec.md
+ */
+ public function readableUuidsOrNull(int $registerId, int $schemaId, array $uuids): ?array {
$uuids = array_values(array_unique(array_filter($uuids, fn ($uuid): bool => is_string($uuid) && $uuid !== '')));
if (empty($uuids) === true) {
return [];
@@ -10159,7 +10212,8 @@ public function filterReadableUuids(int $registerId, int $schemaId, array $uuids
schema: $schema,
_rbac: true,
_multitenancy: true,
- registerId: $registerId
+ registerId: $registerId,
+ tableName: $tableName
);
$result = $qb->executeQuery();
@@ -10183,11 +10237,11 @@ public function filterReadableUuids(int $registerId, int $schemaId, array $uuids
'error' => $e->getMessage(),
]
);
- return [];
+ return null;
}//end try
return $readable;
- }//end filterReadableUuids()
+ }//end readableUuidsOrNull()
/**
* Find all objects for a given schema.
diff --git a/lib/Db/MagicMapper/MagicBulkHandler.php b/lib/Db/MagicMapper/MagicBulkHandler.php
index 4f92739035..9cddd93dd9 100644
--- a/lib/Db/MagicMapper/MagicBulkHandler.php
+++ b/lib/Db/MagicMapper/MagicBulkHandler.php
@@ -214,7 +214,10 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe
// Properties can be at top level OR in 'object' key (structured format).
$propertySource = $object['object'] ?? $object;
foreach ($propertySource as $propertyName => $value) {
- if ($columnRoles->skippedOnWrite(key: $propertyName, schemaProperties: ($schema->getProperties() ?? [])) === true) {
+ // A declared property that would write a metadata column is skipped
+ // with the undeclared `_` keys and `@self`: the metadata columns were
+ // stamped above and a client value must not replace them.
+ if ($columnRoles->skippedOnWrite(key: (string)$propertyName, schemaProperties: ($schema->getProperties() ?? [])) === true) {
continue;
}
@@ -225,15 +228,10 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe
// 1. PostgreSQL rejects empty strings for JSON columns
// 2. Column types might differ from schema (e.g., table created with old schema)
// 3. Empty strings are semantically equivalent to NULL for most use cases.
- if ($value === '') {
- $value = null;
- }
+ $value = match ($value) { '' => null, default => $value };
// Convert complex values for database storage.
- $preparedObject[$columnName] = $value;
- if (is_array($value) === true || is_object($value) === true) {
- $preparedObject[$columnName] = json_encode($value);
- }
+ $preparedObject[$columnName] = match (true) { is_array($value), is_object($value) => json_encode($value), default => $value };
}//end foreach
$prepared[] = $preparedObject;
diff --git a/lib/Db/MagicMapper/MagicMetadataColumn.php b/lib/Db/MagicMapper/MagicMetadataColumn.php
index b66b7ba52b..8f43ecfb9c 100644
--- a/lib/Db/MagicMapper/MagicMetadataColumn.php
+++ b/lib/Db/MagicMapper/MagicMetadataColumn.php
@@ -73,6 +73,11 @@ public function field(string $column, array $columnToPropertyMap): ?string {
* Whether a key of an object being written is metadata or `@self`, not a property column.
*
* A key starting with the prefix is metadata unless the schema declares it as a property.
+ * A declared property whose column is a metadata column (`_owner`, `_authorization`,
+ * `_status`, and the spellings that sanitise to one, `_Owner` or `__owner_`) is skipped
+ * too: the writer stamps the metadata columns from server-side values, and a client
+ * value for a property of that name must never land in the same column. The read side
+ * applies the same rule in {@see self::field()}: the metadata column wins.
*
* @param string $key The object key.
* @param array $schemaProperties The schema's properties.
@@ -86,9 +91,51 @@ public function skippedOnWrite(string $key, array $schemaProperties): bool {
return true;
}
- return str_starts_with($key, self::PREFIX) === true && isset($schemaProperties[$key]) === false;
+ if (str_starts_with($key, self::PREFIX) === false) {
+ return false;
+ }
+
+ return isset($schemaProperties[$key]) === false || $this->shadowsMetadata(key: $key);
}//end skippedOnWrite()
+ /**
+ * Whether the column a property key is written to is a metadata column.
+ *
+ * @param string $key The property key.
+ *
+ * @return bool True when writing the property would write a metadata column.
+ *
+ * @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
+ */
+ public function shadowsMetadata(string $key): bool {
+ $column = $this->columnFor(key: $key);
+ if (str_starts_with($column, self::PREFIX) === false) {
+ return false;
+ }
+
+ return $this->attribute(field: substr($column, strlen(self::PREFIX))) !== null;
+ }//end shadowsMetadata()
+
+ /**
+ * The column a property key is written to, as the writers sanitise it.
+ *
+ * The same rule MagicMapper and MagicBulkHandler apply (camelCase to snake_case,
+ * anything else but letters, digits and underscores to an underscore, runs of
+ * underscores to one, trailing underscores dropped), so a key is judged by the
+ * column it would really write.
+ *
+ * @param string $key The property key.
+ *
+ * @return string The column name.
+ */
+ private function columnFor(string $key): string {
+ $name = strtolower((string)preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $key));
+ $name = (string)preg_replace('/[^a-z0-9_]/', '_', $name);
+ $name = (string)preg_replace('/_+/', '_', $name);
+
+ return rtrim($name, '_');
+ }//end columnFor()
+
/**
* The ObjectEntity attribute a metadata field sets, or null when it has none.
*
diff --git a/lib/Db/MagicMapper/MagicRbacHandler.php b/lib/Db/MagicMapper/MagicRbacHandler.php
index 5aeafe974d..175e8b2e54 100644
--- a/lib/Db/MagicMapper/MagicRbacHandler.php
+++ b/lib/Db/MagicMapper/MagicRbacHandler.php
@@ -130,6 +130,10 @@ class MagicRbacHandler {
* bundled together; nullable so adding them is not a fatal at existing
* construction sites.
* @param DenyEnforcementMode|null $denyEnforcementMode The staging switch; nullable for the same reason.
+ * @param MagicMetadataColumnHealer|null $metadataColumns Knows which metadata columns a table that could not be healed still lacks.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection; the optional
+ * collaborators are nullable so existing construction sites keep working.
*/
public function __construct(
private readonly IUserSession $userSession,
@@ -141,6 +145,7 @@ public function __construct(
private readonly LoggerInterface $logger,
private readonly ?RbacResolvers $resolvers = null,
private readonly ?DenyEnforcementMode $denyEnforcementMode = null,
+ private readonly ?MagicMetadataColumnHealer $metadataColumns = null,
) {
}//end __construct()
@@ -328,6 +333,7 @@ private function ownerAdmitConditionsSql(array $userGroups, ?string $userId, str
* @param string $action The action being filtered; a grant only counts when it carries that permission.
* @param Schema|null $schema The schema, so its drafts can be kept to their owners (decision 180).
* @param string $columnPrefix Table alias with its dot for the `_status` column, empty when unaliased.
+ * @param string|null $tableName The bare magic table; a `_status` column it lacks is left out of the draft predicate.
*
* @return string A SQL predicate true for rows this caller may reach.
*
@@ -341,6 +347,7 @@ private function reachableRowSqlFor(
string $action,
?Schema $schema = null,
string $columnPrefix = '',
+ ?string $tableName = null,
): string {
$reachable = $this->objectScope()->notPrivateOrGrantedSql(
authColumn: $columnName,
@@ -354,14 +361,34 @@ private function reachableRowSqlFor(
// object, unless the schema lets others see drafts. Added HERE, the one
// predicate both list emitters use, so the owner admits beside it keep
// the owner's own drafts in view on every path.
+ // A table that could not be given its `_status` column cannot hold a
+ // draft, and naming the column would fail the query for everyone but an
+ // administrator (REQ-MTH-002: such a table degrades quietly).
$drafts = new DraftStatusPolicy();
- if ($schema !== null && $drafts->draftsVisibleToOthers(schema: $schema) === false) {
+ if ($schema !== null && $drafts->draftsVisibleToOthers(schema: $schema) === false && $this->statusColumnMissing(tableName: $tableName) === false) {
$reachable = '(' . $reachable . ' AND ' . $drafts->notADraftSql(columnPrefix: $columnPrefix) . ')';
}
return $reachable;
}//end reachableRowSqlFor()
+ /**
+ * Whether a table is known to lack its `_status` column after an attempt to heal it.
+ *
+ * @param string|null $tableName Bare magic table name, null when unknown.
+ *
+ * @return bool True when the draft predicate must be left out for this table.
+ *
+ * @spec openspec/changes/magic-table-metadata-self-healing/specs/object-lifecycle/spec.md#requirement-a-table-that-cannot-be-healed-degrades-quietly-req-mth-002
+ */
+ private function statusColumnMissing(?string $tableName): bool {
+ if ($this->metadataColumns === null || $tableName === null || $tableName === '') {
+ return false;
+ }
+
+ return in_array('_status', $this->metadataColumns->missingFor(tableName: $tableName), true);
+ }//end statusColumnMissing()
+
/**
* The deny term for one list query, or `false` when nothing is visible.
*
@@ -504,6 +531,8 @@ private function denyFilterSqlFor(
* @param IQueryBuilder $qb Query builder to modify
* @param Schema $schema Schema with authorization configuration
* @param string $action CRUD action to check (default: 'read')
+ * @param string|null $tableName The bare magic table being read, so a `_status` column it could not be
+ * given is left out of the draft predicate. Null keeps the predicate.
*
* @return void
*
@@ -517,6 +546,7 @@ public function applyRbacFilters(
IQueryBuilder $qb,
Schema $schema,
string $action = 'read',
+ ?string $tableName = null,
): void {
$user = $this->userSession->getUser();
$userId = $user?->getUID();
@@ -594,7 +624,8 @@ public function applyRbacFilters(
userId: $userId,
action: $action,
schema: $schema,
- columnPrefix: 't.'
+ columnPrefix: 't.',
+ tableName: $tableName
)
);
@@ -1632,6 +1663,7 @@ private function deniesHere(
* @param Schema $schema The schema of the rows the subquery reads.
* @param string $alias The alias those rows carry in the subquery.
* @param string $action The CRUD action being filtered.
+ * @param string|null $tableName The bare magic table of the aliased rows; a `_status` column it lacks is left out of the draft predicate.
*
* @return string A predicate, always non-empty, safe to AND into a WHERE.
*
@@ -1639,7 +1671,7 @@ private function deniesHere(
*
* @spec openspec/changes/query-related-schema-rows/specs/zoeken-filteren/spec.md
*/
- public function buildRbacPredicateForAlias(Schema $schema, string $alias, string $action = 'read'): string {
+ public function buildRbacPredicateForAlias(Schema $schema, string $alias, string $action = 'read', ?string $tableName = null): string {
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $alias) !== 1) {
throw new InvalidArgumentException(
sprintf('\'%s\' is not a table alias an access predicate may be built for.', $alias)
@@ -1649,7 +1681,8 @@ public function buildRbacPredicateForAlias(Schema $schema, string $alias, string
$result = $this->buildRbacConditionsSql(
schema: $schema,
action: $action,
- columnPrefix: $alias . '.'
+ columnPrefix: $alias . '.',
+ tableName: $tableName
);
if (($result['bypass'] ?? false) === true) {
@@ -1677,6 +1710,7 @@ public function buildRbacPredicateForAlias(Schema $schema, string $alias, string
*
* @param Schema $schema The schema of the table being read.
* @param string $action The action the rows are filtered for (default: 'read').
+ * @param string|null $tableName The bare magic table being read; a `_status` column it lacks is left out of the draft predicate.
*
* @return string|null Null when no row restriction applies (an admin); otherwise
* a non-empty predicate safe to AND into a WHERE, `1 = 0`
@@ -1685,8 +1719,8 @@ public function buildRbacPredicateForAlias(Schema $schema, string $alias, string
*
* @spec openspec/specs/rbac-scopes/spec.md
*/
- public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read'): ?string {
- $result = $this->buildRbacConditionsSql(schema: $schema, action: $action);
+ public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read', ?string $tableName = null): ?string {
+ $result = $this->buildRbacConditionsSql(schema: $schema, action: $action, tableName: $tableName);
if (($result['bypass'] ?? false) === true) {
return null;
}
@@ -1711,6 +1745,7 @@ public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read'
* UNION member. Unqualified, a column name still parses inside a
* subquery and binds to the innermost FROM, which is silently wrong
* the moment the related table does not carry it.
+ * @param string|null $tableName The bare magic table; a `_status` column it lacks is left out of the draft predicate.
*
* @return array{bypass: bool, conditions: string[]} Result with:
* - 'bypass' => true means no filtering needed (user has full access)
@@ -1718,7 +1753,7 @@ public function buildRbacRowPredicateSql(Schema $schema, string $action = 'read'
*
* @SuppressWarnings(PHPMD.NPathComplexity) Mirrors applyRbacFilters dispatch; carries the system-owner carve-out (openregister#1617).
*/
- public function buildRbacConditionsSql(Schema $schema, string $action = 'read', string $columnPrefix = ''): array {
+ public function buildRbacConditionsSql(Schema $schema, string $action = 'read', string $columnPrefix = '', ?string $tableName = null): array {
$user = $this->userSession->getUser();
$userId = $user?->getUID();
@@ -1765,7 +1800,8 @@ public function buildRbacConditionsSql(Schema $schema, string $action = 'read',
userId: $userId,
userGroups: $userGroups,
columnPrefix: $columnPrefix,
- schema: $schema
+ schema: $schema,
+ tableName: $tableName
);
if ($terms === null) {
return ['bypass' => false, 'conditions' => []];
@@ -1840,6 +1876,7 @@ public function buildRbacConditionsSql(Schema $schema, string $action = 'read',
* subquery and binds to the innermost FROM, which is silently wrong
* the moment the related table does not carry it.
* @param Schema|null $schema The schema, so its drafts can be kept to their owners (decision 180).
+ * @param string|null $tableName The bare magic table; a `_status` column it lacks is left out of the draft predicate.
*
* @return array{denyTerm: string|null, notPrivate: string, ownerAdmits: string[]}|null The term pieces, or null
* when the deny term
@@ -1851,7 +1888,8 @@ private function buildRbacSqlTerms(
?string $userId,
array $userGroups,
string $columnPrefix = '',
- ?Schema $schema = null
+ ?Schema $schema = null,
+ ?string $tableName = null
): ?array {
$denyTerm = $this->denyFilterSqlFor(
authorization: $authorization,
@@ -1872,7 +1910,8 @@ private function buildRbacSqlTerms(
userId: $userId,
action: $action,
schema: $schema,
- columnPrefix: $columnPrefix
+ columnPrefix: $columnPrefix,
+ tableName: $tableName
);
$ownerAdmits = $this->ownerAdmitConditionsSql(
diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php
index 92eb3619c9..da081402f5 100644
--- a/lib/Db/MagicMapper/MagicSearchHandler.php
+++ b/lib/Db/MagicMapper/MagicSearchHandler.php
@@ -444,6 +444,8 @@ public function searchObjects(
* @param bool $_rbac Whether to apply RBAC filtering.
* @param bool $_multitenancy Whether to apply multitenancy filtering.
* @param int|null $registerId The register of the table being read, for shared master data.
+ * @param string|null $tableName The bare magic table being read, so a `_status` column it could not be
+ * given is left out of the draft predicate. Null keeps the predicate.
*
* @return void
*
@@ -455,6 +457,7 @@ public function applyAccessControlToQuery(
bool $_rbac = true,
bool $_multitenancy = true,
?int $registerId = null,
+ ?string $tableName = null,
): void {
// Mirror the list path: public schemas bypass multitenancy by default.
// No explicit multitenancy request exists on the single-object read path,
@@ -471,7 +474,8 @@ public function applyAccessControlToQuery(
_rbac: $_rbac,
_multitenancy: $resolvedMultitenancy,
multitenancyExplicit: false,
- registerId: $registerId
+ registerId: $registerId,
+ tableName: $tableName
);
}//end applyAccessControlToQuery()
@@ -534,7 +538,8 @@ public function buildFilteredQuery(array $query, Schema $schema, string $tableNa
_rbac: $_rbac,
_multitenancy: $_multitenancy,
multitenancyExplicit: $multitenancyExplicit,
- registerId: ($registerId ?? $this->registerIdFromQuery(query: $query))
+ registerId: ($registerId ?? $this->registerIdFromQuery(query: $query)),
+ tableName: $tableName
);
// Apply metadata, object-field and ID filters.
@@ -805,7 +810,8 @@ public function buildWhereConditionsSql(
schema: $schema,
rbac: $_rbac,
connection: $connection,
- registerId: $registerId
+ registerId: $registerId,
+ tableName: $tableName
)
);
@@ -1055,6 +1061,7 @@ private function draftConditionSql(array $query, string $columnPrefix, ?string $
* @param mixed $rbac The raw `_rbac` flag as the caller wrote it.
* @param mixed $connection The connection, for value quoting.
* @param integer|null $registerId The register whose table this is built for.
+ * @param string|null $tableName The bare magic table, so a missing `_status` column is left out of the draft predicate.
*
* @return string[] The conditions, without leading AND.
*
@@ -1065,7 +1072,8 @@ private function boundaryConditionsSql(
Schema $schema,
mixed $rbac,
mixed $connection,
- ?int $registerId
+ ?int $registerId,
+ ?string $tableName = null
): array {
$conditions = [];
@@ -1100,7 +1108,7 @@ private function boundaryConditionsSql(
}
if ($rbac === true) {
- $rbacCondition = $this->buildRbacConditionSql(schema: $schema);
+ $rbacCondition = $this->buildRbacConditionSql(schema: $schema, tableName: $tableName);
if ($rbacCondition !== null) {
$conditions[] = $rbacCondition;
}
@@ -1369,12 +1377,13 @@ private function buildOrganizationConditionSql(
/**
* Build the RBAC SQL condition
*
- * @param Schema $schema Schema for RBAC rules
+ * @param Schema $schema Schema for RBAC rules
+ * @param string|null $tableName The bare magic table, so a missing `_status` column is left out of the draft predicate
*
* @return string|null SQL condition or null if no RBAC filtering needed
*/
- private function buildRbacConditionSql(Schema $schema): ?string {
- $rbacResult = $this->rbacHandler->buildRbacConditionsSql(schema: $schema, action: 'read');
+ private function buildRbacConditionSql(Schema $schema, ?string $tableName = null): ?string {
+ $rbacResult = $this->rbacHandler->buildRbacConditionsSql(schema: $schema, action: 'read', tableName: $tableName);
if ($rbacResult['bypass'] === false) {
// User doesn't have unconditional access.
@@ -2485,6 +2494,7 @@ private function registerIdFromQuery(array $query): ?int {
* @param bool $_multitenancy Whether multitenancy filtering is enabled
* @param bool $multitenancyExplicit Whether multitenancy was explicitly requested
* @param int|null $registerId The register of the table being read, for shared master data
+ * @param string|null $tableName The bare magic table being read, so a missing `_status` column is left out of the draft predicate
*
* @return void
*
@@ -2497,6 +2507,7 @@ private function applyAccessControlFilters(
bool $_multitenancy,
bool $multitenancyExplicit,
?int $registerId = null,
+ ?string $tableName = null,
): void {
// Whether the organisation boundary applies is decided in ONE place
// (multitenancyApplies), because the string-SQL path renders the same
@@ -2538,7 +2549,8 @@ private function applyAccessControlFilters(
$this->rbacHandler->applyRbacFilters(
qb: $qb,
schema: $schema,
- action: 'read'
+ action: 'read',
+ tableName: $tableName
);
}
}//end applyAccessControlFilters()
diff --git a/lib/Db/TaskMapper.php b/lib/Db/TaskMapper.php
index 29350484a9..94ddd79bc9 100644
--- a/lib/Db/TaskMapper.php
+++ b/lib/Db/TaskMapper.php
@@ -581,15 +581,19 @@ private function applyInboxPredicates(IQueryBuilder $qb, TaskInboxCriteria $crit
* reason {@see applyExternalExclusion()} gives: that is the case looking at
* its own work, and it must show what waits and on what.
*
+ * The portal seam calls it without criteria: a resident's task list is the
+ * only inbox an external task has, so a task the case holds back waits out
+ * of sight there too, always.
+ *
* @param IQueryBuilder $qb The query under construction.
- * @param TaskInboxCriteria $criteria Carries the anchors and `includeBlocked`.
+ * @param TaskInboxCriteria|null $criteria Carries the anchors and `includeBlocked`; null for the portal seam.
*
* @return void
*
* @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight
*/
- private function applyBlockedExclusion(IQueryBuilder $qb, TaskInboxCriteria $criteria): void {
- if ($criteria->includeBlocked === true || $criteria->objectUuid !== null || $criteria->runUuid !== null) {
+ private function applyBlockedExclusion(IQueryBuilder $qb, ?TaskInboxCriteria $criteria = null): void {
+ if ($criteria !== null && ($criteria->includeBlocked === true || $criteria->objectUuid !== null || $criteria->runUuid !== null)) {
return;
}
@@ -695,7 +699,7 @@ public function countOpenExternalForParty(string $partyReference): int {
}//end countOpenExternalForParty()
/**
- * The portal seam's predicate: this party's, external, still open.
+ * The portal seam's predicate: this party's, external, still open, and not held back by an open blocker.
*
* @param IQueryBuilder $qb The query under construction.
* @param string $partyReference The stored party reference.
@@ -708,6 +712,7 @@ private function applyPartyPredicates(IQueryBuilder $qb, string $partyReference)
$qb->andWhere($qb->expr()->eq('assignee', $qb->createNamedParameter($partyReference)));
$qb->andWhere($qb->expr()->eq('performer_type', $qb->createNamedParameter(Task::PERFORMER_EXTERNAL)));
$qb->andWhere($qb->expr()->eq('is_terminal', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
+ $this->applyBlockedExclusion(qb: $qb);
}//end applyPartyPredicates()
/**
diff --git a/lib/Event/DecisionChangeRequestedEvent.php b/lib/Event/DecisionChangeRequestedEvent.php
index a2b2aa7210..60f8239956 100644
--- a/lib/Event/DecisionChangeRequestedEvent.php
+++ b/lib/Event/DecisionChangeRequestedEvent.php
@@ -51,6 +51,13 @@ class DecisionChangeRequestedEvent extends Event {
*/
private ?string $reason = null;
+ /**
+ * Whether a listener has refused: a refusal is sticky.
+ *
+ * @var bool
+ */
+ private bool $refused = false;
+
/**
* Constructor.
*
@@ -120,13 +127,20 @@ public function getSubject(): array {
}//end getSubject()
/**
- * Let the change through.
+ * Let the change through, unless a listener has already refused it.
+ *
+ * A refusal is a veto and stays: with two apps listening, the order they
+ * are registered in must not turn one app's refusal into the other's allow.
*
* @return void
*
* @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002
*/
public function allow(): void {
+ if ($this->refused === true) {
+ return;
+ }
+
$this->allowed = true;
$this->reason = null;
}//end allow()
@@ -141,6 +155,7 @@ public function allow(): void {
* @spec openspec/changes/reviewer-owns-their-decisions/specs/decisions/spec.md#requirement-the-app-that-owns-the-subject-may-answer-who-supervises-req-dec-002
*/
public function refuse(string $reason): void {
+ $this->refused = true;
$this->allowed = false;
$this->reason = $reason;
}//end refuse()
diff --git a/lib/Exception/DecisionConflictException.php b/lib/Exception/DecisionConflictException.php
new file mode 100644
index 0000000000..a9a905877c
--- /dev/null
+++ b/lib/Exception/DecisionConflictException.php
@@ -0,0 +1,31 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Exception
+ * @package OCA\OpenRegister\Exception
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Exception;
+
+use RuntimeException;
+
+/**
+ * Thrown by the guarded decision link when somebody else decided first.
+ */
+class DecisionConflictException extends RuntimeException {
+}//end class
diff --git a/lib/Exception/DraftAlreadyLeftException.php b/lib/Exception/DraftAlreadyLeftException.php
new file mode 100644
index 0000000000..9cfca232ea
--- /dev/null
+++ b/lib/Exception/DraftAlreadyLeftException.php
@@ -0,0 +1,36 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Exception
+ * @package OCA\OpenRegister\Exception
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Exception;
+
+/**
+ * A promotion was allowed for an object that has already left draft.
+ *
+ * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft
+ */
+class DraftAlreadyLeftException extends ValidationException {
+}//end class
diff --git a/lib/Exception/ProviderUnavailableException.php b/lib/Exception/ProviderUnavailableException.php
index 1bce3ba46d..bdee77c818 100644
--- a/lib/Exception/ProviderUnavailableException.php
+++ b/lib/Exception/ProviderUnavailableException.php
@@ -41,6 +41,7 @@ class ProviderUnavailableException extends \RuntimeException {
public const CAUSE_OPENCONNECTOR_SOURCE_MISSING = 'openconnector-source-missing';
public const CAUSE_UPSTREAM_SERVICE_DOWN = 'upstream-service-down';
public const CAUSE_PROVIDER_AUTH = 'provider-auth';
+ public const CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES = 'connector-cannot-withhold-bodies';
/**
* The cause classification.
@@ -69,7 +70,8 @@ public function __construct(string $message, string $cause, ?\Throwable $previou
* Return the cause classification.
*
* @return string One of openconnector-down |
- * openconnector-source-missing | upstream-service-down.
+ * openconnector-source-missing | upstream-service-down |
+ * provider-auth | connector-cannot-withhold-bodies.
*
* @spec openspec/changes/retrofit-2026-05-24-b-exception-all/tasks.md#task-2
*/
diff --git a/lib/Listener/DecisionTableValidationListener.php b/lib/Listener/DecisionTableValidationListener.php
index 84f78844c6..f6baa8833f 100644
--- a/lib/Listener/DecisionTableValidationListener.php
+++ b/lib/Listener/DecisionTableValidationListener.php
@@ -33,6 +33,7 @@
use OCA\OpenRegister\Service\Dmn\DecisionTableStore;
use OCA\OpenRegister\Service\Dmn\DecisionTableValidator;
use OCA\OpenRegister\Service\ObjectService;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use Throwable;
@@ -102,7 +103,17 @@ public function handle(Event $event): void {
* @return void
*/
private function validate(ObjectCreatingEvent|ObjectUpdatingEvent $event, ObjectEntity $object): void {
- if ($this->isDecisionTable(object: $object) === false) {
+ $isTable = $this->isDecisionTable(object: $object);
+ if ($isTable === false) {
+ return;
+ }
+
+ if ($isTable === null) {
+ $this->refuse(
+ event: $event,
+ message: 'The object could not be checked against the decision-table rules, so it was not saved: its schema or register could not be read.'
+ );
+
return;
}
@@ -112,32 +123,47 @@ private function validate(ObjectCreatingEvent|ObjectUpdatingEvent $event, Object
}
$problems = $this->validator->validate(table: $data);
- $clash = $this->keyTakenBy(key: trim((string)($data['key'] ?? '')), uuid: (string)$object->getUuid());
- if ($clash !== null) {
- $problems[] = sprintf('the key "%s" is already used by decision table %s', (string)$data['key'], $clash);
+ $key = trim((string)($data['key'] ?? ''));
+ $clash = $this->keyTakenBy(key: $key, uuid: (string)$object->getUuid());
+ if ($clash === null) {
+ $problems[] = sprintf('the key "%s" could not be checked against the tables already stored', $key);
+ } elseif ($clash !== '') {
+ $problems[] = sprintf('the key "%s" is already used by decision table %s', $key, $clash);
}
if ($problems === []) {
return;
}
- $event->setErrors(
- [
- 'code' => self::ERROR_CODE,
- 'message' => 'The decision table cannot be saved: ' . implode('; ', $problems) . '.',
- ]
- );
- $event->stopPropagation();
+ $this->refuse(event: $event, message: 'The decision table cannot be saved: ' . implode('; ', $problems) . '.');
}//end validate()
+ /**
+ * Refuse the save.
+ *
+ * @param ObjectCreatingEvent|ObjectUpdatingEvent $event The save event.
+ * @param string $message Why.
+ *
+ * @return void
+ */
+ private function refuse(ObjectCreatingEvent|ObjectUpdatingEvent $event, string $message): void {
+ $event->setErrors(['code' => self::ERROR_CODE, 'message' => $message]);
+ $event->stopPropagation();
+ }//end refuse()
+
/**
* Whether the object is a row of the decision-table schema.
*
+ * A schema or register that does not exist means the object is not a
+ * decision table. One that could not be read (a database error) means it
+ * cannot be told, and that answer is null: the guard must not wave a table
+ * through because it could not look.
+ *
* @param ObjectEntity $object The object being saved.
*
- * @return bool True for a decision table.
+ * @return bool|null True for a decision table, false for any other object, null when it cannot be told.
*/
- private function isDecisionTable(ObjectEntity $object): bool {
+ private function isDecisionTable(ObjectEntity $object): ?bool {
$reference = $object->getSchema();
if ($reference === null || $reference === '') {
return false;
@@ -145,8 +171,10 @@ private function isDecisionTable(ObjectEntity $object): bool {
try {
$schema = $this->schemas->find($reference, _rbac: false, _multitenancy: false);
- } catch (Throwable $unresolved) {
+ } catch (DoesNotExistException $unresolved) {
return false;
+ } catch (Throwable $unreadable) {
+ return null;
}
if ($schema->getSlug() !== DecisionTableStore::SCHEMA) {
@@ -164,9 +192,9 @@ private function isDecisionTable(ObjectEntity $object): bool {
*
* @param ObjectEntity $object The object being saved.
*
- * @return bool True when its register is `decision-tables`.
+ * @return bool|null True when its register is `decision-tables`, null when the register could not be read.
*/
- private function inDecisionTableRegister(ObjectEntity $object): bool {
+ private function inDecisionTableRegister(ObjectEntity $object): ?bool {
$reference = $object->getRegister();
if ($reference === null || $reference === '') {
return false;
@@ -174,8 +202,10 @@ private function inDecisionTableRegister(ObjectEntity $object): bool {
try {
$register = $this->registers->find($reference, _rbac: false, _multitenancy: false);
- } catch (Throwable $unresolved) {
+ } catch (DoesNotExistException $unresolved) {
return false;
+ } catch (Throwable $unreadable) {
+ return null;
}
return $register->getSlug() === DecisionTableStore::REGISTER;
@@ -187,11 +217,11 @@ private function inDecisionTableRegister(ObjectEntity $object): bool {
* @param string $key The key being saved.
* @param string $uuid The uuid of the row being saved.
*
- * @return string|null The other table's uuid, or null when the key is free.
+ * @return string|null The other table's uuid, '' when the key is free, null when the search could not run.
*/
private function keyTakenBy(string $key, string $uuid): ?string {
if ($key === '') {
- return null;
+ return '';
}
try {
@@ -214,6 +244,6 @@ private function keyTakenBy(string $key, string $uuid): ?string {
}
}
- return null;
+ return '';
}//end keyTakenBy()
}//end class
diff --git a/lib/Listener/FormDestinationSchemaListener.php b/lib/Listener/FormDestinationSchemaListener.php
new file mode 100644
index 0000000000..0391ba439e
--- /dev/null
+++ b/lib/Listener/FormDestinationSchemaListener.php
@@ -0,0 +1,153 @@
+
+ *
+ * @category Listener
+ * @package OCA\OpenRegister\Listener
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Listener;
+
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Event\SchemaUpdatedEvent;
+use OCA\OpenRegister\Service\Form\FormDependentsCheck;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Unpublishes the forms a schema write broke, for every writer but the schema API.
+ *
+ * @template-implements IEventListener
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it
+ */
+class FormDestinationSchemaListener implements IEventListener {
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container Resolves the check once a definition changed.
+ * @param LoggerInterface $logger Records a check that could not run and a refusal that could not be honoured.
+ */
+ public function __construct(
+ private readonly ContainerInterface $container,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Judge the dependent forms of a schema whose definition changed.
+ *
+ * @param Event $event The event.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it
+ */
+ public function handle(Event $event): void {
+ if ($event instanceof SchemaUpdatedEvent === false) {
+ return;
+ }
+
+ $schema = $event->getNewSchema();
+ if ($this->definitionChanged(new: $schema, old: $event->getOldSchema()) === false) {
+ return;
+ }
+
+ try {
+ $check = $this->container->get(FormDependentsCheck::class);
+ if ($check instanceof FormDependentsCheck === false || $check->isClaimed(schemaId: (int)$schema->getId()) === true) {
+ return;
+ }
+
+ $assessment = $check->assess(schema: $schema);
+ if ($assessment['affected'] === []) {
+ return;
+ }
+
+ if ($assessment['refused'] === true) {
+ $this->logger->warning(
+ message: '[FormDestinationSchemaListener] An owning app set formDestinationBreak to refuse, but this schema write '
+ . 'has no response to refuse; the broken forms are unpublished instead',
+ context: ['schema' => $assessment['schema'], 'forms' => count($assessment['affected'])]
+ );
+ $assessment['refused'] = false;
+ }
+
+ $check->apply(assessment: $assessment);
+ } catch (Throwable $e) {
+ // The write has happened. A check that fails must not undo it, but it must leave a trace.
+ $this->logger->warning(
+ message: '[FormDestinationSchemaListener] The published forms of a changed schema could not be re-checked',
+ context: ['schema' => (int)$schema->getId(), 'error' => $e->getMessage()]
+ );
+ }
+ }//end handle()
+
+ /**
+ * Whether a write changed what a form is judged against: the properties or the required list.
+ *
+ * Compared by content, not by key order, so a re-import that only reorders
+ * the keys judges no form.
+ *
+ * @param Schema $new The schema after the write.
+ * @param Schema $old The schema before it.
+ *
+ * @return bool True when properties or required differ.
+ */
+ private function definitionChanged(Schema $new, Schema $old): bool {
+ return $this->canonical(value: ($new->getProperties() ?? [])) !== $this->canonical(value: ($old->getProperties() ?? []))
+ || $this->canonical(value: ($new->getRequired() ?? [])) !== $this->canonical(value: ($old->getRequired() ?? []));
+ }//end definitionChanged()
+
+ /**
+ * A value with every map's keys in the same order, recursively.
+ *
+ * @param array $value The value.
+ *
+ * @return array The value, maps sorted by key.
+ */
+ private function canonical(array $value): array {
+ foreach ($value as $key => $item) {
+ if (is_array($item) === true) {
+ $value[$key] = $this->canonical(value: $item);
+ }
+ }
+
+ ksort($value);
+
+ return $value;
+ }//end canonical()
+}//end class
diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php
index 6546168726..8ae8750b21 100644
--- a/lib/Listener/FrozenNodeWriteListener.php
+++ b/lib/Listener/FrozenNodeWriteListener.php
@@ -36,8 +36,10 @@
use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
use OCP\Files\Events\Node\BeforeNodeWrittenEvent;
+use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\HintException;
+use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Throwable;
@@ -50,12 +52,18 @@
* and those never pass the controller. This listener is that door.
*
* How a node finds its object: object folders live in the `openregister`
- * account's home and are named by the object's UUID. A node outside that
- * home is ignored at once (one string compare, so the rest of the instance
- * pays nothing). Inside it, the nearest ancestor named like a UUID is the
+ * account's home and are named by the object's UUID. A node in that home is
+ * checked by its path. A node reached through a share (the way a person gets
+ * at an object's files) has the path of the sharee's own tree, so it is
+ * resolved by file id instead: a node whose owner is not the `openregister`
+ * account is ignored at once (one owner compare, so the rest of the instance
+ * pays nothing); one that is owned by it is looked up by id in the account's
+ * home, and a node that does not exist yet stands for its nearest existing
+ * ancestor. In the home, the nearest ancestor named like a UUID is the
* candidate owner, and it counts only when that object's own `folder` is
* that ancestor's id, the same proof `FolderManagementHandler::getObjectForFile()`
- * asks for.
+ * asks for. The mapper that resolves the object is fetched from the container
+ * only at that point, so a node outside every object folder never builds it.
*
* Fail closed: when the lookup of a candidate owner FAILS (a database error,
* anything but "no such object"), the write is refused and logged with the
@@ -89,14 +97,19 @@ class FrozenNodeWriteListener implements IEventListener {
/**
* Constructor.
*
- * @param FileWriteGuard $guard Decides whether the owning object refuses file writes.
- * @param MagicMapper $objects Resolves the owning object by its UUID.
- * @param LoggerInterface $logger Records a refusal on an unresolvable owner.
+ * @param FileWriteGuard $guard Decides whether the owning object refuses file writes.
+ * @param ContainerInterface $container Resolves the MagicMapper (the owning object by its UUID) once a node
+ * has proved to be in an object folder: this listener sits on the four
+ * node events of every file operation on the instance, and the mapper's
+ * graph is wide.
+ * @param LoggerInterface $logger Records a refusal on an unresolvable owner.
+ * @param IRootFolder $rootFolder Finds a shared node's twin in the `openregister` home by file id.
*/
public function __construct(
private readonly FileWriteGuard $guard,
- private readonly MagicMapper $objects,
+ private readonly ContainerInterface $container,
private readonly LoggerInterface $logger,
+ private readonly IRootFolder $rootFolder,
) {
}//end __construct()
@@ -169,17 +182,59 @@ private function refusalFor(Node $node): ?ObjectStateWriteException {
}
$home = '/' . FileOwnershipHandler::APP_USER . '/files/';
+ $anchor = $node;
+ $includeSelf = false;
if (str_starts_with($path, $home) === false) {
- return null;
+ try {
+ $resolved = $this->homeNodeOf(node: $node, home: $home);
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ '[FrozenNodeWriteListener] A file write was refused: node {node} is owned by the app account but could not be placed in its home.',
+ ['node' => $this->nodeId(node: $node), 'path' => $path, 'exception' => $e]
+ );
+ return new ObjectStateWriteException(
+ message: 'Cannot write to this file: the object that owns it could not be checked. Try again later.',
+ previous: $e
+ );
+ }
+
+ if ($resolved === null) {
+ return null;
+ }
+
+ $anchor = $resolved['node'];
+ $includeSelf = ($resolved['exact'] === false);
}
- $candidate = $this->candidateOwnerFolder(node: $node);
+ $candidate = $this->candidateOwnerFolder(node: $anchor, includeSelf: $includeSelf);
if ($candidate === null) {
return null;
}
+ $object = $this->owningObjectOf(node: $node, path: $path, candidate: $candidate);
+ if ($object instanceof ObjectStateWriteException) {
+ return $object;
+ }
+
+ if ($object === null) {
+ return null;
+ }
+
+ return $this->guard->refusalFor(object: $object);
+ }//end refusalFor()
+
+ /**
+ * The object that owns a file's folder, or a refusal when it cannot be resolved.
+ *
+ * @param Node $node The node about to change.
+ * @param string $path The node's path, for the log.
+ * @param array{uuid: string, folderId: int|string} $candidate The ancestor folder named like a UUID.
+ *
+ * @return ObjectEntity|ObjectStateWriteException|null The owning object; the refusal when the lookup failed; null when the folder owns nothing.
+ */
+ private function owningObjectOf(Node $node, string $path, array $candidate): ObjectEntity|ObjectStateWriteException|null {
try {
- $found = $this->objects->findAcrossAllSources(
+ $found = $this->container->get(MagicMapper::class)->findAcrossAllSources(
identifier: $candidate['uuid'],
_rbac: false,
_multitenancy: false
@@ -206,21 +261,80 @@ private function refusalFor(Node $node): ?ObjectStateWriteException {
return null;
}
- return $this->guard->refusalFor(object: $object);
- }//end refusalFor()
+ return $object;
+ }//end owningObjectOf()
+
+ /**
+ * The node in the `openregister` home that a node outside it stands for, found by file id.
+ *
+ * A node reached through a share has the path of the acting user's tree,
+ * but is the same file as the one in the home. Only a node owned by the
+ * `openregister` account can be one, so everything else answers null
+ * without a lookup. A node that does not exist yet (a write or a rename
+ * target) has no id: its nearest existing ancestor stands for it, and
+ * `exact` says that it is not the node itself.
+ *
+ * @param Node $node The node about to change.
+ * @param string $home The `openregister` home prefix, with trailing slash.
+ *
+ * @return array{node: Node, exact: bool}|null The twin in the home, or null when this node is not one of the app's files.
+ *
+ * @throws Throwable When the home cannot be searched (the caller refuses).
+ */
+ private function homeNodeOf(Node $node, string $home): ?array {
+ $current = $node;
+ $exact = true;
+ for ($depth = 0; $depth < self::MAX_DEPTH; $depth++) {
+ try {
+ $owner = $current->getOwner();
+ $identifier = $current->getId();
+ } catch (Throwable $e) {
+ // Not stored yet: the nearest existing ancestor decides.
+ try {
+ $current = $current->getParent();
+ } catch (Throwable $parentError) {
+ return null;
+ }
+
+ $exact = false;
+ continue;
+ }
+
+ if ($owner === null || $owner->getUID() !== FileOwnershipHandler::APP_USER) {
+ return null;
+ }
+
+ $twins = $this->rootFolder->getUserFolder(FileOwnershipHandler::APP_USER)->getById($identifier);
+ foreach ($twins as $twin) {
+ if (str_starts_with($twin->getPath(), $home) === true) {
+ return ['node' => $twin, 'exact' => $exact];
+ }
+ }
+
+ return null;
+ }
+
+ return null;
+ }//end homeNodeOf()
/**
* The nearest ancestor of a node named like a UUID, with its folder id.
*
- * @param Node $node The node.
+ * @param Node $node The node.
+ * @param bool $includeSelf Whether the node itself may be the owner folder (a node standing for a not-yet-existing child).
*
* @return array{uuid: string, folderId: int|string}|null The candidate, or null when no ancestor qualifies.
+ *
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag names where the walk starts (the node or its parent), not a second behaviour.
*/
- private function candidateOwnerFolder(Node $node): ?array {
+ private function candidateOwnerFolder(Node $node, bool $includeSelf = false): ?array {
$current = $node;
for ($depth = 0; $depth < self::MAX_DEPTH; $depth++) {
try {
- $current = $current->getParent();
+ if ($depth > 0 || $includeSelf === false) {
+ $current = $current->getParent();
+ }
+
$name = $current->getName();
} catch (Throwable $e) {
return null;
diff --git a/lib/Mcp/AttributeToolScanner.php b/lib/Mcp/AttributeToolScanner.php
index 802071801f..ead9e5064d 100644
--- a/lib/Mcp/AttributeToolScanner.php
+++ b/lib/Mcp/AttributeToolScanner.php
@@ -66,6 +66,17 @@
* @package OCA\OpenRegister\Mcp
*/
final class AttributeToolScanner {
+
+ /**
+ * The keys the MCP specification reserves for tool hints in a descriptor's
+ * `annotations` object. A client may auto-approve on them, so a free-form
+ * mark must not carry one: OpenRegister's own hint and grant checks decide
+ * what a tool is, and a mark is only a claim.
+ *
+ * @var list
+ */
+ public const RESERVED_ANNOTATION_KEYS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint', 'title'];
+
/**
* Scan every declared scannable class for one app and return every
* attributed-method descriptor found across all of them.
@@ -372,7 +383,11 @@ private function unrecognisedDeclaration(McpTool $attribute): ?array {
$badAnnotation = $this->malformedAnnotation(annotations: $attribute->annotations);
if ($badAnnotation !== null) {
- return ['key' => 'annotations', 'value' => $badAnnotation, 'allowed' => ['non-empty string key => scalar value']];
+ return [
+ 'key' => 'annotations',
+ 'value' => $badAnnotation,
+ 'allowed' => ['non-empty string key => scalar value, other than the MCP hint keys ' . implode(', ', self::RESERVED_ANNOTATION_KEYS)],
+ ];
}
return null;
@@ -380,12 +395,18 @@ private function unrecognisedDeclaration(McpTool $attribute): ?array {
/**
* The first entry of a declared annotations map that is not a non-empty
- * string key holding a scalar, or null when the whole map is well formed.
+ * string key holding a scalar, or that uses one of the MCP specification's
+ * reserved hint keys, or null when the whole map is well formed.
*
* The map is free-form on purpose (OpenRegister reads none of it), but
* not shapeless: a nested array or an integer key would reach a consumer
* as something it cannot compare against a mark, and the tool would
- * silently never qualify. Refusing it at scan time names the mistake.
+ * silently never qualify. Refusing it at scan time names the mistake. The
+ * map is forwarded to external MCP clients under `annotations`, the key the
+ * specification uses for tool hints, so a hint key in it (`readOnlyHint` on
+ * a write tool) would be trusted by a client that auto-approves read-only
+ * tools while OpenRegister's own checks say otherwise; those keys are
+ * refused too, compared case-insensitively.
*
* @param array $annotations The declared map.
*
@@ -396,7 +417,7 @@ private function unrecognisedDeclaration(McpTool $attribute): ?array {
*/
private function malformedAnnotation(array $annotations): ?string {
foreach ($annotations as $key => $value) {
- if (is_string($key) === false || trim($key) === '' || is_scalar($value) === false) {
+ if (is_string($key) === false || trim($key) === '' || is_scalar($value) === false || $this->isReservedAnnotationKey(key: $key) === true) {
return (string) $key;
}
}
@@ -404,6 +425,20 @@ private function malformedAnnotation(array $annotations): ?string {
return null;
}//end malformedAnnotation()
+ /**
+ * Whether a key is one the MCP specification reserves for tool hints.
+ *
+ * @param string $key The annotation key.
+ *
+ * @return bool
+ *
+ * @spec openspec/specs/ai-mcp/spec.md
+ * (Requirement: REQ-ATTR-007 — A curated attribute tool forwards free-form annotations)
+ */
+ private function isReservedAnnotationKey(string $key): bool {
+ return in_array(strtolower(trim($key)), array_map('strtolower', self::RESERVED_ANNOTATION_KEYS), true);
+ }//end isReservedAnnotationKey()
+
/**
* Infer the `inputSchema` from the method's parameter type hints and
* docblock `@param` tags. Parameters without a default value are
diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php
index 2328542739..1e04d8ccbc 100644
--- a/lib/Notification/Notifier.php
+++ b/lib/Notification/Notifier.php
@@ -618,16 +618,14 @@ private function prepareCredentialRelinkNeeded(INotification $notification, $l):
$l->t(
'The connection to %s is no longer accepted by the provider, so anything using it has '
. 'stopped. This cannot be repaired automatically: open Connected accounts in your '
- . 'personal settings and reconnect the account.',
+ . 'user settings and reconnect the account.',
[$provider]
)
);
+ // The connected accounts live in OpenRegister's own user settings dialog.
$notification->setLink(
- $this->urlGenerator->linkToRouteAbsolute(
- 'settings.PersonalSettings.index',
- ['section' => 'additional']
- )
+ $this->urlGenerator->linkToRouteAbsolute('openregister.dashboard.page') . '#user-settings'
);
$notification->setIcon(
diff --git a/lib/Repair/ImportDecisionTableRegister.php b/lib/Repair/ImportDecisionTableRegister.php
index 0eb67a50b2..3d3da71dfc 100644
--- a/lib/Repair/ImportDecisionTableRegister.php
+++ b/lib/Repair/ImportDecisionTableRegister.php
@@ -30,6 +30,7 @@
namespace OCA\OpenRegister\Repair;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ConfigurationService;
use OCP\App\IAppManager;
use OCP\Migration\IOutput;
@@ -68,7 +69,7 @@ class ImportDecisionTableRegister implements IRepairStep {
*
* @var string
*/
- private const CONFIG_APP_ID = 'openregister.decision-tables';
+ private const CONFIG_APP_ID = DescriptorConfigIds::DECISION_TABLES;
/**
* Constructor.
diff --git a/lib/Repair/ImportDismissedPairRegister.php b/lib/Repair/ImportDismissedPairRegister.php
index 427644c21e..dfd652f2c0 100644
--- a/lib/Repair/ImportDismissedPairRegister.php
+++ b/lib/Repair/ImportDismissedPairRegister.php
@@ -39,6 +39,7 @@
namespace OCA\OpenRegister\Repair;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ConfigurationService;
use OCP\App\IAppManager;
use OCP\Migration\IOutput;
@@ -75,7 +76,7 @@ class ImportDismissedPairRegister implements IRepairStep {
*
* @var string
*/
- private const CONFIG_APP_ID = 'openregister.dismissed-pair';
+ private const CONFIG_APP_ID = DescriptorConfigIds::DISMISSED_PAIR;
/**
* Constructor.
diff --git a/lib/Repair/ImportDsarRegisters.php b/lib/Repair/ImportDsarRegisters.php
index b08b5f50fe..bf56b6d445 100644
--- a/lib/Repair/ImportDsarRegisters.php
+++ b/lib/Repair/ImportDsarRegisters.php
@@ -38,6 +38,7 @@
namespace OCA\OpenRegister\Repair;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ConfigurationService;
use OCP\App\IAppManager;
use OCP\Migration\IOutput;
@@ -70,11 +71,11 @@ class ImportDsarRegisters implements IRepairStep {
*/
private const REGISTERS = [
'/lib/Settings/data_subject_request_register.json' => [
- 'appId' => 'openregister.dsar-cases',
+ 'appId' => DescriptorConfigIds::DSAR_CASES,
'version' => '1.2.0',
],
'/lib/Settings/dsar_policy_pack_register.json' => [
- 'appId' => 'openregister.dsar-policy-packs',
+ 'appId' => DescriptorConfigIds::DSAR_POLICY_PACKS,
'version' => '1.1.0',
],
];
diff --git a/lib/Repair/ImportEdepotTransferRegister.php b/lib/Repair/ImportEdepotTransferRegister.php
index 9e4d188436..5ea8b9f746 100644
--- a/lib/Repair/ImportEdepotTransferRegister.php
+++ b/lib/Repair/ImportEdepotTransferRegister.php
@@ -32,6 +32,7 @@
namespace OCA\OpenRegister\Repair;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ConfigurationService;
use OCP\App\IAppManager;
use OCP\Migration\IOutput;
@@ -69,7 +70,7 @@ class ImportEdepotTransferRegister implements IRepairStep {
*
* @var string
*/
- private const CONFIG_APP_ID = 'openregister.edepot-transfers';
+ private const CONFIG_APP_ID = DescriptorConfigIds::EDEPOT_TRANSFERS;
/**
* Constructor.
diff --git a/lib/Repair/ImportMergeOperationRegister.php b/lib/Repair/ImportMergeOperationRegister.php
index 41bcb71060..cee8eac142 100644
--- a/lib/Repair/ImportMergeOperationRegister.php
+++ b/lib/Repair/ImportMergeOperationRegister.php
@@ -46,6 +46,7 @@
namespace OCA\OpenRegister\Repair;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ConfigurationService;
use OCP\App\IAppManager;
use OCP\Migration\IOutput;
@@ -82,7 +83,7 @@ class ImportMergeOperationRegister implements IRepairStep {
*
* @var string
*/
- private const CONFIG_APP_ID = 'openregister.merge-operation';
+ private const CONFIG_APP_ID = DescriptorConfigIds::MERGE_OPERATION;
/**
* Constructor.
diff --git a/lib/Repair/ImportTrustConfigurationRegister.php b/lib/Repair/ImportTrustConfigurationRegister.php
index 1931e70fc2..dc25e920fe 100644
--- a/lib/Repair/ImportTrustConfigurationRegister.php
+++ b/lib/Repair/ImportTrustConfigurationRegister.php
@@ -38,6 +38,7 @@
namespace OCA\OpenRegister\Repair;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ConfigurationService;
use OCP\App\IAppManager;
use OCP\Migration\IOutput;
@@ -72,7 +73,7 @@ class ImportTrustConfigurationRegister implements IRepairStep {
*
* @var string
*/
- private const CONFIG_APP_ID = 'openregister.trust-configuration';
+ private const CONFIG_APP_ID = DescriptorConfigIds::TRUST_CONFIGURATION;
/**
* Constructor.
diff --git a/lib/Repair/SeedVocabularyRegister.php b/lib/Repair/SeedVocabularyRegister.php
index ae45c23bd5..c1cd465bc8 100644
--- a/lib/Repair/SeedVocabularyRegister.php
+++ b/lib/Repair/SeedVocabularyRegister.php
@@ -35,6 +35,7 @@
namespace OCA\OpenRegister\Repair;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ConfigurationService;
use OCA\OpenRegister\Service\SystemOperationContext;
use OCA\OpenRegister\Service\VocabularyImportService;
@@ -77,7 +78,7 @@ class SeedVocabularyRegister implements IRepairStep {
*
* @var string
*/
- private const CONFIG_APP_ID = 'openregister.vocabulary';
+ private const CONFIG_APP_ID = DescriptorConfigIds::VOCABULARY;
/**
* App-relative paths of the bundled TOOI SKOS/JSON-LD seed fixtures.
diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php
index 21b181592b..6942765819 100644
--- a/lib/Service/Aggregation/AggregationRunner.php
+++ b/lib/Service/Aggregation/AggregationRunner.php
@@ -50,6 +50,7 @@
use OCA\OpenRegister\Exception\RegisterNotFoundException;
use OCA\OpenRegister\Exception\SchemaNotInRegisterException;
use OCA\OpenRegister\Service\LanguageService;
+use OCA\OpenRegister\Service\Object\DraftListLens;
use OCA\OpenRegister\Service\Object\PermissionHandler;
use OCA\OpenRegister\Service\Object\TranslationHandler;
use OCA\OpenRegister\Service\ObjectSource\DbalObjectSourceProvider;
@@ -2556,6 +2557,34 @@ private function tryNativeAggregation(
$whereParts[] = "(_archived IS NULL OR _archived = 'null' OR _archived = '')";
}
+ // Draft lens. A list hides drafts by default (and, for a schema with
+ // `draftsListed: false`, the caller's own as well); a count over the
+ // same rows has to agree with it, or a tile reads higher than the page
+ // under it. The clause is DraftListLens's own, for the mode a list with
+ // no query would use, and is left out of a table that could not be
+ // given its `_status` column (REQ-MTH-002).
+ $draftLens = new DraftListLens();
+ $statusMissing = in_array('_status', $this->magicMapper->getMetadataColumnHealer()->missingFor(tableName: $tableName), true);
+ if ($statusMissing === false) {
+ $draftOwner = $this->userSession->getUser()?->getUID();
+ $ownerBinding = null;
+ if ($draftOwner !== null && $draftOwner !== '') {
+ $ownerBinding = '?';
+ }
+
+ $draftPredicate = $draftLens->sql(
+ mode: $draftLens->modeFor(query: [], schema: $schema),
+ columnPrefix: '',
+ owner: $ownerBinding
+ );
+ if ($draftPredicate !== null) {
+ $whereParts[] = $draftPredicate;
+ if ($ownerBinding !== null && str_contains($draftPredicate, '?') === true) {
+ $bindings[] = $draftOwner;
+ }
+ }
+ }
+
// SECURITY: the organisation boundary. This fast path bypasses
// MagicMapper entirely, so it must reproduce the SAME rule the list
// path applies — no wider, and no narrower.
@@ -2640,7 +2669,7 @@ private function tryNativeAggregation(
// restriction (an admin); internal callers that already decided who
// may see the figure pass `rowRbac: false`.
if ($rowRbac === true) {
- $rowPredicate = $this->magicMapper->rbacRowPredicateSql(schema: $schema, action: 'read');
+ $rowPredicate = $this->magicMapper->rbacRowPredicateSql(schema: $schema, action: 'read', register: $register);
if ($rowPredicate !== null) {
$whereParts[] = $rowPredicate;
}
diff --git a/lib/Service/Audit/HistoryEntryRedactor.php b/lib/Service/Audit/HistoryEntryRedactor.php
index d2d51bc884..cebc2d4932 100644
--- a/lib/Service/Audit/HistoryEntryRedactor.php
+++ b/lib/Service/Audit/HistoryEntryRedactor.php
@@ -161,6 +161,10 @@ private function filterChanged(array $changed, Schema $schema, array $hidden): a
$changed = $this->stripNestedWriteOnly(changed: $changed, schema: $schema);
+ if (is_array($changed['cascadeContext'] ?? null) === true) {
+ $changed['cascadeContext'] = $this->filterCascade(cascade: $changed['cascadeContext'], hidden: $hidden);
+ }
+
if (is_array($changed['@self'] ?? null) === false) {
return $changed;
}
@@ -178,6 +182,32 @@ private function filterChanged(array $changed, Schema $schema, array $hidden): a
return $changed;
}//end filterChanged()
+ /**
+ * The cascade context of a referential-integrity entry, as a reader is given it.
+ *
+ * The context names the object whose delete cascaded into this one
+ * (`triggerObject`, `triggerSchema`), and that object may be one the reader
+ * may not read: the object read path would not tell them it exists. They are
+ * withheld for every reader; the kind of cascade stays, and the property that
+ * carried the reference stays unless the reader may not see that property.
+ *
+ * @param array $cascade The `cascadeContext` of one entry.
+ * @param array $hidden Withheld property names.
+ *
+ * @return array The context without the other object's identity.
+ */
+ private function filterCascade(array $cascade, array $hidden): array {
+ $cascade['triggerObject'] = null;
+ $cascade['triggerSchema'] = null;
+
+ $property = ($cascade['property'] ?? null);
+ if (is_string($property) === true && in_array(explode('.', $property)[0], $hidden, true) === true) {
+ $cascade['property'] = null;
+ }
+
+ return $cascade;
+ }//end filterCascade()
+
/**
* Strip declared nested write-only paths from the old and new values.
*
diff --git a/lib/Service/Calculation/RateSchedule.php b/lib/Service/Calculation/RateSchedule.php
index 17b573cd2c..efd30a76c8 100644
--- a/lib/Service/Calculation/RateSchedule.php
+++ b/lib/Service/Calculation/RateSchedule.php
@@ -134,11 +134,14 @@ public function accrued(int|float $quantity, array $bands, int|float|null $cap):
* @spec openspec/changes/graduated-rate-calculation/specs/computed-fields/spec.md
*/
public function rateAt(int|float $unit, array $bands): int|float|null {
+ // The schedule is checked whatever the unit is: a malformed one must raise an evaluation error and never be read as
+ // an amount, and `graduated` already refuses it at any quantity.
+ $checked = $this->normalise(bands: $bands);
if ($unit <= 0) {
return null;
}
- foreach ($this->normalise(bands: $bands) as $band) {
+ foreach ($checked as $band) {
if ($band['upTo'] === null || $unit <= $band['upTo']) {
return $band['rate'];
}
diff --git a/lib/Service/Case/CasePlanEnsurer.php b/lib/Service/Case/CasePlanEnsurer.php
index da7472448f..85fcd19e41 100644
--- a/lib/Service/Case/CasePlanEnsurer.php
+++ b/lib/Service/Case/CasePlanEnsurer.php
@@ -44,6 +44,9 @@
/**
* Ensures plan items with recorded states, idempotently.
*
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One verb whose steps are each a small check (states, history, anchor,
+ * insert, post-commit arming); the total sums them, no single method is complex.
+ *
* @spec openspec/specs/flow-cases/spec.md#requirement-plan-items-can-be-ensured-convergently-with-their-recorded-states
*/
class CasePlanEnsurer {
@@ -56,6 +59,8 @@ class CasePlanEnsurer {
* @param CasePlanDefinition $definitions The one definition validator.
* @param CasePlanTransitions $transitions The one lifecycle table.
* @param IDBConnection $db Holds the transaction.
+ * @param CasePlanItemTimers|null $timers Arms the due and expiry timers of an item ensured as active: no transition
+ * event is dispatched for it, so the listener that arms them never hears of it.
*/
public function __construct(
private readonly CaseItemMapper $items,
@@ -63,6 +68,7 @@ public function __construct(
private readonly CasePlanDefinition $definitions,
private readonly CasePlanTransitions $transitions,
private readonly IDBConnection $db,
+ private readonly ?CasePlanItemTimers $timers = null,
) {
}//end __construct()
@@ -135,13 +141,49 @@ public function ensure(
throw $failure;
}
+ $rows = $this->items->findByObject(objectUuid: $objectUuid);
+ $this->armTimers(rows: $rows, created: $created, actor: $actor);
+
return [
'created' => $created,
'existing' => $kept,
- 'items' => array_map(static fn (CaseItem $row): array => $row->jsonSerialize(), $this->items->findByObject(objectUuid: $objectUuid)),
+ 'items' => array_map(static fn (CaseItem $row): array => $row->jsonSerialize(), $rows),
];
}//end ensure()
+ /**
+ * Arm the deadlines of the items this call created as active.
+ *
+ * An item inserted in a state it carries goes through no state machine, so
+ * no `CaseItemTransitionedEvent` reaches the timer listener, and an
+ * item that is active with a `dueAt` or `expiresAt` (a statutory one
+ * above all) would never get its timer. Done after the commit, like the
+ * listener: a refused arm is logged and, for a statutory expiry, marked
+ * for the re-arm pass, and never undoes the ensure. An item that already
+ * existed is not touched: its timers were armed when it became active.
+ *
+ * @param array $rows Every row of the object, after the commit.
+ * @param array $created The keys this call created.
+ * @param string $actor The ensuring identity.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+ private function armTimers(array $rows, array $created, string $actor): void {
+ if ($this->timers === null) {
+ return;
+ }
+
+ $entered = array_filter(
+ $rows,
+ static fn (CaseItem $row): bool => $row->getState() === CaseItem::STATE_ACTIVE && in_array((string)$row->getItemKey(), $created, true)
+ );
+ foreach ($entered as $row) {
+ $this->timers->onEntered(item: $row, actor: $actor);
+ }
+ }//end armTimers()
+
/**
* Every node's state must be one its type can reach from `available`.
*
diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php
index 34ce0f3b86..73522436aa 100644
--- a/lib/Service/Case/CasePlanItemTimers.php
+++ b/lib/Service/Case/CasePlanItemTimers.php
@@ -10,7 +10,11 @@
* core's own rule for an enforcing outcome. Reaching a terminal state
* cancels the item's open timers. The core decides WHEN; this class only
* decides what a deadline means for an item. Nothing here ever blocks a
- * transition: a refused arm or cancel is logged.
+ * transition: a refused arm or cancel is logged. A refused arm of a statutory
+ * (`wettelijk`) expiry is also marked ({@see UnarmedTimerMarkers}), because an
+ * item that is active past a legal term with nothing to end it is not a log
+ * line to move on from: {@see CasePlanTimerRearm} arms it again, or enforces
+ * the expiry when the term has passed meanwhile.
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -63,15 +67,28 @@ class CasePlanItemTimers {
*/
private const ACTOR = 'case-plan';
+ /**
+ * What {@see rearmStatutoryExpiry()} did.
+ */
+ public const REARM_ARMED = 'armed';
+
+ public const REARM_OVERDUE = 'overdue';
+
+ public const REARM_NONE = 'none';
+
+ public const REARM_FAILED = 'failed';
+
/**
* Constructor.
*
* @param FlowTimerService $timers The one clock.
* @param LoggerInterface $logger Failure reporting.
+ * @param UnarmedTimerMarkers|null $markers Where an item whose statutory expiry could not be armed is remembered.
*/
public function __construct(
private readonly FlowTimerService $timers,
private readonly LoggerInterface $logger,
+ private readonly ?UnarmedTimerMarkers $markers = null,
) {
}//end __construct()
@@ -104,6 +121,40 @@ public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $no
return $armed;
}//end onEntered()
+ /**
+ * Arm the statutory expiry of an item that is active without one, again.
+ *
+ * Called by {@see CasePlanTimerRearm} for an item the first arm failed
+ * for. Nothing is armed for an item that has no statutory expiry, and
+ * nothing for one whose term has already passed: that one is reported as
+ * overdue for the caller to enforce.
+ *
+ * @param CaseItem $item The item.
+ * @param DateTimeInterface $now The clock.
+ * @param string|null $actor The arming identity.
+ *
+ * @return string One of the REARM_* constants.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+ public function rearmStatutoryExpiry(CaseItem $item, DateTimeInterface $now, ?string $actor = null): string {
+ $expires = $item->getExpiresAt();
+ if ($expires === null || $this->legalEffect(item: $item, purpose: FlowTimer::PURPOSE_EXPIRY) !== FlowTimer::LEGAL_WETTELIJK) {
+ return self::REARM_NONE;
+ }
+
+ $moment = DateTimeImmutable::createFromInterface($now);
+ if ($expires->getTimestamp() <= $moment->getTimestamp()) {
+ return self::REARM_OVERDUE;
+ }
+
+ if ($this->arm(item: $item, purpose: FlowTimer::PURPOSE_EXPIRY, deadline: $expires, actor: $actor, now: $moment) === true) {
+ return self::REARM_ARMED;
+ }
+
+ return self::REARM_FAILED;
+ }//end rearmStatutoryExpiry()
+
/**
* Cancel the open timers of an item that reached a terminal state.
*
@@ -215,6 +266,35 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin
return true;
} catch (Throwable $failure) {
+ if ($config['legalEffect'] === FlowTimer::LEGAL_WETTELIJK) {
+ // The transition is already committed and the caller swallows an
+ // exception raised from here (CasePlanStateMachine::flushEvents()),
+ // so this cannot fail it. What it can do is say loudly that the item
+ // is active without the timer that enforces its statutory expiry, and
+ // leave a durable marker the re-arm job acts on.
+ $this->mark(item: $item);
+ $this->logger->error(
+ sprintf(
+ '[CasePlanItemTimers] Plan item %s of case object %s is active WITHOUT its statutory (%s) %s timer: '
+ . 'the term will not run until the re-arm pass arms it. %s',
+ (string)$item->getItemKey(),
+ (string)$item->getObjectUuid(),
+ FlowTimer::LEGAL_WETTELIJK,
+ $purpose,
+ $failure->getMessage()
+ ),
+ [
+ 'item' => $item->getUuid(),
+ 'case' => $item->getObjectUuid(),
+ 'legalEffect' => FlowTimer::LEGAL_WETTELIJK,
+ 'purpose' => $purpose,
+ 'exception' => $failure,
+ ]
+ );
+
+ return false;
+ }
+
$this->logger->warning(
'[CasePlanItemTimers] A plan-item timer was refused; the transition stands: ' . $failure->getMessage(),
['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure]
@@ -224,6 +304,24 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin
}
}//end arm()
+ /**
+ * Remember that an item lacks its statutory expiry timer.
+ *
+ * @param CaseItem $item The item.
+ *
+ * @return void
+ */
+ private function mark(CaseItem $item): void {
+ if ($this->markers === null || $this->markers->add(itemUuid: (string)$item->getUuid()) === true) {
+ return;
+ }
+
+ $this->logger->error(
+ '[CasePlanItemTimers] The marker of an item without its statutory timer could not be stored; nothing will arm it again.',
+ ['item' => $item->getUuid(), 'case' => $item->getObjectUuid()]
+ );
+ }//end mark()
+
/**
* A due date advises; an expiry enforces only on a statutory item and
* otherwise counts as a service norm.
diff --git a/lib/Service/Case/CasePlanService.php b/lib/Service/Case/CasePlanService.php
index 356a2acf21..4808f4c583 100644
--- a/lib/Service/Case/CasePlanService.php
+++ b/lib/Service/Case/CasePlanService.php
@@ -68,6 +68,11 @@
*/
class CasePlanService {
+ /**
+ * What an in-process caller's actor id starts with (`system:`): a label for the audit, not a Nextcloud account.
+ */
+ public const SYSTEM_ACTOR_PREFIX = 'system:';
+
/**
* Constructor.
*
@@ -84,6 +89,8 @@ class CasePlanService {
* @param LoggerInterface $logger Failure reporting.
* @param CasePlanEnsurer|null $ensurer Convergent bring-over; built from the
* same collaborators when not injected.
+ * @param CasePlanItemTimers|null $timers Arms the deadlines of items the ensurer creates as active, when it is
+ * built here.
*/
public function __construct(
private readonly CaseItemMapper $items,
@@ -98,6 +105,7 @@ public function __construct(
private readonly IDBConnection $db,
private readonly LoggerInterface $logger,
private ?CasePlanEnsurer $ensurer = null,
+ private readonly ?CasePlanItemTimers $timers = null,
) {
}//end __construct()
@@ -252,7 +260,8 @@ public function ensureItems(string $objectUuid, ?int $registerId, ?int $schemaId
audits: $this->audits,
definitions: $this->definitions,
transitions: new CasePlanTransitions(),
- db: $this->db
+ db: $this->db,
+ timers: $this->timers
);
}
@@ -769,7 +778,7 @@ private function systemActor(string $app): string {
);
}
- return 'system:' . $app;
+ return self::SYSTEM_ACTOR_PREFIX . $app;
}//end systemActor()
/**
diff --git a/lib/Service/Case/CasePlanTimerRearm.php b/lib/Service/Case/CasePlanTimerRearm.php
new file mode 100644
index 0000000000..77aec77136
--- /dev/null
+++ b/lib/Service/Case/CasePlanTimerRearm.php
@@ -0,0 +1,170 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\Case
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Case;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use OCA\OpenRegister\Db\CaseItem;
+use OCA\OpenRegister\Db\CaseItemMapper;
+use OCA\OpenRegister\Db\FlowTimer;
+use OCA\OpenRegister\Db\FlowTimerMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * The re-arm pass over the marked plan items.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+class CasePlanTimerRearm {
+
+ /**
+ * The cause reference an enforced expiry records instead of a timer uuid.
+ */
+ public const CAUSE_REF = 'rearm-pass';
+
+ /**
+ * Constructor.
+ *
+ * @param UnarmedTimerMarkers $markers The items that lack their statutory timer.
+ * @param CaseItemMapper $items The plan-item table.
+ * @param FlowTimerMapper $timerRows Reads the timers an item already has.
+ * @param CasePlanItemTimers $timers Arms the timer.
+ * @param CasePlanService $plans Enforces an expiry that passed meanwhile.
+ * @param LoggerInterface $logger Failure reporting.
+ */
+ public function __construct(
+ private readonly UnarmedTimerMarkers $markers,
+ private readonly CaseItemMapper $items,
+ private readonly FlowTimerMapper $timerRows,
+ private readonly CasePlanItemTimers $timers,
+ private readonly CasePlanService $plans,
+ private readonly LoggerInterface $logger,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * Run one pass.
+ *
+ * @param DateTimeInterface|null $now The clock; null is the real clock.
+ *
+ * @return array{armed: int, enforced: int, dropped: int, failed: int} Work performed.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+ public function run(?DateTimeInterface $now = null): array {
+ $moment = DateTimeImmutable::createFromInterface($now ?? new DateTimeImmutable());
+ $result = ['armed' => 0, 'enforced' => 0, 'dropped' => 0, 'failed' => 0];
+ foreach (array_keys($this->markers->all()) as $uuid) {
+ try {
+ $outcome = $this->rearm(itemUuid: (string)$uuid, now: $moment);
+ } catch (Throwable $failure) {
+ $outcome = 'failed';
+ $this->logger->error(
+ '[CasePlanTimerRearm] Plan item ' . $uuid . ' still lacks its statutory expiry timer: ' . $failure->getMessage(),
+ ['item' => $uuid, 'exception' => $failure]
+ );
+ }
+
+ $result[$outcome]++;
+ }
+
+ return $result;
+ }//end run()
+
+ /**
+ * Settle one marked item.
+ *
+ * @param string $itemUuid The item.
+ * @param DateTimeImmutable $now The clock.
+ *
+ * @return string `armed`, `enforced`, `dropped` or `failed`.
+ */
+ private function rearm(string $itemUuid, DateTimeImmutable $now): string {
+ try {
+ $item = $this->items->findByUuid(uuid: $itemUuid);
+ } catch (DoesNotExistException) {
+ $this->markers->remove(itemUuid: $itemUuid);
+
+ return 'dropped';
+ }
+
+ if ($item->getState() !== CaseItem::STATE_ACTIVE || $this->hasOpenExpiry(item: $item) === true) {
+ $this->markers->remove(itemUuid: $itemUuid);
+
+ return 'dropped';
+ }
+
+ $outcome = $this->timers->rearmStatutoryExpiry(item: $item, now: $now);
+ if ($outcome === CasePlanItemTimers::REARM_FAILED) {
+ return 'failed';
+ }
+
+ if ($outcome === CasePlanItemTimers::REARM_OVERDUE) {
+ // The term ran out while nothing was watching it: end the item as the fired timer would have.
+ $this->plans->onTimerExpired(itemUuid: $itemUuid, timerUuid: self::CAUSE_REF);
+ $this->markers->remove(itemUuid: $itemUuid);
+
+ return 'enforced';
+ }
+
+ $this->markers->remove(itemUuid: $itemUuid);
+ if ($outcome === CasePlanItemTimers::REARM_ARMED) {
+ return 'armed';
+ }
+
+ return 'dropped';
+ }//end rearm()
+
+ /**
+ * Whether the item already has an expiry timer that is still running or suspended.
+ *
+ * @param CaseItem $item The item.
+ *
+ * @return bool
+ */
+ private function hasOpenExpiry(CaseItem $item): bool {
+ $open = $this->timerRows->findBySubject(
+ subjectType: CasePlanItemTimers::SUBJECT_TYPE,
+ subjectUuid: (string)$item->getUuid(),
+ states: [FlowTimer::STATE_ARMED, FlowTimer::STATE_SUSPENDED]
+ );
+ foreach ($open as $timer) {
+ if ($timer->getPurpose() === FlowTimer::PURPOSE_EXPIRY) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end hasOpenExpiry()
+}//end class
diff --git a/lib/Service/Case/CaseRealisationService.php b/lib/Service/Case/CaseRealisationService.php
index b9332700a4..2a172efd46 100644
--- a/lib/Service/Case/CaseRealisationService.php
+++ b/lib/Service/Case/CaseRealisationService.php
@@ -100,6 +100,14 @@ public function realise(CaseItem $item, string $actor): void {
$flow = trim((string)($item->getPlanSettings()['flows'][(string)$item->getItemKey()] ?? ''));
$runnable = in_array($item->getPlanItemType(), [CaseItem::TYPE_STAGE, CaseItem::TYPE_PROCESS_TASK], true);
if ($runnable === true && $flow !== '') {
+ // An in-process caller's `system:` is a label for the audit, not an account. As the run's user it
+ // would become the run's `runAs`, and every scoped step would be refused ("not a user account"), so the
+ // run is queued without a caller and the flow's own attribution (or its declared runAs) decides.
+ $user = $actor;
+ if (str_starts_with($actor, CasePlanService::SYSTEM_ACTOR_PREFIX) === true) {
+ $user = null;
+ }
+
$run = $this->runs->queue(
flowId: $flow,
subject: [
@@ -109,7 +117,7 @@ public function realise(CaseItem $item, string $actor): void {
],
trigger: self::RUN_TRIGGER,
context: ['caseItem' => (string)$item->getUuid()],
- user: $actor
+ user: $user
);
$item->setRealisationKind(CaseItem::REALISATION_RUN);
$item->setRealisationUuid($run->getUuid());
diff --git a/lib/Service/Case/Nodes/CaseAdvanceNode.php b/lib/Service/Case/Nodes/CaseAdvanceNode.php
index 85990ca4fa..416b78ff40 100644
--- a/lib/Service/Case/Nodes/CaseAdvanceNode.php
+++ b/lib/Service/Case/Nodes/CaseAdvanceNode.php
@@ -214,11 +214,8 @@ public function execute(array $items, array $config, array $context): array {
*/
private function advance(string $objectUuid, string $key, string $to, string $uid, ?string $reason): array {
$plan = $this->plans->getPlan(objectUuid: $objectUuid, uid: $uid);
- foreach (($plan['items'] ?? []) as $row) {
- if (($row['key'] ?? null) !== $key) {
- continue;
- }
-
+ $row = $this->liveRealisation(items: (array)($plan['items'] ?? []), key: $key);
+ if ($row !== null) {
$moved = $this->plans->transition(itemUuid: (string)$row['uuid'], to: $to, uid: $uid, reason: $reason);
return ['item' => $key, 'state' => (string)$moved->getState()];
@@ -228,4 +225,52 @@ private function advance(string $objectUuid, string $key, string $to, string $ui
$this->l10n->t('The case plan on object %1$s has no item "%2$s".', [$objectUuid, $key])
);
}//end advance()
+
+ /**
+ * The row of a key that can still move: a repeating item has one row per
+ * realisation (same key, `realisationCount` + 1), and the first row in plan
+ * order is the oldest, which is already completed or terminated. A row
+ * that is not terminal wins (the newest of them, should several be open);
+ * when every realisation is terminal the newest is returned, so the step
+ * fails on the realisation that is actually current.
+ *
+ * @param array> $items The plan's rows.
+ * @param string $key The plan-item key.
+ *
+ * @return array|null The row, or null when the plan has no such key.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case
+ */
+ private function liveRealisation(array $items, string $key): ?array {
+ $chosen = null;
+ foreach ($items as $row) {
+ if (($row['key'] ?? null) !== $key) {
+ continue;
+ }
+
+ if ($chosen === null || $this->outranks(candidate: $row, current: $chosen) === true) {
+ $chosen = $row;
+ }
+ }
+
+ return $chosen;
+ }//end liveRealisation()
+
+ /**
+ * Whether a row of the same key is a better one to advance than the one held.
+ *
+ * @param array $candidate The row under consideration.
+ * @param array $current The row held so far.
+ *
+ * @return bool True when the candidate is open and the held row is not, or both are alike and the candidate is a later realisation.
+ */
+ private function outranks(array $candidate, array $current): bool {
+ $candidateOpen = (($candidate['isTerminal'] ?? false) === false);
+ $currentOpen = (($current['isTerminal'] ?? false) === false);
+ if ($candidateOpen !== $currentOpen) {
+ return $candidateOpen;
+ }
+
+ return (int)($candidate['realisationCount'] ?? 1) > (int)($current['realisationCount'] ?? 1);
+ }//end outranks()
}//end class
diff --git a/lib/Service/Case/Nodes/CaseNodeBase.php b/lib/Service/Case/Nodes/CaseNodeBase.php
index 87d36fe8fe..0a694132f6 100644
--- a/lib/Service/Case/Nodes/CaseNodeBase.php
+++ b/lib/Service/Case/Nodes/CaseNodeBase.php
@@ -145,7 +145,9 @@ protected function actingUid(array $context): string {
/**
* The distinct objects the step acts on, with the register and schema
- * each names in its `@self` block, when numeric.
+ * the received item names in its `@self` block, when numeric and when the
+ * target is that item's own object (a configured `uuid` naming another
+ * object carries none).
*
* @param array $items The incoming items.
* @param array $config The step configuration.
@@ -168,7 +170,8 @@ protected function targets(array $items, array $config): array {
$json = [];
}
- $uuid = trim((string)($json['uuid'] ?? ($json['id'] ?? '')));
+ $own = trim((string)($json['uuid'] ?? ($json['id'] ?? '')));
+ $uuid = $own;
if (is_string($configured) === true && trim($configured) !== '') {
$uuid = trim((string)FlowValueTemplate::render($configured, $json));
}
@@ -182,16 +185,39 @@ protected function targets(array $items, array $config): array {
$self = [];
}
+ // The register and schema in `@self` belong to the RECEIVED item. A configured `uuid` may name another
+ // object (a parent, a related record); anchoring that object's plan to the item's register and schema
+ // would make a later ensure refuse the anchor and every read that filters on them miss the rows, so
+ // such a plan is anchored to neither.
+ $ownObject = ($uuid === $own);
$targets[$uuid] = [
'uuid' => $uuid,
- 'register' => $this->intOrNull(value: ($self['register'] ?? null)),
- 'schema' => $this->intOrNull(value: ($self['schema'] ?? null)),
+ 'register' => $this->anchorId(value: ($self['register'] ?? null), ownObject: $ownObject),
+ 'schema' => $this->anchorId(value: ($self['schema'] ?? null), ownObject: $ownObject),
];
}//end foreach
return $targets;
}//end targets()
+ /**
+ * The register or schema id to anchor a plan to: the item's own, only for the item's own object.
+ *
+ * @param mixed $value The `@self` value.
+ * @param bool $ownObject Whether the target is the object the item is.
+ *
+ * @return int|null The id, or null when it is not numeric or the target is another object.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-a-flow-step-opens-or-advances-a-case
+ */
+ private function anchorId(mixed $value, bool $ownObject): ?int {
+ if ($ownObject === false) {
+ return null;
+ }
+
+ return $this->intOrNull(value: $value);
+ }//end anchorId()
+
/**
* A numeric id, or null.
*
diff --git a/lib/Service/Case/UnarmedTimerMarkers.php b/lib/Service/Case/UnarmedTimerMarkers.php
new file mode 100644
index 0000000000..c13efaabfd
--- /dev/null
+++ b/lib/Service/Case/UnarmedTimerMarkers.php
@@ -0,0 +1,155 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\Case
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Case;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use OCA\OpenRegister\AppInfo\Application;
+use OCP\IAppConfig;
+use Throwable;
+
+/**
+ * Remembers which plan items still lack their statutory expiry timer.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+class UnarmedTimerMarkers {
+
+ /**
+ * The app-config key of the marker map (a lazy value: it is read by one job, not on a request).
+ */
+ public const KEY = 'case_unarmed_statutory_timers';
+
+ /**
+ * Constructor.
+ *
+ * @param IAppConfig $appConfig OpenRegister's app config.
+ */
+ public function __construct(
+ private readonly IAppConfig $appConfig,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * Mark an item: its statutory expiry timer could not be armed.
+ *
+ * The first marker of an item keeps its moment, so how long an item has
+ * been without its timer stays readable.
+ *
+ * @param string $itemUuid The plan item.
+ * @param DateTimeInterface|null $now The clock; null is the real clock.
+ *
+ * @return bool True when the marker is stored.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+ public function add(string $itemUuid, ?DateTimeInterface $now = null): bool {
+ $markers = $this->all();
+ if (isset($markers[$itemUuid]) === true) {
+ return true;
+ }
+
+ $moment = DateTimeImmutable::createFromInterface($now ?? new DateTimeImmutable());
+ $markers[$itemUuid] = $moment->format(DATE_ATOM);
+
+ return $this->store(markers: $markers);
+ }//end add()
+
+ /**
+ * Clear an item's marker.
+ *
+ * @param string $itemUuid The plan item.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+ public function remove(string $itemUuid): void {
+ $markers = $this->all();
+ if (isset($markers[$itemUuid]) === false) {
+ return;
+ }
+
+ unset($markers[$itemUuid]);
+ $this->store(markers: $markers);
+ }//end remove()
+
+ /**
+ * Every marked item, with the moment its arm first failed.
+ *
+ * @return array Item uuid to an ISO 8601 moment.
+ *
+ * @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
+ */
+ public function all(): array {
+ try {
+ $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY, '', true), true);
+ } catch (Throwable) {
+ return [];
+ }
+
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ $markers = [];
+ foreach ($decoded as $uuid => $moment) {
+ if (is_string($uuid) === true && $uuid !== '' && is_string($moment) === true) {
+ $markers[$uuid] = $moment;
+ }
+ }
+
+ return $markers;
+ }//end all()
+
+ /**
+ * Store the map, or drop the key when it is empty.
+ *
+ * @param array $markers The map.
+ *
+ * @return bool True when stored.
+ */
+ private function store(array $markers): bool {
+ try {
+ if ($markers === []) {
+ $this->appConfig->deleteKey(Application::APP_ID, self::KEY);
+
+ return true;
+ }
+
+ return $this->appConfig->setValueString(Application::APP_ID, self::KEY, (string)json_encode($markers), true);
+ } catch (Throwable) {
+ return false;
+ }
+ }//end store()
+}//end class
diff --git a/lib/Service/Configuration/DescriptorConfigIds.php b/lib/Service/Configuration/DescriptorConfigIds.php
new file mode 100644
index 0000000000..5e792a1575
--- /dev/null
+++ b/lib/Service/Configuration/DescriptorConfigIds.php
@@ -0,0 +1,98 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\Configuration
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Configuration;
+
+/**
+ * Maps an OpenRegister descriptor file to the configuration id its import runs under.
+ */
+final class DescriptorConfigIds {
+
+ /**
+ * The id the descriptors without a dedicated one share.
+ */
+ public const SHARED = 'openregister';
+
+ public const DECISION_TABLES = 'openregister.decision-tables';
+
+ public const DISMISSED_PAIR = 'openregister.dismissed-pair';
+
+ public const EDEPOT_TRANSFERS = 'openregister.edepot-transfers';
+
+ public const MERGE_OPERATION = 'openregister.merge-operation';
+
+ public const TRUST_CONFIGURATION = 'openregister.trust-configuration';
+
+ public const VOCABULARY = 'openregister.vocabulary';
+
+ public const DSAR_CASES = 'openregister.dsar-cases';
+
+ public const DSAR_POLICY_PACKS = 'openregister.dsar-policy-packs';
+
+ /**
+ * The descriptors that import under an id of their own, by file name.
+ *
+ * @var array
+ */
+ public const BY_FILE = [
+ 'decision_table_register.json' => self::DECISION_TABLES,
+ 'dismissed_pair_register.json' => self::DISMISSED_PAIR,
+ 'edepot_transfer_register.json' => self::EDEPOT_TRANSFERS,
+ 'merge_operation_register.json' => self::MERGE_OPERATION,
+ 'trust_configuration_register.json' => self::TRUST_CONFIGURATION,
+ 'vocabulary_register.json' => self::VOCABULARY,
+ 'data_subject_request_register.json' => self::DSAR_CASES,
+ 'dsar_policy_pack_register.json' => self::DSAR_POLICY_PACKS,
+ ];
+
+ /**
+ * The configuration id a descriptor of an app is imported under.
+ *
+ * Only OpenRegister's own descriptors have a dedicated id; any other app's
+ * file, whatever it is called, imports under the app's id.
+ *
+ * @param string $appId The app that ships the descriptor.
+ * @param string $file The descriptor's file name.
+ *
+ * @return string The configuration id.
+ *
+ * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md
+ */
+ public static function forFile(string $appId, string $file): string {
+ if ($appId !== self::SHARED) {
+ return $appId;
+ }
+
+ return (self::BY_FILE[$file] ?? self::SHARED);
+ }//end forFile()
+}//end class
diff --git a/lib/Service/Configuration/ImportHandler.php b/lib/Service/Configuration/ImportHandler.php
index 54966792ef..fc652d1263 100644
--- a/lib/Service/Configuration/ImportHandler.php
+++ b/lib/Service/Configuration/ImportHandler.php
@@ -300,6 +300,13 @@ class ImportHandler {
*/
private ?SelectionListSeeder $selectionListSeeder = null;
+ /**
+ * The shipped version of each seed object, built on first use from the app config.
+ *
+ * @var SeedVersionLedger|null
+ */
+ private ?SeedVersionLedger $seedVersions = null;
+
/**
* Collector for declared RBAC group ids. Dependency-free value object,
* created lazily via {@see self::rbacGroupCollector()}.
@@ -3609,7 +3616,21 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a
$importedVersion = $objectData['@self']['version'] ?? $objectData['version'] ?? '1.0.0';
$existingVersion = $existingObjectData['@self']['version'] ?? $existingObjectData['version'] ?? '1.0.0';
- if (version_compare($importedVersion, $existingVersion, '>') > 0) {
+
+ // Compared with the version this seed was last SHIPPED at when the
+ // import remembers one, not with the object's own version: every
+ // later save of the object bumps its patch, and a stored `1.4.8`
+ // (shipped 1.4.7, edited once) would otherwise make a shipped
+ // `1.4.8` look current and keep it from ever arriving.
+ $seedKey = null;
+ $shipped = null;
+ if ($appId !== null && $appId !== '') {
+ $seedKey = (int)$registerId . '/' . (int)$schemaId . '/' . $slug;
+ $shipped = $this->seedVersions()->shipped(appId: $appId, seed: $seedKey);
+ }
+
+ $baseline = ($shipped ?? $existingVersion);
+ if (version_compare($importedVersion, $baseline, '>') > 0) {
$uuid = $existingObjectData['@self']['id'] ?? $existingObjectData['id'] ?? null;
// CRITICAL: Pass Register and Schema OBJECTS, not IDs.
// This avoids organisation filter issues in find().
@@ -3628,15 +3649,22 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a
currentUser: $actingUser
);
$result['objects'][] = $object;
+ $this->rememberShippedVersion(appId: $appId, seedKey: $seedKey, version: (string)$importedVersion);
}
- if (version_compare($importedVersion, $existingVersion, '>') <= 0) {
+ if (version_compare($importedVersion, $baseline, '>') <= 0) {
// COUNTED, not just logged. Skipping an unchanged
// object is the DESIGNED behaviour of a re-import,
// but a debug line is invisible to the caller, and
// the caller is the one that has to tell an operator
// whether the import worked.
$result['unchanged']['objects']++;
+ if ($shipped === null) {
+ // A seed stored before the import remembered its shipped version: what the file ships now is
+ // what the instance holds (the object cannot be told apart from an edited one otherwise).
+ $this->rememberShippedVersion(appId: $appId, seedKey: $seedKey, version: (string)$importedVersion);
+ }
+
$this->logger->debug(
message: '[ImportHandler] Skipped object update: imported version not higher',
context: [
@@ -3647,6 +3675,7 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a
'schema' => $schemaId,
'importedVersion' => $importedVersion,
'existingVersion' => $existingVersion,
+ 'shippedVersion' => $shipped,
]
);
return;
@@ -3671,9 +3700,46 @@ private function importListedObject(array $objectData, ?string $appId, ?IUser $a
currentUser: $actingUser
);
$result['objects'][] = $object;
+ if ($appId !== null && $appId !== '') {
+ $this->rememberShippedVersion(
+ appId: $appId,
+ seedKey: (int)$registerId . '/' . (int)$schemaId . '/' . $slug,
+ version: (string)($objectData['@self']['version'] ?? $objectData['version'] ?? '1.0.0')
+ );
+ }
}//end if
}//end importListedObject()
+ /**
+ * The ledger of the versions the seed objects were last shipped at.
+ *
+ * @return SeedVersionLedger
+ */
+ private function seedVersions(): SeedVersionLedger {
+ if ($this->seedVersions === null) {
+ $this->seedVersions = new SeedVersionLedger(appConfig: $this->appConfig);
+ }
+
+ return $this->seedVersions;
+ }//end seedVersions()
+
+ /**
+ * Remember the version a seed object was shipped at, when the import knows its app.
+ *
+ * @param string|null $appId The importing app.
+ * @param string|null $seedKey The seed's identity; null when there is no app to remember it under.
+ * @param string $version The shipped version.
+ *
+ * @return void
+ */
+ private function rememberShippedVersion(?string $appId, ?string $seedKey, string $version): void {
+ if ($appId === null || $appId === '' || $seedKey === null) {
+ return;
+ }
+
+ $this->seedVersions()->record(appId: $appId, seed: $seedKey, version: $version);
+ }//end rememberShippedVersion()
+
/**
* Record a listed object that failed twice: counted, named and logged.
*
diff --git a/lib/Service/Configuration/SeedVersionLedger.php b/lib/Service/Configuration/SeedVersionLedger.php
new file mode 100644
index 0000000000..17d66d13bc
--- /dev/null
+++ b/lib/Service/Configuration/SeedVersionLedger.php
@@ -0,0 +1,150 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\Configuration
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ *
+ * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Configuration;
+
+use OCP\IAppConfig;
+
+/**
+ * Remembers the shipped version of each seed object, per app id.
+ *
+ * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md
+ */
+class SeedVersionLedger {
+
+ /**
+ * The app-config key prefix of one app id's map.
+ */
+ private const KEY_PREFIX = 'imported_seed_versions_';
+
+ /**
+ * Longest app config key Nextcloud accepts.
+ */
+ private const MAX_KEY_LENGTH = 64;
+
+ /**
+ * The maps read so far in this request, by app id.
+ *
+ * @var array>
+ */
+ private array $maps = [];
+
+ /**
+ * Constructor.
+ *
+ * @param IAppConfig $appConfig OpenRegister's app config.
+ */
+ public function __construct(
+ private readonly IAppConfig $appConfig,
+ ) {
+
+ }//end __construct()
+
+ /**
+ * The version a seed was last shipped at, or null when the import never recorded one.
+ *
+ * @param string $appId The importing app id.
+ * @param string $seed The seed's identity: register id, schema id and slug.
+ *
+ * @return string|null The version.
+ *
+ * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md
+ */
+ public function shipped(string $appId, string $seed): ?string {
+ return ($this->map(appId: $appId)[$seed] ?? null);
+ }//end shipped()
+
+ /**
+ * Record the version a seed was shipped at, when it differs from the recorded one.
+ *
+ * @param string $appId The importing app id.
+ * @param string $seed The seed's identity: register id, schema id and slug.
+ * @param string $version The shipped version.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md
+ */
+ public function record(string $appId, string $seed, string $version): void {
+ $map = $this->map(appId: $appId);
+ if (($map[$seed] ?? null) === $version) {
+ return;
+ }
+
+ $map[$seed] = $version;
+ $this->maps[$appId] = $map;
+ $this->appConfig->setValueString('openregister', $this->key(appId: $appId), (string)json_encode($map), lazy: true);
+ }//end record()
+
+ /**
+ * One app id's map, read once per request.
+ *
+ * @param string $appId The app id.
+ *
+ * @return array Seed identity to shipped version.
+ */
+ private function map(string $appId): array {
+ if (isset($this->maps[$appId]) === false) {
+ $decoded = json_decode($this->appConfig->getValueString('openregister', $this->key(appId: $appId), '', lazy: true), true);
+ if (is_array($decoded) === false) {
+ $decoded = [];
+ }
+
+ $map = [];
+ foreach ($decoded as $seed => $version) {
+ if (is_string($version) === true) {
+ $map[(string)$seed] = $version;
+ }
+ }
+
+ $this->maps[$appId] = $map;
+ }
+
+ return $this->maps[$appId];
+ }//end map()
+
+ /**
+ * The app-config key of an app id's map, hashed when the plain key would pass Nextcloud's limit.
+ *
+ * @param string $appId The app id.
+ *
+ * @return string
+ */
+ private function key(string $appId): string {
+ $key = self::KEY_PREFIX . $appId;
+ if (strlen($key) <= self::MAX_KEY_LENGTH) {
+ return $key;
+ }
+
+ return self::KEY_PREFIX . sha1($appId);
+ }//end key()
+}//end class
diff --git a/lib/Service/CountedRegisterImport.php b/lib/Service/CountedRegisterImport.php
index 5cf4dd3a60..95c8b5f280 100644
--- a/lib/Service/CountedRegisterImport.php
+++ b/lib/Service/CountedRegisterImport.php
@@ -62,7 +62,8 @@ public function __construct(
* @param string $version The version of the imported document.
* @param int $fragments How many register.d fragments it carries.
* @param string $via Who asked, for the audit trail.
- * @param callable $import Runs the import; returns success, path, via, message.
+ * @param callable $import Runs the import; returns success, path, via, message, and importJobId and
+ * importAppId when it recorded an import job.
*
* @return array The reimport() result.
*
@@ -96,6 +97,13 @@ public function run(
'replaced' => $replaced,
];
+ // The job the import ran under, so a caller that must undo exactly this import (the example-set loader) is
+ // told which one it was and does not have to guess it from a before/after diff of every job on the instance.
+ if (is_string($run['importJobId'] ?? null) === true) {
+ $result['importJobId'] = $run['importJobId'];
+ $result['importAppId'] = (string) ($run['importAppId'] ?? '');
+ }
+
if ($run['success'] === false) {
$result['outcome'] = 'failed';
$result['reason'] = sprintf('The import through %s failed: %s', $run['via'], $run['message']);
diff --git a/lib/Service/Credential/OAuth2Endpoints.php b/lib/Service/Credential/OAuth2Endpoints.php
index 02942603ee..793d1b04e4 100644
--- a/lib/Service/Credential/OAuth2Endpoints.php
+++ b/lib/Service/Credential/OAuth2Endpoints.php
@@ -17,7 +17,7 @@
* WHERE MAY THE FLOW SEND SOMEBODY AFTERWARDS. An attacker-chosen redirect target on
* a callback is an open redirect, so a proposed return URL is reduced to a PATH on
* this instance and anything carrying its own scheme or host is discarded in favour
- * of personal settings. The reduced value is then carried inside the signed state, so
+ * of the app page. The reduced value is then carried inside the signed state, so
* the callback only ever redirects somewhere the instance itself approved rather than
* to anything present in the request that came back.
*
diff --git a/lib/Service/Decision/DecisionVisibility.php b/lib/Service/Decision/DecisionVisibility.php
index 2f3043dc52..9eae3fa434 100644
--- a/lib/Service/Decision/DecisionVisibility.php
+++ b/lib/Service/Decision/DecisionVisibility.php
@@ -55,8 +55,12 @@ class DecisionVisibility {
* `bases` is the ground; `skipAnonymization` is the release-or-redact
* verdict itself, so leaving it would tell the second reviewer what the
* first one decided. `decisionRecord` is the embedded decision.
+ * `anonymized` and `anonymizedValue` are what an anonymise pass wrote from
+ * that verdict: a released occurrence stays `anonymized: false` while a
+ * redacted one reads `true` with its placeholder, so left in they would
+ * give the verdict away in a blind second review that follows a pass.
*/
- public const MASKED_FIELDS = ['bases', 'skipAnonymization', 'decisionRecord'];
+ public const MASKED_FIELDS = ['bases', 'skipAnonymization', 'decisionRecord', 'anonymized', 'anonymizedValue'];
/**
* Constructor.
diff --git a/lib/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php
index 3e132599a0..67c59137ca 100644
--- a/lib/Service/Decision/RelationDecisionService.php
+++ b/lib/Service/Decision/RelationDecisionService.php
@@ -32,6 +32,7 @@
use OCA\OpenRegister\Db\EntityRelation;
use OCA\OpenRegister\Db\EntityRelationMapper;
use OCA\OpenRegister\Exception\CustomValidationException;
+use OCA\OpenRegister\Exception\DecisionConflictException;
use OCP\IDBConnection;
use OCP\IUser;
use Throwable;
@@ -40,6 +41,9 @@
* Applies a decision to a relation and records it as a decision object.
*
* @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The decision flow meets the relation, its mapper, the decision
+ * store, the policies and the ownership check; the conflict exception is the compare-and-swap's one extra collaborator.
*/
class RelationDecisionService {
@@ -91,7 +95,7 @@ public function __construct(
*
* @return array{relation: EntityRelation, decision: Decision|null} The relation and its current decision.
*
- * @throws DecisionOwnedException When the decision belongs to someone else.
+ * @throws DecisionOwnedException When the decision belongs to someone else, its record is missing, or another reviewer decided first.
* @throws CustomValidationException When the body is malformed.
*
* @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001
@@ -102,14 +106,9 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr
unset($body[self::NOTE_FIELD]);
$actorId = $actor->getUID();
- $current = $this->decisions->find(uuid: $relation->getDecisionUuid());
- if ($current !== null) {
- $resolved = $this->policies->forRelation(relation: $relation);
- $refusal = $this->ownership->refusal(decision: $current, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']);
- if ($refusal !== null) {
- throw new DecisionOwnedException(refusal: $refusal);
- }
- }
+ $linkedUuid = $relation->getDecisionUuid();
+ $current = $this->decisions->find(uuid: $linkedUuid);
+ $this->assertMayChange(relation: $relation, current: $current, linkedUuid: $linkedUuid, actorId: $actorId);
$before = [$relation->getBases(), $relation->getSkipAnonymization()];
@@ -140,8 +139,19 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr
note: $note,
supersedes: $current?->getUuid()
);
- $updated = $this->relationMapper->linkDecision(relation: $updated, decisionUuid: (string)$decision->getUuid());
+ // Compare-and-swap on the link read above: two reviewers taking a
+ // first decision at once cannot both link theirs (the second finds
+ // the link taken and is refused).
+ $updated = $this->relationMapper->linkDecision(
+ relation: $updated,
+ decisionUuid: (string)$decision->getUuid(),
+ expectedUuid: $linkedUuid,
+ guarded: true
+ );
$this->db->commit();
+ } catch (DecisionConflictException) {
+ $this->db->rollBack();
+ throw new DecisionOwnedException(refusal: ['error' => 'decision-owned', 'decision' => null]);
} catch (Throwable $error) {
$this->db->rollBack();
throw $error;
@@ -150,6 +160,41 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr
return ['relation' => $updated, 'decision' => $decision];
}//end decide()
+ /**
+ * Refuse a write on a decision the actor does not own.
+ *
+ * A relation that names a decision whose record cannot be found belongs to
+ * nobody but a supervisor: DecisionVisibility hides that state, and a
+ * reviewer must not take it over through the gap.
+ *
+ * @param EntityRelation $relation The relation.
+ * @param Decision|null $current The decision record the relation links, when it exists.
+ * @param string|null $linkedUuid The decision uuid the relation names.
+ * @param string $actorId The reviewer.
+ *
+ * @return void
+ *
+ * @throws DecisionOwnedException When the decision belongs to someone else.
+ */
+ private function assertMayChange(EntityRelation $relation, ?Decision $current, ?string $linkedUuid, string $actorId): void {
+ $owned = $current;
+ if ($owned === null && $linkedUuid !== null && $linkedUuid !== '') {
+ $owned = new Decision();
+ $owned->setUuid($linkedUuid);
+ $owned->setDecidedBy('');
+ }
+
+ if ($owned === null) {
+ return;
+ }
+
+ $resolved = $this->policies->forRelation(relation: $relation);
+ $refusal = $this->ownership->refusal(decision: $owned, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']);
+ if ($refusal !== null) {
+ throw new DecisionOwnedException(refusal: $refusal);
+ }
+ }//end assertMayChange()
+
/**
* Whether this write is a decision worth recording.
*
diff --git a/lib/Service/Decision/ReviewPolicyResolver.php b/lib/Service/Decision/ReviewPolicyResolver.php
index 5fb5b2ee37..ffd7577b91 100644
--- a/lib/Service/Decision/ReviewPolicyResolver.php
+++ b/lib/Service/Decision/ReviewPolicyResolver.php
@@ -79,8 +79,17 @@ public function __construct(
*/
public function forRelation(EntityRelation $relation): array {
$subject = $this->subjectOf(relation: $relation);
+ $lookupFailed = (($subject['lookupFailed'] ?? false) === true);
+ unset($subject['lookupFailed']);
$schema = (string)($subject['schema'] ?? '');
+ if ($lookupFailed === true) {
+ // Fail closed: the file's object could not be found, so the schema
+ // that may hide or restrict the decision is unknown. Never fall
+ // to the instance default, which is open when unset.
+ return ['policy' => $this->closedPolicy(), 'subject' => $subject];
+ }
+
if ($schema === '') {
return ['policy' => $this->instanceDefault(), 'subject' => $subject];
}
@@ -89,15 +98,13 @@ public function forRelation(EntityRelation $relation): array {
$configuration = ($this->schemaMapper->find(id: $schema, _rbac: false, _multitenancy: false)->getConfiguration() ?? []);
} catch (Throwable $error) {
// Fail closed: a policy that cannot be read is decider-owned with
- // no supervisors, so only the decider and an administrator may.
+ // no supervisors and hides the decision from other reviewers, so
+ // only the decider and an administrator may see or change it.
$this->logger->warning(
message: '[ReviewPolicyResolver] The schema could not be read; the decision stays with its decider.',
context: ['schema' => $schema, 'error' => $error->getMessage()]
);
- return [
- 'policy' => ReviewPolicy::fromDeclaration(declaration: ['ownership' => ReviewPolicy::OWNERSHIP_DECIDER]),
- 'subject' => $subject,
- ];
+ return ['policy' => $this->closedPolicy(), 'subject' => $subject];
}
if (array_key_exists(ReviewPolicy::ANNOTATION, $configuration) === false) {
@@ -113,7 +120,8 @@ public function forRelation(EntityRelation $relation): array {
* @param EntityRelation $relation The relation.
*
* @return array subjectType, subjectId, fileId, objectId,
- * register, schema and objectUuid.
+ * register, schema and objectUuid; `lookupFailed`
+ * (true) when the file-to-object lookup threw.
*/
private function subjectOf(EntityRelation $relation): array {
$subject = [
@@ -139,9 +147,10 @@ private function subjectOf(EntityRelation $relation): array {
}
} catch (Throwable $error) {
$this->logger->info(
- message: '[ReviewPolicyResolver] The file\'s object could not be found; the instance default applies.',
+ message: '[ReviewPolicyResolver] The file\'s object could not be found; the decision stays with its decider.',
context: ['fileId' => $fileId, 'error' => $error->getMessage()]
);
+ $subject['lookupFailed'] = true;
return $subject;
}
@@ -154,6 +163,17 @@ private function subjectOf(EntityRelation $relation): array {
return $subject;
}//end subjectOf()
+ /**
+ * The policy for a declaration that cannot be read: decider-owned, no supervisors, others hidden.
+ *
+ * @return ReviewPolicy The policy.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess) ReviewPolicy::fromDeclaration is its named constructor.
+ */
+ private function closedPolicy(): ReviewPolicy {
+ return ReviewPolicy::fromDeclaration(declaration: ['ownership' => ReviewPolicy::OWNERSHIP_DECIDER, 'hideOthers' => true]);
+ }//end closedPolicy()
+
/**
* The instance default, for files that belong to no schema. Unset is open.
*
diff --git a/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php
index 0a7db1e081..699b774983 100644
--- a/lib/Service/Deferral/ListenerDeferralService.php
+++ b/lib/Service/Deferral/ListenerDeferralService.php
@@ -73,13 +73,28 @@ class ListenerDeferralService {
public const DEFAULT_CHUNK_SIZE = 100;
/**
- * The longest job argument Nextcloud accepts, as its own JSON encoding
- * (`OC\BackgroundJob\JobList::MAX_ARGUMENT_JSON_LENGTH`). Longer, and
- * `IJobList::add()` throws and the whole chunk is lost.
+ * The longest job argument Nextcloud 32 and 33 accept, as its own JSON
+ * encoding (a literal in `OC\BackgroundJob\JobList::add()` there). Also the
+ * limit used when the running server does not name its own.
*
* @var integer
*/
- public const MAX_ARGUMENT_LENGTH = 32000;
+ public const ARGUMENT_LENGTH_NC32 = 4000;
+
+ /**
+ * The longest job argument Nextcloud 34 and later accept
+ * (`OC\BackgroundJob\JobList::MAX_ARGUMENT_JSON_LENGTH`, introduced in 34).
+ *
+ * @var integer
+ */
+ public const ARGUMENT_LENGTH_NC34 = 32000;
+
+ /**
+ * The server's own name for the limit, defined from Nextcloud 34 on.
+ *
+ * @var string
+ */
+ private const SERVER_LIMIT_CONSTANT = 'OC\\BackgroundJob\\JobList::MAX_ARGUMENT_JSON_LENGTH';
/**
* App-config key of the deferral kill switch.
@@ -138,6 +153,7 @@ class ListenerDeferralService {
* @param IJobList $jobList Job list the chunk jobs are enqueued on.
* @param IAppConfig $appConfig App config holding the kill switch.
* @param LoggerInterface $logger PSR logger.
+ * @param int|null $argumentLimit Job argument limit to respect; null reads the running server's.
*
* @return void
*/
@@ -147,9 +163,37 @@ public function __construct(
private readonly IJobList $jobList,
private readonly IAppConfig $appConfig,
private readonly LoggerInterface $logger,
+ private readonly ?int $argumentLimit = null,
) {
}//end __construct()
+ /**
+ * The longest job argument the running Nextcloud accepts, as JSON.
+ *
+ * The limit is 4000 characters on Nextcloud 32 and 33 and 32000 from 34
+ * on; `info.xml` supports 32 up to 35, so it is read from the server's own
+ * constant when that exists and is 4000 otherwise (the safe side: a chunk
+ * under 4000 is accepted everywhere).
+ *
+ * @return int The limit in characters.
+ *
+ * @spec openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md#requirement-a-deferred-listener-chunk-fits-the-job-argument-limit
+ */
+ public function getArgumentLimit(): int {
+ if ($this->argumentLimit !== null) {
+ return $this->argumentLimit;
+ }
+
+ if (defined(self::SERVER_LIMIT_CONSTANT) === true) {
+ $serverLimit = constant(self::SERVER_LIMIT_CONSTANT);
+ if (is_int($serverLimit) === true && $serverLimit > 0) {
+ return $serverLimit;
+ }
+ }
+
+ return self::ARGUMENT_LENGTH_NC32;
+ }//end getArgumentLimit()
+
/**
* Whether deferral is enabled on this instance.
*
@@ -259,12 +303,16 @@ public function flushAll(): void {
*
* A chunk is cut by size as well as by count: an entry may carry the
* object's previous data (an update notification's `oldData`), and 25 of
- * those passed Nextcloud's 32,000-character job argument limit. The job
- * list then refused the whole chunk and every entry in it was dropped
- * with one log line (zuiddrecht live test, 14 October 2026: 11 chunks of
- * 25 in one cron run). Now the entries are spread over as many jobs as
- * the limit needs; only an entry that alone exceeds it is dropped, and it
- * is named in the log.
+ * those passed Nextcloud's job argument limit (4000 characters on 32 and
+ * 33, 32000 from 34). The job list then refused the whole chunk and every
+ * entry in it was dropped with one log line (zuiddrecht live test, 14
+ * October 2026: 11 chunks of 25 in one cron run). Now the entries are
+ * spread over as many jobs as the running server's limit needs. An entry
+ * that alone exceeds it goes without its `oldData`, which keeps the entry
+ * but means the conditioned `updated` rules and the `calculatedChange`
+ * rules, which fail closed without the previous data, are skipped for that
+ * object (the log names it); only an entry that is still too large after
+ * that is dropped, and it is named in the log.
*
* @param string $jobClass FQCN of the job to enqueue.
*
@@ -299,13 +347,39 @@ private function chunksUnderLimit(string $jobClass, array $entries): array {
$envelope = new DeferredListenerContext(userId: $this->capturedUserId, orgUuid: $this->capturedOrgUuid, entries: []);
// Measured the way the job list measures: plain json_encode. Each entry
// adds its own length plus the comma that joins it to the next.
- $budget = (self::MAX_ARGUMENT_LENGTH - strlen((string)json_encode($envelope->toJobArguments())));
+ $budget = ($this->getArgumentLimit() - strlen((string)json_encode($envelope->toJobArguments())));
$chunks = [];
$current = [];
$used = 0;
foreach ($entries as $entry) {
$length = (strlen((string)json_encode($entry)) + 1);
+ if ($length > $budget && array_key_exists('oldData', $entry) === true) {
+ // The previous data is the bulk of a large update. Losing it beats
+ // losing the entry, but it is not free: rules that compare old
+ // against new fail closed without it and are skipped.
+ $reduced = $entry;
+ unset($reduced['oldData']);
+ $reducedLength = (strlen((string)json_encode($reduced)) + 1);
+ if ($reducedLength <= $budget) {
+ $this->logger->warning(
+ message: '[ListenerDeferralService] A deferred listener entry was too large for a background job; '
+ . 'it was enqueued without its previous data; conditioned updated rules and '
+ . 'calculatedChange rules are skipped for this object',
+ context: [
+ 'file' => __FILE__,
+ 'line' => __LINE__,
+ 'jobClass' => $jobClass,
+ 'uuid' => (string)($entry['uuid'] ?? ''),
+ 'length' => $length,
+ 'limit' => $this->getArgumentLimit(),
+ ]
+ );
+ $entry = $reduced;
+ $length = $reducedLength;
+ }
+ }
+
if ($length > $budget) {
$this->logger->error(
message: '[ListenerDeferralService] A deferred listener entry is larger than a background job may carry; it was not enqueued',
@@ -315,6 +389,7 @@ private function chunksUnderLimit(string $jobClass, array $entries): array {
'jobClass' => $jobClass,
'uuid' => (string)($entry['uuid'] ?? ''),
'length' => $length,
+ 'limit' => $this->getArgumentLimit(),
]
);
continue;
diff --git a/lib/Service/Dmn/DecisionTableStore.php b/lib/Service/Dmn/DecisionTableStore.php
index 8d025870c9..b6cbcdcaca 100644
--- a/lib/Service/Dmn/DecisionTableStore.php
+++ b/lib/Service/Dmn/DecisionTableStore.php
@@ -82,7 +82,7 @@ public function __construct(
*
* @return array The table: key, name, hitPolicy, inputs, outputs, rules.
*
- * @throws UnexpectedValueException When no table carries the reference, or the stored table cannot be executed.
+ * @throws UnexpectedValueException When no table carries the reference, more than one does, or the stored table cannot be executed.
*
* @spec openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md
*/
@@ -147,6 +147,8 @@ public function evaluate(string $reference, array $inputs): array {
* @param string $reference The table's key or uuid.
*
* @return array|null The table, or null when none carries the reference.
+ *
+ * @throws UnexpectedValueException When the register cannot be read, or more than one table carries the key.
*/
private function lookup(string $reference): ?array {
try {
@@ -165,6 +167,7 @@ private function lookup(string $reference): ?array {
);
}
+ $byKey = [];
$byUuid = null;
foreach ((array)$rows as $entity) {
$data = $entity->getObject();
@@ -173,7 +176,8 @@ private function lookup(string $reference): ?array {
}
if (trim((string)($data['key'] ?? '')) === $reference) {
- return $data;
+ $byKey[(string)$entity->getUuid()] = $data;
+ continue;
}
if ($byUuid === null && (string)$entity->getUuid() === $reference) {
@@ -181,6 +185,19 @@ private function lookup(string $reference): ?array {
}
}
+ // Two rows with one key (a save that went around the guard, an import) are two tables, and the register
+ // returns them in no fixed order: answering with the first would decide by a table nobody chose, and
+ // which one could change between requests.
+ if (count($byKey) > 1) {
+ throw new UnexpectedValueException(
+ sprintf('More than one decision table has the key "%s" (%s); the key must name exactly one.', $reference, implode(', ', array_keys($byKey)))
+ );
+ }
+
+ if ($byKey !== []) {
+ return array_values($byKey)[0];
+ }
+
return $byUuid;
}//end lookup()
}//end class
diff --git a/lib/Service/ExampleSet/ExampleSetAppConfig.php b/lib/Service/ExampleSet/ExampleSetAppConfig.php
index f8d7531ac1..60d64ecec5 100644
--- a/lib/Service/ExampleSet/ExampleSetAppConfig.php
+++ b/lib/Service/ExampleSet/ExampleSetAppConfig.php
@@ -101,8 +101,21 @@ public function apply(string $app, string $key, string $value, bool $generate):
try {
if ($generate === true) {
- $this->appConfig->setValueString($app, $key, $this->random->generate(self::GENERATED_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC), false, true);
- return ['error' => '', 'entry' => ['app' => $app, 'key' => $key, 'had' => $had, 'old' => '', 'new' => '', 'generated' => true]];
+ $generated = $this->random->generate(self::GENERATED_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC);
+ $this->appConfig->setValueString($app, $key, $generated, false, true);
+ // A one-way fingerprint, never the value: remove can tell the generated value from one an administrator put there.
+ return [
+ 'error' => '',
+ 'entry' => [
+ 'app' => $app,
+ 'key' => $key,
+ 'had' => $had,
+ 'old' => '',
+ 'new' => '',
+ 'generated' => true,
+ 'fingerprint' => hash('sha256', $generated),
+ ],
+ ];
}
$this->appConfig->setValueString($app, $key, $value);
@@ -133,6 +146,13 @@ public function lost(array $entry): bool {
/**
* Put back what an entry replaced, while the key still holds what the set wrote.
*
+ * A generated value is recognised by its fingerprint (the record never
+ * holds the value): one that no longer has it was replaced by somebody,
+ * and stays. A key that held an empty value before the set generated one
+ * gets that value back; a key that was not there is deleted. An entry
+ * from before fingerprints was recorded has none to compare and is
+ * restored as it always was.
+ *
* @param array $entry A record entry from apply().
*
* @return string `restored`, `left` (somebody changed it since) or `failed`.
@@ -142,13 +162,13 @@ public function lost(array $entry): bool {
public function revert(array $entry): string {
$app = (string) $entry['app'];
$key = (string) $entry['key'];
- $changed = ($this->has(app: $app, key: $key) === true && $this->read(app: $app, key: $key) !== (string) $entry['new']);
- if (($entry['generated'] ?? false) === false && $changed === true) {
+ $generated = (($entry['generated'] ?? false) === true);
+ if ($this->changedSince(entry: $entry) === true) {
return 'left';
}
try {
- if ($entry['had'] === true && ($entry['generated'] ?? false) === false) {
+ if ($entry['had'] === true && ($generated === false || isset($entry['fingerprint']) === true)) {
$this->appConfig->setValueString($app, $key, (string) $entry['old']);
return 'restored';
}
@@ -161,6 +181,31 @@ public function revert(array $entry): string {
return 'restored';
}//end revert()
+ /**
+ * Whether the key no longer holds what the set wrote: a written value that differs, or a generated one that lost its fingerprint.
+ *
+ * @param array $entry A record entry from apply().
+ *
+ * @return bool
+ */
+ private function changedSince(array $entry): bool {
+ $app = (string) $entry['app'];
+ $key = (string) $entry['key'];
+ if ($this->has(app: $app, key: $key) === false) {
+ return false;
+ }
+
+ if (($entry['generated'] ?? false) === true) {
+ if (isset($entry['fingerprint']) === false) {
+ return false;
+ }
+
+ return hash_equals((string) $entry['fingerprint'], hash('sha256', $this->read(app: $app, key: $key))) === false;
+ }
+
+ return $this->read(app: $app, key: $key) !== (string) $entry['new'];
+ }//end changedSince()
+
/**
* Whether a key exists.
*
diff --git a/lib/Service/ExampleSet/ExampleSetChanges.php b/lib/Service/ExampleSet/ExampleSetChanges.php
index aace3cf4d2..d9cfe27b69 100644
--- a/lib/Service/ExampleSet/ExampleSetChanges.php
+++ b/lib/Service/ExampleSet/ExampleSetChanges.php
@@ -80,7 +80,18 @@ public function resolve(array $change, array $context): array {
return array_merge($none, ['refusal' => 'cannot resolve '.implode(', ', array_unique($gaps))]);
}
- $row = $this->target(change: $change, where: (array) $fill['where'], objects: (array) ($context['objects'] ?? []));
+ $found = $this->target(change: $change, where: (array) $fill['where'], objects: (array) ($context['objects'] ?? []));
+ $row = $found[0] ?? null;
+ if (count($found) > 1) {
+ return array_merge(
+ $none,
+ [
+ 'refusal' => count($found).' '.$change['register'].' '.$change['schema'].' rows match '
+ .json_encode($fill['where'], JSON_UNESCAPED_UNICODE).'; a change needs exactly one to change',
+ ]
+ );
+ }
+
if ($row === null && is_string($change['object'] ?? null) === true) {
return array_merge($none, ['refusal' => 'object "'.$change['object'].'" is not written earlier in this fragment']);
}
@@ -179,29 +190,54 @@ public function apply(array $change, array $resolved): array {
*
* Made like a change, so only a field that differs is written, its old
* value is recorded, the install proves it and remove can put it back.
+ * A field is refreshed only while it still holds what the set wrote
+ * (`$written`): a different value is somebody's edit and is left as it
+ * is, and named.
*
- * @param array $object The declared object (`register`, `schema`, `jsonFields`).
- * @param array $row The object as stored.
- * @param array $values The resolved values, by top-level field.
+ * @param array $object The declared object (`register`, `schema`, `jsonFields`).
+ * @param array $row The object as stored.
+ * @param array $values The resolved values, by top-level field.
+ * @param array $written What the set last wrote into those fields, by field.
*
- * @return array{error: string, entry: array|null} The record entry (null when every field
- * already holds its value), or why the write failed.
+ * @return array{error: string, entry: array|null, left: array} The record entry (null
+ * when no field had to be written), or why the write failed, and the fields left because somebody changed them.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023
*/
- public function refresh(array $object, array $row, array $values): array {
+ public function refresh(array $object, array $row, array $values, array $written=[]): array {
$change = ['register' => (string) $object['register'], 'schema' => (string) $object['schema'], 'jsonFields' => $this->jsonFields(change: $object)];
$row = $this->paths->withJsonDecoded(row: $row, fields: $change['jsonFields']);
- return $this->apply(
+ $same = static fn (mixed $first, mixed $second): bool => json_encode($first) === json_encode($second);
+ $todo = [];
+ $left = [];
+ foreach ($values as $field => $value) {
+ $held = $this->paths->atPath(row: $row, path: (string) $field);
+ if ($held['found'] === true && $same($held['value'], $value) === true) {
+ continue;
+ }
+
+ $wrote = array_key_exists($field, $written);
+ if ($held['found'] === true && $wrote === true && $same($held['value'], $written[$field]) === true) {
+ $todo[$field] = $value;
+ continue;
+ }
+
+ $left[] = (string) $field;
+ }
+
+ // Nothing to write (every field already holds its value, or was edited) makes apply() answer no entry.
+ $made = $this->apply(
change: $change,
resolved: [
'row' => $row,
- 'merged' => $this->paths->withJsonText(row: array_merge($row, $values), fields: $change['jsonFields']),
- 'set' => $values,
+ 'merged' => $this->paths->withJsonText(row: array_merge($row, $todo), fields: $change['jsonFields']),
+ 'set' => $todo,
'append' => [],
]
);
+
+ return ($made + ['left' => $left]);
}//end refresh()
/**
@@ -239,24 +275,37 @@ public function lost(array $entry): array {
}//end lost()
/**
- * Put back what a change replaced and take out what it appended.
+ * Put back what a change replaced and take out what it appended, while the object still holds what the change wrote.
+ *
+ * A path whose value is no longer the one the change wrote was edited
+ * since: it is left as it is now and named, as app config and user
+ * preferences are. A value the change appended is taken out of its list
+ * wherever the list is now, because that undoes exactly the change.
*
* @param array $entry A record entry from apply().
*
- * @return bool True when the object no longer carries the change, or is gone.
+ * @return array{ok: bool, left: array} Whether the object no longer carries the change (or is gone),
+ * and the paths left because somebody changed them.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-field-a-schema-keeps-as-json-text-is-sent-as-text-req-exs-012
*/
- public function revert(array $entry): bool {
+ public function revert(array $entry): array {
$row = $this->store->get(register: (string) $entry['register'], schema: (string) $entry['schema'], id: (string) $entry['id']);
if ($row === null) {
- return true;
+ return ['ok' => true, 'left' => []];
}
$fields = $this->jsonFields(change: $entry);
$row = $this->paths->withJsonDecoded(row: $row, fields: $fields);
+ $left = [];
foreach (array_reverse((array) $entry['set']) as $set) {
+ $held = $this->paths->atPath(row: $row, path: $set['path']);
+ if ($held['found'] === false || $held['value'] !== $set['new']) {
+ $left[] = (string) $set['path'];
+ continue;
+ }
+
$row = $this->paths->withoutPath(row: $row, path: $set['path']);
if ($set['had'] === true) {
$row = $this->paths->withPath(row: $row, path: $set['path'], value: $set['old']);
@@ -277,8 +326,9 @@ public function revert(array $entry): bool {
}
$row = $this->paths->withJsonText(row: $row, fields: $fields);
+ $why = $this->store->update(register: (string) $entry['register'], schema: (string) $entry['schema'], id: (string) $entry['id'], data: $row);
- return $this->store->update(register: (string) $entry['register'], schema: (string) $entry['schema'], id: (string) $entry['id'], data: $row) === '';
+ return ['ok' => ($why === ''), 'left' => $left];
}//end revert()
/**
@@ -309,39 +359,29 @@ private function jsonFields(array $change): array {
}//end jsonFields()
/**
- * The one object a change describes: an object written earlier in the same
- * fragment by its key, or the object its `where` finds; null otherwise.
+ * The objects a change describes: the object written earlier in the same
+ * fragment by its key, or every object its `where` finds.
*
* @param array $change The declared change.
* @param array $where The filled `where`.
* @param array> $objects The fragment's objects so far, by key.
*
- * @return array|null
+ * @return array> None, one, or every match; a change is made only on exactly one.
*/
- private function target(array $change, array $where, array $objects): ?array {
+ private function target(array $change, array $where, array $objects): array {
if (is_string($change['object'] ?? null) === true) {
- return ($objects[$change['object']] ?? null);
- }
+ $earlier = ($objects[$change['object']] ?? null);
+ if ($earlier === null) {
+ return [];
+ }
- $register = (string) $change['register'];
- $schema = (string) $change['schema'];
- if ($where === []) {
- return null;
+ return [$earlier];
}
- foreach ([$where, []] as $filters) {
- foreach ($this->store->find(register: $register, schema: $schema, filters: $filters) as $row) {
- $match = true;
- foreach ($where as $field => $value) {
- $match = ($match === true && ($row[$field] ?? null) === $value);
- }
-
- if ($match === true) {
- return $row;
- }
- }
+ if ($where === []) {
+ return [];
}
- return null;
+ return $this->store->matching(register: (string) $change['register'], schema: (string) $change['schema'], where: $where);
}//end target()
}//end class
diff --git a/lib/Service/ExampleSet/ExampleSetInstaller.php b/lib/Service/ExampleSet/ExampleSetInstaller.php
index 0967980de9..52693d43a6 100644
--- a/lib/Service/ExampleSet/ExampleSetInstaller.php
+++ b/lib/Service/ExampleSet/ExampleSetInstaller.php
@@ -134,7 +134,7 @@ public function check(array $plan, array $adopt=[], ?DateTimeImmutable $today=nu
$standIn = $this->standIn(app: $fragment['app'], object: $object, data: $resolved['data']);
$planned[] = ['register' => $object['register'], 'schema' => $object['schema'], 'row' => $standIn];
- $kept = $this->kept(record: $record, app: $fragment['app'], key: $object['key']);
+ $kept = $this->kept(record: $record, app: $fragment['app'], key: $object['key'])['row'];
$objects[$object['key']] = ($kept ?? $standIn);
}//end foreach
@@ -180,6 +180,7 @@ public function install(array $plan, array $adopt=[], string $password='', ?Date
'passwords' => [],
'types' => [],
'dropped' => [],
+ 'left' => [],
'missing' => [],
'lost' => [],
'ok' => false,
@@ -296,7 +297,16 @@ private function installFragment(array $plan, array $fragment, array $base, arra
$report['types'][$type] ??= ['declared' => 0, 'created' => 0, 'kept' => 0, 'arrived' => 0];
$report['types'][$type]['declared']++;
- $kept = $this->kept(record: $record, app: $fragment['app'], key: $object['key']);
+ $held = $this->kept(record: $record, app: $fragment['app'], key: $object['key']);
+ if ($held['error'] !== '') {
+ // A read that failed proves nothing about the object: writing it again would leave two copies and
+ // forget the original, so nothing is written and the recorded id stays.
+ $report['dropped'][] = $fragment['file'].': object "'.$object['key'].'": the object the record holds could not be read ('.$held['error'].')';
+ $objects[$object['key']] = ['id' => (string) $record['objects'][$name]['id']];
+ continue;
+ }
+
+ $kept = $held['row'];
if ($kept !== null) {
$report['types'][$type]['kept']++;
$objects[$object['key']] = $this->refreshReferences(
@@ -329,6 +339,11 @@ private function installFragment(array $plan, array $fragment, array $base, arra
$report['types'][$type]['created']++;
$record['objects'][$name] = ['register' => $object['register'], 'schema' => $object['schema'], 'id' => $made['id']];
+ $refs = array_intersect_key($resolved['data'], array_flip($this->referenceFields(object: $object)));
+ if ($refs !== []) {
+ $record['objects'][$name]['refs'] = $refs;
+ }
+
$this->record->write(set: (string) $plan['set'], record: $record);
$written[$name] = $resolved['data'];
$report = $this->attachFiles(plan: $plan, fragment: $fragment, object: $object, objectId: $made['id'], report: $report, record: $record);
@@ -383,7 +398,16 @@ private function refreshReferences(array $plan, array $fragment, array $object,
return $row;
}
- $made = $this->changes->refresh(object: $object, row: $row, values: array_intersect_key($resolved['data'], array_flip($fields)));
+ $made = $this->changes->refresh(
+ object: $object,
+ row: $row,
+ values: array_intersect_key($resolved['data'], array_flip($fields)),
+ written: $this->writtenReferences(record: $record, name: $name, key: $fragment['app'].'/'.$object['key'])
+ );
+ foreach ($made['left'] as $field) {
+ $report['left'][] = $fragment['file'].': object "'.$object['key'].'": field "'.$field.'" was changed after the install and is left as it is';
+ }
+
if ($made['error'] !== '') {
$report['dropped'][] = $fragment['file'].': object "'.$object['key'].'": its references cannot be refreshed: '.$made['error'];
$report['changes'][$name] = 'failed';
@@ -410,6 +434,29 @@ private function refreshReferences(array $plan, array $fragment, array $object,
return ($this->store->get(register: $object['register'], schema: $object['schema'], id: (string) $entry['id']) ?? $row);
}//end refreshReferences()
+ /**
+ * What the install last wrote into the reference fields of a kept object.
+ *
+ * The value a refresh wrote when there was one (the newest `new` of the
+ * `#references` change), else the value the object was created with.
+ *
+ * @param array $record The record.
+ * @param string $name The record key of the refresh change, `/#references`.
+ * @param string $key The record key of the object, `/`.
+ *
+ * @return array The value by field; a field of a record that predates this is missing.
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023
+ */
+ private function writtenReferences(array $record, string $name, string $key): array {
+ $written = (array) ($record['objects'][$key]['refs'] ?? []);
+ foreach ((array) ($record['changes'][$name]['set'] ?? []) as $set) {
+ $written[(string) $set['path']] = $set['new'];
+ }
+
+ return $written;
+ }//end writtenReferences()
+
/**
* The top-level fields of an object whose declared value references another object.
*
@@ -544,9 +591,10 @@ private function resolve(array $object, array $context, array $planned): array {
/**
* The id of the one row a lookup by business key describes.
*
- * The match is checked here as well: a register that does not filter on a
+ * The match is checked as well: a register that does not filter on a
* property hands over every row, and the first of those is not the one
- * that was asked for.
+ * that was asked for. Two rows that match (two organisations may share a
+ * business key) refuse the lookup, naming the count.
*
* @param string $register The register of the object being written.
* @param array $lookup `schema` and `where`; `register` when it is another one.
@@ -568,14 +616,13 @@ private function lookedUp(string $register, array $lookup, array $context, array
return ['id' => '', 'reason' => 'nothing: a lookup needs a schema and a where'];
}
- // Asked with the filter first, then without: OpenRegister does not
- // filter on every kind of property, and the match is checked here anyway.
- foreach ([$where, []] as $filters) {
- foreach ($this->store->find(register: $from, schema: $schema, filters: $filters) as $row) {
- if ($this->matches(row: $row, where: $where) === true) {
- return ['id' => $this->store->idOf(row: $row), 'reason' => ''];
- }
- }
+ $rows = $this->store->matching(register: $from, schema: $schema, where: $where);
+ if (count($rows) > 1) {
+ return ['id' => '', 'reason' => count($rows).' '.$from.' '.$schema.' rows with '.$this->asWords(where: $where).'; a lookup needs exactly one'];
+ }
+
+ if ($rows !== []) {
+ return ['id' => $this->store->idOf(row: $rows[0]), 'reason' => ''];
}
$id = $this->plannedMatch(planned: $planned, register: $from, schema: $schema, where: $where);
@@ -583,13 +630,24 @@ private function lookedUp(string $register, array $lookup, array $context, array
return ['id' => $id, 'reason' => ''];
}
+ return ['id' => '', 'reason' => 'no '.$from.' '.$schema.' with '.$this->asWords(where: $where)];
+ }//end lookedUp()
+
+ /**
+ * The asked values as words for a refusal.
+ *
+ * @param array $where The asked values, by field.
+ *
+ * @return string
+ */
+ private function asWords(array $where): string {
$words = [];
foreach ($where as $field => $value) {
$words[] = $field.' '.json_encode($value, JSON_UNESCAPED_UNICODE);
}
- return ['id' => '', 'reason' => 'no '.$from.' '.$schema.' with '.implode(' and ', $words)];
- }//end lookedUp()
+ return implode(' and ', $words);
+ }//end asWords()
/**
* The stand-in id of an object declared earlier in the set that a lookup describes, or ''.
@@ -603,7 +661,7 @@ private function lookedUp(string $register, array $lookup, array $context, array
*/
private function plannedMatch(array $planned, string $register, string $schema, array $where): string {
foreach ($planned as $object) {
- if ($object['register'] === $register && $object['schema'] === $schema && $this->matches(row: $object['row'], where: $where) === true) {
+ if ($object['register'] === $register && $object['schema'] === $schema && $this->store->rowMatches(row: $object['row'], where: $where) === true) {
return (string) $object['row']['id'];
}
}
@@ -611,45 +669,25 @@ private function plannedMatch(array $planned, string $register, string $schema,
return '';
}//end plannedMatch()
- /**
- * Whether a row holds every asked value.
- *
- * @param array $row The row.
- * @param array $where The asked values, by field.
- *
- * @return bool
- */
- private function matches(array $row, array $where): bool {
- foreach ($where as $field => $value) {
- $held = ($row[$field] ?? null);
- if ($held === $value) {
- continue;
- }
-
- if (is_scalar($held) === false || is_scalar($value) === false || (string) $held !== (string) $value) {
- return false;
- }
- }
-
- return true;
- }//end matches()
-
/**
* The row the record holds for an object, when it is still there.
*
+ * A read that failed is not "it is gone": the error comes back and the caller must not write the object again.
+ *
* @param array $record The record.
* @param string $app The app of the fragment.
* @param string $key The object key.
*
- * @return array|null
+ * @return array{row: array|null, error: string} The row; null with '' when the record holds none or the
+ * instance lost it; null with why when the read failed.
*/
- private function kept(array $record, string $app, string $key): ?array {
+ private function kept(array $record, string $app, string $key): array {
$held = ($record['objects'][$app.'/'.$key] ?? null);
if ($held === null) {
- return null;
+ return ['row' => null, 'error' => ''];
}
- return $this->store->get(register: $held['register'], schema: $held['schema'], id: $held['id']);
+ return $this->store->read(register: $held['register'], schema: $held['schema'], id: $held['id']);
}//end kept()
/**
diff --git a/lib/Service/ExampleSet/ExampleSetInstance.php b/lib/Service/ExampleSet/ExampleSetInstance.php
index 633f55e857..d3f6bad30f 100644
--- a/lib/Service/ExampleSet/ExampleSetInstance.php
+++ b/lib/Service/ExampleSet/ExampleSetInstance.php
@@ -32,6 +32,7 @@
use OCA\OpenRegister\Db\Organisation;
use OCA\OpenRegister\Db\OrganisationMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use Psr\Container\ContainerInterface;
use Throwable;
@@ -47,6 +48,15 @@ class ExampleSetInstance {
*/
private const LIMIT = 1000;
+ /**
+ * What delete() reports.
+ */
+ public const DELETED = 'deleted';
+
+ public const LEFT = 'left';
+
+ public const FAILED = 'failed';
+
/**
* Constructor.
*
@@ -126,8 +136,11 @@ public function ensure(array $declared): array {
try {
$found = $this->mapper()->findBySlug(slug: $declared['slug']);
return ['uuid' => (string) $found->getUuid(), 'created' => false, 'error' => ''];
- } catch (Throwable) {
+ } catch (DoesNotExistException) {
// Not there: created below.
+ } catch (Throwable $e) {
+ // A lookup that failed says nothing about the organisation; creating one now could duplicate the slug.
+ return ['uuid' => '', 'created' => false, 'error' => $e->getMessage()];
}
try {
@@ -144,28 +157,44 @@ public function ensure(array $declared): array {
}//end ensure()
/**
- * Delete an organisation by uuid; true when it is gone.
+ * Delete an organisation by uuid unless somebody else belongs to it.
+ *
+ * Only a lookup that says the organisation does not exist reads as gone;
+ * any other error leaves it in the record to be tried again. An
+ * organisation with a member that is neither an account the set made or
+ * adopted nor a leftover of an account that no longer exists is a tenant
+ * now, and deleting it would change what its objects mean and who sees
+ * them, so it stays.
*
- * @param string $uuid The organisation's uuid.
+ * @param string $uuid The organisation's uuid.
+ * @param (callable(string): bool)|null $ignorable Whether a member does not keep the organisation; null: none does.
*
- * @return bool
+ * @return string `deleted` (also when it was already gone), `left` (it has other members) or `failed`.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007
*/
- public function delete(string $uuid): bool {
+ public function delete(string $uuid, ?callable $ignorable = null): string {
try {
$organisation = $this->mapper()->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ return self::DELETED;
} catch (Throwable) {
- return true;
+ return self::FAILED;
+ }
+
+ foreach (($organisation->getUsers() ?? []) as $member) {
+ if ($ignorable === null || $ignorable((string) $member) === false) {
+ return self::LEFT;
+ }
}
try {
$this->mapper()->delete(entity: $organisation);
} catch (Throwable) {
- return false;
+ return self::FAILED;
}
- return true;
+ return self::DELETED;
}//end delete()
/**
diff --git a/lib/Service/ExampleSet/ExampleSetPrerequisites.php b/lib/Service/ExampleSet/ExampleSetPrerequisites.php
index a391d5c7ba..c691af5ebb 100644
--- a/lib/Service/ExampleSet/ExampleSetPrerequisites.php
+++ b/lib/Service/ExampleSet/ExampleSetPrerequisites.php
@@ -103,7 +103,8 @@ public function context(array $plan, DateTimeImmutable $today): array {
}//end context()
/**
- * Import every required register that is not on the instance, and record the import.
+ * Import every required register that is not on the instance, or that this set imported and removed the data of,
+ * and record the import.
*
* @param array $plan The plan.
* @param array $record The record; gains the imports.
@@ -117,7 +118,9 @@ private function ensureRegisters(array $plan, array &$record): array {
foreach ((array) ($plan['requires'] ?? []) as $slug => $required) {
$slug = (string) $slug;
$result['outcome'][$slug] = 'kept';
- if ($this->registers->exists(slug: $slug) === true) {
+ // A register this set imported whose import was removed again is on the instance without its data.
+ $dataRemoved = (array_key_exists($slug, $record['registers']) === true && $record['registers'][$slug] === []);
+ if ($this->registers->exists(slug: $slug) === true && $dataRemoved === false) {
continue;
}
diff --git a/lib/Service/ExampleSet/ExampleSetRecord.php b/lib/Service/ExampleSet/ExampleSetRecord.php
index 0a510289cf..29d7fdf761 100644
--- a/lib/Service/ExampleSet/ExampleSetRecord.php
+++ b/lib/Service/ExampleSet/ExampleSetRecord.php
@@ -91,7 +91,7 @@ public function __construct(
*/
public function read(string $set): array {
$record = self::BLANK;
- $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::PREFIX.$set, ''), true);
+ $decoded = json_decode($this->stored(set: $set), true);
if (is_array($decoded) === false) {
return $record;
}
@@ -138,7 +138,7 @@ private function memberships(array $decoded): array {
*
* @param array $decoded The stored objects.
*
- * @return array
+ * @return array}>
*/
private function objects(array $decoded): array {
$objects = [];
@@ -149,6 +149,10 @@ private function objects(array $decoded): array {
'schema' => (string) ($object['schema'] ?? ''),
'id' => $object['id'],
];
+ // What the install wrote into the object's reference fields, so a later install can tell its own value from an edit.
+ if (is_array($object['refs'] ?? null) === true && $object['refs'] !== []) {
+ $objects[(string) $key]['refs'] = $object['refs'];
+ }
}
}
@@ -166,7 +170,13 @@ private function objects(array $decoded): array {
*/
public function isEmpty(array $record): bool {
foreach (array_keys(self::BLANK) as $part) {
- if (($record[$part] ?? []) !== []) {
+ $held = ($record[$part] ?? []);
+ if ($part === 'registers') {
+ // A slug with no job left only remembers that its import was removed; it is not something installed.
+ $held = array_filter((array) $held, static fn ($jobs): bool => $jobs !== []);
+ }
+
+ if ($held !== []) {
return false;
}
}
@@ -177,6 +187,9 @@ public function isEmpty(array $record): bool {
/**
* Keep the record, or drop the key when nothing is left in it.
*
+ * What stays when nothing is installed is the slug of each register the
+ * set imported whose import jobs were all removed again (an empty job list).
+ *
* @param string $set The set id.
* @param array $record The record.
*
@@ -186,10 +199,51 @@ public function isEmpty(array $record): bool {
*/
public function write(string $set, array $record): void {
if ($this->isEmpty(record: $record) === true) {
+ // A register this set imported and removed again is remembered, so the next install imports its data again.
+ if (($record['registers'] ?? []) !== []) {
+ $this->store(set: $set, json: (string) json_encode(['registers' => $record['registers']]));
+ return;
+ }
+
$this->appConfig->deleteKey(Application::APP_ID, self::PREFIX.$set);
return;
}
- $this->appConfig->setValueString(Application::APP_ID, self::PREFIX.$set, (string) json_encode($record));
+ $this->store(set: $set, json: (string) json_encode($record));
}//end write()
+
+ /**
+ * The stored record as JSON text, '' when there is none.
+ *
+ * A record is kept as a lazy app-config value: Nextcloud loads every
+ * non-lazy value of every app on every request, and a demo instance's
+ * record (each object id, change, file, config entry and preference) is
+ * large and rewritten after every object an install creates. A record an
+ * earlier version stored as a non-lazy value is still found, and moves to
+ * the lazy store the next time it is written.
+ *
+ * @param string $set The set id.
+ *
+ * @return string
+ */
+ private function stored(string $set): string {
+ $json = $this->appConfig->getValueString(Application::APP_ID, self::PREFIX.$set, '', true);
+ if ($json === '') {
+ $json = $this->appConfig->getValueString(Application::APP_ID, self::PREFIX.$set, '', false);
+ }
+
+ return $json;
+ }//end stored()
+
+ /**
+ * Store the record's JSON text as a lazy value.
+ *
+ * @param string $set The set id.
+ * @param string $json The record as JSON text.
+ *
+ * @return void
+ */
+ private function store(string $set, string $json): void {
+ $this->appConfig->setValueString(Application::APP_ID, self::PREFIX.$set, $json, true);
+ }//end store()
}//end class
diff --git a/lib/Service/ExampleSet/ExampleSetRegisters.php b/lib/Service/ExampleSet/ExampleSetRegisters.php
index 211681e7c6..9a24e10304 100644
--- a/lib/Service/ExampleSet/ExampleSetRegisters.php
+++ b/lib/Service/ExampleSet/ExampleSetRegisters.php
@@ -6,8 +6,7 @@
* The OpenRegister registers an example set needs, named in the cast file's
* `requires` list. A required register that is not on the instance is
* imported from OpenRegister's own descriptor, the same way
- * `POST /api/register-descriptors/openregister/{slug}/import` does, or from
- * its mock descriptor when the set requires the mock. The
+ * `POST /api/register-descriptors/openregister/{slug}/import` does. The
* import jobs that import created are returned, so the set can remove exactly
* those objects again and leave an import somebody else ran alone.
*
@@ -51,12 +50,6 @@ class ExampleSetRegisters {
*/
private const APP = 'openregister';
- /**
- * The configuration ids OpenRegister's descriptor imports run under: its
- * own registers, and its mock registers, which import under their own id.
- */
- private const IMPORT_IDS = ['openregister', 'openregister.mock'];
-
/**
* Constructor.
*
@@ -93,10 +86,16 @@ public function exists(string $slug): bool {
/**
* Import a register from OpenRegister's own descriptor.
*
+ * The import job is the one the import itself reports. It is not worked out
+ * from the jobs the instance holds before and after: a job another process
+ * records in that window (a repair step during an upgrade, an
+ * administrator's re-import) would be taken for the set's, and remove would
+ * soft-delete its objects.
+ *
* @param string $slug The register slug.
*
* @return array{error: string, jobs: array} Why it failed, or ''; and
- * the import jobs it created.
+ * the import job it ran under.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009
*/
@@ -116,7 +115,7 @@ public function import(string $slug): array {
* @param string $slug The register slug.
*
* @return array{error: string, jobs: array} Why it failed, or ''; and
- * the import jobs it created.
+ * the import job it ran under.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009
*/
@@ -127,26 +126,24 @@ public function importMock(string $slug): array {
}//end importMock()
/**
- * Run one descriptor import and return why it failed and the import jobs it created.
+ * Run one descriptor import and return why it failed and the import job it reports.
*
* @param callable(RegisterDescriptorService): array $import The import.
*
* @return array{error: string, jobs: array}
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-set-imports-the-openregister-registers-it-requires-req-exs-009
*/
private function recordedImport(callable $import): array {
try {
- $before = $this->jobIds();
$result = $import($this->container->get(RegisterDescriptorService::class));
- $after = $this->jobIds();
} catch (Throwable $e) {
return ['error' => $e->getMessage(), 'jobs' => []];
}
$jobs = [];
- foreach ($after as $key => $job) {
- if (isset($before[$key]) === false) {
- $jobs[] = $job;
- }
+ if (is_string($result['importJobId'] ?? null) === true && $result['importJobId'] !== '') {
+ $jobs[] = ['appId' => (string) ($result['importAppId'] ?? self::APP), 'jobId' => $result['importJobId']];
}
$error = '';
@@ -194,21 +191,4 @@ public function removeImport(string $appId, string $jobId): bool {
return $clean;
}//end removeImport()
-
- /**
- * Every recorded import job of OpenRegister's own descriptors.
- *
- * @return array Keyed by `appId/jobId`.
- */
- private function jobIds(): array {
- $recorder = $this->container->get(AppImportJobRecorder::class);
- $jobs = [];
- foreach (self::IMPORT_IDS as $appId) {
- foreach ($recorder->jobs(appId: $appId) as $job) {
- $jobs[$appId.'/'.$job['jobId']] = ['appId' => $appId, 'jobId' => (string) $job['jobId']];
- }
- }
-
- return $jobs;
- }//end jobIds()
}//end class
diff --git a/lib/Service/ExampleSet/ExampleSetRemover.php b/lib/Service/ExampleSet/ExampleSetRemover.php
index 266a351fc1..98842e695d 100644
--- a/lib/Service/ExampleSet/ExampleSetRemover.php
+++ b/lib/Service/ExampleSet/ExampleSetRemover.php
@@ -131,13 +131,14 @@ public function remove(string $set): array {
$record['memberships'] = array_values($record['memberships']);
$this->record->write(set: $set, record: $record);
+ $accounts = array_map('strval', array_keys($record['users']));
$record = $this->removeGroupsAndAccounts(set: $set, record: $record, report: $report);
$this->record->write(set: $set, record: $record);
// Last: the set's own objects may point into a register it imported,
// and may belong to an organisation it created.
$record = $this->removeImports(set: $set, record: $record, report: $report);
- $this->removeOrganisations(set: $set, record: $record, report: $report);
+ $this->removeOrganisations(set: $set, record: $record, report: $report, accounts: $accounts);
return $report;
}//end remove()
@@ -147,17 +148,23 @@ public function remove(string $set): array {
*
* @param string $set The set id.
* @param array $record The record.
- * @param array $report The report; counts and failures are added.
+ * @param array $report The report; counts, paths left as somebody changed them, and failures.
*
* @return array The record without the reverted changes.
*/
private function revertChanges(string $set, array $record, array &$report): array {
foreach (array_reverse($record['changes'], true) as $name => $entry) {
- if ($this->changes->revert(entry: $entry) === false) {
+ $reverted = $this->changes->revert(entry: $entry);
+ if ($reverted['ok'] === false) {
$report['failed'][] = 'change '.$name.' on '.$entry['register'].' '.$entry['schema'].' ('.$entry['id'].')';
continue;
}
+ foreach ($reverted['left'] as $path) {
+ $report['left'][] = 'change '.$name.' on '.$entry['register'].' '.$entry['schema'].' ('.$entry['id'].'), path '.$path
+ .', which somebody changed after the install';
+ }
+
unset($record['changes'][$name]);
$report['removed']['changes']++;
$this->record->write(set: $set, record: $record);
@@ -169,6 +176,10 @@ private function revertChanges(string $set, array $record, array &$report): arra
/**
* Remove the register imports this set made.
*
+ * The register itself and its schemas stay, as they may hold other people's
+ * data; the record keeps the slug without jobs so that an install imports
+ * the register's data again (REQ-EXS-009).
+ *
* @param string $set The set id.
* @param array $record The record.
* @param array $report The report; counts and failures are added.
@@ -188,10 +199,9 @@ private function removeImports(string $set, array $record, array &$report): arra
$report['removed']['register imports']++;
}
+ // A slug whose jobs are all gone stays with no jobs: the register and its schemas remain on the instance, but
+ // the data the import made does not, and the next install must import it again.
$record['registers'][$slug] = $left;
- if ($left === []) {
- unset($record['registers'][$slug]);
- }
$this->record->write(set: $set, record: $record);
}//end foreach
@@ -302,19 +312,33 @@ private function restoreUserPreferences(string $set, array $record, array &$repo
*
* @param string $set The set id.
* @param array $record The record.
- * @param array $report The report; counts and failures are added.
+ * @param array $report The report; counts, organisations kept and failures are added.
+ * @param array $accounts The accounts the record held before remove deleted them; they, and any
+ * member that is no account any more, do not keep an organisation.
*
* @return void
*/
- private function removeOrganisations(string $set, array $record, array &$report): void {
+ private function removeOrganisations(string $set, array $record, array &$report, array $accounts): void {
$left = [];
foreach ($record['organisations'] as $uuid) {
- if ($this->instance->delete(uuid: $uuid) === false) {
+ $outcome = $this->instance->delete(
+ uuid: $uuid,
+ ignorable: fn (string $member): bool => (
+ in_array($member, $accounts, true) === true || $this->accounts->userExists(uid: $member) === false
+ )
+ );
+ if ($outcome === ExampleSetInstance::FAILED) {
$report['failed'][] = 'OpenRegister organisation '.$uuid;
$left[] = $uuid;
continue;
}
+ if ($outcome === ExampleSetInstance::LEFT) {
+ $report['left'][] = 'OpenRegister organisation '.$uuid.', which has members the set did not add';
+ $left[] = $uuid;
+ continue;
+ }
+
$report['removed']['organisations']++;
}
diff --git a/lib/Service/ExampleSet/ExampleSetStore.php b/lib/Service/ExampleSet/ExampleSetStore.php
index 58ed18ab76..f844b214bf 100644
--- a/lib/Service/ExampleSet/ExampleSetStore.php
+++ b/lib/Service/ExampleSet/ExampleSetStore.php
@@ -31,6 +31,7 @@
use OCA\OpenRegister\Service\Object\ValidateObject;
use OCA\OpenRegister\Service\ObjectService;
+use OCP\AppFramework\Db\DoesNotExistException;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Throwable;
@@ -40,6 +41,8 @@
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The one adapter between the loader and ObjectService/ValidateObject:
* every OpenRegister failure becomes a refusal or a reason here.
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) The loader's whole register surface (read, match, write, delete,
+ * ids) in one adapter, so the loader never touches ObjectService.
*
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-check-validates-every-object-against-its-real-schema-without-writing-req-exs-004
*/
@@ -167,9 +170,77 @@ public function find(string $register, string $schema, array $filters): array {
return $plain;
}//end find()
+ /**
+ * Every row of one schema that holds all the asked values, each once.
+ *
+ * Asked with the filter first, then without: OpenRegister does not filter
+ * on every kind of property, so a register that ignores the filter hands
+ * over every row, and the match is checked here either way. Reads across
+ * organisations, so a business key two tenants share yields both rows.
+ *
+ * @param string $register The register slug.
+ * @param string $schema The schema slug.
+ * @param array $where The asked values, by field.
+ *
+ * @return array> The matching rows; empty when none matches.
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-every-placeholder-resolves-or-the-set-is-refused-req-exs-002
+ */
+ public function matching(string $register, string $schema, array $where): array {
+ foreach ([$where, []] as $filters) {
+ $found = [];
+ foreach ($this->find(register: $register, schema: $schema, filters: $filters) as $row) {
+ if ($this->rowMatches(row: $row, where: $where) === false) {
+ continue;
+ }
+
+ $id = $this->idOf(row: $row);
+ if ($id === '') {
+ $id = 'row-'.count($found);
+ }
+
+ $found[$id] = $row;
+ }
+
+ if ($found !== []) {
+ return array_values($found);
+ }
+ }
+
+ return [];
+ }//end matching()
+
+ /**
+ * Whether a row holds every asked value.
+ *
+ * @param array $row The row.
+ * @param array $where The asked values, by field.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-every-placeholder-resolves-or-the-set-is-refused-req-exs-002
+ */
+ public function rowMatches(array $row, array $where): bool {
+ foreach ($where as $field => $value) {
+ $held = ($row[$field] ?? null);
+ if ($held === $value) {
+ continue;
+ }
+
+ if (is_scalar($held) === false || is_scalar($value) === false || (string) $held !== (string) $value) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end rowMatches()
+
/**
* One row by its id, or null when it is not there.
*
+ * Null is also what a read that failed gives; a caller that must tell
+ * the two apart uses {@see self::read()}.
+ *
* @param string $register The register slug.
* @param string $schema The schema slug.
* @param string $id The row's id.
@@ -179,24 +250,51 @@ public function find(string $register, string $schema, array $filters): array {
* @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-an-install-proves-what-arrived-req-exs-006
*/
public function get(string $register, string $schema, string $id): ?array {
+ return $this->read(register: $register, schema: $schema, id: $id)['row'];
+ }//end get()
+
+ /**
+ * One row by its id, and why it could not be read when that is not simply "it is not there".
+ *
+ * Only a row OpenRegister says does not exist (a miss, or null) is
+ * absent. A database error, a magic-table error or a schema that cannot
+ * be resolved says nothing about the row, so it comes back as an error:
+ * a caller must not read it as "the instance lost the object".
+ *
+ * @param string $register The register slug.
+ * @param string $schema The schema slug.
+ * @param string $id The row's id.
+ *
+ * @return array{row: array|null, error: string} The row with `id` set, or null with '' when it is
+ * not there, or null with why the read failed.
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-second-install-writes-nothing-req-exs-005
+ */
+ public function read(string $register, string $schema, string $id): array {
$service = $this->objectService();
- if ($service === null || $id === '') {
- return null;
+ if ($service === null) {
+ return ['row' => null, 'error' => 'OpenRegister\'s object service is not available'];
+ }
+
+ if ($id === '') {
+ return ['row' => null, 'error' => ''];
}
try {
$row = $service->find(id: $id, register: $register, schema: $schema, _rbac: false, _multitenancy: false);
- } catch (Throwable) {
- return null;
+ } catch (DoesNotExistException) {
+ return ['row' => null, 'error' => ''];
+ } catch (Throwable $e) {
+ return ['row' => null, 'error' => $e->getMessage()];
}
$row = $this->plain(row: $row);
if ($row === null || $this->idOf(row: $row) !== $id) {
- return null;
+ return ['row' => null, 'error' => ''];
}
- return (['id' => $id] + $row);
- }//end get()
+ return ['row' => (['id' => $id] + $row), 'error' => ''];
+ }//end read()
/**
* Write one row.
@@ -282,11 +380,15 @@ public function deleteRecorded(string $register, string $schema, string $id): st
return 'OpenRegister\'s object service is not available';
}
- // A row that cannot be found is gone, as the record's other steps read it.
+ // Only a row OpenRegister says is not there is gone; a read that failed
+ // proves nothing, so the row stays in the record and the reason is named.
try {
$row = $service->find(id: $id, register: $register, schema: $schema, _rbac: false, _multitenancy: false);
- } catch (Throwable) {
+ } catch (DoesNotExistException) {
$row = null;
+ } catch (Throwable $e) {
+ $this->logger->warning('OpenRegister: example set read before delete failed', ['schema' => $schema, 'id' => $id, 'reason' => $e->getMessage()]);
+ return 'it could not be read: '.$e->getMessage();
}
if ($row?->hasActiveLegalHold() === true) {
diff --git a/lib/Service/File/DeleteFileHandler.php b/lib/Service/File/DeleteFileHandler.php
index 16cbb58ef7..12b499fc31 100644
--- a/lib/Service/File/DeleteFileHandler.php
+++ b/lib/Service/File/DeleteFileHandler.php
@@ -125,8 +125,10 @@ public function deleteFile(Node|string|int $file, ?ObjectEntity $object = null):
throw new NotPermittedException("File {$file->getName()} is not deletable by the current session");
}
+ $fileId = $file->getId();
try {
$file->delete();
+ $survives = ($this->rootFolder->getFirstNodeById($fileId) !== null);
} catch (Exception $e) {
$this->logger->error(
message: '[DeleteFileHandler] Failed to delete file: ' . $e->getMessage(),
@@ -135,6 +137,19 @@ public function deleteFile(Node|string|int $file, ?ObjectEntity $object = null):
return false;
}
+ if ($survives === true) {
+ // Through the Node API Nextcloud ignores an operation a listener
+ // aborted (the frozen-object guard does): delete() returns, the file
+ // is still there. Saying true here makes every caller (the example
+ // set remover, the merge) believe a file is gone that is not.
+ $this->logger->warning(
+ message: '[DeleteFileHandler] The file was not deleted: it is still there after delete() '
+ . '(an operation was aborted, for instance by a frozen object).',
+ context: ['file' => __FILE__, 'line' => __LINE__, 'fileId' => $fileId]
+ );
+ return false;
+ }
+
return true;
}//end deleteFile()
}//end class
diff --git a/lib/Service/File/FileBatchHandler.php b/lib/Service/File/FileBatchHandler.php
index b0cf0b8648..ca6b2e9666 100644
--- a/lib/Service/File/FileBatchHandler.php
+++ b/lib/Service/File/FileBatchHandler.php
@@ -185,7 +185,12 @@ private function executeAction(
$this->fileService->unpublishFile(object: $object, filePath: $fileId);
break;
case 'delete':
- $this->fileService->deleteFile(file: $fileId, object: $object);
+ // FileService answers false for a file that is still there (an
+ // operation a listener aborted, such as the frozen-object guard):
+ // that is a failed delete, not a succeeded one.
+ if ($this->fileService->deleteFile(file: $fileId, object: $object) === false) {
+ throw new Exception('The file could not be deleted');
+ }
break;
case 'label':
$labels = $params['labels'] ?? [];
diff --git a/lib/Service/File/ObjectFolderResponder.php b/lib/Service/File/ObjectFolderResponder.php
index a156fc5d97..c3c997ce50 100644
--- a/lib/Service/File/ObjectFolderResponder.php
+++ b/lib/Service/File/ObjectFolderResponder.php
@@ -25,6 +25,7 @@
use InvalidArgumentException;
use OCA\OpenRegister\Exception\ObjectFileAccessDeniedException;
use OCA\OpenRegister\Exception\ObjectFolderConflictException;
+use OCA\OpenRegister\Exception\ObjectStateWriteException;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\NotFoundException;
use OCP\IL10N;
@@ -38,7 +39,9 @@
* gives, so a refusal never confirms the object exists; a reader who may not
* change it gets 403. A path that could leave the folder or a name that cannot
* be used is 400, a path or node not in the folder is 404, a taken name or a
- * locked file is 409. Anything else is a 500 that names nothing internal.
+ * locked file is 409, and so is a change to the files of a frozen or archived
+ * object, which answers with the state, who, when and why. Anything else is a
+ * 500 that names nothing internal.
*
* @spec openspec/changes/object-folder-in-files-browser/specs/file-actions/spec.md#requirement-changing-an-objects-folder-and-its-subfolders-follows-the-objects-update-rule
*/
@@ -87,6 +90,10 @@ public function failure(Throwable $e): JSONResponse {
return new JSONResponse(data: ['error' => $this->l10n->t('Not found')], statusCode: 404);
}
+ if ($e instanceof ObjectStateWriteException === true) {
+ return new JSONResponse(data: $e->toResponseBody(), statusCode: ObjectStateWriteException::HTTP_STATUS);
+ }
+
if ($e instanceof ObjectFolderConflictException === true) {
return new JSONResponse(data: ['error' => $this->conflictMessage(code: (int)$e->getCode())], statusCode: 409);
}
diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php
index 9da7fa34c8..eeb20f14f0 100644
--- a/lib/Service/FileService.php
+++ b/lib/Service/FileService.php
@@ -2337,8 +2337,22 @@ public function moveFile(ObjectEntity $sourceObject, int $fileId, ObjectEntity $
// Copy first.
$newFile = $this->copyFile(sourceObject: $sourceObject, fileId: $fileId, targetObject: $targetObject);
- // Delete source.
- $this->deleteFile(file: $fileId, object: $sourceObject);
+ // Delete source. A source that is still there afterwards (the
+ // frozen-object guard aborted the delete, which Nextcloud does not
+ // report) makes this a copy, not a move: the copy is taken back and the
+ // caller told, instead of leaving the file in two objects.
+ if ($this->deleteFile(file: $fileId, object: $sourceObject) === false) {
+ try {
+ $this->deleteFile(file: $newFile, object: $targetObject);
+ } catch (Exception $e) {
+ $this->logger->warning(
+ message: "[FileService] Could not take back the copy of file {$fileId} after the source could not be deleted: " . $e->getMessage(),
+ context: ['file' => __FILE__, 'line' => __LINE__]
+ );
+ }
+
+ throw new Exception('The file could not be moved: it could not be removed from the source object');
+ }
$sourceUuid = $sourceObject->getUuid();
$targetUuid = $targetObject->getUuid();
diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php
index f7da616e9d..72f4b2d294 100644
--- a/lib/Service/Flow/FlowRunService.php
+++ b/lib/Service/Flow/FlowRunService.php
@@ -881,6 +881,9 @@ public function retry(FlowRun $run): ?FlowRun {
* the seam exists to close, silently: the wrong person's answer arrives
* correctly formatted.
*
+ * Signals that arrive before the run wakes are merged, not replaced: a later
+ * signal adds its keys to the waiting one and wins only a key both carry.
+ *
* @param FlowRun $run The suspended run to wake.
* @param array $payload What the signaller wants the run to know.
*
@@ -896,7 +899,18 @@ public function signal(FlowRun $run, array $payload = []): ?FlowRun {
return null;
}
+ // Two listeners on one event may each signal the same suspended run.
+ // The signal still waiting is kept and the new payload is merged over
+ // it (a key both set takes the later value), so the answer does not
+ // depend on which listener ran last; a consumed signal is removed by
+ // persistResult(), so nothing left over from an earlier question can
+ // reach here.
$context = ($run->getContext() ?? []);
+ $waiting = ($context[self::SIGNAL_CONTEXT_KEY] ?? null);
+ if (is_array($waiting) === true) {
+ $payload = array_merge($waiting, $payload);
+ }
+
$context[self::SIGNAL_CONTEXT_KEY] = $payload;
$run->setContext($context);
@@ -1087,7 +1101,10 @@ public function executeNode(FlowRun $run, array $flow, object $subject, string $
}
if ($config !== []) {
- $step['config'] = array_merge((array)($step['config'] ?? []), $config);
+ $step['config'] = array_merge(
+ (array)($step['config'] ?? []),
+ $this->callerConfigFor(stepType: (string)($step['type'] ?? ''), config: $config)
+ );
}
$run->setStatus(FlowRun::STATUS_RUNNING);
@@ -1135,6 +1152,54 @@ private function stepById(array $pinned, string $nodeId): ?array {
return null;
}//end stepById()
+ /**
+ * Keep only the caller's keys that the node declares it reads.
+ *
+ * The caller of a direct invocation may read the subject, which is not the
+ * same as being the flow's author: a key the node's own vocabulary
+ * ({@see IFlowNodeConfigKeys::configKeys()}, else the keys of its
+ * {@see IFlowNodeConfigForm}) does not list is one the author never meant
+ * to expose, so it never reaches the node. A node that declares neither
+ * takes nothing from the caller (fail closed): the published step's own
+ * config is all it runs with.
+ *
+ * @param string $stepType The published step's node type.
+ * @param array $config The caller's config.
+ *
+ * @return array The caller's config limited to the node's declared keys.
+ *
+ * @spec openspec/changes/or-flow-run-node/specs/flow-run-node/spec.md#scenario-the-callers-config-reaches-the-node
+ */
+ private function callerConfigFor(string $stepType, array $config): array {
+ try {
+ $node = $this->registry->get(type: $stepType);
+ } catch (Throwable) {
+ // The dispatcher refuses an unknown type itself; nothing of the caller's reaches it.
+ return [];
+ }
+
+ $declared = null;
+ if ($node instanceof IFlowNodeConfigKeys) {
+ $declared = array_map(static fn ($key): string => (string)$key, $node->configKeys());
+ } else if ($node instanceof IFlowNodeConfigForm) {
+ $declared = array_map(
+ static fn ($field): string => (string)(((array)$field)['key'] ?? ''),
+ $node->configForm()
+ );
+ }
+
+ if ($declared === null) {
+ $this->logger->warning(
+ message: '[FlowRunService] A direct-invoke caller sent config to a node that declares no config keys; it was ignored.',
+ context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $stepType, 'keys' => array_keys($config)]
+ );
+
+ return [];
+ }
+
+ return array_intersect_key($config, array_flip($declared));
+ }//end callerConfigFor()
+
/**
* Fail a run whose named node is not part of the graph it is pinned to.
*
diff --git a/lib/Service/Form/FormDependentsCheck.php b/lib/Service/Form/FormDependentsCheck.php
index 321dc195a8..e064463355 100644
--- a/lib/Service/Form/FormDependentsCheck.php
+++ b/lib/Service/Form/FormDependentsCheck.php
@@ -62,6 +62,13 @@ class FormDependentsCheck {
*/
public const SUBJECT = 'form_unpublished';
+ /**
+ * The schemas whose save a caller is judging itself, by id, for this request.
+ *
+ * @var array
+ */
+ private array $claimed = [];
+
/**
* Constructor.
*
@@ -81,6 +88,39 @@ public function __construct(
}//end __construct()
+ /**
+ * Say that the caller of this save judges the dependent forms itself.
+ *
+ * The schema API assesses BEFORE the write, so it can refuse the save, and
+ * applies after it. Every other writer of a schema (the configuration
+ * importer, the import endpoint, the AI and MCP tools) has no response to
+ * refuse, and is covered by {@see \OCA\OpenRegister\Listener\FormDestinationSchemaListener}, which
+ * skips a save its caller has claimed so no form is unpublished or
+ * announced twice.
+ *
+ * @param int $schemaId The schema about to be saved.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it
+ */
+ public function claim(int $schemaId): void {
+ $this->claimed[$schemaId] = true;
+ }//end claim()
+
+ /**
+ * Whether the caller of this schema's save judges the dependent forms itself.
+ *
+ * @param int $schemaId The schema.
+ *
+ * @return bool True when it was claimed in this request.
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-saving-a-schema-must-re-check-the-forms-that-submit-into-it
+ */
+ public function isClaimed(int $schemaId): bool {
+ return isset($this->claimed[$schemaId]);
+ }//end isClaimed()
+
/**
* Judge every published form that submits into the proposed schema.
*
@@ -151,13 +191,22 @@ public function apply(array $assessment): array {
* @param string $schema The schema slug.
* @param array> $findings Why.
*
- * @return string `unpublished`, or `unpublish-failed` when the owning app could not.
+ * @return string `unpublished`, `unpublish-failed` when the owning app could not, or
+ * `unpublish-unavailable` when it registered the form without a way to unpublish it.
*/
private function unpublish(FormDependent $form, string $schema, array $findings): string {
+ if ($form->unpublish === null) {
+ // Nothing was unpublished: say so, and do not tell the author it was.
+ $this->logger->warning(
+ message: '[FormDependentsCheck] A form broken by a schema change has no way to be unpublished; it stays published',
+ context: ['form' => $form->id, 'app' => $form->app, 'schema' => $schema]
+ );
+
+ return 'unpublish-unavailable';
+ }
+
try {
- if ($form->unpublish !== null) {
- ($form->unpublish)();
- }
+ ($form->unpublish)();
} catch (Throwable $exception) {
$this->logger->error(
message: '[FormDependentsCheck] An owning app could not unpublish a form broken by a schema change',
diff --git a/lib/Service/Form/FormDestinationValidator.php b/lib/Service/Form/FormDestinationValidator.php
index ddb2bda716..13b07a3017 100644
--- a/lib/Service/Form/FormDestinationValidator.php
+++ b/lib/Service/Form/FormDestinationValidator.php
@@ -42,6 +42,7 @@
namespace OCA\OpenRegister\Service\Form;
use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Service\Schemas\GeneratedIdentifierDeclaration;
use OCP\IL10N;
/**
@@ -516,7 +517,7 @@ private function requiredFindings(Schema $schema, array $sourced): array {
$findings = [];
foreach (array_values(array_unique(array_map('strval', $required))) as $name) {
$property = (array)($properties[$name] ?? []);
- if (in_array($name, $sourced, true) === true || $this->isServerFilled(property: $property) === true) {
+ if (in_array($name, $sourced, true) === true || self::isServerFilled(property: $property) === true) {
continue;
}
@@ -531,17 +532,29 @@ private function requiredFindings(Schema $schema, array $sourced): array {
}//end requiredFindings()
/**
- * Whether the server fills a property: a default, a computation, or the serverSet marker.
+ * Whether the server fills a property: a default, a computation, a generated identifier, or the serverSet marker.
+ *
+ * The one definition of "no field needs to": the form's mapping check and
+ * the draft policy's `required` excusals both ask it, so a draft that one
+ * accepts the other accepts too.
*
* @param array $property The schema property.
*
* @return bool True when no field needs to.
+ *
+ * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-drafts-owner-must-be-able-to-make-it-definitive-through-the-object-api
*/
- private function isServerFilled(array $property): bool {
+ public static function isServerFilled(array $property): bool {
if (array_key_exists('default', $property) === true || isset($property['computed']) === true) {
return true;
}
+ // An `x-openregister-generated` identifier is issued by the creating
+ // listener, and on the save that leaves draft (decision 227).
+ if (array_key_exists(GeneratedIdentifierDeclaration::ANNOTATION, $property) === true) {
+ return true;
+ }
+
return self::marker(property: $property, name: self::MARKER_SERVER_SET);
}//end isServerFilled()
diff --git a/lib/Service/Form/FormIdempotencyStore.php b/lib/Service/Form/FormIdempotencyStore.php
index 7bdd3fef3d..987a68ab92 100644
--- a/lib/Service/Form/FormIdempotencyStore.php
+++ b/lib/Service/Form/FormIdempotencyStore.php
@@ -9,7 +9,14 @@
*
* Stored in app data, one small JSON file per key, named by a hash of the
* form scope and the key so a key never names a path. It holds the answer
- * (reference, id, receivedAt, confirmation), never the submitted payload.
+ * (reference, id, receivedAt, confirmation), never the submitted payload, and
+ * a binding: a hash of who submitted and of what. The key is client-chosen, so
+ * it alone never earns somebody the stored answer: another subject, or the
+ * same key with another payload, is refused with a 409 that reveals nothing.
+ *
+ * A key is reserved (an exclusive lock) while its submit runs, so a double
+ * click or a parallel retry with the same key waits out with a 409 instead of
+ * writing a second object.
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
@@ -30,11 +37,15 @@
namespace OCA\OpenRegister\Service\Form;
+use OCA\OpenRegister\Exception\FormSubmitRefusedException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
+use OCP\IL10N;
+use OCP\Lock\ILockingProvider;
+use OCP\Lock\LockedException;
/**
* Find, remember and purge submit answers by key.
@@ -60,27 +71,90 @@ class FormIdempotencyStore {
/**
* Constructor.
*
- * @param IAppData $appData The app's data store.
- * @param ITimeFactory $time The clock.
+ * @param IAppData $appData The app's data store.
+ * @param ITimeFactory $time The clock.
+ * @param ILockingProvider $locks Reserves a key while its submit runs.
+ * @param IL10N $l10n Translations, for refusals a resident reads.
*/
public function __construct(
private readonly IAppData $appData,
private readonly ITimeFactory $time,
+ private readonly ILockingProvider $locks,
+ private readonly IL10N $l10n,
) {
}//end __construct()
/**
- * The answer remembered under a key, or null when none is live.
+ * What a stored answer is bound to: the submitter and the payload.
+ *
+ * @param string $subject The submitting subject (a user id, a scope value, or an anonymous marker).
+ * @param array $payload What was submitted.
+ *
+ * @return string The binding.
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference
+ */
+ public function bindingFor(string $subject, array $payload): string {
+ return hash('sha256', $subject . "\0" . json_encode($this->canonical(value: $payload)));
+ }//end bindingFor()
+
+ /**
+ * Reserve a key for the length of its submit.
+ *
+ * @param string $scope The form scope.
+ * @param string $key The Idempotency-Key.
+ *
+ * @return void
*
- * @param string $scope The form scope (form id, or the caller's own).
+ * @throws FormSubmitRefusedException 409 when a submit with this key is still running.
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference
+ */
+ public function reserve(string $scope, string $key): void {
+ try {
+ $this->locks->acquireLock($this->lockPath(scope: $scope, key: $key), ILockingProvider::LOCK_EXCLUSIVE);
+ } catch (LockedException) {
+ throw new FormSubmitRefusedException(
+ message: $this->l10n->t('This form is already being sent. Please wait a moment.'),
+ status: 409
+ );
+ }
+ }//end reserve()
+
+ /**
+ * Release a key reserved by reserve().
+ *
+ * @param string $scope The form scope.
* @param string $key The Idempotency-Key.
*
+ * @return void
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference
+ */
+ public function release(string $scope, string $key): void {
+ try {
+ $this->locks->releaseLock($this->lockPath(scope: $scope, key: $key), ILockingProvider::LOCK_EXCLUSIVE);
+ } catch (\Throwable) {
+ // A lock that is already gone leaves nothing to release.
+ return;
+ }
+ }//end release()
+
+ /**
+ * The answer remembered under a key, or null when none is live.
+ *
+ * @param string $scope The form scope (form id, or the caller's own).
+ * @param string $key The Idempotency-Key.
+ * @param string $binding The binding of this request (see bindingFor()).
+ *
* @return array|null The answer.
*
+ * @throws FormSubmitRefusedException 409 when the key was used by another subject or for another payload.
+ *
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference
*/
- public function find(string $scope, string $key): ?array {
+ public function find(string $scope, string $key, string $binding): ?array {
$folder = $this->folder(create: false);
$name = $this->name(scope: $scope, key: $key);
if ($folder === null || $folder->fileExists($name) === false) {
@@ -95,6 +169,15 @@ public function find(string $scope, string $key): ?array {
return null;
}
+ if (hash_equals($entry['binding'], $binding) === false) {
+ // The same answer to a different asker would hand over their
+ // reference and confirmation; say only that the key is taken.
+ throw new FormSubmitRefusedException(
+ message: $this->l10n->t('This Idempotency-Key was already used for another submission.'),
+ status: 409
+ );
+ }
+
return $entry['response'];
}//end find()
@@ -104,14 +187,15 @@ public function find(string $scope, string $key): ?array {
* @param string $scope The form scope.
* @param string $key The Idempotency-Key.
* @param array $response The answer.
+ * @param string $binding The binding of the request that got it (see bindingFor()).
*
* @return void
*
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference
*/
- public function remember(string $scope, string $key, array $response): void {
+ public function remember(string $scope, string $key, array $response, string $binding): void {
$folder = $this->folder(create: true);
- $content = (string)json_encode(['expiresAt' => $this->time->getTime() + self::TTL, 'response' => $response]);
+ $content = (string)json_encode(['expiresAt' => $this->time->getTime() + self::TTL, 'binding' => $binding, 'response' => $response]);
$name = $this->name(scope: $scope, key: $key);
if ($folder->fileExists($name) === true) {
$folder->getFile($name)->putContent($content);
@@ -155,7 +239,7 @@ public function purge(): int {
*
* @param ISimpleFile $file The file.
*
- * @return array{expiresAt: int, response: array}|null The entry.
+ * @return array{expiresAt: int, binding: string, response: array}|null The entry.
*/
private function read(ISimpleFile $file): ?array {
$entry = json_decode($file->getContent(), true);
@@ -163,9 +247,45 @@ private function read(ISimpleFile $file): ?array {
return null;
}
- return ['expiresAt' => $entry['expiresAt'], 'response' => $entry['response']];
+ // An entry without a binding (written before bindings) matches nobody.
+ return ['expiresAt' => $entry['expiresAt'], 'binding' => (string)($entry['binding'] ?? ''), 'response' => $entry['response']];
}//end read()
+ /**
+ * A value with every map's keys in order, so equal payloads hash equally.
+ *
+ * @param mixed $value The value.
+ *
+ * @return mixed The value, keys sorted at every level.
+ */
+ private function canonical(mixed $value): mixed {
+ if (is_array($value) === false) {
+ return $value;
+ }
+
+ foreach ($value as $name => $item) {
+ $value[$name] = $this->canonical(value: $item);
+ }
+
+ if (array_is_list($value) === false) {
+ ksort($value);
+ }
+
+ return $value;
+ }//end canonical()
+
+ /**
+ * The lock path for a scope and key: the file's own hash, under this app.
+ *
+ * @param string $scope The form scope.
+ * @param string $key The key.
+ *
+ * @return string The lock path.
+ */
+ private function lockPath(string $scope, string $key): string {
+ return 'openregister/form-submit-key/' . hash('sha256', $scope . "\0" . $key);
+ }//end lockPath()
+
/**
* The file name for a scope and key: a hash, so a key never names a path.
*
diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php
index a7f876b264..cc20fc31dc 100644
--- a/lib/Service/Form/FormSubmitService.php
+++ b/lib/Service/Form/FormSubmitService.php
@@ -47,6 +47,7 @@
use OCA\OpenRegister\Db\Schema;
use OCA\OpenRegister\Db\SchemaMapper;
use OCA\OpenRegister\Exception\CustomValidationException;
+use OCA\OpenRegister\Exception\DraftAlreadyLeftException;
use OCA\OpenRegister\Exception\FormSubmitRefusedException;
use OCA\OpenRegister\Exception\NotAuthorizedException;
use OCA\OpenRegister\Service\Object\DraftFinder;
@@ -71,6 +72,8 @@
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The complexity is the submit contract:
* map, validate all, write in order, compensate, answer. Split, the all-or-none rule would
* live in two files that must agree on what a plan is.
+ * @SuppressWarnings(PHPMD.TooManyMethods) One private method per step of the submit (map, validate,
+ * write, compensate, answer, idempotency, uploads); the steps are the contract, not incidental helpers.
* @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::marker() and
* markedProperties() are pure reads of a schema property's `x-openregister` block;
* DraftStatusPolicy's promotion allowance is request-scoped state shared with the save path.
@@ -156,7 +159,7 @@ public function __construct(
*
* @return array The answer: reference, id, receivedAt, confirmation, objects.
*
- * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later.
+ * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 409 key taken or in use, 503 try again later.
*
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-must-create-the-destination-in-one-request-and-return-its-reference
*/
@@ -215,7 +218,7 @@ public function saveDraft(
string $scope = '',
?DraftOwner $owner = null,
): array {
- $owner = $this->ownerOf(subject: $subject, owner: $owner);
+ $owner = DraftOwner::resolve(subject: $subject, owner: $owner);
if ($owner === null) {
throw new FormSubmitRefusedException(message: $this->l10n->t('Please sign in to save this form and carry on later.'), status: 401);
}
@@ -228,6 +231,10 @@ public function saveDraft(
$this->validateAll(plans: $plans, mode: self::MODE_DRAFT);
$created = $this->writeAll(plans: $plans, subject: $owner->user(), status: ObjectEntity::STATUS_DRAFT, uuid: $draftId, owner: $owner);
+ // The files now sit on the draft: the tokens are spent, as after a submit,
+ // so they cannot be redeemed again for another object or a later save.
+ $this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans)));
+
return ['id' => (string)$created[0]['object']->getUuid(), 'status' => ObjectEntity::STATUS_DRAFT];
}//end saveDraft()
@@ -249,7 +256,7 @@ public function saveDraft(
*
* @return array The answer: reference, id, receivedAt, confirmation, objects.
*
- * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 503 try again later.
+ * @throws FormSubmitRefusedException 422 refused payload, 403 not allowed, 404 no destination, 409 key taken or in use, 503 try again later.
*
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-submit-writing-several-objects-must-commit-all-or-none
*/
@@ -263,28 +270,76 @@ public function submitAll(
?DraftOwner $owner = null,
): array {
$key = trim((string)$idempotencyKey);
+ if ($key === '') {
+ return $this->submitOnce(writes: $writes, payload: $payload, subject: $subject, scope: $scope, draftId: $draftId, owner: $owner);
+ }
+
$keyScope = $this->keyScope(scope: $scope, writes: $writes);
- if ($key !== '') {
- $remembered = $this->keys->find(scope: $keyScope, key: $key);
+ $binding = $this->keys->bindingFor(subject: $this->submitterOf(subject: $subject, owner: $owner), payload: $payload);
+
+ // Reserve the key for the length of the submit: a parallel retry with
+ // the same key is refused (409) instead of writing a second object.
+ $this->keys->reserve(scope: $keyScope, key: $key);
+ try {
+ $remembered = $this->keys->find(scope: $keyScope, key: $key, binding: $binding);
if ($remembered !== null) {
/*
* @var array $remembered
*/
return $remembered;
}
+
+ $answer = $this->submitOnce(writes: $writes, payload: $payload, subject: $subject, scope: $scope, draftId: $draftId, owner: $owner);
+ $this->keys->remember(scope: $keyScope, key: $key, response: $answer, binding: $binding);
+ } finally {
+ $this->keys->release(scope: $keyScope, key: $key);
}
+ return $answer;
+ }//end submitAll()
+
+ /**
+ * Write one submit and answer it, without any idempotency handling.
+ *
+ * @param array> $writes The writes, in order.
+ * @param array $payload What the submitter filled in.
+ * @param IUser|null $subject The signed-in subject; null for anonymous.
+ * @param string $scope The form id the tokens belong to.
+ * @param string|null $draftId The subject's draft to leave (one write only).
+ * @param DraftOwner|null $owner The draft's asserted owner.
+ *
+ * @return array The answer.
+ *
+ * @throws FormSubmitRefusedException 422, 403, 404 or 503.
+ */
+ private function submitOnce(array $writes, array $payload, ?IUser $subject, string $scope, ?string $draftId, ?DraftOwner $owner): array {
$plans = $this->plan(writes: $writes, payload: $payload, scope: $scope);
[$created, $receivedAt] = $this->writeOrPromote(plans: $plans, subject: $subject, draftId: $draftId, owner: $owner);
$this->uploads->claim(tokens: array_merge(...array_map(static fn (array $plan): array => $plan['tokens'], $plans)));
- $answer = $this->answer(created: $created, receivedAt: $receivedAt);
- if ($key !== '') {
- $this->keys->remember(scope: $keyScope, key: $key, response: $answer);
+
+ return $this->answer(created: $created, receivedAt: $receivedAt);
+ }//end submitOnce()
+
+ /**
+ * Who the submit is by, as the idempotency binding reads it.
+ *
+ * @param IUser|null $subject The signed-in subject.
+ * @param DraftOwner|null $owner The asserted draft owner.
+ *
+ * @return string The user id, a scope value, or the anonymous marker.
+ */
+ private function submitterOf(?IUser $subject, ?DraftOwner $owner): string {
+ if ($subject !== null) {
+ return 'user:' . $subject->getUID();
}
- return $answer;
- }//end submitAll()
+ if ($owner !== null && $owner->isScoped() === true) {
+ return 'scope:' . $owner->value();
+ }
+
+ return 'anonymous';
+ }//end submitterOf()
/**
* Validate and write a fresh submit, or promote a draft.
@@ -300,7 +355,7 @@ public function submitAll(
*/
private function writeOrPromote(array $plans, ?IUser $subject, ?string $draftId, ?DraftOwner $owner = null): array {
if ($draftId !== null) {
- return $this->promoteDraft(plans: $plans, owner: $this->ownerOf(subject: $subject, owner: $owner), draftId: $draftId);
+ return $this->promoteDraft(plans: $plans, owner: DraftOwner::resolve(subject: $subject, owner: $owner), draftId: $draftId);
}
$this->validateAll(plans: $plans);
@@ -331,6 +386,12 @@ private function promoteDraft(array $plans, ?DraftOwner $owner, string $draftId)
DraftStatusPolicy::allowPromotion(uuid: $draftId);
try {
$created = $this->writeAll(plans: $plans, subject: $owner?->user(), status: ObjectEntity::STATUS_ACTIVE, uuid: $draftId, owner: $owner);
+ // Two requests on one draft can both pass the "still a draft" read.
+ // The save that did not use the allowance found it already sent: it
+ // answers as for a draft that is gone and announces nothing.
+ if (DraftStatusPolicy::promotionWasUsed(uuid: $draftId) === false) {
+ throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404);
+ }
} finally {
DraftStatusPolicy::resetPromotions();
}
@@ -366,7 +427,7 @@ public function isOwnerOfDraft(array $destination, string $draftId, ?IUser $subj
return false;
}
- $owner = $this->ownerOf(subject: $subject, owner: $owner);
+ $owner = DraftOwner::resolve(subject: $subject, owner: $owner);
return $this->drafts->own(register: $register, schema: $schema, draftId: $draftId, owner: $owner) !== null;
}//end isOwnerOfDraft()
@@ -394,26 +455,6 @@ private function draftData(array $plan, string $draftId, ?DraftOwner $owner): ar
return DraftFinder::dataOf(object: $draft);
}//end draftData()
- /**
- * The draft's owner: the asserted one, else the signed-in subject, else nobody.
- *
- * @param IUser|null $subject The signed-in subject.
- * @param DraftOwner|null $owner The asserted owner.
- *
- * @return DraftOwner|null The owner, or null when there is none.
- */
- private function ownerOf(?IUser $subject, ?DraftOwner $owner): ?DraftOwner {
- if ($owner !== null) {
- return $owner;
- }
-
- if ($subject !== null) {
- return DraftOwner::ofUser(user: $subject);
- }
-
- return null;
- }//end ownerOf()
-
/**
* Read the owner's draft by id: its data, for resuming a form.
*
@@ -434,7 +475,7 @@ private function ownerOf(?IUser $subject, ?DraftOwner $owner): ?DraftOwner {
public function readDraft(array $destination, string $draftId, ?IUser $subject, ?DraftOwner $owner = null): array {
$register = $this->register(reference: $destination['register'] ?? null);
$schema = $this->schema(reference: $destination['schema'] ?? null);
- $owner = $this->ownerOf(subject: $subject, owner: $owner);
+ $owner = DraftOwner::resolve(subject: $subject, owner: $owner);
$draft = $this->drafts->own(register: $register, schema: $schema, draftId: $draftId, owner: $owner);
if ($draft === null) {
throw new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404);
@@ -460,7 +501,7 @@ public function readDraft(array $destination, string $draftId, ?IUser $subject,
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value
*/
public function findDraft(array $destination, ?IUser $subject, ?DraftOwner $owner = null, array $match = []): ?array {
- $owner = $this->ownerOf(subject: $subject, owner: $owner);
+ $owner = DraftOwner::resolve(subject: $subject, owner: $owner);
if ($owner === null) {
return null;
}
@@ -530,7 +571,8 @@ private function plan(array $writes, array $payload, string $scope): array {
*
* Only mapped fields reach the object: the submitter does not choose which
* properties a form writes. Without a mapping the payload is the object,
- * minus `_` control keys and `@` metadata, as the objects endpoint does.
+ * minus `_` control keys, `@` metadata and the `id` / `uuid` that would
+ * name an existing object, as the objects endpoint does.
*
* @param array|null $mapping The mapping.
* @param array $payload The payload.
@@ -573,11 +615,21 @@ private function mapPayload(?array $mapping, array $payload, string $scope): arr
}
}
+ // A submit never chooses the uuid of the object it writes. The save
+ // takes `id` (or `@self.id`) as the uuid of an existing object and
+ // updates it, so a submitter who names one would write over it, with
+ // RBAC off when the owner is a scope value. The only way to name an
+ // existing object is the draft id, which DraftFinder::own() checked.
+ unset($object['id']);
+ if (is_array($object['@self'] ?? null) === true) {
+ unset($object['@self']['id']);
+ }
+
return [$object, $files, $tokens];
}//end mapPayload()
/**
- * Property => value: the mapped fields, or without a mapping the payload minus control and metadata keys.
+ * Property => value: the mapped fields, or without a mapping the payload minus control keys, metadata and the object's `id` / `uuid`.
*
* @param array|null $mapping The mapping.
* @param array $payload The payload.
@@ -588,9 +640,8 @@ private function pairsOf(?array $mapping, array $payload): array {
$pairs = [];
if ($mapping === null) {
foreach ($payload as $name => $value) {
- $name = (string)$name;
- if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) {
- $pairs[$name] = $value;
+ if ($this->isPropertyKey(name: (string)$name) === true) {
+ $pairs[(string)$name] = $value;
}
}
@@ -608,6 +659,17 @@ private function pairsOf(?array $mapping, array $payload): array {
return $pairs;
}//end pairsOf()
+ /**
+ * Whether a payload key can be a property of the object: not a control key, metadata, or the `id` / `uuid` that name an object.
+ *
+ * @param string $name The payload key.
+ *
+ * @return bool True when it may reach the object.
+ */
+ private function isPropertyKey(string $name): bool {
+ return str_starts_with($name, '_') === false && str_starts_with($name, '@') === false && in_array($name, ['id', 'uuid'], true) === false;
+ }//end isPropertyKey()
+
/**
* The upload token a payload value carries, or null.
*
@@ -648,6 +710,12 @@ private function validateAll(array $plans, string $mode = self::MODE_FULL): void
$excused = array_merge($policy->requiredExcusals(schema: $plan['schema']), $excused);
}
+ // An upload token moved its file out of the object (mapPayload), so the
+ // property is absent from the validated object although it was supplied.
+ foreach (array_keys($plan['files']) as $uploaded) {
+ $excused[(string)$uploaded] = 'uploaded';
+ }
+
try {
$result = $this->validator->validateObject(
object: $object,
@@ -826,6 +894,10 @@ private function refusalFor(Throwable $exception, array $plan): FormSubmitRefuse
return $exception;
}
+ if ($exception instanceof DraftAlreadyLeftException) {
+ return new FormSubmitRefusedException(message: $this->l10n->t('This draft does not exist.'), status: 404);
+ }
+
$write = null;
if ($plan['named'] === true) {
$write = $plan['as'];
diff --git a/lib/Service/Form/FormUploadStore.php b/lib/Service/Form/FormUploadStore.php
index df4b29257e..eb8251bc0d 100644
--- a/lib/Service/Form/FormUploadStore.php
+++ b/lib/Service/Form/FormUploadStore.php
@@ -35,6 +35,7 @@
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
+use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IL10N;
use OCP\ITempManager;
@@ -60,6 +61,17 @@ class FormUploadStore {
*/
public const TTL = 86400;
+ /**
+ * The size cap, in bytes, of a file property that declares none (20 MiB).
+ *
+ * The upload endpoint is a public page, so "no `maxSize`" must not mean
+ * "no limit": it would store whatever PHP lets through for anyone. A
+ * property that declares a limit, larger or smaller, keeps it.
+ *
+ * @var int
+ */
+ public const DEFAULT_MAX_BYTES = 20971520;
+
/**
* Constructor.
*
@@ -106,7 +118,11 @@ public function issue(string $formId, string $property, array $rule, array $file
$size = (int)($file['size'] ?? filesize($path));
$limit = $this->rules->maxSizeBytes(fileConfig: $fileConfig);
- if ($limit > 0 && $size > $limit) {
+ if ($limit <= 0) {
+ $limit = self::DEFAULT_MAX_BYTES;
+ }
+
+ if ($size > $limit) {
$this->refuse(
property: $property,
code: 'file-too-large',
@@ -201,7 +217,7 @@ public function claim(array $tokens): void {
}//end claim()
/**
- * Delete every expired, unclaimed token.
+ * Delete every expired, unclaimed token, and the bytes of an upload whose record was never written.
*
* @return int How many tokens were deleted, so a zero is a counted zero.
*
@@ -215,25 +231,58 @@ public function purge(): int {
$now = $this->time->getTime();
$deleted = 0;
+ $gone = [];
foreach ($folder->getDirectoryListing() as $file) {
$name = $file->getName();
- if (str_ends_with($name, '.json') === false) {
+ if (str_ends_with($name, '.json') === true) {
+ $token = substr($name, 0, -5);
+ $meta = $this->meta(token: $token);
+ if ($meta !== null && $meta['expiresAt'] > $now) {
+ continue;
+ }
+
+ $this->deleteToken(folder: $folder, token: $token);
+ $gone[$token] = true;
+ $deleted++;
continue;
}
- $token = substr($name, 0, -5);
- $meta = $this->meta(token: $token);
- if ($meta !== null && $meta['expiresAt'] > $now) {
- continue;
+ if ($this->isOrphanedBytes(folder: $folder, file: $file, gone: $gone, now: $now) === true) {
+ $file->delete();
+ $deleted++;
}
-
- $this->deleteToken(folder: $folder, token: $token);
- $deleted++;
}
return $deleted;
}//end purge()
+ /**
+ * Whether a file is the bytes of an upload whose record was never written, and old enough not to be in flight.
+ * The bytes are written first, so a failed second write leaves a `.bin`
+ * issue() writes the bytes first, so a failed second write leaves a `.bin`
+ * nothing else lists. One younger than a token's lifetime may still be an
+ * upload in flight, and is left alone.
+ *
+ * @param ISimpleFolder $folder The store folder.
+ * @param ISimpleFile $file A file of the folder.
+ * @param array $gone Tokens this purge already deleted.
+ * @param int $now The moment of the purge.
+ *
+ * @return bool True when the purge deletes it.
+ */
+ private function isOrphanedBytes(ISimpleFolder $folder, ISimpleFile $file, array $gone, int $now): bool {
+ $name = $file->getName();
+ if (str_ends_with($name, '.bin') === false) {
+ return false;
+ }
+
+ $token = substr($name, 0, -4);
+
+ return isset($gone[$token]) === false
+ && $folder->fileExists($token . '.json') === false
+ && ($file->getMTime() + self::TTL) <= $now;
+ }//end isOrphanedBytes()
+
/**
* A token's record, or null when absent or unreadable.
*
diff --git a/lib/Service/Integration/CallSignature.php b/lib/Service/Integration/CallSignature.php
new file mode 100644
index 0000000000..287882151f
--- /dev/null
+++ b/lib/Service/Integration/CallSignature.php
@@ -0,0 +1,63 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @link https://www.OpenRegister.app
+ *
+ * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Integration;
+
+use ReflectionMethod;
+
+/**
+ * Reads the signature of a connector's `call`, so a caller can ask before it passes an option.
+ *
+ * The connector is another app and a different version of it is installed on
+ * every channel; PHP refuses an unknown named argument only when the method
+ * runs. Asked of the signature, the answer is the same for every call and no
+ * call is made to find out.
+ *
+ * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies
+ */
+final class CallSignature {
+
+ /**
+ * Whether `$service->$method()` names the parameter, or collects named arguments.
+ *
+ * @param object $service The connector's CallService.
+ * @param string $method The method asked about.
+ * @param string $name The parameter name.
+ *
+ * @return bool True when the method takes it.
+ *
+ * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies
+ */
+ public function accepts(object $service, string $method, string $name): bool {
+ if (method_exists($service, $method) === false) {
+ return false;
+ }
+
+ foreach ((new ReflectionMethod($service, $method))->getParameters() as $parameter) {
+ if ($parameter->getName() === $name || $parameter->isVariadic() === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end accepts()
+}//end class
diff --git a/lib/Service/Integration/ExternalIntegrationRouter.php b/lib/Service/Integration/ExternalIntegrationRouter.php
index ca38dcfa93..baff6da85d 100644
--- a/lib/Service/Integration/ExternalIntegrationRouter.php
+++ b/lib/Service/Integration/ExternalIntegrationRouter.php
@@ -54,6 +54,10 @@
* classifies the failure mode when something goes wrong. Per-provider
* specifics (URL paths, payload shapes) live in the provider
* implementations themselves. The router only provides the safe transport.
+ *
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The transport meets the app
+ * manager, the container, the object service, the source entity, the fleet id
+ * resolver and the connector's call signature; each is one side of the join.
*/
class ExternalIntegrationRouter {
use ConnectorRequestTrait;
@@ -706,8 +710,10 @@ private function invokeWithMeta($source, string $method, string $path, array $op
*
* With the `withholdBodies` option the connector records the call without
* its request and response bodies. A connector whose `call` has no such
- * parameter is not called at all: PHP refuses an unknown named argument
- * before the method runs, and the caller reports that as upstream-down.
+ * parameter is not called at all, and the call is refused with its own
+ * cause (`connector-cannot-withhold-bodies`) before PHP gets the chance to
+ * refuse the unknown named argument: that would read as an outage and be
+ * logged at error level on every call of an older connector.
*
* @param mixed $source Resolved source entity.
* @param string $method HTTP method.
@@ -717,6 +723,7 @@ private function invokeWithMeta($source, string $method, string $path, array $op
* @return mixed The raw CallService response.
*
* @throws \RuntimeException When CallService is unreachable.
+ * @throws ProviderUnavailableException When bodies are to be withheld and the connector cannot.
*
* @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies
*/
@@ -725,6 +732,16 @@ private function dispatch($source, string $method, string $path, array $options)
unset($options['withholdBodies']);
$callService = $this->container->get($this->callServiceClass());
+ if ($withholdBodies === true && (new CallSignature())->accepts(service: $callService, method: 'call', name: 'withholdBodies') === false) {
+ $this->logger->info(
+ '[ExternalIntegrationRouter] The connector CallService cannot withhold bodies; the call is not made.'
+ );
+ throw new ProviderUnavailableException(
+ message: 'The connector cannot record a call without its bodies, so the call is not made.',
+ cause: ProviderUnavailableException::CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES
+ );
+ }
+
$options = $this->connectorOptions(options: $options);
$path = $this->connectorEndpoint(source: $source, path: $path);
diff --git a/lib/Service/Integration/Providers/BrpPersonProvider.php b/lib/Service/Integration/Providers/BrpPersonProvider.php
index 528bd0a73d..e5ed9b9e9d 100644
--- a/lib/Service/Integration/Providers/BrpPersonProvider.php
+++ b/lib/Service/Integration/Providers/BrpPersonProvider.php
@@ -98,14 +98,6 @@ class BrpPersonProvider extends AbstractIntegrationProvider {
// go through FleetAppId, or the comparison is false on half the fleet.
private const REQUIRED_APP = 'integriq';
- /**
- * Default field set requested from HaalCentraal. The BRP API requires an
- * explicit `fields` list on every query. Mirrors the field set pipelinq's
- * `HaalCentraalClient` requests so the consuming app's normaliser receives
- * the subtrees it expects.
- *
- * @var array
- */
/**
* The source configuration flag that allows the residents count.
*/
@@ -121,6 +113,14 @@ class BrpPersonProvider extends AbstractIntegrationProvider {
*/
public const CAUSE_INVALID_IDENTIFIER = 'invalid-identifier';
+ /**
+ * Default field set requested from HaalCentraal. The BRP API requires an
+ * explicit `fields` list on every query. Mirrors the field set pipelinq's
+ * `HaalCentraalClient` requests so the consuming app's normaliser receives
+ * the subtrees it expects.
+ *
+ * @var array
+ */
private const DEFAULT_FIELDS = [
'burgerservicenummer',
'naam.voornamen',
diff --git a/lib/Service/MigrationPack/MappingEngine.php b/lib/Service/MigrationPack/MappingEngine.php
index ea18a97cd0..e94bfed373 100644
--- a/lib/Service/MigrationPack/MappingEngine.php
+++ b/lib/Service/MigrationPack/MappingEngine.php
@@ -40,6 +40,7 @@
namespace OCA\OpenRegister\Service\MigrationPack;
use DateTime;
+use OCP\IL10N;
/**
* Maps one source row (CSV row / Excel row / decoded JSON object) onto a set
@@ -48,6 +49,21 @@
* @spec openspec/specs/migration-mapping-packs/spec.md
*/
class MappingEngine {
+ /**
+ * Constructor.
+ *
+ * The row errors this engine returns are shown to the person running the
+ * import or the editor's preview, so each message goes through the app's
+ * translations; the values inside them stay as found.
+ *
+ * @param IL10N $l10n The app's translations.
+ */
+ public function __construct(
+ private readonly IL10N $l10n,
+ ) {
+
+ }//end __construct()
+
/**
* Apply a pack definition to one source row.
*
@@ -82,7 +98,7 @@ public function mapRow(array $pack, array $sourceRow, int $rowNumber): array {
'source' => $source,
'target' => $target,
'transform' => $transformId,
- 'message' => sprintf('Required source field "%s" is missing or empty', $source),
+ 'message' => $this->l10n->t('Required source field "%s" is missing or empty', [$source]),
];
continue;
}
@@ -232,7 +248,7 @@ private function applyTransform($value, ?array $transform, array $sourceRow): ar
case 'const':
return ['value' => ($transform['value'] ?? null), 'error' => null];
default:
- return ['value' => null, 'error' => 'Unknown transform type "' . (string)($transform['type'] ?? '') . '"'];
+ return ['value' => null, 'error' => $this->l10n->t('Unknown transform type "%s"', [(string)($transform['type'] ?? '')])];
}//end switch
}//end applyTransform()
@@ -260,7 +276,7 @@ private function applyDateTransform($value, array $transform): array {
if ($date === false) {
return [
'value' => null,
- 'error' => sprintf('Could not parse date "%s" with format "%s"', $stringValue, $sourceFormat),
+ 'error' => $this->l10n->t('Could not parse date "%1$s" with format "%2$s"', [$stringValue, $sourceFormat]),
];
}
}
@@ -269,7 +285,7 @@ private function applyDateTransform($value, array $transform): array {
$date = new DateTime($stringValue);
}
} catch (\Throwable $e) {
- return ['value' => null, 'error' => sprintf('Could not parse date "%s": %s', $stringValue, $e->getMessage())];
+ return ['value' => null, 'error' => $this->l10n->t('Could not parse date "%1$s": %2$s', [$stringValue, $e->getMessage()])];
}
return ['value' => $date->format($targetFormat), 'error' => null];
@@ -313,7 +329,7 @@ private function applyMapTransform($value, array $transform, bool $coerceBool):
// through unmapped into the target schema property.
return [
'value' => null,
- 'error' => sprintf('Value "%s" has no mapping and no default is configured', $key),
+ 'error' => $this->l10n->t('Value "%s" has no mapping and no default is configured', [$key]),
];
}//end applyMapTransform()
diff --git a/lib/Service/MigrationPack/MigrationPackRequestReader.php b/lib/Service/MigrationPack/MigrationPackRequestReader.php
index e449b3f42e..844d6df089 100644
--- a/lib/Service/MigrationPack/MigrationPackRequestReader.php
+++ b/lib/Service/MigrationPack/MigrationPackRequestReader.php
@@ -139,8 +139,9 @@ public function definition(IRequest $request): array {
unset($definition['id']);
$errors = $this->validator->validate(definition: $definition);
- $errors[] = 'An "id" in the request body that equals the row id in the URL cannot be told apart from it.'
- . ' Send the pack as {"definition": {...}} to keep that id.';
+ $errors[] = $this->l10n->t('An "id" in the request body that equals the row id in the URL cannot be told apart from it.')
+ . ' '
+ . $this->l10n->t('Send the pack as {"definition": {...}} to keep that id.');
throw new InvalidPackDefinitionException(errors: $errors);
}//end definition()
diff --git a/lib/Service/MigrationPack/PackDefinitionValidator.php b/lib/Service/MigrationPack/PackDefinitionValidator.php
index c143e4040f..e9936b238d 100644
--- a/lib/Service/MigrationPack/PackDefinitionValidator.php
+++ b/lib/Service/MigrationPack/PackDefinitionValidator.php
@@ -34,6 +34,7 @@
namespace OCA\OpenRegister\Service\MigrationPack;
use OCA\OpenRegister\Exception\InvalidPackDefinitionException;
+use OCP\IL10N;
/**
* Structural + business-rule validator for a migration-pack JSON document.
@@ -65,6 +66,21 @@ class PackDefinitionValidator {
*/
public const ALLOWED_ID_STRATEGY_TYPES = ['sourceField', 'generate'];
+ /**
+ * Constructor.
+ *
+ * Every message is shown to the person editing the pack (the pack editor
+ * lists them under the save error), so each goes through the app's
+ * translations; the property names and values inside them stay as written.
+ *
+ * @param IL10N $l10n The app's translations.
+ */
+ public function __construct(
+ private readonly IL10N $l10n,
+ ) {
+
+ }//end __construct()
+
/**
* Validate a pack definition document.
*
@@ -117,11 +133,11 @@ public function assertValid(array $definition): void {
private function validateId(array $definition): array {
$id = $definition['id'] ?? null;
if (is_string($id) === false || $id === '') {
- return ['"id" is required and must be a non-empty string'];
+ return [$this->l10n->t('"%s" is required and must be a non-empty string', ['id'])];
}
if (preg_match('/^[a-z0-9][a-z0-9-]*$/', $id) !== 1) {
- return ['"id" must be a lowercase slug (letters, digits, hyphens), got "' . $id . '"'];
+ return [$this->l10n->t('"id" must be a lowercase slug (letters, digits, hyphens), got "%s"', [$id])];
}
return [];
@@ -137,7 +153,7 @@ private function validateId(array $definition): array {
private function validateName(array $definition): array {
$name = $definition['name'] ?? null;
if (is_string($name) === false || $name === '') {
- return ['"name" is required and must be a non-empty string'];
+ return [$this->l10n->t('"%s" is required and must be a non-empty string', ['name'])];
}
return [];
@@ -154,8 +170,10 @@ private function validateSourceFormat(array $definition): array {
$format = $definition['sourceFormat'] ?? null;
if (is_string($format) === false || in_array($format, self::ALLOWED_SOURCE_FORMATS, true) === false) {
return [
- '"sourceFormat" must be one of: ' . implode(', ', self::ALLOWED_SOURCE_FORMATS)
- . ' (got ' . var_export($format, true) . ')',
+ $this->l10n->t(
+ '%1$s must be one of: %2$s (got %3$s)',
+ ['"sourceFormat"', implode(', ', self::ALLOWED_SOURCE_FORMATS), var_export($format, true)]
+ ),
];
}
@@ -172,7 +190,7 @@ private function validateSourceFormat(array $definition): array {
private function validateVersion(array $definition): array {
$version = $definition['version'] ?? null;
if (is_string($version) === false || preg_match('/^\d+\.\d+\.\d+$/', $version) !== 1) {
- return ['"version" must be a semver string (MAJOR.MINOR.PATCH), got ' . var_export($version, true)];
+ return [$this->l10n->t('"version" must be a semver string (MAJOR.MINOR.PATCH), got %s', [var_export($version, true)])];
}
return [];
@@ -197,33 +215,36 @@ private function validateVersion(array $definition): array {
private function validateFieldMappings(array $definition): array {
$mappings = $definition['fieldMappings'] ?? null;
if (is_array($mappings) === false || empty($mappings) === true) {
- return ['"fieldMappings" is required and must be a non-empty array'];
+ return [$this->l10n->t('"fieldMappings" is required and must be a non-empty array')];
}
$errors = [];
foreach (array_values($mappings) as $position => $mapping) {
- $row = 'Mapping row ' . ($position + 1);
+ $number = ($position + 1);
if (is_array($mapping) === false) {
- $errors[] = $row . ' must be an object';
+ $errors[] = $this->l10n->t('Mapping row %s must be an object', [$number]);
continue;
}
$source = $mapping['source'] ?? null;
if (is_string($source) === false || $source === '') {
- $errors[] = $row . ': source is required and must be a non-empty string';
+ $errors[] = $this->l10n->t('Mapping row %1$s: %2$s is required and must be a non-empty string', [$number, 'source']);
}
$target = $mapping['target'] ?? null;
if (is_string($target) === false || $target === '') {
- $errors[] = $row . ': target is required and must be a non-empty string';
+ $errors[] = $this->l10n->t('Mapping row %1$s: %2$s is required and must be a non-empty string', [$number, 'target']);
}
if (isset($mapping['required']) === true && is_bool($mapping['required']) === false) {
- $errors[] = $row . ': required must be a boolean when present';
+ $errors[] = $this->l10n->t('Mapping row %s: required must be a boolean when present', [$number]);
}
if (isset($mapping['transform']) === true) {
- $errors = array_merge($errors, $this->validateTransform(transform: $mapping['transform'], path: $row . ': transform'));
+ $errors = array_merge(
+ $errors,
+ $this->validateTransform(transform: $mapping['transform'], path: $this->l10n->t('Mapping row %s: transform', [$number]))
+ );
}
}//end foreach
@@ -243,55 +264,57 @@ private function validateFieldMappings(array $definition): array {
*/
private function validateTransform($transform, string $path): array {
if (is_array($transform) === false) {
- return [$path . ' must be an object with a "type" key'];
+ return [$this->l10n->t('%s must be an object with a "type" key', [$path])];
}
$type = $transform['type'] ?? null;
if (is_string($type) === false || in_array($type, self::ALLOWED_TRANSFORM_TYPES, true) === false) {
return [
- $path . '.type must be one of: ' . implode(', ', self::ALLOWED_TRANSFORM_TYPES)
- . ' (got ' . var_export($type, true) . ')',
+ $this->l10n->t(
+ '%1$s must be one of: %2$s (got %3$s)',
+ [$path . '.type', implode(', ', self::ALLOWED_TRANSFORM_TYPES), var_export($type, true)]
+ ),
];
}
switch ($type) {
case 'date':
if (isset($transform['sourceFormat']) === true && is_string($transform['sourceFormat']) === false) {
- return [$path . '.sourceFormat must be a string when present'];
+ return [$this->l10n->t('%s must be a string when present', [$path . '.sourceFormat'])];
}
if (isset($transform['targetFormat']) === true && is_string($transform['targetFormat']) === false) {
- return [$path . '.targetFormat must be a string when present'];
+ return [$this->l10n->t('%s must be a string when present', [$path . '.targetFormat'])];
}
break;
case 'bool-map':
if (is_array($transform['map'] ?? null) === false || empty($transform['map']) === true) {
- return [$path . '.map is required and must be a non-empty object for a bool-map transform'];
+ return [$this->l10n->t('%1$s is required and must be a non-empty object for a %2$s transform', [$path . '.map', 'bool-map'])];
}
break;
case 'lookup':
if (is_array($transform['map'] ?? null) === false || empty($transform['map']) === true) {
- return [$path . '.map is required and must be a non-empty object for a lookup transform'];
+ return [$this->l10n->t('%1$s is required and must be a non-empty object for a %2$s transform', [$path . '.map', 'lookup'])];
}
break;
case 'concat':
if (is_array($transform['fields'] ?? null) === false || empty($transform['fields']) === true) {
- return [$path . '.fields is required and must be a non-empty array for a concat transform'];
+ return [$this->l10n->t('%1$s is required and must be a non-empty array for a %2$s transform', [$path . '.fields', 'concat'])];
}
foreach ($transform['fields'] as $fieldIndex => $field) {
if (is_string($field) === false || $field === '') {
- return [$path . '.fields[' . $fieldIndex . '] must be a non-empty string'];
+ return [$this->l10n->t('%s must be a non-empty string', [$path . '.fields[' . $fieldIndex . ']'])];
}
}
break;
case 'const':
if (array_key_exists('value', $transform) === false) {
- return [$path . '.value is required for a const transform'];
+ return [$this->l10n->t('%1$s is required for a %2$s transform', [$path . '.value', 'const'])];
}
break;
@@ -317,7 +340,7 @@ private function validateDefaults(array $definition): array {
}
if (is_array($definition['defaults']) === false) {
- return ['"defaults" must be an object of target-property => default value when present'];
+ return [$this->l10n->t('"defaults" must be an object of target-property => default value when present')];
}
return [];
@@ -336,12 +359,12 @@ private function validateSkipRows(array $definition): array {
}
if (is_array($definition['skipRows']) === false) {
- return ['"skipRows" must be an array of row numbers when present'];
+ return [$this->l10n->t('"skipRows" must be an array of row numbers when present')];
}
foreach ($definition['skipRows'] as $row) {
if (is_int($row) === false || $row < 1) {
- return ['"skipRows" entries must be positive integers'];
+ return [$this->l10n->t('"skipRows" entries must be positive integers')];
}
}
@@ -358,21 +381,23 @@ private function validateSkipRows(array $definition): array {
private function validateIdStrategy(array $definition): array {
$idStrategy = $definition['idStrategy'] ?? null;
if (is_array($idStrategy) === false) {
- return ['"idStrategy" is required and must be an object with a "type" key'];
+ return [$this->l10n->t('"idStrategy" is required and must be an object with a "type" key')];
}
$type = $idStrategy['type'] ?? null;
if (is_string($type) === false || in_array($type, self::ALLOWED_ID_STRATEGY_TYPES, true) === false) {
return [
- 'idStrategy.type must be one of: ' . implode(', ', self::ALLOWED_ID_STRATEGY_TYPES)
- . ' (got ' . var_export($type, true) . ')',
+ $this->l10n->t(
+ '%1$s must be one of: %2$s (got %3$s)',
+ ['idStrategy.type', implode(', ', self::ALLOWED_ID_STRATEGY_TYPES), var_export($type, true)]
+ ),
];
}
if ($type === 'sourceField'
&& (is_string($idStrategy['field'] ?? null) === false || $idStrategy['field'] === '')
) {
- return ['idStrategy.field is required and must be a non-empty string when idStrategy.type is "sourceField"'];
+ return [$this->l10n->t('idStrategy.field is required and must be a non-empty string when idStrategy.type is "sourceField"')];
}
return [];
diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php
index 501df9692c..fde0a061c0 100644
--- a/lib/Service/Notification/AnnotationNotificationDispatcher.php
+++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php
@@ -581,6 +581,7 @@ public function dispatchWithSchema(ObjectEntity $object, string $trigger, array
$partiesReached += $this->dispatchToAddresses(
recipientsSpec: (array)($spec['recipients'] ?? []),
object: $object,
+ schema: $schema,
data: $data,
channels: $channels,
ruleId: (string)$name,
@@ -3341,6 +3342,7 @@ private function dispatchToParties(
*
* @param array $recipientsSpec The rule's `recipients` declaration.
* @param ObjectEntity $object The triggering object.
+ * @param Schema $schema The triggering object's schema.
* @param array $data Its data.
* @param array $channels The rule's channels.
* @param string $ruleId The rule, for the history row.
@@ -3355,6 +3357,7 @@ private function dispatchToParties(
private function dispatchToAddresses(
array $recipientsSpec,
ObjectEntity $object,
+ Schema $schema,
array $data,
array $channels,
string $ruleId,
@@ -3384,7 +3387,8 @@ private function dispatchToAddresses(
recipientsSpec: $recipientsSpec,
subject: $subject,
body: $body,
- category: $category
+ category: $category,
+ schema: $schema
);
$reached = 0;
diff --git a/lib/Service/Notification/ReferencedAddressNotifier.php b/lib/Service/Notification/ReferencedAddressNotifier.php
index db136f19ef..ce58a88828 100644
--- a/lib/Service/Notification/ReferencedAddressNotifier.php
+++ b/lib/Service/Notification/ReferencedAddressNotifier.php
@@ -29,6 +29,9 @@
namespace OCA\OpenRegister\Service\Notification;
use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\Object\SaveObject\RelationCascadeHandler;
use OCA\OpenRegister\Service\ObjectService;
use Psr\Log\LoggerInterface;
@@ -39,7 +42,12 @@
* fired the rule: a melder may not be allowed to read the supplier, and the
* rule still has to reach it. The read is bounded to the triggering object's
* own organisation instead, so a reference can never pull an address out of
- * another tenant.
+ * another tenant. It must also be an object of the schema the reference
+ * property points at (`$ref`): the value is data the person saving the
+ * triggering object controls, so without that check it could be aimed at any
+ * same-organisation object that happens to carry the named field. A reference
+ * property that declares no `$ref` names no schema to check against and is
+ * refused.
*
* @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007
*/
@@ -88,16 +96,20 @@ class ReferencedAddressNotifier {
/**
* Constructor.
*
- * @param ObjectService $objects The object layer, for the referenced object.
- * @param EmailSender $email The email channel.
- * @param OptOutAuthority $optOut Asks integriq whether an address may be mailed.
- * @param LoggerInterface $logger Logger.
+ * @param ObjectService $objects The object layer, for the referenced object.
+ * @param EmailSender $email The email channel.
+ * @param OptOutAuthority $optOut Asks integriq whether an address may be mailed.
+ * @param LoggerInterface $logger Logger.
+ * @param SchemaMapper $schemas The schemas, for the properties of a referenced object.
+ * @param RelationCascadeHandler $refs Resolves a property's `$ref` to a schema id.
*/
public function __construct(
private readonly ObjectService $objects,
private readonly EmailSender $email,
private readonly OptOutAuthority $optOut,
private readonly LoggerInterface $logger,
+ private readonly SchemaMapper $schemas,
+ private readonly RelationCascadeHandler $refs,
) {
}//end __construct()
@@ -114,6 +126,7 @@ public function __construct(
* @param string $subject The rule's subject.
* @param string $body The rule's message.
* @param string $category The rule's message category.
+ * @param Schema|null $schema The triggering object's schema, which declares what each reference may point at.
*
* @return array One outcome per `email` entry.
*
@@ -126,6 +139,7 @@ public function notify(
string $subject,
string $body,
string $category,
+ ?Schema $schema = null,
): array {
$entries = [];
foreach ($recipientsSpec as $recipient) {
@@ -134,7 +148,7 @@ public function notify(
}
$path = trim((string)($recipient['field'] ?? ''));
- $entries[] = ['field' => $path] + $this->resolveAddress(object: $object, data: $data, path: $path);
+ $entries[] = ['field' => $path] + $this->resolveAddress(object: $object, data: $data, path: $path, schema: $schema);
}
if ($entries === []) {
@@ -172,24 +186,27 @@ public function notify(
* Each segment but the last is read from the current data; when its value
* is a reference (a uuid string, or an object carrying `id` or `uuid`) the
* referenced object's data becomes the current data. The last segment is
- * the address.
+ * the address. A referenced object must be of the schema the reference
+ * property's `$ref` names; without a schema to read that from, no
+ * reference is followed.
*
* @param ObjectEntity $object The triggering object.
* @param array $data Its data.
* @param string $path The dotted path, e.g. `supplierRef.contactEmail`.
+ * @param Schema|null $schema The triggering object's schema.
*
* @return array{address: string|null, reason: string|null}
*
* @spec openspec/specs/external-recipient-opt-out/spec.md#requirement-an-email-recipient-reads-its-address-through-a-reference-req-ero-007
*/
- public function resolveAddress(ObjectEntity $object, array $data, string $path): array {
+ public function resolveAddress(ObjectEntity $object, array $data, string $path, ?Schema $schema = null): array {
$segments = array_values(array_filter(explode('.', $path), static fn (string $segment): bool => $segment !== ''));
if ($segments === [] || count($segments) > (self::MAX_HOPS + 1)) {
return ['address' => null, 'reason' => self::REASON_NO_REFERENCE];
}
$last = array_pop($segments);
- $followed = $this->follow(object: $object, data: $data, segments: $segments);
+ $followed = $this->follow(object: $object, data: $data, segments: $segments, schema: $schema);
if ($followed['reason'] !== null) {
return ['address' => null, 'reason' => $followed['reason']];
}
@@ -213,11 +230,13 @@ public function resolveAddress(ObjectEntity $object, array $data, string $path):
* @param ObjectEntity $object The triggering object.
* @param array $data Its data.
* @param array $segments The reference segments, in order.
+ * @param Schema|null $schema The triggering object's schema.
*
* @return array{data: array, reason: string|null} The data reached, or why it was not.
*/
- private function follow(ObjectEntity $object, array $data, array $segments): array {
+ private function follow(ObjectEntity $object, array $data, array $segments, ?Schema $schema): array {
$current = $data;
+ $currentSchema = $schema;
foreach ($segments as $segment) {
$reference = $this->referenceOf(value: ($current[$segment] ?? null));
if ($reference === null) {
@@ -233,7 +252,12 @@ private function follow(ObjectEntity $object, array $data, array $segments): arr
return ['data' => [], 'reason' => self::REASON_OTHER_TENANT];
}
+ if ($this->isOfDeclaredSchema(schema: $currentSchema, property: $segment, related: $related) === false) {
+ return ['data' => [], 'reason' => self::REASON_REFERENCE_NOT_FOUND];
+ }
+
$current = $related->getObject();
+ $currentSchema = $this->schemaOf(object: $related);
}
return ['data' => $current, 'reason' => null];
@@ -314,6 +338,68 @@ private function findReferenced(string $id): ?ObjectEntity {
}
}//end findReferenced()
+ /**
+ * Whether a referenced object is of the schema its reference property points at.
+ *
+ * The property's `$ref` is the only declaration of what the reference may
+ * name, so a property without one, or one that does not resolve to a schema,
+ * accepts nothing: the value is data the person saving the object controls.
+ *
+ * @param Schema|null $schema The schema that declares the property.
+ * @param string $property The reference property.
+ * @param ObjectEntity $related The referenced object.
+ *
+ * @return bool
+ */
+ private function isOfDeclaredSchema(?Schema $schema, string $property, ObjectEntity $related): bool {
+ $declared = (($schema?->getProperties() ?? [])[$property] ?? null);
+ $ref = null;
+ if (is_array($declared) === true) {
+ $ref = ($declared['$ref'] ?? null);
+ }
+
+ if (is_string($ref) === false || trim($ref) === '') {
+ $this->logger->debug(
+ sprintf('[ReferencedAddressNotifier] property "%s" declares no $ref, so its reference is not followed', $property)
+ );
+ return false;
+ }
+
+ $expected = $this->refs->resolveSchemaReference(reference: trim($ref));
+ $actual = (string)($related->getSchema() ?? '');
+ if ($expected === null || $actual === '' || $expected !== $actual) {
+ $this->logger->debug(
+ sprintf('[ReferencedAddressNotifier] reference "%s" is not an object of the schema "%s" declares', $property, $ref)
+ );
+ return false;
+ }
+
+ return true;
+ }//end isOfDeclaredSchema()
+
+ /**
+ * The schema of a referenced object, for the next hop; null when it cannot be read.
+ *
+ * @param ObjectEntity $object The referenced object.
+ *
+ * @return Schema|null
+ */
+ private function schemaOf(ObjectEntity $object): ?Schema {
+ $id = (string)($object->getSchema() ?? '');
+ if ($id === '') {
+ return null;
+ }
+
+ try {
+ return $this->schemas->find(id: $id, _rbac: false, _multitenancy: false);
+ } catch (\Throwable $e) {
+ $this->logger->debug(
+ sprintf('[ReferencedAddressNotifier] schema "%s" could not be read: %s', $id, $e->getMessage())
+ );
+ return null;
+ }
+ }//end schemaOf()
+
/**
* Whether the referenced object may be read for the triggering one.
*
diff --git a/lib/Service/Object/DraftActivation.php b/lib/Service/Object/DraftActivation.php
index b2a74981b7..ad2389cad2 100644
--- a/lib/Service/Object/DraftActivation.php
+++ b/lib/Service/Object/DraftActivation.php
@@ -45,6 +45,7 @@
use OCA\OpenRegister\Db\Schema;
use OCA\OpenRegister\Exception\CustomValidationException;
use OCA\OpenRegister\Exception\DraftActivationRefusedException;
+use OCA\OpenRegister\Exception\DraftAlreadyLeftException;
use OCA\OpenRegister\Exception\NotAuthorizedException;
use OCA\OpenRegister\Service\Form\FormPayloadFindings;
use OCA\OpenRegister\Service\ObjectService;
@@ -131,6 +132,12 @@ public function activate(Register $register, Schema $schema, string $draftId, Dr
_multitenancy: true,
currentUser: $owner->user()
);
+ // Two requests on one draft can both pass the "still a draft" read.
+ // The save that did not use the allowance found it already sent:
+ // it announces nothing, and answers as for an object that is no draft.
+ if (DraftStatusPolicy::promotionWasUsed(uuid: $uuid) === false) {
+ throw $this->alreadyLeft();
+ }
} catch (Throwable $exception) {
throw $this->refusalFor(exception: $exception);
} finally {
@@ -257,6 +264,10 @@ private function validateInFull(Schema $schema, array $object): void {
* @throws Throwable When the save failed for a reason that is not the caller's to fix.
*/
private function refusalFor(Throwable $exception): DraftActivationRefusedException {
+ if ($exception instanceof DraftAlreadyLeftException) {
+ return $this->alreadyLeft();
+ }
+
if ($this->findings->isPayloadRefusal(exception: $exception) === true) {
return $this->invalid(findings: $this->findings->fromThrowable(exception: $exception));
}
@@ -271,6 +282,18 @@ private function refusalFor(Throwable $exception): DraftActivationRefusedExcepti
throw $exception;
}//end refusalFor()
+ /**
+ * The 409 refusal for a draft a parallel request already made definitive.
+ *
+ * @return DraftActivationRefusedException The refusal.
+ */
+ private function alreadyLeft(): DraftActivationRefusedException {
+ return new DraftActivationRefusedException(
+ message: $this->l10n->t('This is not a draft, so there is nothing to make definitive.'),
+ reason: DraftActivationRefusedException::NOT_A_DRAFT
+ );
+ }//end alreadyLeft()
+
/**
* The 422 refusal.
*
diff --git a/lib/Service/Object/DraftExpiry.php b/lib/Service/Object/DraftExpiry.php
index da142535b4..4ab9a4ef61 100644
--- a/lib/Service/Object/DraftExpiry.php
+++ b/lib/Service/Object/DraftExpiry.php
@@ -17,7 +17,10 @@
* referenced object) keeps the draft and is counted as refused.
*
* The work of one run is bounded: at most MAX_REMOVALS drafts are removed,
- * the rest is counted as deferred and waits for the next run.
+ * and at most MAX_PAGES x PAGE_SIZE expired drafts are read per register and
+ * schema pair. The list asks the store for the expired drafts alone, so the
+ * rows read are expired rows, and what is left after the cap (removals and
+ * reads alike) is counted as deferred and waits for the next run.
*
* Nothing here names an app or a procedure (decision 182).
*
@@ -163,22 +166,26 @@ private function walk(DateTimeImmutable $now, int $budget): array {
foreach (($register->getSchemas() ?? []) as $schemaId) {
$schema = $this->schemaOf(schemaId: $schemaId);
- if ($schema === null || $this->holdsDrafts(schema: $schema) === false) {
- continue;
- }
-
- $days = $policy->draftLifetimeDays(schema: $schema);
+ $days = $this->lifetimeOf(schema: $schema, policy: $policy);
if ($days === null) {
continue;
}
$counts['schemas']++;
- $expired = $this->expiredIn(register: $register, schema: $schema, cutoff: $now->modify('-' . $days . ' days'));
- if ($expired === []) {
+ $cutoff = $now->modify('-' . $days . ' days');
+ [$expired, $unread] = $this->expiredIn(register: $register, schema: $schema, cutoff: $cutoff);
+ if ($expired === [] && $unread === 0) {
continue;
}
- $row = $this->removeWithin(register: $register, schema: $schema, expired: $expired, budget: ($budget - $counts['removed'] - $counts['refused']));
+ $row = $this->removeWithin(
+ register: $register,
+ schema: $schema,
+ expired: $expired,
+ budget: ($budget - $counts['removed'] - $counts['refused']),
+ cutoff: $cutoff,
+ unread: $unread
+ );
$row['lifetimeDays'] = $days;
$counts['perSchema'][] = $row;
foreach (['expired', 'removed', 'refused', 'deferred'] as $key) {
@@ -190,6 +197,22 @@ private function walk(DateTimeImmutable $now, int $budget): array {
return $counts;
}//end walk()
+ /**
+ * How many days the drafts of a schema may go unchanged, or null when the sweep leaves the schema alone.
+ *
+ * @param Schema|null $schema The schema, null when it could not be loaded.
+ * @param DraftStatusPolicy $policy The lifetime rules.
+ *
+ * @return int|null The days.
+ */
+ private function lifetimeOf(?Schema $schema, DraftStatusPolicy $policy): ?int {
+ if ($schema === null || $this->holdsDrafts(schema: $schema) === false) {
+ return null;
+ }
+
+ return $policy->draftLifetimeDays(schema: $schema);
+ }//end lifetimeOf()
+
/**
* Whether a schema stores objects of its own, so it can hold a draft.
*
@@ -211,19 +234,21 @@ private function holdsDrafts(Schema $schema): bool {
* @param Schema $schema The schema.
* @param array $expired The uuids of its expired drafts.
* @param int $budget How many delete attempts are left in this run.
+ * @param DateTimeImmutable $cutoff The cutoff the list was made with, asked again just before each delete.
+ * @param int $unread How many expired drafts lie beyond what one run reads; they wait for the next run.
*
* @return array{register: string, schema: string, lifetimeDays: int, expired: int, removed: int, refused: int, deferred: int}
* The counts of this pair; the caller fills in the lifetime.
*/
- private function removeWithin(Register $register, Schema $schema, array $expired, int $budget): array {
+ private function removeWithin(Register $register, Schema $schema, array $expired, int $budget, DateTimeImmutable $cutoff, int $unread=0): array {
$row = [
'register' => (string)($register->getSlug() ?? $register->getId()),
'schema' => (string)($schema->getSlug() ?? $schema->getId()),
'lifetimeDays' => 0,
- 'expired' => count($expired),
+ 'expired' => (count($expired) + $unread),
'removed' => 0,
'refused' => 0,
- 'deferred' => 0,
+ 'deferred' => $unread,
];
foreach ($expired as $uuid) {
@@ -232,7 +257,7 @@ private function removeWithin(Register $register, Schema $schema, array $expired
continue;
}
- if ($this->remove(register: $register, schema: $schema, uuid: $uuid) === true) {
+ if ($this->remove(register: $register, schema: $schema, uuid: $uuid, cutoff: $cutoff) === true) {
$row['removed']++;
continue;
}
@@ -268,29 +293,79 @@ private function schemaOf(mixed $schemaId): ?Schema {
}//end schemaOf()
/**
- * The uuids of the drafts in one register and schema that nobody changed since the cutoff.
+ * The uuids of the drafts in one register and schema that nobody changed since the cutoff, and how many more lie beyond the read cap.
*
- * Read in full before anything is deleted, so a delete never shifts the
- * page the next read asks for.
+ * The store is asked for the expired drafts alone (last changed before the
+ * cutoff, or never changed and created before it), so the rows one run
+ * reads are expired rows and a schema full of young drafts cannot hide an
+ * old one behind them. Read in full before anything is deleted, so a
+ * delete never shifts the page the next read asks for. When the cap of
+ * MAX_PAGES full pages is reached, the rest is counted, not read.
*
* @param Register $register The register.
* @param Schema $schema The schema.
* @param DateTimeImmutable $cutoff Drafts last changed before this moment are expired.
*
- * @return array The uuids.
+ * @return array{0: array, 1: int} The uuids, and the number of expired drafts left unread.
*/
private function expiredIn(Register $register, Schema $schema, DateTimeImmutable $cutoff): array {
+ $moment = gmdate('Y-m-d H:i:s', $cutoff->getTimestamp());
+ $filters = [
+ // Changed at some point, and not since the cutoff.
+ ['updated' => ['lt' => $moment]],
+ // Never changed: the created moment counts.
+ ['updated' => 'IS NULL', 'created' => ['lt' => $moment]],
+ ];
+
$expired = [];
- for ($page = 0; $page < self::MAX_PAGES; $page++) {
+ $unread = 0;
+ $pages = self::MAX_PAGES;
+ foreach ($filters as $filter) {
+ $query = [
+ '@self' => (['register' => (string)$register->getId(), 'schema' => (string)$schema->getId()] + $filter),
+ // Drafts alone, whoever owns them (decision 201's lens).
+ DraftListLens::PARAMETER => 'true',
+ ];
+ if ($pages <= 0) {
+ $unread += $this->countOf(register: $register, schema: $schema, query: $query);
+ continue;
+ }
+
+ $scan = $this->scan(register: $register, schema: $schema, query: $query, cutoff: $cutoff, pages: $pages);
+ array_push($expired, ...$scan['uuids']);
+ if ($scan['failed'] === true) {
+ // A list that cannot be read is logged once; the schema's drafts wait for the next run.
+ break;
+ }
+
+ $pages -= $scan['pages'];
+ if ($scan['full'] === true) {
+ $unread += max(1, ($this->countOf(register: $register, schema: $schema, query: $query) - $scan['rows']));
+ }
+ }
+
+ return [array_values(array_unique($expired)), $unread];
+ }//end expiredIn()
+
+ /**
+ * Read the pages of one list, up to a page budget.
+ *
+ * @param Register $register The register.
+ * @param Schema $schema The schema.
+ * @param array $query The list query, without paging.
+ * @param DateTimeImmutable $cutoff Drafts last changed before this moment are expired.
+ * @param int $pages How many pages may be read.
+ *
+ * @return array{uuids: array, pages: int, rows: int, full: bool, failed: bool}
+ * The expired uuids, the pages and rows read, whether the last page was full when the pages ran out,
+ * and whether a read failed.
+ */
+ private function scan(Register $register, Schema $schema, array $query, DateTimeImmutable $cutoff, int $pages): array {
+ $scan = ['uuids' => [], 'pages' => 0, 'rows' => 0, 'full' => false, 'failed' => false];
+ for ($page = 0; $page < $pages; $page++) {
try {
$found = $this->objects->searchObjects(
- query: [
- '@self' => ['register' => (string)$register->getId(), 'schema' => (string)$schema->getId()],
- '_limit' => self::PAGE_SIZE,
- '_offset' => ($page * self::PAGE_SIZE),
- // Drafts alone, whoever owns them (decision 201's lens).
- DraftListLens::PARAMETER => 'true',
- ],
+ query: ($query + ['_limit' => self::PAGE_SIZE, '_offset' => ($page * self::PAGE_SIZE)]),
_rbac: false,
_multitenancy: false
);
@@ -299,26 +374,55 @@ private function expiredIn(Register $register, Schema $schema, DateTimeImmutable
message: '[DraftExpiry] The drafts of a schema could not be listed',
context: ['register' => $register->getSlug(), 'schema' => $schema->getSlug(), 'exception' => $exception->getMessage()]
);
+ $scan['failed'] = true;
break;
}
if (is_array($found) === false) {
+ $scan['failed'] = true;
break;
}
+ $scan['pages']++;
+ $scan['rows'] += count($found);
foreach ($found as $candidate) {
+ // The store was asked for expired drafts; the object is asked once more.
if ($this->isExpired(candidate: $candidate, cutoff: $cutoff) === true) {
- $expired[] = (string)$candidate->getUuid();
+ $scan['uuids'][] = (string)$candidate->getUuid();
}
}
if (count($found) < self::PAGE_SIZE) {
- break;
+ return $scan;
}
+
+ $scan['full'] = (($page + 1) === $pages);
}//end for
- return $expired;
- }//end expiredIn()
+ return $scan;
+ }//end scan()
+
+ /**
+ * How many drafts one list matches, for what lies beyond the read cap.
+ *
+ * @param Register $register The register.
+ * @param Schema $schema The schema.
+ * @param array $query The list query, without paging.
+ *
+ * @return int The count; zero when it cannot be asked.
+ */
+ private function countOf(Register $register, Schema $schema, array $query): int {
+ try {
+ return (int)$this->objects->countSearchObjects(query: $query, _rbac: false, _multitenancy: false);
+ } catch (Throwable $exception) {
+ $this->logger->warning(
+ message: '[DraftExpiry] The expired drafts beyond the read cap could not be counted',
+ context: ['register' => $register->getSlug(), 'schema' => $schema->getSlug(), 'exception' => $exception->getMessage()]
+ );
+
+ return 0;
+ }
+ }//end countOf()
/**
* Whether a search hit is a draft whose last change is before the cutoff.
@@ -351,14 +455,39 @@ private function isExpired(mixed $candidate, DateTimeImmutable $cutoff): bool {
/**
* Delete one expired draft through the ordinary delete path.
*
- * @param Register $register The register.
- * @param Schema $schema The schema.
- * @param string $uuid The draft.
+ * The list was read before the first delete, and a sweep can take a
+ * while: the owner may have sent the draft since. The object is read
+ * again here and deleted only while it is still a draft last changed
+ * before the cutoff, so a submission that just got its receipt is never
+ * removed. An object that cannot be read again is left alone.
+ *
+ * @param Register $register The register.
+ * @param Schema $schema The schema.
+ * @param string $uuid The draft.
+ * @param DateTimeImmutable $cutoff Drafts last changed before this moment are expired.
*
* @return bool True when it was removed.
*/
- private function remove(Register $register, Schema $schema, string $uuid): bool {
+ private function remove(Register $register, Schema $schema, string $uuid, DateTimeImmutable $cutoff): bool {
try {
+ $current = $this->objects->find(
+ id: $uuid,
+ register: $register,
+ schema: $schema,
+ _rbac: false,
+ _multitenancy: false,
+ _render: false,
+ _audit: false
+ );
+ if ($this->isExpired(candidate: $current, cutoff: $cutoff) === false) {
+ $this->logger->info(
+ message: '[DraftExpiry] A draft was not removed: it is no longer an expired draft',
+ context: ['register' => $register->getSlug(), 'schema' => $schema->getSlug(), 'uuid' => $uuid]
+ );
+
+ return false;
+ }
+
// RBAC off: a background job has no session, and the lifetime the
// schema declares is the authorisation. Everything else the delete
// path guards (append-only, referential integrity, files) stays
diff --git a/lib/Service/Object/DraftOwner.php b/lib/Service/Object/DraftOwner.php
index 98f6aaddf3..c18feb5028 100644
--- a/lib/Service/Object/DraftOwner.php
+++ b/lib/Service/Object/DraftOwner.php
@@ -130,6 +130,24 @@ public static function portalSubject(string $subjectRef): self {
return self::scoped(scope: self::PORTAL_SCOPE, reference: $subjectRef);
}//end portalSubject()
+ /**
+ * The draft's owner: the asserted one, else the signed-in subject, else nobody.
+ *
+ * @param IUser|null $subject The signed-in subject.
+ * @param DraftOwner|null $owner The asserted owner.
+ *
+ * @return self|null The owner, or null when there is none.
+ *
+ * @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-a-draft-must-be-able-to-be-owned-by-a-scope-value
+ */
+ public static function resolve(?IUser $subject, ?self $owner): ?self {
+ if ($owner !== null || $subject === null) {
+ return $owner;
+ }
+
+ return self::ofUser(user: $subject);
+ }//end resolve()
+
/**
* The value stored in, and compared with, the object's owner.
*
diff --git a/lib/Service/Object/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php
index c750782408..dfc177e346 100644
--- a/lib/Service/Object/DraftStatusPolicy.php
+++ b/lib/Service/Object/DraftStatusPolicy.php
@@ -37,12 +37,16 @@
use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Exception\DraftAlreadyLeftException;
use OCA\OpenRegister\Exception\ValidationException;
use OCA\OpenRegister\Service\Form\FormDestinationValidator;
/**
* Set, keep, leave and hide drafts.
*
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) One rule set (decisions 180 and 216): every save path and both list
+ * emitters ask one of these questions, and none of them has a second home to move to.
+ *
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft
*/
class DraftStatusPolicy {
@@ -81,6 +85,23 @@ public static function allowPromotion(string $uuid): void {
self::$promotions[$uuid] = true;
}//end allowPromotion()
+ /**
+ * Whether the save that followed allowPromotion() used the allowance for an object.
+ *
+ * A save that leaves draft consumes the allowance. One that did not (the
+ * stored object had already left draft, because a parallel request won)
+ * leaves it standing, and the caller must not announce a creation again.
+ *
+ * @param string $uuid The object.
+ *
+ * @return bool True when no allowance for the object remains.
+ *
+ * @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft
+ */
+ public static function promotionWasUsed(string $uuid): bool {
+ return isset(self::$promotions[$uuid]) === false;
+ }//end promotionWasUsed()
+
/**
* Forget every allowance (tests, and a submit that was refused after allowing).
*
@@ -100,12 +121,23 @@ public static function resetPromotions(): void {
*
* @return string|null The status to store; null keeps the date-deduced status.
*
- * @throws ValidationException When the move is not allowed.
+ * @throws ValidationException When the move is not allowed, or (DraftAlreadyLeftException) the draft to promote already left draft.
*
* @spec openspec/changes/form-destination-validator/specs/form-destination/spec.md#requirement-an-object-must-be-able-to-carry-the-explicit-lifecycle-status-draft
*/
public function resolveStatus(ObjectEntity $entity, array $selfData): ?string {
$current = $entity->getStatus();
+
+ // An allowance for an object that is no longer a draft means a parallel
+ // request sent it first. This save would be an ordinary update that
+ // announces the creation a second time, so it is refused.
+ if ($entity->getId() !== null
+ && $current !== ObjectEntity::STATUS_DRAFT
+ && isset(self::$promotions[(string)$entity->getUuid()]) === true
+ ) {
+ throw new DraftAlreadyLeftException(message: 'This draft has already left draft.');
+ }
+
if (array_key_exists('status', $selfData) === false || $selfData['status'] === null || $selfData['status'] === '') {
return $current;
}
@@ -203,9 +235,9 @@ public function requiredExcusals(Schema $schema): array {
/**
* The properties the server fills, excused from `required` while a payload is judged in full.
*
- * A property with a default, a computation or the serverSet marker is
- * filled on save or by a creating listener, so a payload without it is
- * complete. The form submit and the owner's activation excuse the same
+ * A property with a default, a computation, a generated identifier
+ * (`x-openregister-generated`) or the serverSet marker is filled on save or
+ * by a creating listener, so a payload without it is complete. The form submit and the owner's activation excuse the same
* set, so a draft that one accepts the other accepts too.
*
* @param Schema $schema The schema.
@@ -213,7 +245,7 @@ public function requiredExcusals(Schema $schema): array {
*
* @return array Property => reason, in ValidateObject's notSupplied shape.
*
- * @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::marker() reads a schema property; there is no instance to inject.
+ * @SuppressWarnings(PHPMD.StaticAccess) FormDestinationValidator::isServerFilled() reads a schema property; there is no instance to inject.
*
* @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-drafts-owner-must-be-able-to-make-it-definitive-through-the-object-api
*/
@@ -224,10 +256,7 @@ public function serverFilledExcusals(Schema $schema, array $object): array {
continue;
}
- $filled = array_key_exists('default', $property) === true
- || isset($property['computed']) === true
- || FormDestinationValidator::marker(property: $property, name: FormDestinationValidator::MARKER_SERVER_SET);
- if ($filled === true) {
+ if (FormDestinationValidator::isServerFilled(property: $property) === true) {
$excused[(string)$name] = 'server-set';
}
}
diff --git a/lib/Service/Object/MergeHandler.php b/lib/Service/Object/MergeHandler.php
index 50e7e86994..807a24b85f 100644
--- a/lib/Service/Object/MergeHandler.php
+++ b/lib/Service/Object/MergeHandler.php
@@ -405,7 +405,7 @@ private function transferObjectFiles(ObjectEntity $sourceObject, ObjectEntity $t
$fileName = $file->getName();
// Create new file in target object folder.
- $this->fileService->addFile(
+ $copy = $this->fileService->addFile(
objectEntity: $targetObject,
fileName: $fileName,
content: $fileContent,
@@ -413,8 +413,14 @@ private function transferObjectFiles(ObjectEntity $sourceObject, ObjectEntity $t
tags: []
);
- // Delete original file from source.
- $this->fileService->deleteFile(file: $file, object: $sourceObject);
+ // Delete original file from source. A source file that is still
+ // there afterwards (a frozen or archived source: the delete was
+ // aborted and Nextcloud does not say so) is not a transfer: the
+ // copy is taken back instead of reporting the file as moved.
+ if ($this->fileService->deleteFile(file: $file, object: $sourceObject) === false) {
+ $this->fileService->deleteFile(file: $copy, object: $targetObject);
+ throw new Exception('The file could not be removed from the source object');
+ }
$result['files'][] = [
'name' => $fileName,
@@ -472,8 +478,10 @@ private function deleteObjectFiles(ObjectEntity $sourceObject): array {
$fileName = $file->getName();
- // Delete the file using FileService.
- $this->fileService->deleteFile(file: $file, object: $sourceObject);
+ // Delete the file using FileService; false means it is still there.
+ if ($this->fileService->deleteFile(file: $file, object: $sourceObject) === false) {
+ throw new Exception('The file could not be deleted');
+ }
$result['files'][] = [
'name' => $fileName,
diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php
index afff4b12e4..7d4b1ae270 100644
--- a/lib/Service/Object/PermissionHandler.php
+++ b/lib/Service/Object/PermissionHandler.php
@@ -42,6 +42,7 @@
use OCA\OpenRegister\Event\CustomScopeEvaluatingEvent;
use OCA\OpenRegister\Exception\AuthorizationUnresolvableException;
use OCA\OpenRegister\Exception\NotAuthorizedException;
+use OCA\OpenRegister\Service\AnonymousEvaluationContext;
use OCA\OpenRegister\Service\ConditionMatcher;
use OCA\OpenRegister\Service\Rbac\DenyEnforcementMode;
use OCA\OpenRegister\Service\Rbac\DenyResolver;
@@ -441,6 +442,16 @@ public function hasPermission(
return true;
}
+ // A draft answers to its owner only (decision 180). The read predicate
+ // of the list queries keeps it from everybody else, but a check that
+ // is handed the stored object (bulk save, bulk delete, the readable
+ // audit trail, a transition, a file) is a second door onto the same
+ // object and must say the same. Asked before the cache: an object
+ // that is not a draft costs one comparison.
+ if ($object !== null && $this->draftIsKeptFromCaller(object: $object, schema: $schema, userId: $userId) === true) {
+ return false;
+ }
+
// Build a per-request cache key. The object UUID (when present) is part
// of the key so conditional rules with `match` clauses are still
// re-evaluated per object — the cache only deduplicates repeated calls
@@ -492,6 +503,60 @@ public function hasPermission(
return $verdict;
}//end hasPermission()
+ /**
+ * Whether the object is a draft that its schema keeps from this caller, whatever the action.
+ *
+ * The same answer as the read predicate (MagicRbacHandler): an
+ * administrator and the command line without a user (occ, cron, repair
+ * steps) are not kept from a draft, the draft's owner is not, and
+ * everybody else is, including an anonymous web caller. A schema that sets
+ * `draftsVisible: true` shows its drafts to anyone who may read the
+ * schema. Every verb is refused, not only `read`: somebody who cannot
+ * read a draft cannot change, delete or read the history of it either.
+ *
+ * @param ObjectEntity $object The object the check is about.
+ * @param Schema $schema Its schema.
+ * @param string|null $userId The caller, null for the session user.
+ *
+ * @return bool True when the caller is refused every action on this object.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess) AnonymousEvaluationContext is an ambient-context marker by design.
+ *
+ * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-draft-must-not-be-changed-by-somebody-who-cannot-read-it
+ */
+ private function draftIsKeptFromCaller(ObjectEntity $object, Schema $schema, ?string $userId): bool {
+ if ($object->isDraft() === false) {
+ return false;
+ }
+
+ $subject = ($userId ?? $this->userSession->getUser()?->getUID());
+ if ((new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $subject) === false) {
+ return false;
+ }
+
+ if ($subject === null) {
+ // Nobody is signed in: the command line is the system and passes,
+ // as it does on the read path; an anonymous web caller is kept.
+ return AnonymousEvaluationContext::isActive() === true || $this->isCommandLine() === false;
+ }
+
+ // An administrator reads every draft, so is not kept from one.
+ $user = $this->userManager->get($subject);
+
+ return $user === null || in_array('admin', $this->groupManager->getUserGroupIds($user), true) === false;
+ }//end draftIsKeptFromCaller()
+
+ /**
+ * Whether PHP runs on the command line. A seam, so a test can read as a web request.
+ *
+ * @return bool True under the CLI SAPI.
+ *
+ * @spec openspec/specs/rbac-scopes/spec.md
+ */
+ protected function isCommandLine(): bool {
+ return PHP_SAPI === 'cli';
+ }//end isCommandLine()
+
/**
* Dispatch the per-action telemetry event.
*
@@ -1829,6 +1894,11 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult
$activeOrganisation = $this->getActiveOrganisationForContext();
+ // An administrator reads every draft, so is not kept from one here
+ // (the same check hasPermission() and the read predicate make).
+ $callerIsAdmin = ($currentUser !== null
+ && in_array('admin', $this->groupManager->getUserGroupIds($currentUser), true) === true);
+
// Get objects for permission checking.
//
// This must be the CROSS-TABLE lookup. `MagicMapper::findAll()` needs a
@@ -1862,6 +1932,13 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult
try {
$schema = $this->schemaMapper->find($objectSchema);
+ // Another user's draft is kept from this caller as on every
+ // other path: it is left out like an object that does not
+ // exist, so the delete does not announce it.
+ if ($callerIsAdmin === false && (new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $userId) === true) {
+ continue;
+ }
+
// Property-level RBAC (per-property authorization + writeOnly
// stripping) lives in PropertyRbacHandler on the read path; this
// loop only decides object-level visibility.
diff --git a/lib/Service/Object/ReferenceKey.php b/lib/Service/Object/ReferenceKey.php
new file mode 100644
index 0000000000..7ed918be0a
--- /dev/null
+++ b/lib/Service/Object/ReferenceKey.php
@@ -0,0 +1,58 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @link https://www.OpenRegister.app
+ *
+ * @spec openspec/specs/referential-integrity/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Object;
+
+/**
+ * One reading of `referenceKey`, for the validator that accepts a schema and the service that acts on it.
+ *
+ * An empty `referenceKey` and the literal `uuid` both mean a plain uuid
+ * reference, to which every `onDelete` action applies. A schema save and the
+ * delete that later honours it read the same declaration, and two readings of
+ * it (the validator refusing `referenceKey: uuid` with `CASCADE`, the service
+ * honouring it) is a schema that is refused for something that would have
+ * worked, or accepted for something that does not.
+ *
+ * @spec openspec/specs/referential-integrity/spec.md
+ */
+final class ReferenceKey {
+
+ /**
+ * The target's key property, or null for a uuid reference.
+ *
+ * Read from the property, or from its `items` for an array of references.
+ *
+ * @param array $property The property configuration array.
+ *
+ * @return string|null The target's key property, or null for a uuid reference.
+ *
+ * @spec openspec/specs/referential-integrity/spec.md
+ */
+ public function keyOf(array $property): ?string {
+ $key = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null));
+ if (is_string($key) === false || trim($key) === '' || trim($key) === 'uuid') {
+ return null;
+ }
+
+ return trim($key);
+ }//end keyOf()
+}//end class
diff --git a/lib/Service/Object/ReferentialIntegrityService.php b/lib/Service/Object/ReferentialIntegrityService.php
index 40d50434aa..e12375c0fa 100644
--- a/lib/Service/Object/ReferentialIntegrityService.php
+++ b/lib/Service/Object/ReferentialIntegrityService.php
@@ -44,6 +44,7 @@
use OCP\ICacheFactory;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
+use RuntimeException;
use Symfony\Component\Uid\Uuid;
/**
@@ -827,12 +828,7 @@ private function extractTargetRef(array $property): ?string {
* @spec openspec/specs/referential-integrity/spec.md
*/
private function extractReferenceKey(array $property): ?string {
- $key = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null));
- if (is_string($key) === false || trim($key) === '' || trim($key) === 'uuid') {
- return null;
- }
-
- return trim($key);
+ return (new ReferenceKey())->keyOf(property: $property);
}//end extractReferenceKey()
/**
@@ -1323,17 +1319,25 @@ private function findReferencingInMagicTable(
string $targetUuid,
bool $isArray,
): array {
- $fullTableName = 'oc_openregister_table_' . $register->getId() . '_' . $schema->getId();
-
- // Convert camelCase property name to snake_case column name and quote it.
- $columnName = strtolower(preg_replace('/[A-Z]/', '_$0', $propertyName));
- $quotedCol = '"' . str_replace('"', '""', $columnName) . '"';
-
// Validate inputs before database access (also ensures Psalm sees param usage before \OC:: call).
if (empty($targetUuid) === true) {
return [];
}
+ $platform = $this->db->getDatabasePlatform();
+
+ // The table with the instance's prefix, and the column the way the table
+ // was built (the schema's column map, collision suffixes included), both
+ // quoted by the connection's own platform: a double-quoted identifier is a
+ // string literal to MySQL and MariaDB (no ANSI_QUOTES), so `"code" = ?`
+ // compared the text 'code' with the value, matched nothing and let the
+ // delete through. Null-safe via get_debug_type() (a mocked IDBConnection
+ // returns a bare platform double in unit tests).
+ $quotedTable = $platform->quoteIdentifier(
+ $this->db->getQueryBuilder()->getTableName($this->objectEntityMapper->getTableNameForRegisterSchema(register: $register, schema: $schema))
+ );
+ $quotedCol = $platform->quoteIdentifier($this->columnOf(schema: $schema, propertyName: $propertyName));
+
// Build the array/scalar SQL variant selector before accessing the database.
// For array properties we need JSON_CONTAINS / jsonb @> operators; for scalars a simple = suffices.
$queryMode = 'scalar';
@@ -1341,8 +1345,6 @@ private function findReferencingInMagicTable(
$queryMode = 'array';
}
- $platform = $this->db->getDatabasePlatform();
- // Null-safe via get_debug_type() (a mocked IDBConnection returns null here in unit tests).
$isPostgres = stripos(get_debug_type($platform), 'PostgreSQL') !== false;
$deletedCheck = '_deleted IS NULL';
@@ -1350,7 +1352,7 @@ private function findReferencingInMagicTable(
$deletedCheck = "(_deleted IS NULL OR _deleted = 'null'::jsonb)";
}
- $selectClause = "SELECT _uuid, _register, _schema, _deleted, {$quotedCol} AS _prop FROM {$fullTableName}";
+ $selectClause = "SELECT _uuid, _register, _schema, _deleted, {$quotedCol} AS _prop FROM {$quotedTable}";
$whereCondition = "{$deletedCheck} AND {$quotedCol} = ?";
if ($queryMode === 'array' && $isPostgres === true) {
@@ -1398,6 +1400,31 @@ private function findReferencingInMagicTable(
return $results;
}//end findReferencingInMagicTable()
+ /**
+ * The physical column of a schema property, as the schema's table was built.
+ *
+ * Asked of the mapper's own column map rather than derived here, so a
+ * property whose column carries a collision suffix, or a name the local
+ * camel-to-snake rule would spell differently, is found where it lives.
+ *
+ * @param Schema $schema The citing schema.
+ * @param string $propertyName The citing property.
+ *
+ * @return string The column name.
+ *
+ * @throws RuntimeException When the schema's table has no column for the property.
+ *
+ * @spec openspec/specs/referential-integrity/spec.md
+ */
+ private function columnOf(Schema $schema, string $propertyName): string {
+ $column = ($this->objectEntityMapper->buildTableColumnsFromSchema(schema: $schema)[$propertyName]['name'] ?? null);
+ if (is_string($column) === false || $column === '') {
+ throw new RuntimeException('The table of schema ' . $schema->getId() . ' has no column for property ' . $propertyName);
+ }
+
+ return $column;
+ }//end columnOf()
+
/**
* Check whether a property is required by the given schema.
*
diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php
index 10e286a4d6..bab7686261 100644
--- a/lib/Service/Object/SaveObject.php
+++ b/lib/Service/Object/SaveObject.php
@@ -3332,7 +3332,7 @@ public function saveObject(
register: $register,
schema: $schema,
data: $data,
- selfData: $selfData,
+ selfData: $this->selfDataForUpdate(selfData: $selfData, _rbac: $_rbac),
folderId: $folderId,
persist: $persist,
silent: $silent,
@@ -3724,6 +3724,36 @@ private function findAndValidateExistingObject(
}//end try
}//end findAndValidateExistingObject()
+ /**
+ * The `@self` an update works from: a version the caller carries counts only for a trusted caller.
+ *
+ * An app's seed objects carry their shipped version in `@self.version`, and
+ * an update takes it when it is higher than the stored one, so the next
+ * import reads the seed as current (ObjectVersionHandler::advanceVersion()).
+ * A caller judged by RBAC could send any higher version through the object
+ * API, and a seed that reads as newer than every later shipped version
+ * never receives the app's fix. So `@self.version` is dropped from the
+ * update of a caller whose RBAC applies, and that update bumps the patch
+ * like any other. The importer, an administrator and a system caller save
+ * with RBAC off and keep it.
+ *
+ * @param array $selfData The `@self` the caller sent.
+ * @param bool $_rbac Whether the save is judged for a signed-in caller.
+ *
+ * @return array The `@self` the update uses.
+ *
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Mirrors saveObject()'s own RBAC flag.
+ *
+ * @spec openspec/changes/seed-objects-follow-the-shipped-version/specs/data-import-export/spec.md
+ */
+ private function selfDataForUpdate(array $selfData, bool $_rbac): array {
+ if ($_rbac === true) {
+ unset($selfData['version']);
+ }
+
+ return $selfData;
+ }//end selfDataForUpdate()
+
/**
* Handle update of existing object.
*
diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php
index f2c8d77968..b66c3acb98 100644
--- a/lib/Service/Object/SaveObjects.php
+++ b/lib/Service/Object/SaveObjects.php
@@ -802,6 +802,31 @@ private function applyBulkSafeguards(
existingByUuid: $existingByUuid
);
+ // Step 2b: a draft is kept to its owner. Asked before the appendOnly
+ // gate, whose message would otherwise tell a non-owner that the uuid
+ // names an existing row. The existing row was found
+ // past the read RBAC, so without this a user who cannot read another
+ // user's draft could overwrite it by sending its uuid. The answer is
+ // the one a refused permission gives, so the draft's existence stays
+ // unannounced (same rule as ObjectService::refuseSomebodyElsesDraft).
+ // An administrator reads every draft, so is not kept from one.
+ if ($effectiveRbac === true && $isAdmin === false && $existingEntity !== null) {
+ $draftKept = (new DraftStatusPolicy())->keptFrom(
+ object: $existingEntity,
+ schema: $rowSchema,
+ userId: $currentUser?->getUID()
+ );
+ if ($draftKept === true) {
+ $this->recordSafeguardRejection(
+ object: $sanitised,
+ reason: 'Permission denied for action ' . $action . ' on schema ' . $rowSchema->getSlug(),
+ result: $result,
+ index: $rowIndex
+ );
+ continue;
+ }
+ }
+
// Step 4: appendOnly UPDATE → reject.
if ($action === 'update' && $rowSchema->isAppendOnly() === true) {
$this->recordSafeguardRejection(
diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php
index 59780d6005..f6b5e9c94a 100644
--- a/lib/Service/ObjectService.php
+++ b/lib/Service/ObjectService.php
@@ -3151,6 +3151,7 @@ public function deleteObject(
$scopedSchema = $this->currentSchema;
}
+ $objectToDelete = null;
try {
// With the caller's flags. The delete handler honours them, so a
// lookup that ignored them applied the session's RBAC and tenant
@@ -3212,6 +3213,16 @@ public function deleteObject(
}
}//end try
+ // Somebody else's draft is not the caller's to delete, whatever the
+ // read lookup above let through. Answered as not found, like the
+ // save path (refuseSomebodyElsesDraft), so it is not announced.
+ $deleter = ($currentUser ?? $this->userSession->getUser());
+ if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null
+ && $this->draftKeptFromCaller(object: $objectToDelete, schema: $this->currentSchema, user: $deleter) === true
+ ) {
+ throw new OcpDoesNotExistException('Object not found');
+ }
+
return $this->deleteHandler->deleteObject(
register: $this->currentRegister,
schema: $this->currentSchema,
@@ -3340,6 +3351,30 @@ private function isDeletableDraft(string $uuid, bool $retentionSweep, bool $_rba
}
}//end isDeletableDraft()
+ /**
+ * Whether a stored draft is kept from the caller, as on the read path.
+ *
+ * One answer for the single delete and the bulk delete: another user's
+ * draft is not theirs to remove. An administrator reads every draft, so
+ * is not kept from one.
+ *
+ * @param ObjectEntity $object The stored object.
+ * @param Schema $schema Its schema.
+ * @param IUser|null $user The caller.
+ *
+ * @return bool True when the object is a draft the caller may not touch.
+ *
+ * @spec openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md#requirement-a-draft-must-not-be-changed-by-somebody-who-cannot-read-it
+ */
+ private function draftKeptFromCaller(ObjectEntity $object, Schema $schema, ?IUser $user): bool
+ {
+ if ($user !== null && in_array('admin', $this->groupManager->getUserGroupIds($user), true) === true) {
+ return false;
+ }
+
+ return (new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $user?->getUID());
+ }//end draftKeptFromCaller()
+
/**
* Refuse a delete on an archival-annotated schema.
*
@@ -5557,10 +5592,12 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten
// object still yields its scope.
$deletedRegisterId = null;
$deletedSchemaId = null;
+ $rowEntity = null;
$preResolved = ($preResolvedObjects[$uuid] ?? null);
if ($preResolved !== null) {
$deletedRegisterId = $preResolved->getRegister();
$deletedSchemaId = $preResolved->getSchema();
+ $rowEntity = $preResolved;
}
if ($preResolved === null) {
@@ -5575,6 +5612,7 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten
);
$deletedRegisterId = $preDeleteObject->getRegister();
$deletedSchemaId = $preDeleteObject->getSchema();
+ $rowEntity = $preDeleteObject;
} catch (\Throwable $resolveError) {
// BUG-OBJ-14: scope resolution failed (object already gone or
// not visible) — log and fall back to a broad invalidation.
@@ -5635,14 +5673,35 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten
continue;
}
- try {
- $this->rejectIfArchivalImmutable(schema: $gateSchema, retentionSweep: false);
- } catch (ArchivalImmutableException $archivalRefusal) {
+ // THE DRAFT RULES, ON THE THIRD DOOR TOO (decisions 180 and 216).
+ // The permission filter above already leaves another user's draft
+ // out; this asks again per row, with the row's own schema, so the
+ // loop does not lean on that one gate. A draft is then removed for
+ // good, with its files and its one `draft.removed` audit entry, and
+ // it is not an archival record, so the archival gate does not
+ // apply to it: both exactly as deleteObject() has them.
+ $rowIsDraft = ($rowEntity !== null && $rowEntity->isDraft() === true);
+ if ($rowIsDraft === true && $_rbac === true
+ && $this->draftKeptFromCaller(object: $rowEntity, schema: $gateSchema, user: $this->userSession->getUser()) === true
+ ) {
$skippedUuids[] = $uuid;
- $skippedReasons[$uuid] = $archivalRefusal->toResponseBody();
+ $skippedReasons[$uuid] = [
+ 'error' => 'OBJECT_NOT_PERMITTED',
+ 'message' => 'Object is not visible to this user, or does not exist.',
+ ];
continue;
}
+ if ($rowIsDraft === false) {
+ try {
+ $this->rejectIfArchivalImmutable(schema: $gateSchema, retentionSweep: false);
+ } catch (ArchivalImmutableException $archivalRefusal) {
+ $skippedUuids[] = $uuid;
+ $skippedReasons[$uuid] = $archivalRefusal->toResponseBody();
+ continue;
+ }
+ }
+
$result = $this->deleteHandler->deleteObject(
register: $handlerRegister,
schema: $handlerSchema,
@@ -5650,7 +5709,8 @@ public function deleteObjects(array $uuids=[], bool $_rbac=true, bool $_multiten
originalObjectId: null,
_rbac: $_rbac,
_multitenancy: $_multitenancy,
- preResolved: $handlerPreResolved
+ preResolved: $handlerPreResolved,
+ permanent: $rowIsDraft
);
if ($result === true) {
$deletedObjectIds[] = $uuid;
diff --git a/lib/Service/Portal/PortalTaskService.php b/lib/Service/Portal/PortalTaskService.php
index efd78d62b4..b5c337aaa4 100644
--- a/lib/Service/Portal/PortalTaskService.php
+++ b/lib/Service/Portal/PortalTaskService.php
@@ -43,6 +43,7 @@
use OCA\OpenRegister\Exception\TaskCompleteViaAppException;
use OCA\OpenRegister\Exception\TaskValidationException;
use OCA\OpenRegister\Service\FileService;
+use OCA\OpenRegister\Service\Task\TaskBlockerGuard;
use OCA\OpenRegister\Service\Task\TaskInboxService;
use OCA\OpenRegister\Service\Task\TaskService;
use OCA\OpenRegister\Service\Task\TaskTemporalProjection;
@@ -126,6 +127,10 @@ public function listForSubject(PortalSubject $subject, int $limit = 25, int $off
$contexts = $this->inbox->subjectContextsFor(tasks: $page);
$now = $this->temporal->now();
+ // The page was read with the open-blocker exclusion, so no row of it is
+ // blocked: saying so up front spares every row its own blocker query.
+ $openBlockers = [];
+
$results = [];
foreach ($page as $task) {
// Belt and braces over the WHERE clause: a row that is not this
@@ -134,7 +139,7 @@ public function listForSubject(PortalSubject $subject, int $limit = 25, int $off
continue;
}
- $results[] = $this->inbox->row(task: $task, subjects: $contexts, now: $now);
+ $results[] = $this->inbox->row(task: $task, subjects: $contexts, now: $now, openBlockers: $openBlockers);
}
return [
@@ -153,9 +158,11 @@ public function listForSubject(PortalSubject $subject, int $limit = 25, int $off
*
* @return Task The task.
*
- * @throws DoesNotExistException When absent, not external, or not this
- * subject's: all three read identically, so
- * a stranger learns nothing from the answer.
+ * @throws DoesNotExistException When absent, not external, not this
+ * subject's, or held back by an open blocker
+ * (the list leaves it out too): all read
+ * identically, so a stranger learns nothing
+ * from the answer.
*
* @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-delivery-rides-the-portal-contribution-surface-and-nothing-else
*/
@@ -163,6 +170,7 @@ public function show(PortalSubject $subject, string $uuid): Task {
$task = $this->tasks->get(uuid: $uuid);
if ((string)$task->getPerformerType() !== Task::PERFORMER_EXTERNAL
|| hash_equals($subject->partyReference(), (string)$task->getAssignee()) === false
+ || (new TaskBlockerGuard(tasks: $this->mapper))->isHeldBack(task: $task) === true
) {
throw new DoesNotExistException('No such portal task.');
}
@@ -170,6 +178,7 @@ public function show(PortalSubject $subject, string $uuid): Task {
return $task;
}//end show()
+
/**
* The row shape of one task for the portal, with delivery state and
* case context.
@@ -211,7 +220,7 @@ public function row(Task $task): array {
* @throws TaskCompleteViaAppException When the task names an app action that
* completes it and the caller is not that app (nothing stored).
* @throws TaskValidationException When an upload constraint is violated (nothing stored).
- * @throws DoesNotExistException When no such task exists.
+ * @throws DoesNotExistException When no such task exists, or it waits on an open blocker.
*
* @spec openspec/changes/flow-portal-task/specs/flow-portal-task/spec.md#requirement-an-upload-completion-lands-as-a-file-attachment-on-the-case-object
*/
@@ -229,6 +238,12 @@ public function complete(
// file on somebody else's case.
$task = $this->tasks->openFor(verb: 'complete', uuid: $uuid, actor: $subject->actor());
+ // 1a. A task the case holds back behind an open blocker is not on the
+ // party's list, so it is not theirs to complete either. Asked after
+ // the authorization, so only the matched party can tell it from a
+ // task that does not exist, and before anything is stored.
+ (new TaskBlockerGuard(tasks: $this->mapper))->assertNotHeldBack(task: $task);
+
// 1b. A task that names the app action completing it is completed by
// that app alone, so the app's own checks cannot be skipped by
// calling this seam directly. Only the matched party gets here, so
diff --git a/lib/Service/Query/RelatedRowQueryApplier.php b/lib/Service/Query/RelatedRowQueryApplier.php
index 3b6325ffa7..73ff4a6bfc 100644
--- a/lib/Service/Query/RelatedRowQueryApplier.php
+++ b/lib/Service/Query/RelatedRowQueryApplier.php
@@ -99,10 +99,11 @@ public function apply(IQueryBuilder $qb, array $query, Register $register, strin
foreach (array_values($filters) as $position => $filter) {
$alias = sprintf('rel%d', $position);
$schema = $this->resolveSchema(name: $filter->schema);
+ $relatedTable = $this->tableHandler->getTableNameForRegisterSchema(register: $register, schema: $schema);
$rendered = $clause->render(
filter: $filter,
engine: $engine,
- table: $this->tableHandler->getTableNameForRegisterSchema(register: $register, schema: $schema),
+ table: $relatedTable,
outerAlias: $outerAlias,
innerAlias: $alias,
// 🔴 THE ACCESS PREDICATE FOR THE RELATED SCHEMA, NOT THE OUTER
@@ -112,7 +113,8 @@ public function apply(IQueryBuilder $qb, array $query, Register $register, strin
accessPredicate: $this->rbacHandler->buildRbacPredicateForAlias(
schema: $schema,
alias: $alias,
- action: 'read'
+ action: 'read',
+ tableName: $relatedTable
),
parameterPrefix: $alias,
storage: RelatedRowExistsClause::STORAGE_COLUMNS
diff --git a/lib/Service/Rbac/EffectiveAuthorization.php b/lib/Service/Rbac/EffectiveAuthorization.php
index aae3df203c..c9f4073c23 100644
--- a/lib/Service/Rbac/EffectiveAuthorization.php
+++ b/lib/Service/Rbac/EffectiveAuthorization.php
@@ -117,18 +117,18 @@ private function expandRoles(array $authorization, object $schema, ?object $regi
$register = $this->registerMapper->find($registerId);
}
} catch (\Throwable $e) {
- return $authorization;
+ return $this->grantNothingWhenEmpty(authorization: $authorization);
}
}
if ($register === null) {
- return $authorization;
+ return $this->grantNothingWhenEmpty(authorization: $authorization);
}
$config = $register->getConfiguration();
$roles = $config['roles'] ?? [];
if (empty($roles) === true) {
- return $authorization;
+ return $this->grantNothingWhenEmpty(authorization: $authorization);
}
// Build role map.
@@ -158,7 +158,36 @@ private function expandRoles(array $authorization, object $schema, ?object $regi
}
}
- return $authorization;
+ return $this->grantNothingWhenEmpty(authorization: $authorization);
}//end expandRoles()
+ /**
+ * A roles block that resolved to nothing grants nothing.
+ *
+ * The same rule as `PermissionHandler::grantNothingWhenEmpty()`, which is what
+ * enforcement applies: a block that opted into authorization with `roles` and
+ * whose roles cannot be expanded (no role definitions, or every name unknown)
+ * is deny-all, not "no authorization configured". The OAS must describe the
+ * schema the way it is enforced, so it reads the same four canonical verbs
+ * with nobody in them instead of an empty block.
+ *
+ * @param array $authorization The block after expansion.
+ *
+ * @return array The block, or one granting nobody when it is empty.
+ *
+ * @spec openspec/changes/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md
+ */
+ private function grantNothingWhenEmpty(array $authorization): array {
+ if ($authorization !== []) {
+ return $authorization;
+ }
+
+ return [
+ 'read' => [],
+ 'create' => [],
+ 'update' => [],
+ 'delete' => [],
+ ];
+ }//end grantNothingWhenEmpty()
+
}//end class
diff --git a/lib/Service/Rbac/GrantChangeAuditor.php b/lib/Service/Rbac/GrantChangeAuditor.php
new file mode 100644
index 0000000000..d0057a15ca
--- /dev/null
+++ b/lib/Service/Rbac/GrantChangeAuditor.php
@@ -0,0 +1,202 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @link https://conduction.nl
+ *
+ * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service\Rbac;
+
+use OCA\OpenRegister\Db\AuditTrailMapper;
+use OCA\OpenRegister\Db\ObjectEntity;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Turns a change of who may reach a record into audit-trail entries.
+ *
+ * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ */
+class GrantChangeAuditor {
+
+ /**
+ * The typed action a grant on a record writes.
+ *
+ * @var string
+ */
+ public const ACTION_GRANTED = 'grant_added';
+
+ /**
+ * The typed action a revoked grant writes.
+ *
+ * @var string
+ */
+ public const ACTION_REVOKED = 'grant_revoked';
+
+ /**
+ * The typed action a change of a record's owning group writes.
+ *
+ * @var string
+ */
+ public const ACTION_OWNER_GROUP = 'owner_group_changed';
+
+ /**
+ * Constructor.
+ *
+ * @param AuditTrailMapper $mapper The trail, which owns the chain.
+ * @param SettingsChangeAuditor $settings Writes the change of an organisation's special right list.
+ * @param LoggerInterface $logger The logger.
+ */
+ public function __construct(
+ private readonly AuditTrailMapper $mapper,
+ private readonly SettingsChangeAuditor $settings,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Record a grant on a record.
+ *
+ * @param ObjectEntity $object The record.
+ * @param array $grant The grant as created: `id`, `type`, `sharedWith`, `permissions`, `verbs`.
+ * @param string|null $admittedAs How the caller was admitted (GrantManageGuard::AS_OWNER or ::AS_HOLDER).
+ *
+ * @return void
+ *
+ * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ */
+ public function recordGrant(ObjectEntity $object, array $grant, ?string $admittedAs): void {
+ $this->write(object: $object, action: self::ACTION_GRANTED, changed: $this->describe(grant: $grant, admittedAs: $admittedAs));
+ }//end recordGrant()
+
+ /**
+ * Record a revoked grant on a record.
+ *
+ * @param ObjectEntity $object The record.
+ * @param array $grant The grant as it was: `id`, `type`, `sharedWith`, `permissions`.
+ * @param string|null $admittedAs How the caller was admitted.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ */
+ public function recordRevoke(ObjectEntity $object, array $grant, ?string $admittedAs): void {
+ $this->write(object: $object, action: self::ACTION_REVOKED, changed: $this->describe(grant: $grant, admittedAs: $admittedAs));
+ }//end recordRevoke()
+
+ /**
+ * Record a change of a record's owning group.
+ *
+ * @param ObjectEntity $object The record.
+ * @param string|null $before The owning group before, or null for none.
+ * @param string|null $after The owning group after, or null for none.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ */
+ public function recordOwnerGroup(ObjectEntity $object, ?string $before, ?string $after): void {
+ if ($before === $after) {
+ return;
+ }
+
+ $this->write(
+ object: $object,
+ action: self::ACTION_OWNER_GROUP,
+ changed: ['ownerGroup' => ['old' => $before, 'new' => $after]]
+ );
+ }//end recordOwnerGroup()
+
+ /**
+ * Record a change of the groups that hold the special right on an organisation's records.
+ *
+ * @param string $organisationUuid The organisation.
+ * @param array $before The list before.
+ * @param array $after The list after.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
+ */
+ public function recordSpecialRight(string $organisationUuid, array $before, array $after): void {
+ $key = 'organisation ' . $organisationUuid . ': ' . OrganisationSpecialRights::GRANT_MANAGE;
+
+ $this->settings->recordUpdate(app: 'openregister', before: [$key => $before], after: [$key => $after]);
+ }//end recordSpecialRight()
+
+ /**
+ * What of a grant the entry names.
+ *
+ * @param array $grant The grant.
+ * @param string|null $admittedAs How the caller was admitted.
+ *
+ * @return array The change set of the entry.
+ */
+ private function describe(array $grant, ?string $admittedAs): array {
+ return [
+ 'grant' => $grant['id'] ?? null,
+ 'type' => $grant['type'] ?? null,
+ 'sharedWith' => $grant['sharedWith'] ?? null,
+ 'permissions' => $grant['permissions'] ?? null,
+ 'verbs' => ($grant['verbs'] ?? []),
+ 'admittedAs' => $admittedAs,
+ ];
+ }//end describe()
+
+ /**
+ * Write one entry; never throws.
+ *
+ * @param ObjectEntity $object The record.
+ * @param string $action The typed action.
+ * @param array $changed The change set.
+ *
+ * @return void
+ */
+ private function write(ObjectEntity $object, string $action, array $changed): void {
+ try {
+ $this->mapper->createAuditTrailEntry(object: $object, action: $action, context: $changed);
+ } catch (Throwable $e) {
+ $this->logger->error(
+ message: '[GrantChangeAuditor] Could not record a change of who may reach a record; the change itself was stored',
+ context: [
+ 'file' => __FILE__,
+ 'line' => __LINE__,
+ 'action' => $action,
+ 'uuid' => $object->getUuid(),
+ 'exception' => $e->getMessage(),
+ ]
+ );
+ }
+ }//end write()
+}//end class
diff --git a/lib/Service/Rbac/GrantManageGuard.php b/lib/Service/Rbac/GrantManageGuard.php
index cdaf9403b2..7d3bef9429 100644
--- a/lib/Service/Rbac/GrantManageGuard.php
+++ b/lib/Service/Rbac/GrantManageGuard.php
@@ -115,16 +115,21 @@ public function admittedAs(?string $userId, array $userGroups, ObjectEntity $obj
* @param string[] $userGroups The caller's group ids.
* @param string|null $user The user being let in, or null.
* @param string|null $group The group being let in, or null.
+ * @param string|null $remote The remote user or remote group being let in, or null.
*
* @return bool True when the request must be refused.
*
* @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
*/
- public function letsItselfIn(?string $admittedAs, ?string $userId, array $userGroups, ?string $user, ?string $group): bool {
+ public function letsItselfIn(?string $admittedAs, ?string $userId, array $userGroups, ?string $user, ?string $group, ?string $remote = null): bool {
if ($admittedAs !== self::AS_HOLDER) {
return false;
}
+ if ($remote !== null) {
+ return true;
+ }
+
if ($user !== null && $user === $userId) {
return true;
}
diff --git a/lib/Service/Rbac/HierarchyDescender.php b/lib/Service/Rbac/HierarchyDescender.php
index a092684e27..9a3971974b 100644
--- a/lib/Service/Rbac/HierarchyDescender.php
+++ b/lib/Service/Rbac/HierarchyDescender.php
@@ -51,6 +51,11 @@
/**
* Finds the hierarchical schemas, and reads one level of children at a time.
*
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One class answers the
+ * declaration, the descent and the ascent of a hierarchy over the same memoised
+ * tables, and each question is a short method; splitting them would put the
+ * memo in two places, which is how a stale answer reaches an authorization verdict.
+ *
* @spec openspec/changes/rbac-inherits-to-children/specs/rbac-scopes/spec.md
*/
class HierarchyDescender {
@@ -195,51 +200,92 @@ public function hierarchicalTables(): array {
/**
* The children of a set of parents, as child uuid => parent uuid.
*
+ * With a `$limit` the read itself is bounded: at most that many rows leave
+ * the database, so a caller that stops at a node cap does not first fetch
+ * a level of a hundred thousand rows to find out it is over it. A row
+ * naming itself as its parent is then left out in SQL, so it does not use
+ * up the limit.
+ *
* @param string $table The magic table.
* @param string $parentColumn The column naming the parent.
* @param string[] $parentUuids The parents to read children of.
+ * @param int|null $limit The most rows to read, or null for the whole level.
*
* @return array Child UUID => parent UUID.
*
* @spec openspec/changes/rbac-inherits-to-children/specs/rbac-scopes/spec.md
*/
- public function childrenOf(string $table, string $parentColumn, array $parentUuids): array {
- if (empty($parentUuids) === true) {
+ public function childrenOf(string $table, string $parentColumn, array $parentUuids, ?int $limit = null): array {
+ if (empty($parentUuids) === true || ($limit !== null && $limit < 1)) {
return [];
}
$children = [];
foreach (array_chunk($parentUuids, self::CHUNK) as $chunk) {
- $qb = $this->db->getQueryBuilder();
- $qb->select('_uuid', $parentColumn)
- ->from($table)
- ->where(
- $qb->expr()->in(
- $parentColumn,
- $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY)
- )
- );
+ $remaining = null;
+ if ($limit !== null) {
+ $remaining = ($limit - count($children));
+ }
- $result = $qb->executeQuery();
- foreach ($result->fetchAll() as $row) {
- $childUuid = (string)($row['_uuid'] ?? '');
- $parentUuid = (string)($row[$parentColumn] ?? '');
- if ($childUuid === '' || $parentUuid === '' || $childUuid === $parentUuid) {
- // A row naming ITSELF as its parent is the shortest cycle
- // there is, and it is the one an import writes. Dropping it
- // here means the expander never has to treat it specially.
- continue;
- }
+ $children = array_replace(
+ $children,
+ $this->childrenInChunk(table: $table, parentColumn: $parentColumn, parents: $chunk, limit: $remaining)
+ );
- $children[$childUuid] = $parentUuid;
+ if ($limit !== null && count($children) >= $limit) {
+ break;
}
-
- $result->closeCursor();
- }//end foreach
+ }
return $children;
}//end childrenOf()
+ /**
+ * The children of one chunk of parents, as child uuid => parent uuid.
+ *
+ * @param string $table The magic table.
+ * @param string $parentColumn The column naming the parent.
+ * @param string[] $parents The parents of this chunk.
+ * @param int|null $limit The most rows to read, or null for all of them.
+ *
+ * @return array Child UUID => parent UUID.
+ */
+ private function childrenInChunk(string $table, string $parentColumn, array $parents, ?int $limit): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('_uuid', $parentColumn)
+ ->from($table)
+ ->where(
+ $qb->expr()->in(
+ $parentColumn,
+ $qb->createNamedParameter($parents, IQueryBuilder::PARAM_STR_ARRAY)
+ )
+ );
+
+ if ($limit !== null) {
+ $qb->andWhere($qb->expr()->neq('_uuid', $parentColumn));
+ $qb->setMaxResults($limit);
+ }
+
+ $children = [];
+ $result = $qb->executeQuery();
+ foreach ($result->fetchAll() as $row) {
+ $childUuid = (string)($row['_uuid'] ?? '');
+ $parentUuid = (string)($row[$parentColumn] ?? '');
+ if ($childUuid === '' || $parentUuid === '' || $childUuid === $parentUuid) {
+ // A row naming ITSELF as its parent is the shortest cycle
+ // there is, and it is the one an import writes. Dropping it
+ // here means the expander never has to treat it specially.
+ continue;
+ }
+
+ $children[$childUuid] = $parentUuid;
+ }
+
+ $result->closeCursor();
+
+ return $children;
+ }//end childrenInChunk()
+
/**
* The ancestors of one object, nearest first.
*
diff --git a/lib/Service/Rbac/ObjectOwnershipService.php b/lib/Service/Rbac/ObjectOwnershipService.php
index c4f4a02971..cb12c7d8cd 100644
--- a/lib/Service/Rbac/ObjectOwnershipService.php
+++ b/lib/Service/Rbac/ObjectOwnershipService.php
@@ -137,6 +137,7 @@ class ObjectOwnershipService {
* @param SchemaMapper $schemaMapper Resolves a child's schema.
* @param INotificationManager $notificationManager Tells the previous owner.
* @param LoggerInterface $logger Where a transfer is noted.
+ * @param GrantChangeAuditor|null $grantAuditor Writes an audit-trail entry for a change of the owning group. Null only in legacy fixtures.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; see the
* coupling note on the class.
@@ -155,6 +156,7 @@ public function __construct(
private readonly SchemaMapper $schemaMapper,
private readonly INotificationManager $notificationManager,
private readonly LoggerInterface $logger,
+ private readonly ?GrantChangeAuditor $grantAuditor = null,
) {
}//end __construct()
@@ -378,6 +380,7 @@ public function setOwnerGroup(
// Read-modify-write ONE key, exactly as the scope is written: an action
// override or a scope in the same block must survive this.
$block = ($object->getAuthorization() ?? []);
+ $before = $this->groupNameOf(value: ($block[ObjectScopeResolver::OWNER_GROUP_KEY] ?? null));
if ($group === null || $group === '') {
unset($block[ObjectScopeResolver::OWNER_GROUP_KEY]);
@@ -396,9 +399,30 @@ public function setOwnerGroup(
$object->setAuthorization($block);
+ $this->grantAuditor?->recordOwnerGroup(
+ object: $object,
+ before: $before,
+ after: $this->groupNameOf(value: ($block[ObjectScopeResolver::OWNER_GROUP_KEY] ?? null))
+ );
+
return $block;
}//end setOwnerGroup()
+ /**
+ * The group name an authorization block holds, or null for none.
+ *
+ * @param mixed $value The stored `ownerGroup` value.
+ *
+ * @return string|null The group id.
+ */
+ private function groupNameOf(mixed $value): ?string {
+ if (is_string($value) === true && trim($value) !== '') {
+ return trim($value);
+ }
+
+ return null;
+ }//end groupNameOf()
+
/**
* Move one record to a new owner, record it, announce it, and carry it down.
*
diff --git a/lib/Service/Rbac/ObjectSharingService.php b/lib/Service/Rbac/ObjectSharingService.php
index 7e3faa59e0..673933f8e3 100644
--- a/lib/Service/Rbac/ObjectSharingService.php
+++ b/lib/Service/Rbac/ObjectSharingService.php
@@ -138,6 +138,7 @@ class ObjectSharingService {
* @param InheritedGrantLister $inheritedGrants Lists the grants an ancestor's share confers.
* @param GrantManageGuard $grantManage Who may manage a record's grants: the owner, an administrator,
* or a holder of the special right `grant_manage`.
+ * @param GrantChangeAuditor|null $auditor Writes an audit-trail entry for every grant and revoke. Null only in legacy fixtures.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor injection; each dependency is named in the
* class docblock, and the guard is the tenth.
@@ -153,6 +154,7 @@ public function __construct(
private readonly LoggerInterface $logger,
private readonly InheritedGrantLister $inheritedGrants,
private readonly GrantManageGuard $grantManage,
+ private readonly ?GrantChangeAuditor $auditor = null,
) {
}//end __construct()
@@ -302,7 +304,7 @@ public function grant(
int $permissions = 1,
array $verbs = [],
): array {
- $this->requireMayManageGrants(object: $object, grantType: $type, grantee: trim($shareWith));
+ $admittedAs = $this->requireMayManageGrants(object: $object, grantType: $type, grantee: trim($shareWith));
if (isset(self::GRANTABLE_TYPES[$type]) === false) {
throw new InvalidArgumentException(
@@ -338,13 +340,19 @@ public function grant(
// The caller may re-decide access within this same request.
$this->grantResolver->forget();
- return [
+ $grant = [
'id' => $created->getFullId(),
'type' => $type,
'sharedWith' => $created->getSharedWith(),
'permissions' => $created->getPermissions(),
'verbs' => array_values($verbs),
];
+
+ // Who let this principal in, and when, is the question the trail has
+ // to be able to answer, whoever the caller was admitted as.
+ $this->auditor?->recordGrant(object: $object, grant: $grant, admittedAs: $admittedAs);
+
+ return $grant;
}//end grant()
/**
@@ -583,7 +591,7 @@ private function applyLinkOptions(IShare $share, ?string $password, ?string $exp
* @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
*/
public function revoke(ObjectEntity $object, string $shareId): void {
- $this->requireMayManageGrants(object: $object);
+ $admittedAs = $this->requireMayManageGrants(object: $object);
$folder = $this->resolveFolder(object: $object);
if ($folder === null) {
@@ -600,10 +608,35 @@ public function revoke(ObjectEntity $object, string $shareId): void {
throw new InvalidArgumentException('That grant does not belong to this object');
}
+ $revoked = [
+ 'id' => $share->getFullId(),
+ 'type' => $this->typeLabel(shareType: (int)$share->getShareType()),
+ 'sharedWith' => $share->getSharedWith(),
+ 'permissions' => $share->getPermissions(),
+ ];
+
$this->shareManager->deleteShare($share);
$this->grantResolver->forget();
+
+ $this->auditor?->recordRevoke(object: $object, grant: $revoked, admittedAs: $admittedAs);
}//end revoke()
+ /**
+ * The label of a core share type, as the grants name it.
+ *
+ * @param int $shareType The core share type.
+ *
+ * @return string The label (`user`, `group`, `link`, ...), or the number when it has none.
+ */
+ private function typeLabel(int $shareType): string {
+ $label = array_search($shareType, self::LISTABLE_TYPES, true);
+ if ($label === false) {
+ return (string)$shareType;
+ }
+
+ return (string)$label;
+ }//end typeLabel()
+
/**
* Resolve the object's NC folder, creating it if it has none.
*
@@ -678,7 +711,7 @@ private function requireOwnerOrAdmin(ObjectEntity $object): void {
*
* @throws NotAuthorizedException When the caller may not.
*
- * @return void
+ * @return string How the caller was admitted (GrantManageGuard::AS_OWNER or ::AS_HOLDER).
*
* @spec openspec/changes/special-right-to-manage-grants/specs/object-ownership/spec.md
*/
@@ -686,7 +719,7 @@ private function requireMayManageGrants(
ObjectEntity $object,
?string $grantType = null,
?string $grantee = null,
- ): void {
+ ): string {
$uid = $this->callerUid();
$groups = [];
if ($uid !== null) {
@@ -706,13 +739,16 @@ private function requireMayManageGrants(
userId: $uid,
userGroups: $groups,
user: ($principal['user'] ?? null),
- group: ($principal['group'] ?? null)
+ group: ($principal['group'] ?? null),
+ remote: ($principal['remote'] ?? $principal['remote_group'] ?? null)
) === true
) {
throw new NotAuthorizedException(
message: 'The right to manage grants does not let its holder grant access to itself'
);
}
+
+ return $admittedAs;
}//end requireMayManageGrants()
/**
diff --git a/lib/Service/RegisterDescriptorService.php b/lib/Service/RegisterDescriptorService.php
index 657f31f0b3..d8671ec8b1 100644
--- a/lib/Service/RegisterDescriptorService.php
+++ b/lib/Service/RegisterDescriptorService.php
@@ -44,6 +44,7 @@
namespace OCA\OpenRegister\Service;
use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Service\Configuration\DescriptorConfigIds;
use OCA\OpenRegister\Service\ShippedBaseline\ShippedAuthorizationDecision;
use OCP\App\IAppManager;
use Psr\Log\LoggerInterface;
@@ -223,7 +224,8 @@ public function inventory(): array {
* @param string $slug A register slug the configuration declares.
* @param string $via Who asked, recorded with every authorization block the decision replaces.
*
- * @return array outcome, reason, path, via, version, counts{registers, schemas, authorizationReplaced}, replaced.
+ * @return array outcome, reason, path, via, version, counts{registers, schemas, authorizationReplaced}, replaced,
+ * and, when the import recorded a job, importJobId and importAppId (the configuration id it ran under).
*
* @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md
*/
@@ -275,29 +277,61 @@ public function reimport(string $appId, string $slug, string $via = self::VIA_OC
}//end reimport()
/**
- * Import one non-mock descriptor file, forced, under the app's own configuration id.
+ * Import one non-mock descriptor file, forced, under the configuration id its install-time import uses.
+ *
+ * 🔴 THE CONFIGURATION ID IS THE ONE THE REPAIR STEP USES, not always the
+ * app's. OpenRegister imports eight of its own descriptors under an id of
+ * their own (see {@see DescriptorConfigIds}): importing one under the
+ * shared `openregister` id would set that configuration's version to the
+ * descriptor's, lowering or raising the gate the force:false repair steps
+ * of flows, surveys, credential-broker, flow-timers and intake-sources
+ * compare against, and would append the register to a second configuration
+ * besides its own.
*
* @param string $appId The app.
* @param array{file: string, data: array, registers: array} $descriptor The descriptor.
* @param string $version Its version.
*
- * @return array{success: bool, path: string, via: string, message: string}
+ * @return array{success: bool, path: string, via: string, message: string, importJobId: string|null, importAppId: string}
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess) DescriptorConfigIds::forFile() is a stateless lookup table shared with the
+ * repair steps; injecting it would add a dependency without removing any coupling.
*/
private function importDescriptorFile(string $appId, array $descriptor, string $version): array {
- $via = sprintf('OpenRegister (lib/Settings/%s)', $descriptor['file']);
+ $via = sprintf('OpenRegister (lib/Settings/%s)', $descriptor['file']);
+ $configId = DescriptorConfigIds::forFile(appId: $appId, file: $descriptor['file']);
try {
- $this->configurationService->importFromApp(
- appId: $appId,
+ $imported = $this->configurationService->importFromApp(
+ appId: $configId,
data: $descriptor['data'],
version: $version,
force: true
);
} catch (Throwable $e) {
$this->logger->warning('[RegisterDescriptorService] re-import failed: ' . $e->getMessage());
- return ['success' => false, 'path' => self::PATH_DESCRIPTOR, 'via' => $via, 'message' => $e->getMessage()];
+ return [
+ 'success' => false,
+ 'path' => self::PATH_DESCRIPTOR,
+ 'via' => $via,
+ 'message' => $e->getMessage(),
+ 'importJobId' => null,
+ 'importAppId' => $configId,
+ ];
+ }
+
+ $jobId = ($imported['importJobId'] ?? null);
+ if (is_string($jobId) === false || $jobId === '') {
+ $jobId = null;
}
- return ['success' => true, 'path' => self::PATH_DESCRIPTOR, 'via' => $via, 'message' => ''];
+ return [
+ 'success' => true,
+ 'path' => self::PATH_DESCRIPTOR,
+ 'via' => $via,
+ 'message' => '',
+ 'importJobId' => $jobId,
+ 'importAppId' => $configId,
+ ];
}//end importDescriptorFile()
/**
@@ -347,7 +381,9 @@ private function declaredSlugs(array $document): array {
* `counts` reports what actually landed, so `imported` cannot be said over a
* no-op.
*
- * @return array{outcome: string, reason: string|null, counts?: array{declared: int, imported: int, skipped: int}}
+ * @return array `outcome` and `reason`; `counts` ({declared, imported, skipped}) once the import ran,
+ * with `importJobId` and `importAppId`: the job it recorded and the configuration
+ * id it ran under.
*
* @spec openspec/changes/register-descriptor-admin/specs/register-descriptor-admin/spec.md
*/
@@ -397,12 +433,15 @@ public function reimportMock(string $appId, string $slug): array {
return ['outcome' => 'failed', 'reason' => $e->getMessage()];
}
+ // The job the import ran under, so a caller that undoes the import (an example set's remove) claims exactly it.
+ $job = ['importJobId' => ($result['importJobId'] ?? null), 'importAppId' => $configId];
+
$after = $this->installedRegisterVersions()[strtolower($slug)] ?? null;
if ($after === null) {
return [
'outcome' => 'failed',
'reason' => sprintf('The import reported no error but register "%s" still does not exist.', $slug),
- ];
+ ] + $job;
}
// 🔴 COUNT WHAT LANDED, and fail when a descriptor that declares objects
@@ -426,14 +465,14 @@ public function reimportMock(string $appId, string $slug): array {
$skipped
),
'counts' => ['declared' => $declaredObjects, 'imported' => 0, 'skipped' => $skipped],
- ];
+ ] + $job;
}
return [
'outcome' => 'imported',
'reason' => null,
'counts' => ['declared' => $declaredObjects, 'imported' => $imported, 'skipped' => $skipped],
- ];
+ ] + $job;
}//end reimportMock()
/**
diff --git a/lib/Service/Relation/ObjectTreeService.php b/lib/Service/Relation/ObjectTreeService.php
index fd2d6fb5d4..678f8439be 100644
--- a/lib/Service/Relation/ObjectTreeService.php
+++ b/lib/Service/Relation/ObjectTreeService.php
@@ -38,6 +38,7 @@
use OCA\OpenRegister\Db\RegisterMapper;
use OCA\OpenRegister\Db\Schema;
use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\Object\RenderObject;
use OCA\OpenRegister\Service\Party\PartyDefinition;
use OCA\OpenRegister\Service\Rbac\HierarchyDescender;
use OCA\OpenRegister\Service\Rbac\HierarchyGrantExpander;
@@ -49,6 +50,9 @@
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects) The walk reads the declaration,
* the table, the access filter and the instance cap; each is its own class.
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Two directions, three bounds and a
+ * fail-closed answer for each unreadable level or ancestor; each is a short method, and
+ * splitting them would scatter one walk's bounds over several classes.
*
* @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md
*/
@@ -105,6 +109,7 @@ class ObjectTreeService {
* @param RegisterMapper $registerMapper Resolves the root's register.
* @param SchemaMapper $schemaMapper Resolves the root's schema.
* @param RelationGraphService $graphs Holds the instance's node cap.
+ * @param RenderObject $renderer Strips what the caller may not read from the properties a node carries.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
@@ -114,6 +119,7 @@ public function __construct(
private readonly RegisterMapper $registerMapper,
private readonly SchemaMapper $schemaMapper,
private readonly RelationGraphService $graphs,
+ private readonly RenderObject $renderer,
private readonly LoggerInterface $logger,
) {
}//end __construct()
@@ -165,7 +171,7 @@ public function declarationFor(Schema $schema): ?array {
* @param ObjectEntity $root The object to walk from, already read under the caller's RBAC.
* @param string $direction One of the DIRECTION_ constants.
* @param int|null $depth The depth asked for; null means the declared bound.
- * @param string[] $fields Object properties to copy onto each node.
+ * @param string[] $fields Object properties to copy onto each node, after the read-side redaction.
* @param bool $_rbac Whether to apply the caller's read access to every node.
*
* @return array{root: string, register: int, schema: int, parentProperty: string|null,
@@ -197,7 +203,15 @@ public function tree(
'depth' => 0,
'requestedDepth' => $depth,
'maxDepth' => 0,
- 'nodes' => [$this->node(object: $root, level: 0, parent: null, fields: $fields)],
+ 'nodes' => [
+ $this->node(
+ object: ($this->redacted(objects: [(string)$root->getUuid() => $root], fields: $fields, rbac: $_rbac)[$rootUuid] ?? null),
+ level: 0,
+ parent: null,
+ fields: $fields,
+ uuid: $rootUuid
+ ),
+ ],
'truncated' => false,
'truncatedBy' => null,
];
@@ -290,34 +304,48 @@ private function descendants(string $rootUuid, array $walk): array {
$frontier = [$rootUuid];
for ($level = 1; $level <= $walk['depth']; $level++) {
- $children = $this->readableChildren(table: $table, column: $column, parents: $frontier, seen: $seen, walk: $walk);
- if ($children === null) {
+ // What the cap still lets in, the root counted: the read below is
+ // bounded by it, so a level far wider than the cap is never fetched.
+ $budget = ($cap - count($nodes) - 1);
+ $read = $this->readableChildren(
+ table: $table,
+ column: $column,
+ read: ['parents' => $frontier, 'budget' => $budget, 'seen' => $seen, 'walk' => $walk]
+ );
+ if ($read === null) {
return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_UNREADABLE];
}
- if ($children === []) {
- return ['nodes' => $nodes, 'truncatedBy' => null];
+ if ($read['overflow'] === true) {
+ return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_CAP];
}
- if ((count($nodes) + count($children) + 1) > $cap) {
- return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_CAP];
+ $children = $read['children'];
+ if ($children === []) {
+ return ['nodes' => $nodes, 'truncatedBy' => null];
}
- $objects = $this->load(walk: $walk, uuids: array_keys($children));
+ $objects = $this->redacted(objects: $this->load(walk: $walk, uuids: array_keys($children)), fields: $walk['fields'], rbac: $walk['rbac']);
$frontier = [];
foreach ($children as $child => $parent) {
$seen[$child] = true;
$frontier[] = $child;
$nodes[] = $this->node(object: ($objects[$child] ?? null), level: $level, parent: $parent, fields: $walk['fields'], uuid: $child);
}
- }
+ }//end for
- $more = $this->readableChildren(table: $table, column: $column, parents: $frontier, seen: $seen, walk: $walk);
- if ($more === null) {
+ $read = $this->readableChildren(
+ table: $table,
+ column: $column,
+ read: ['parents' => $frontier, 'budget' => ($cap - count($nodes) - 1), 'seen' => $seen, 'walk' => $walk]
+ );
+ if ($read === null) {
return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_UNREADABLE];
}
- if ($more !== []) {
+ // An overflowing probe still answers from the rows it did read: one
+ // readable child among them is enough to say the depth stopped the walk.
+ if ($read['children'] !== []) {
return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_DEPTH];
}
@@ -327,23 +355,35 @@ private function descendants(string $rootUuid, array $walk): array {
/**
* The children of a level the caller may read and has not met yet, or null when the level cannot be read.
*
- * A cycle in the data shows up here as a child already seen, and is dropped.
+ * The read is bounded by the budget: one row more than the cap still lets
+ * in is asked for, and getting it means the level is wider than the cap
+ * (`overflow`); the access check then runs on the rows that fit only, so
+ * the work stays bounded too. A cycle in the data shows up here as a child
+ * already seen, and is dropped. Null covers both a level that could not be
+ * read and one whose read access could not be established: neither may be
+ * answered as "nothing below".
*
- * @param string $table The magic table.
- * @param string $column The parent column.
- * @param string[] $parents The level to read below.
- * @param array $seen Every uuid already in the answer.
- * @param array $walk The walk.
+ * @param string $table The magic table.
+ * @param string $column The parent column.
+ * @param array $read `parents` (the level to read below), `budget` (the children the cap still
+ * admits), `seen` (the uuids already in the answer) and `walk`.
*
- * @return array|null Child uuid => parent uuid.
+ * @return array{children: array, overflow: bool}|null Child uuid => parent uuid, and whether the level is wider than the budget.
*/
- private function readableChildren(string $table, string $column, array $parents, array $seen, array $walk): ?array {
- if ($parents === []) {
- return [];
+ private function readableChildren(string $table, string $column, array $read): ?array {
+ if ($read['parents'] === []) {
+ return ['children' => [], 'overflow' => false];
}
+ $budget = max(0, $read['budget']);
+
try {
- $children = $this->descender->childrenOf(table: $table, parentColumn: $column, parentUuids: $parents);
+ $children = $this->descender->childrenOf(
+ table: $table,
+ parentColumn: $column,
+ parentUuids: $read['parents'],
+ limit: ($budget + 1)
+ );
} catch (Throwable $e) {
$this->logger->warning(
message: '[ObjectTreeService] A level of the tree could not be read',
@@ -358,16 +398,30 @@ private function readableChildren(string $table, string $column, array $parents,
return null;
}
- $children = array_diff_key($children, $seen);
+ // Counted before the seen ones are dropped: a row that is a cycle
+ // still used up a place of the bounded read, so it may hide a wider level.
+ $overflow = (count($children) > $budget);
+ $children = array_diff_key($children, $read['seen']);
+ if ($overflow === true) {
+ $children = array_slice($children, 0, $budget, true);
+ }
+
+ $readable = $this->readableOnly(walk: $read['walk'], uuids: $children);
+ if ($readable === null) {
+ return null;
+ }
- return $this->readableOnly(walk: $walk, uuids: $children);
+ return ['children' => $readable, 'overflow' => $overflow];
}//end readableChildren()
/**
* The ancestors of the root, nearest first.
*
* The walk stops at the first ancestor the caller may not read: a level
- * above a hidden one would carry the wrong level number.
+ * above a hidden one would carry the wrong level number. A node names the
+ * uuid of its own parent only when that parent is one the caller may read:
+ * the topmost node of a walk that stopped at a hidden ancestor, or at one
+ * that does not exist, carries no parent at all.
*
* @param ObjectEntity $root The root.
* @param array $walk The walk.
@@ -375,51 +429,79 @@ private function readableChildren(string $table, string $column, array $parents,
* @return array{nodes: array>, truncatedBy: string|null} The nodes, nearest first.
*/
private function ancestors(ObjectEntity $root, array $walk): array {
- $nodes = [];
+ $chain = [];
$seen = [(string)$root->getUuid() => true];
$current = $root;
+ $truncatedBy = null;
+ $topParent = null;
+
+ // One step past the depth bound: it only finds out whether there is
+ // more above, and which readable uuid the topmost node then names.
+ for ($level = 1; $level <= ($walk['depth'] + 1); $level++) {
+ $step = $this->parentStep(object: $current, seen: $seen, walk: $walk);
+ if ($step['object'] === null) {
+ $truncatedBy = $step['truncatedBy'];
+ break;
+ }
- for ($level = 1; $level <= $walk['depth']; $level++) {
- $parent = $this->readableParent(object: $current, seen: $seen, walk: $walk);
- if ($parent === null) {
- return ['nodes' => $nodes, 'truncatedBy' => null];
+ $uuid = (string)$step['object']->getUuid();
+ if ($level > $walk['depth']) {
+ $topParent = $uuid;
+ $truncatedBy = self::TRUNCATED_DEPTH;
+ break;
}
- $uuid = (string)$parent->getUuid();
$seen[$uuid] = true;
- $grandparent = self::reference(value: ($parent->getObject()[$walk['property']] ?? null));
- $nodes[] = $this->node(object: $parent, level: -$level, parent: $grandparent, fields: $walk['fields']);
- $current = $parent;
+ $chain[$uuid] = $step['object'];
+ $current = $step['object'];
}
- if ($this->readableParent(object: $current, seen: $seen, walk: $walk) !== null) {
- return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_DEPTH];
+ $shown = $this->redacted(objects: $chain, fields: $walk['fields'], rbac: $walk['rbac']);
+ $uuids = array_keys($chain);
+ $nodes = [];
+ foreach ($uuids as $index => $uuid) {
+ $nodes[] = $this->node(
+ object: ($shown[$uuid] ?? null),
+ level: -($index + 1),
+ parent: ($uuids[($index + 1)] ?? $topParent),
+ fields: $walk['fields'],
+ uuid: $uuid
+ );
}
- return ['nodes' => $nodes, 'truncatedBy' => null];
+ return ['nodes' => $nodes, 'truncatedBy' => $truncatedBy];
}//end ancestors()
/**
* The parent of one object when it exists, is new to the walk and is readable.
*
+ * Tells "there is none" (or it is a cycle) and "the caller may not read it"
+ * from "read access could not be established": the last one is an
+ * incomplete answer, and the tree says so.
+ *
* @param ObjectEntity $object The object.
* @param array $seen Every uuid already in the answer.
* @param array $walk The walk.
*
- * @return ObjectEntity|null The parent.
+ * @return array{object: ObjectEntity|null, truncatedBy: string|null} The parent, or why there is none to show.
*/
- private function readableParent(ObjectEntity $object, array $seen, array $walk): ?ObjectEntity {
+ private function parentStep(ObjectEntity $object, array $seen, array $walk): array {
$parentUuid = self::reference(value: ($object->getObject()[$walk['property']] ?? null));
if ($parentUuid === null || isset($seen[$parentUuid]) === true) {
- return null;
+ return ['object' => null, 'truncatedBy' => null];
}
- if ($this->readableOnly(walk: $walk, uuids: [$parentUuid => '']) === []) {
- return null;
+ $readable = $this->readableOnly(walk: $walk, uuids: [$parentUuid => '']);
+ if ($readable === null) {
+ return ['object' => null, 'truncatedBy' => self::TRUNCATED_UNREADABLE];
+ }
+
+ if ($readable === []) {
+ return ['object' => null, 'truncatedBy' => null];
}
- return ($this->load(walk: $walk, uuids: [$parentUuid])[$parentUuid] ?? null);
- }//end readableParent()
+ return ['object' => ($this->load(walk: $walk, uuids: [$parentUuid])[$parentUuid] ?? null), 'truncatedBy' => null];
+ }//end parentStep()
/**
* The entries of a uuid-keyed map the caller may read.
@@ -427,18 +509,21 @@ private function readableParent(ObjectEntity $object, array $seen, array $walk):
* @param array $walk The walk.
* @param array $uuids Uuid => anything.
*
- * @return array The readable entries, in their order.
+ * @return array|null The readable entries, in their order; null when read access could not be established.
*/
- private function readableOnly(array $walk, array $uuids): array {
+ private function readableOnly(array $walk, array $uuids): ?array {
if ($uuids === [] || $walk['rbac'] === false) {
return $uuids;
}
- $readable = $this->objectMapper->filterReadableUuids(
+ $readable = $this->objectMapper->readableUuidsOrNull(
registerId: (int)$walk['register']->getId(),
schemaId: (int)$walk['schema']->getId(),
uuids: array_map('strval', array_keys($uuids))
);
+ if ($readable === null) {
+ return null;
+ }
return array_intersect_key($uuids, array_flip($readable));
}//end readableOnly()
@@ -459,10 +544,36 @@ private function load(array $walk, array $uuids): array {
);
}//end load()
+ /**
+ * The objects as the caller may read them: write-only properties, properties
+ * the caller's group may not read and encrypted values handled the way every
+ * other read of an object does.
+ *
+ * Only a request that names `fields` has properties to carry, so only then
+ * is anything done. The entities are copied first: the originals are what
+ * the walk reads the parent property from, and are never written back.
+ *
+ * @param array $objects Uuid => object, as the mapper loaded them.
+ * @param string[] $fields The properties the request names.
+ * @param bool $rbac Whether the read is the caller's own (property read access applies).
+ *
+ * @return array Uuid => the redacted copy.
+ */
+ private function redacted(array $objects, array $fields, bool $rbac): array {
+ if ($fields === [] || $objects === []) {
+ return $objects;
+ }
+
+ $copies = array_map(static fn (ObjectEntity $object): ObjectEntity => clone $object, $objects);
+ $this->renderer->redactWriteOnlyFromRows(rows: $copies, _rbac: $rbac);
+
+ return $copies;
+ }//end redacted()
+
/**
* One node of the answer.
*
- * @param ObjectEntity|null $object The object, when it loaded.
+ * @param ObjectEntity|null $object The object as the caller may read it, when it loaded.
* @param int $level 0 for the root, negative above, positive below.
* @param string|null $parent The parent uuid.
* @param string[] $fields Properties to copy.
diff --git a/lib/Service/Schemas/PropertyValidatorHandler.php b/lib/Service/Schemas/PropertyValidatorHandler.php
index 6294abee11..9306605b89 100644
--- a/lib/Service/Schemas/PropertyValidatorHandler.php
+++ b/lib/Service/Schemas/PropertyValidatorHandler.php
@@ -26,6 +26,7 @@
namespace OCA\OpenRegister\Service\Schemas;
use Exception;
+use OCA\OpenRegister\Service\Object\ReferenceKey;
use OCA\OpenRegister\Service\Search\PropertySearchProfile;
/**
@@ -977,8 +978,10 @@ public function validateProperty(array $property, string $path = ''): bool {
}
// A reference by key only refuses: the other actions rewrite or
- // delete by uuid and would act on the wrong value.
- $referenceKey = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null));
+ // delete by uuid and would act on the wrong value. An empty
+ // referenceKey or the literal `uuid` is a plain uuid reference,
+ // read the way the delete reads it.
+ $referenceKey = (new ReferenceKey())->keyOf(property: $property);
if ($referenceKey !== null && strtoupper((string)$property['onDelete']) !== 'RESTRICT') {
throw new Exception("'referenceKey' at '$path' is only valid with onDelete RESTRICT");
}
diff --git a/lib/Service/Search/ObjectSearchResultFormatter.php b/lib/Service/Search/ObjectSearchResultFormatter.php
index 8e805b573e..e37f6c4fad 100644
--- a/lib/Service/Search/ObjectSearchResultFormatter.php
+++ b/lib/Service/Search/ObjectSearchResultFormatter.php
@@ -190,9 +190,10 @@ private function resolveObjectUrl(array $object, array $selfData, array $ids): s
objectData: $flatData
);
- // A placeholder the object could not fill (its `case` is empty) would
- // leave a literal `{case}` in the link. Such a hit opens OpenRegister's
- // own page instead of a broken one.
+ // A placeholder the object could not fill (its `case` is absent or empty:
+ // ownLinkProperties() passes neither) would leave a literal `{case}` in
+ // the link. Such a hit opens OpenRegister's own page instead of a broken
+ // one.
if ($objectUrl !== null && preg_match('/\{[A-Za-z0-9_]+\}/', $objectUrl) !== 1) {
return $objectUrl;
}
@@ -206,7 +207,11 @@ private function resolveObjectUrl(array $object, array $selfData, array $ids): s
/**
* The object's own properties a deep-link template may name: declared
* (not `@self`, not an `_`-prefixed key the pipeline attached, the same
- * line {@see buildExcerpt()} draws) and scalar. Values are URL-encoded,
+ * line {@see buildExcerpt()} draws) and scalar, and holding a value. An
+ * empty or blank string and a boolean are left out, so the placeholder
+ * stays unfilled and the hit falls back to OpenRegister's own page: filled
+ * with nothing, `/apps/dossiq/cases/{case}` would become `/apps/dossiq/cases/`
+ * and no brace would be left to notice it by. Values are URL-encoded,
* because they land in a path and a value like `../settings` must not
* walk out of it.
*
@@ -224,7 +229,7 @@ private function ownLinkProperties(array $object): array {
continue;
}
- if (is_scalar($value) === false) {
+ if (is_scalar($value) === false || is_bool($value) === true || trim((string)$value) === '') {
continue;
}
diff --git a/lib/Service/Settings/FileSettingsHandler.php b/lib/Service/Settings/FileSettingsHandler.php
index c7c6ef230a..2d42098f02 100644
--- a/lib/Service/Settings/FileSettingsHandler.php
+++ b/lib/Service/Settings/FileSettingsHandler.php
@@ -255,7 +255,9 @@ public function updateFileSettingsOnly(array $fileData): array {
'entityRecognitionMethod' => $fileData['entityRecognitionMethod'] ?? 'auto',
// Auto (unconfigured marker), regex, presidio, openanonymiser, llm, hybrid.
// Who owns a redaction decision on a file that belongs to no
- // schema (reviewer-owns-their-decisions); null is open.
+ // schema (reviewer-owns-their-decisions); null is open. A save
+ // that does not carry the key keeps the stored declaration (the
+ // admin form never sends it); an explicit null clears it.
ReviewPolicy::SETTINGS_KEY => $this->reviewDefault(fileData: $fileData),
];
@@ -278,16 +280,27 @@ public function updateFileSettingsOnly(array $fileData): array {
/**
* The instance review default from a settings save, refused when unsound.
*
+ * A save without the key keeps the stored declaration; an explicit null clears it.
+ *
* @param array $fileData The settings being saved.
*
- * @return array|null The declaration, or null when none is given.
+ * @return array|null The declaration to store, or null when there is none.
*
* @throws InvalidArgumentException When the declaration names an ownership nobody can honour.
*
* @spec openspec/changes/reviewer-owns-their-decisions/specs/entity-relation-grondslagen/spec.md#requirement-a-reviewers-decision-is-changed-only-by-that-reviewer-or-a-supervisor-req-rod-001
*/
private function reviewDefault(array $fileData): ?array {
- $declaration = ($fileData[ReviewPolicy::SETTINGS_KEY] ?? null);
+ if (array_key_exists(ReviewPolicy::SETTINGS_KEY, $fileData) === false) {
+ $stored = ($this->getFileSettingsOnly()[ReviewPolicy::SETTINGS_KEY] ?? null);
+ if (is_array($stored) === true) {
+ return $stored;
+ }
+
+ return null;
+ }
+
+ $declaration = $fileData[ReviewPolicy::SETTINGS_KEY];
if ($declaration === null) {
return null;
}
diff --git a/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php b/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php
index 45ba1b648b..2234425172 100644
--- a/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php
+++ b/lib/Service/ShippedBaseline/GuardedDescriptorMerge.php
@@ -161,9 +161,12 @@ public function merge(array $baseline, array $live, array $incoming, array $deci
}
// An administrator's re-import is the explicit decision REQ-LCA-003
- // waits for, for every authorization block either side carries.
+ // waits for, for every authorization block the shipped definition
+ // carries. A block only the instance holds is not decided: the app
+ // shipped nothing to take, so the local policy stays (and is
+ // reported) instead of the schema turning default-open.
if ($this->decidedVia() !== null) {
- $decisions = array_values(array_unique(array_merge($decisions, $this->blocks->roots($live, $incoming))));
+ $decisions = array_values(array_unique(array_merge($decisions, $this->blocks->roots($incoming))));
}
$blocks = $this->blocks->settle(states: $states, decisions: $decisions);
diff --git a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php
index d3e2f82238..a165717004 100644
--- a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php
+++ b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php
@@ -52,6 +52,9 @@
/**
* Guards an app-shipped schema update against local changes.
*
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One guard for every shipped definition kind: baseline read, merge,
+ * audit of each authorization change and the fail-closed fallback share one definition of what "shipped" means.
+ *
* @spec openspec/changes/local-changes-to-app-shipped-configuration/specs/schema-import/spec.md
*/
class ShippedConfigurationGuard {
@@ -163,6 +166,12 @@ public function guardSchemaUpdate(
decisions: $decisions
);
$this->recordReplaced(slug: $slug, app: $app, roots: $result['replacedLocal']);
+ $this->recordAppliedAuthorization(
+ slug: $slug,
+ app: $app,
+ applied: $result['applied'],
+ recorded: array_merge($result['replacedLocal'], $decisions)
+ );
if ($record === true) {
$this->baselines->record(
@@ -173,18 +182,7 @@ public function guardSchemaUpdate(
);
}
- if ($result['conflicts'] !== []) {
- // INFO on the parts that were left alone, because this is the
- // decision somebody re-reads the upgrade log to find. The
- // upgrade itself completes either way.
- $this->logger->info(
- sprintf(
- '[ShippedConfigurationGuard] %s: %d part(s) changed on both sides, kept local and reported',
- $slug,
- count($result['conflicts'])
- )
- );
- }
+ $this->logKeptLocal(slug: $slug, conflicts: $result['conflicts']);
foreach ($decisions as $path) {
$this->recordDecision(slug: $slug, path: (string)$path, app: $app);
@@ -199,15 +197,17 @@ public function guardSchemaUpdate(
'conflicts' => $result['conflicts'],
];
} catch (Throwable $e) {
- // An upgrade must finish. A guard that cannot run means the import
- // behaves as it did before the guard existed, which is a known
- // state, not a broken one.
+ // An upgrade must finish, so the import still goes ahead. But a
+ // guard that cannot run must not hand the app's authorization over
+ // what an administrator set here: that is the one thing the guard
+ // exists to prevent, and failing towards the shipped policy is
+ // failing open. The instance's own authorization blocks are kept.
$this->logger->error(
- sprintf('[ShippedConfigurationGuard] %s: guard skipped, importing unguarded: %s', $slug, $e->getMessage())
+ sprintf('[ShippedConfigurationGuard] %s: guard skipped, importing unguarded but keeping the instance authorization: %s', $slug, $e->getMessage())
);
return [
- 'definition' => $incoming,
+ 'definition' => $this->keepingLiveAuthorization(live: $live, incoming: $incoming),
'baseline' => null,
'guarded' => false,
'applied' => [],
@@ -217,6 +217,110 @@ public function guardSchemaUpdate(
}//end try
}//end guardSchemaUpdate()
+ /**
+ * Log, at INFO, the parts that changed on both sides and were left alone.
+ *
+ * INFO, because this is the decision somebody re-reads the upgrade log to
+ * find. The upgrade itself completes either way.
+ *
+ * @param string $slug The schema slug.
+ * @param array> $conflicts The parts kept local.
+ *
+ * @return void
+ */
+ private function logKeptLocal(string $slug, array $conflicts): void {
+ if ($conflicts === []) {
+ return;
+ }
+
+ $this->logger->info(
+ sprintf('[ShippedConfigurationGuard] %s: %d part(s) changed on both sides, kept local and reported', $slug, count($conflicts))
+ );
+ }//end logKeptLocal()
+
+ /**
+ * The incoming definition with the instance's own authorization blocks put back.
+ *
+ * For an import the guard could not judge: the schema's `authorization` and
+ * each existing property's `authorization` stay as the instance has them
+ * (a block the instance does not have is not added); a property the
+ * instance does not hold yet brings its shipped block.
+ *
+ * @param array $live What the instance runs.
+ * @param array $incoming What the app now ships.
+ *
+ * @return array The definition to write.
+ */
+ private function keepingLiveAuthorization(array $live, array $incoming): array {
+ $definition = $this->withAuthorizationOf(target: $incoming, live: $live);
+
+ $liveProperties = ($live['properties'] ?? null);
+ if (is_array($liveProperties) === false || is_array($definition['properties'] ?? null) === false) {
+ return $definition;
+ }
+
+ foreach ($definition['properties'] as $name => $property) {
+ if (is_array($property) === false || is_array($liveProperties[$name] ?? null) === false) {
+ continue;
+ }
+
+ $definition['properties'][$name] = $this->withAuthorizationOf(target: $property, live: $liveProperties[$name]);
+ }
+
+ return $definition;
+ }//end keepingLiveAuthorization()
+
+ /**
+ * A definition part carrying the `authorization` block of the instance's own part, or none when the instance has none.
+ *
+ * @param array $target The part from the incoming definition.
+ * @param array $live The same part as the instance has it.
+ *
+ * @return array The part with the instance's authorization.
+ */
+ private function withAuthorizationOf(array $target, array $live): array {
+ if (array_key_exists('authorization', $live) === false) {
+ unset($target['authorization']);
+ return $target;
+ }
+
+ $target['authorization'] = $live['authorization'];
+
+ return $target;
+ }//end withAuthorizationOf()
+
+ /**
+ * Record, on the audit trail, the authorization blocks an upgrade took from the app because only the app changed them.
+ *
+ * An explicit decision is recorded by recordReplaced(); this is the other
+ * way a block changes under an instance, and who widened access is what
+ * the trail has to be able to answer.
+ *
+ * @param string $slug The schema slug.
+ * @param string $app The app that ships it.
+ * @param array $applied The paths the merge took from upstream.
+ * @param array $recorded Roots already on the trail.
+ *
+ * @return void
+ */
+ private function recordAppliedAuthorization(string $slug, string $app, array $applied, array $recorded): void {
+ $roots = [];
+ foreach ($applied as $path) {
+ $path = (string)$path;
+ $sep = preg_quote(DescriptorParts::SEPARATOR, '/');
+ $pattern = '/^(authorization|properties' . $sep . '[^' . $sep . ']+' . $sep . 'authorization)(?:' . $sep . '|$)/';
+ if (preg_match($pattern, $path, $matches) === 1) {
+ $roots[$matches[1]] = true;
+ }
+ }
+
+ foreach (array_keys($roots) as $root) {
+ if (in_array($root, $recorded, true) === false) {
+ $this->recordDecision(slug: $slug, path: $root, app: $app, decision: 'applied-shipped');
+ }
+ }
+ }//end recordAppliedAuthorization()
+
/**
* Record what an app shipped for a schema it has just created.
*
@@ -522,15 +626,16 @@ private function recordReplaced(string $slug, string $app, array $roots): void {
* @param string $path The part.
* @param string $app The app.
* @param string $via What decided it, when not the acting user alone (an occ re-import).
+ * @param string $decision `accept-shipped` for a conflict an administrator took, `applied-shipped` for a block only the app changed.
*
* @return void
*/
- private function recordDecision(string $slug, string $path, string $app, string $via = ''): void {
+ private function recordDecision(string $slug, string $path, string $app, string $via = '', string $decision = 'accept-shipped'): void {
$changed = [
'schema' => $slug,
'path' => $path,
'app' => $app,
- 'decision' => 'accept-shipped',
+ 'decision' => $decision,
];
if ($via !== '') {
$changed['via'] = $via;
diff --git a/lib/Service/Task/TaskBlockerGuard.php b/lib/Service/Task/TaskBlockerGuard.php
index d3339e53be..6afbfef020 100644
--- a/lib/Service/Task/TaskBlockerGuard.php
+++ b/lib/Service/Task/TaskBlockerGuard.php
@@ -81,7 +81,15 @@ public function refuseHandWrittenState(array $data): void {
/**
* Refuse a blocker that is the task itself, does not exist, or closes a loop.
*
- * @param Task $task The task about to be created, uuid already set.
+ * With `$mayRead`, the caller's own read rule: a blocker the caller may not
+ * read is refused in the words of one that does not exist. Without that, a
+ * person who knows a task uuid could tell from the answer (and later from
+ * the `blocked` flag of their own task) that a task of somebody else's
+ * exists and whether it is still open. Trusted callers (flows, migrations)
+ * pass none: they declare blockers of their own making.
+ *
+ * @param Task $task The task about to be created, uuid already set.
+ * @param callable|null $mayRead Answers whether the caller may read a given Task; null for a trusted caller.
*
* @return void
*
@@ -89,7 +97,7 @@ public function refuseHandWrittenState(array $data): void {
*
* @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight
*/
- public function assertDeclarable(Task $task): void {
+ public function assertDeclarable(Task $task, ?callable $mayRead = null): void {
$blockedBy = (string)$task->getBlockedBy();
if ($blockedBy === '') {
return;
@@ -113,6 +121,10 @@ public function assertDeclarable(Task $task): void {
return;
}
+ if ($this->isHiddenFromCaller(blocker: $blocker, named: ($next === $blockedBy), mayRead: $mayRead) === true) {
+ throw new TaskValidationException(message: sprintf("blockedBy names task '%s', and no task has that uuid.", $blockedBy));
+ }
+
$next = (string)$blocker->getBlockedBy();
if ($next === $own) {
throw new TaskValidationException(
@@ -121,4 +133,64 @@ public function assertDeclarable(Task $task): void {
}
}
}//end assertDeclarable()
+
+ /**
+ * Whether the caller may not read the blocker they named.
+ *
+ * Only the task the caller NAMES is held to their read rule: the chain
+ * behind it is walked for loops and never reported back.
+ *
+ * @param Task $blocker The blocker found.
+ * @param bool $named Whether it is the one the caller named, not one further up its chain.
+ * @param callable|null $mayRead The caller's read rule; null for a trusted caller.
+ *
+ * @return bool True when it must be refused as if it did not exist.
+ */
+ private function isHiddenFromCaller(Task $blocker, bool $named, ?callable $mayRead): bool {
+ return $named === true && $mayRead !== null && $mayRead($blocker) !== true;
+ }//end isHiddenFromCaller()
+
+ /**
+ * Refuse a task that waits on an open blocker as one that does not exist.
+ *
+ * For a seam that never lists a held-back task (the portal): reading or
+ * completing one by its uuid must not be a way around the list.
+ *
+ * @param Task $task The task.
+ *
+ * @return void
+ *
+ * @throws DoesNotExistException When the task waits on a blocker that is still open.
+ *
+ * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight
+ */
+ public function assertNotHeldBack(Task $task): void {
+ if ($this->isHeldBack(task: $task) === true) {
+ throw new DoesNotExistException('No such task.');
+ }
+ }//end assertNotHeldBack()
+
+ /**
+ * Whether the task waits on a blocker that is still open.
+ *
+ * A blocker that no longer exists blocks nothing.
+ *
+ * @param Task $task The task.
+ *
+ * @return bool True when its blocker exists and is not terminal.
+ *
+ * @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight
+ */
+ public function isHeldBack(Task $task): bool {
+ $blockedBy = (string)$task->getBlockedBy();
+ if ($blockedBy === '') {
+ return false;
+ }
+
+ try {
+ return $this->tasks->findByUuid(uuid: $blockedBy)->getIsTerminal() !== true;
+ } catch (DoesNotExistException) {
+ return false;
+ }
+ }//end isHeldBack()
}//end class
diff --git a/lib/Service/Task/TaskService.php b/lib/Service/Task/TaskService.php
index 0ca82ba984..e988c7fde0 100644
--- a/lib/Service/Task/TaskService.php
+++ b/lib/Service/Task/TaskService.php
@@ -257,7 +257,14 @@ public function create(array $data, ?string $actor): Task {
}
}
- return $this->import(data: $data, actor: $actor);
+ // An ordinary caller may only name a blocker they may read; an
+ // administrator reads every task, so for them it could only pass.
+ $mayReadBlocker = null;
+ if ($this->authorization->isAdministrator(uid: $actor) === false) {
+ $mayReadBlocker = fn (Task $blocker): bool => $this->authorization->mayRead(task: $blocker, uid: $actor);
+ }
+
+ return $this->import(data: $data, actor: $actor, mayReadBlocker: $mayReadBlocker);
}//end create()
/**
@@ -268,6 +275,8 @@ public function create(array $data, ?string $actor): Task {
*
* @param array $data The task fields, canonical or legacy.
* @param string|null $actor The creating identity.
+ * @param callable|null $mayReadBlocker Answers whether the creating caller may read a blocker; create() passes
+ * the caller's own rule for an ordinary user, a trusted caller passes none.
*
* @return Task The created task, with candidates indexed and creation audited.
*
@@ -277,11 +286,11 @@ public function create(array $data, ?string $actor): Task {
* @spec openspec/specs/flow-tasks/spec.md#requirement-one-lifecycle-with-every-legacy-value-mapped-onto-it
* @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight
*/
- public function import(array $data, ?string $actor): Task {
+ public function import(array $data, ?string $actor, ?callable $mayReadBlocker = null): Task {
$data = $this->locate(data: $data);
$task = $this->builder->fromData(data: $data, actor: $actor);
$this->refuseUnrenderableForm(task: $task);
- (new TaskBlockerGuard(tasks: $this->tasks))->assertDeclarable(task: $task);
+ (new TaskBlockerGuard(tasks: $this->tasks))->assertDeclarable(task: $task, mayRead: $mayReadBlocker);
$this->authorizeOrRecord(verb: 'create', task: $task, actor: $actor);
$this->pending = [
'assignee' => null,
diff --git a/lib/Service/WebhookService.php b/lib/Service/WebhookService.php
index 83848c5859..fc94eb7c0f 100644
--- a/lib/Service/WebhookService.php
+++ b/lib/Service/WebhookService.php
@@ -48,6 +48,7 @@
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
+use Throwable;
/**
* WebhookService handles webhook delivery and request interception
@@ -677,15 +678,34 @@ public function dispatchEvent(Event $_event, string $eventName, array $payload):
// latency a function of third-party uptime (async-webhook-delivery /
// ADR-009 Rule 5). WebhookDeliveryJob performs the same delivery + logging.
foreach ($webhooks as $webhook) {
- $this->jobList->add(
- WebhookDeliveryJob::class,
- [
- 'webhook_id' => $webhook->getId(),
- 'event_name' => $eventName,
- 'payload' => $payload,
- 'attempt' => 1,
- ]
- );
+ try {
+ $this->jobList->add(
+ WebhookDeliveryJob::class,
+ [
+ 'webhook_id' => $webhook->getId(),
+ 'event_name' => $eventName,
+ 'payload' => $payload,
+ 'attempt' => 1,
+ ]
+ );
+ } catch (Throwable $e) {
+ // Nextcloud refuses a job argument over 4000 characters (32 and 33)
+ // or 32000 (34 and later), and the payload is the whole event. The
+ // refusal must not abort the webhooks after this one, nor surface
+ // as an exception in the listener (where it is only a log line);
+ // this delivery is lost, and the log says which one and why.
+ $this->logger->error(
+ message: '[WebhookService] A webhook delivery could not be enqueued and was not sent',
+ context: [
+ 'file' => __FILE__,
+ 'line' => __LINE__,
+ 'webhook_id' => $webhook->getId(),
+ 'event' => $eventName,
+ 'payload_length' => strlen((string)json_encode($payload)),
+ 'error' => $e->getMessage(),
+ ]
+ );
+ }
}
}//end dispatchEvent()
diff --git a/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md b/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md
index fa705d3be5..bbc543dc36 100644
--- a/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md
+++ b/openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md
@@ -11,13 +11,19 @@ NOT be stored. A create that carries `blocked`, or a state of `blocked`, SHALL
be refused.
A create SHALL be refused when `blockedBy` names the task itself, names no
-existing task, or closes a loop of blockers.
+existing task, or closes a loop of blockers. A caller who is not an administrator
+SHALL be refused a blocker they may not read in the words of one that does not exist,
+so `blockedBy` is no way to probe for a task.
An inbox read SHALL leave out every blocked task, in its results and in its
total. A read anchored to an object or a run SHALL still list blocked tasks,
carrying `blocked` and `blockedBy`. A read with `includeBlocked=true` SHALL
list them in any scope.
+The portal list of an external task SHALL leave out a blocked task, in its results
+and in its total, and a portal party SHALL NOT be able to read or complete one by its
+uuid: it answers like a task that does not exist, until the blocker is terminal.
+
When a blocker reaches a terminal state, every open task it blocked SHALL be
released with no person acting: it SHALL get an audit entry `released` naming
the blocker, it SHALL be announced again to the projections, and it SHALL
@@ -60,3 +66,18 @@ return to the inbox.
- **GIVEN** task A blocked by task B
- **WHEN** a task is created blocked by itself, by a uuid no task has, or as B's blocker with A's chain leading back to it
- **THEN** the create SHALL be refused
+
+#### Scenario: A blocked portal task waits out of sight in the portal too
+@e2e exclude unit; TaskMapperExternalTest covers the predicate, PortalTaskServiceTest the read and the completion
+
+- **GIVEN** an external task assigned to a party, blocked by an open task
+- **WHEN** the party lists their portal tasks, reads it by its uuid, or tries to complete it
+- **THEN** it SHALL NOT be listed or counted, and reading or completing it SHALL answer as for a task that does not exist
+- **AND** once the blocker is completed the task SHALL be listed and completable again
+
+#### Scenario: A blocker the caller may not read is refused like a missing one
+@e2e exclude unit; TaskBlockedByCreateTest
+
+- **GIVEN** a task of another organisation that the caller may not read
+- **WHEN** the caller creates a task blocked by it
+- **THEN** the create SHALL be refused with the same message as for a uuid no task has
diff --git a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md
index 5dc0bb6005..9eced6bf23 100644
--- a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md
+++ b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/proposal.md
@@ -16,8 +16,9 @@ Zuiddrecht live test, 14 October 2026: one cron run that swept hundreds of dossi
## What changes
-- `ListenerDeferralService::flushBuffer()` splits the buffer into consecutive chunks whose JSON argument stays within `MAX_ARGUMENT_LENGTH` (32,000), measured the way Nextcloud's job list measures it, and enqueues each.
-- An entry larger than any job may carry is logged at error level with its job class and uuid, and the rest of its chunk still goes.
+- `ListenerDeferralService::flushBuffer()` splits the buffer into consecutive chunks whose JSON argument stays within the running server's job argument limit (`ListenerDeferralService::getArgumentLimit()`: `JobList::MAX_ARGUMENT_JSON_LENGTH` where Nextcloud defines it, 32,000 from 34, else the 4,000 of Nextcloud 32 and 33), measured the way Nextcloud's job list measures it, and enqueues each.
+- An entry larger than the limit is enqueued without its `oldData` (the entry is kept; conditioned `updated` rules and `calculatedChange` rules, which fail closed without the previous data, are skipped for that object) and the reduction is logged at warning level.
+- An entry still too large after that is logged at error level with its job class and uuid, and the rest of its chunk still goes.
## Screens
diff --git a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md
index a8d185f402..4b8a3a96da 100644
--- a/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md
+++ b/openspec/changes/deferred-listener-batches-stay-under-the-job-limit/specs/event-driven-architecture/spec.md
@@ -4,14 +4,20 @@
### Requirement: A deferred listener chunk fits the job argument limit
-The listener deferral service SHALL enqueue a buffered chunk as one or more background jobs whose JSON-encoded argument each stays within Nextcloud's limit of 32,000 characters, keeping every entry once and in order. An entry that alone exceeds the limit SHALL be left out and logged with its job class and uuid; the other entries of its chunk SHALL still be enqueued.
+The listener deferral service SHALL enqueue a buffered chunk as one or more background jobs whose JSON-encoded argument each stays within the running Nextcloud's job argument limit (4,000 characters on Nextcloud 32 and 33, 32,000 from 34; read from the server when it names the limit, 4,000 otherwise), keeping every entry once and in order. An entry that alone exceeds the limit SHALL be enqueued without its `oldData` and the reduction logged, naming that conditioned `updated` rules and `calculatedChange` rules are skipped for that object (they fail closed without the previous data); an entry that still exceeds it SHALL be left out and logged with its job class and uuid. The other entries of its chunk SHALL still be enqueued.
#### Scenario: Twenty-five updates of large cases
- GIVEN a schema that declares update notifications and 25 updates of cases of about 2,000 characters each in one request
- WHEN the buffer is flushed
-- THEN every update reaches the job list once, in order, spread over more than one job, and no job argument is longer than 32,000 characters
+- THEN every update reaches the job list once, in order, spread over more than one job, and no job argument is longer than the active limit (checked at 4,000 and at 32,000)
- @e2e exclude pinned through `AnnotationNotificationListener` by `DeferredChunksFitTheJobLimitTest::testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes`
+#### Scenario: An entry too large for the active limit is kept without its previous data
+- GIVEN an update whose previous data alone makes its entry longer than the active limit
+- WHEN the buffer is flushed
+- THEN the entry is enqueued without `oldData`, and the log names its uuid and says that conditioned `updated` rules and `calculatedChange` rules are skipped for this object
+- @e2e exclude pinned by `DeferredChunksFitTheJobLimitTest::testAnOversizedUpdateIsKeptWithoutItsOldData`
+
#### Scenario: One entry too large for any job
- GIVEN three updates of which the middle one is larger than a job may carry
- WHEN the buffer is flushed
diff --git a/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md b/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md
index 8d1d34c824..1f307aca02 100644
--- a/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md
+++ b/openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md
@@ -147,7 +147,7 @@ A draft SHALL have a lifetime of 30 days unless its schema says otherwise (decis
OpenRegister SHALL run one background job, declared where it declares its jobs (`appinfo/info.xml`), once a day (decision 216). For every schema that stores its own objects and has a draft lifetime, in every register that carries it, the job SHALL remove each object in status `draft` whose last change (`updated`, else `created`) is older than that lifetime. It SHALL remove nothing else: an object that is not a draft, a draft of a schema that opts out, and a draft without any date SHALL stay. A schema served from an object source SHALL NOT be asked for drafts. Removal SHALL go through the ordinary object delete (`ObjectService::deleteObject()`), so its guards apply and the draft's files go with it; a delete that path refuses SHALL be logged and counted, and the sweep SHALL carry on.
-The work of one run SHALL be bounded (decision 228): at most 1,000 delete attempts per run, and at most 5,000 drafts read per register and schema. What is left SHALL be counted as deferred and SHALL wait for the next run. The job SHALL log one line per register and schema that held an expired draft (register, schema, lifetime, expired, removed, refused, deferred) and one line with the totals, a zero included, and SHALL surface a failure rather than report success. Generic by construction (decision 182): no app carries a draft cleanup of its own.
+The work of one run SHALL be bounded (decision 228): at most 1,000 delete attempts per run, and at most 5,000 drafts read per register and schema, the store being asked for the drafts past their lifetime alone (last changed before the cutoff, or never changed and created before it) so that the rows read are expired rows. What is left, beyond the delete attempts and beyond the rows read, SHALL be counted as deferred and SHALL wait for the next run, so that expired = removed + refused + deferred. The job SHALL log one line per register and schema that held an expired draft (register, schema, lifetime, expired, removed, refused, deferred) and one line with the totals, a zero included, at a level the default log level shows (warning) whenever a draft was removed or refused, and SHALL surface a failure rather than report success. Generic by construction (decision 182): no app carries a draft cleanup of its own.
Because the default applies to drafts that exist at the upgrade, the first run after it removes drafts that were kept until then. The changelog and the release notes SHALL say so.
diff --git a/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index ce521c6911..cb51574892 100644
--- a/openspec/changes/example-sets/specs/example-sets/spec.md
+++ b/openspec/changes/example-sets/specs/example-sets/spec.md
@@ -26,7 +26,7 @@ The loader SHALL read `lib/Settings/example-sets/.json` from every app that
### 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.
+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 lookup that more than one row answers, which two organisations holding the same key do, SHALL be refused naming the count, never resolved to whichever row comes first). 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`
@@ -68,7 +68,7 @@ The loader SHALL create a Nextcloud account for every cast person with a `uid`,
### 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.
+`--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 things it may write are the import of a register the set requires (REQ-EXS-009), because those schemas are what it checks against, and the OpenRegister organisations the cast declares and the instance lacks (REQ-EXS-016), because `{{organisation:...}}` placeholders resolve to them; both are recorded, and remove takes them away again. The command SHALL say so in its `--check` option text and in its closing output. 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
@@ -101,7 +101,7 @@ The loader SHALL create a Nextcloud account for every cast person with a `uid`,
### 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.
+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; only a read that says the object does not exist counts as lost, and an object whose read FAILED (a database or magic-table error, a schema that cannot be resolved) SHALL NOT be written again: the install writes nothing for it, keeps the recorded id, reports it as not written, and the next install tries the read again.
#### Scenario: A repeated install is a no-op
- **GIVEN** a complete install of set `demo`
@@ -119,7 +119,7 @@ After writing, the loader SHALL read every object it created in this run back fr
### 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.
+`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; an object whose read before the delete failed (anything but "it does not exist") cannot be told gone, so it stays and is named with the reason.
#### Scenario: Remove leaves foreign data alone
- **GIVEN** an object in the same schema that the set did not write
@@ -137,13 +137,18 @@ After writing, the loader SHALL read every object it created in this run back fr
### 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.
+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 job that import reports it ran under (with the configuration id it ran under), never a job found by comparing the jobs the instance holds before and after, which would claim an import another process recorded meanwhile. 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. The register and its schemas stay on the instance, as they may hold other people's data; so that a later install does not find a register without its data, the record keeps the slug of a register whose import was removed (without jobs, and not counted as an install), and the next install imports that register's data again although the register exists. 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 whose data was removed is imported again
+- **GIVEN** a set that requires `brp`, installed on an instance without it and then removed
+- **WHEN** the set is installed again
+- **THEN** `brp` SHALL be imported again (its data was removed although the register stayed), reported as `imported`, and remove SHALL delete the objects of that second 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
@@ -175,7 +180,7 @@ A cast group or fragment group whose gid is `admin` (in any letter case) SHALL r
### 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.
+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 `where` finds no object or more than one (naming the count; two organisations may hold the same business key) 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. A path whose value is no longer the one the change wrote (somebody edited it after the install) SHALL be left as it is and named in the output as left in place, as for app config and user preferences.
#### 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
@@ -226,7 +231,7 @@ A fragment MAY declare `serviceAccounts`, each with a `key`, a `uid`, a `display
### 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.
+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; the record holds only a one-way fingerprint of it, so that remove can tell the generated value from one somebody put there. 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 (a generated key whose value no longer has the recorded fingerprint included), naming it; a generated key that existed with an empty value gets that value back. 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`
@@ -240,13 +245,18 @@ A fragment MAY declare `appConfig`, each entry a `key` and a `value` (placeholde
### 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.
+`{{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. Only a lookup that says the organisation does not exist SHALL read as absent (an install creates it) or as already gone (remove forgets it); any other error SHALL refuse the install (nothing is created) or leave the organisation in the record to be tried again. Remove SHALL NOT delete an organisation that has a member other than the accounts the set made or adopted (a leftover membership of an account that no longer exists does not count): it SHALL stay, stay in the record, and be named as left in place.
#### 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
+#### Scenario: An organisation somebody joined stays
+- **GIVEN** an organisation the set created, to which an administrator added a real account
+- **WHEN** the set is removed
+- **THEN** the organisation SHALL stay and stay in the record, and the output SHALL name it as left in place
+
### 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.
@@ -327,13 +337,18 @@ Removing an example set is an administrative act on data the set itself wrote, n
### 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.
+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 AND is still the value the install wrote there (the record keeps what each object was created with in its reference fields, and a refresh keeps its newest value), so a reinstall after a partial remove does not leave a kept object pointing at an object that was deleted and written again. A field that holds any other value, or that the record holds no written value for, is somebody's edit: it SHALL be left as it is and named in the output (`Left as it is`), and it SHALL NOT make the install fail. 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 user's edit of a reference is left
+- **GIVEN** a kept publication whose theme reference a user pointed at an unrelated id after the install
+- **WHEN** the set is installed again
+- **THEN** the reference SHALL keep the user's value, no `#references` change SHALL be recorded, and the output SHALL name the field as left as it is
+
#### Scenario: A refreshed set installs again without writes
- **GIVEN** a set whose references were refreshed
- **WHEN** it is installed again and then removed
diff --git a/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md
index af1e91a99f..478ace7369 100644
--- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md
+++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md
@@ -30,7 +30,7 @@
### 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`.
+`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 and minus `id` and `uuid` is the object; a submit never chooses the uuid of the object it writes, so an `id` or `@self.id` is dropped from the object whatever the mapping says, and the only way to name an existing object is the draft id), 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
@@ -47,9 +47,21 @@
#### 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
+- **WHEN** the same key is submitted again within 24 hours by the same subject with the same payload
- **THEN** the response repeats object 7's reference and no object is created
+#### Scenario: A repeated key from somebody else, or with another payload, is refused
+
+- **GIVEN** a submit with key `k1` that created object 7
+- **WHEN** another subject, or the same subject with another payload, submits key `k1` within 24 hours
+- **THEN** the response is 409, carries nothing of object 7's answer, and no object is created
+
+#### Scenario: A parallel retry with the same key is refused
+
+- **GIVEN** a submit with key `k1` that is still being written
+- **WHEN** the same key arrives again before it finishes
+- **THEN** the second request is answered 409 and only one 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.
@@ -62,7 +74,7 @@ The service SHALL validate every write before the first. When a write is refused
### 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.
+`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. A draft save SHALL claim its tokens the same way once the draft is written, because the files then sit on the draft. 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
@@ -72,7 +84,7 @@ The service SHALL validate every write before the first. When a write is refused
### 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).
+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). It holds for every writer of a schema, not only the schema API: a schema written by the configuration importer, the import endpoint or the AI and MCP tools has no response to refuse, so the broken forms are unpublished and their authors notified there too (an owning app's `refuse` setting is logged as not honourable), and the schema API, which judges the forms itself, claims its own save so no form is unpublished twice.
#### Scenario: A new required property unpublishes a form that lacks it
@@ -80,6 +92,12 @@ When a schema's properties or required list are saved, OpenRegister SHALL dispat
- **WHEN** `priority` becomes required
- **THEN** the form is unpublished, the response names it, and its author is notified
+#### Scenario: A schema import that adds a required property unpublishes a form that lacks it
+
+- **GIVEN** a published form into `ticket` without `priority`
+- **WHEN** a configuration import makes `priority` required on `ticket`
+- **THEN** the form is unpublished and its author is notified, as for an edit through the schema API
+
### 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).
diff --git a/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md b/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md
index f1dcf91985..5c1555c951 100644
--- a/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md
+++ b/openspec/changes/object-archive-state/specs/object-lifecycle/spec.md
@@ -186,7 +186,7 @@ withdrawing and locking SHALL be audit facts.
### Requirement: File writes honour the frozen and archived marker (REQ-OAS-007)
-Every write to a file attached to a frozen or archived object SHALL be refused: creating, replacing, updating, renaming, moving out or in, deleting, locking and unlocking, through the OpenRegister file API and through Nextcloud Files or WebDAV in the object's folder. The refusal SHALL use `ObjectStateWriteException` and SHALL name the state, the actor, the moment and the reason from the marker; the API SHALL answer 409 (`423` is not used, so clients keep one code for a state refusal). Reading, downloading and previewing SHALL stay allowed. When the owning object of a node inside the register folder tree cannot be resolved, the write SHALL be refused.
+Every write to a file attached to a frozen or archived object SHALL be refused: creating, replacing, updating, renaming, moving out or in, deleting, locking and unlocking, through the OpenRegister file API, through the object-folder endpoints (`POST/PUT/DELETE .../folder`, `POST .../folder/upload`) and through Nextcloud Files or WebDAV in the object's folder. The refusal SHALL use `ObjectStateWriteException` and SHALL name the state, the actor, the moment and the reason from the marker; the API SHALL answer 409 (`423` is not used, so clients keep one code for a state refusal). Reading, downloading and previewing SHALL stay allowed. When the owning object of a node inside the register folder tree cannot be resolved, the write SHALL be refused.
#### Scenario: a withdrawn publication's attachment cannot be replaced
- **GIVEN** a publication frozen on withdrawal by `functioneel-beheer` with reason "ingetrokken, besluit 2026-114"
@@ -194,6 +194,14 @@ Every write to a file attached to a frozen or archived object SHALL be refused:
- **THEN** the upload is refused with a message naming the freeze, who froze it, when and the reason
- **AND** the attachment can still be downloaded
+#### Scenario: the object-folder endpoints refuse a frozen object
+
+
+- **GIVEN** a frozen or archived object and a person who may update it
+- **WHEN** they create a folder, upload, rename or delete in its object folder through the folder endpoints
+- **THEN** the answer is 409 with `state`, `by`, `at` and `reason`, and nothing in the folder is touched
+- **AND** a person who may not update it still gets 403, and listing the folder still works
+
#### Scenario: a WebDAV write into the frozen folder is aborted
diff --git a/openspec/changes/object-tree-read/specs/referential-integrity/spec.md b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md
index 5f7988fbce..fcea42003d 100644
--- a/openspec/changes/object-tree-read/specs/referential-integrity/spec.md
+++ b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md
@@ -27,6 +27,14 @@ parent SHALL answer with the root alone.
- **THEN** each organisation appears once and the answer returns
- @e2e exclude {cycle guard, covered by ObjectTreeServiceTest}
+#### Scenario: a level wider than the node cap is not read in full
+
+- **GIVEN** a holding with fifty subsidiaries and an instance node cap of ten
+- **WHEN** a client reads the holding's tree
+- **THEN** the level is read with a row limit of at most one more than the cap still admits, never in full
+- **AND** `truncated` is true with `truncatedBy` `cap`
+- @e2e exclude {work bound, covered by ObjectTreeServiceTest}
+
#### Scenario: a depth below the tree's height is reported
- **GIVEN** the holding of the first scenario
@@ -55,3 +63,46 @@ SHALL be answered with 404 before anything is walked.
- **WHEN** the user requests its tree
- **THEN** the answer is 404 and no level is read
- @e2e exclude {covered by ObjectRelationsControllerTreeTest}
+
+#### Scenario: a failing access check is an incomplete tree
+
+- **GIVEN** a holding with subsidiaries and an access check that cannot be established
+- **WHEN** a client reads the holding's tree
+- **THEN** no subsidiary is returned
+- **AND** `truncated` is true with `truncatedBy` `unreadable`, not `null`
+- @e2e exclude {fail-closed path, covered by ObjectTreeServiceTest}
+
+#### Scenario: the topmost ancestor does not name a hidden parent
+
+- **GIVEN** a subsidiary below a parent the caller may read, which is below a grandparent the caller may not
+- **WHEN** the caller reads the subsidiary's ancestors
+- **THEN** the parent is returned with `parent` null
+- @e2e exclude {RBAC pruning, covered by ObjectTreeServiceTest}
+
+### Requirement: The tree carries only what the caller may read of an object's properties (REQ-OTR-003)
+
+The `fields` a client names SHALL be read from the object as every other read of it
+renders it: a `writeOnly` property is never returned, a property whose `authorization.read`
+the caller does not satisfy is never returned, and an encrypted property is returned
+decrypted for a caller who may read it. This holds for the root and for every ancestor
+and descendant, and `fields` is a projection of that result.
+
+#### Scenario: a write-only secret is not returned by naming it
+
+- **GIVEN** a schema property `secret` with `writeOnly: true` and a property the caller's groups may not read
+- **WHEN** the caller reads the tree with both names in `fields`
+- **THEN** neither value is returned on any node
+- @e2e exclude {redaction seam, covered by ObjectTreeServiceTest}
+
+### Requirement: The tree read honours doelbinding (REQ-OTR-004)
+
+On a register or schema that asks for a declared purpose, the tree read (and the relation
+graph and its CSV) SHALL refuse a request that names none, with the same 403 body the
+object read answers, after the read check and before anything is walked.
+
+#### Scenario: a purpose-bound schema refuses an unbound tree read
+
+- **GIVEN** a schema that requires a declared purpose and a caller who may read the object
+- **WHEN** the caller reads the tree without a purpose
+- **THEN** the answer is 403 and no level is read
+- @e2e exclude {covered by ObjectRelationsControllerTreeTest}
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 64ad7e5962..1487874fc6 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
@@ -32,6 +32,17 @@ refused (404), even when the flow and node id otherwise resolve.
caller keys win, authored keys the caller did not send stay, and the
published version pin does not drop the caller's config
+#### Scenario: A caller key the node does not declare is dropped
+
+- **GIVEN** a published node that declares the config keys `templateId` and
+ `folder`
+- **WHEN** the caller runs it directly with
+ `config: {templateId: "picked", webhookUrl: "https://example.invalid/"}`
+- **THEN** the node executes with `{templateId: "picked", folder: "Besluiten"}`:
+ only keys the node declares (`configKeys()`, else the keys of its
+ `configForm()`) are taken from the caller, and a node that declares neither
+ takes nothing from the caller
+
### 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/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md b/openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md
index 1f598f4373..366b13cd09 100644
--- 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
@@ -8,7 +8,7 @@ An administrator re-imports an app's real register configuration on demand, and
### 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.
+`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. Of OpenRegister's own descriptors, the ones whose install and upgrade steps import under a configuration id of their own (`openregister.decision-tables`, `openregister.dismissed-pair`, `openregister.edepot-transfers`, `openregister.merge-operation`, `openregister.trust-configuration`, `openregister.vocabulary`, `openregister.dsar-cases`, `openregister.dsar-policy-packs`) SHALL be re-imported under that same id, read from the one map the install steps read, so the re-import moves no version gate another descriptor shares under `openregister`; the others import under `openregister`. The result SHALL carry the import job and the configuration id it ran under when the import recorded one.
#### Scenario: The app ships both a mock and a real descriptor for the same register
@@ -17,6 +17,12 @@ An administrator re-imports an app's real register configuration on demand, and
- **THEN** the configuration of version 0.97.0 is imported under configuration id `portaliq`
- **AND** no demo object from the mock is imported
+#### Scenario: A core descriptor keeps its install-time configuration id
+
+- **GIVEN** OpenRegister ships `merge_operation_register.json`, which its repair step imports under `openregister.merge-operation`
+- **WHEN** an administrator re-imports register `merge-operation` of `openregister`
+- **THEN** the import runs under configuration id `openregister.merge-operation`, not `openregister`
+
#### Scenario: Fragments are part of the configuration
- **GIVEN** an app ships `register.d/10-extra.json` adding a schema
@@ -71,7 +77,7 @@ An administrator re-imports an app's real register configuration on demand, and
### 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.
+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. The administrator panel (Register descriptors) SHALL ask for confirmation before it runs a re-import, saying that the app's schema authorization is taken from the file over any local change and that the app's other registers may be re-imported with it, and SHALL show the response's count and every replaced block (schema and path) in its outcome note, as a warning and not as a plain success when any block was replaced.
#### Scenario: Counts come from the database
@@ -84,3 +90,4 @@ The real re-import SHALL report registers and schemas as created, updated and un
- **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`
+- **AND** the panel's outcome note shows the count and `portalAccount: authorization` as a warning, after the administrator confirmed the re-import
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 ee1b6279b5..1dd4a44349 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
@@ -32,7 +32,7 @@ When ownership is `decider` for the relation's schema (from `x-openregister-revi
### 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"`, 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.
+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`), without `bases` and `skipAnonymization`, and without `anonymized` and `anonymizedValue` (an anonymise pass writes both from the verdict, so they would show it), 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`
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
index 68918778b2..693e78b428 100644
--- 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
@@ -4,7 +4,7 @@
### 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.
+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), non-scalar values, booleans, and empty or blank strings MUST NOT be passed, so a property that holds nothing leaves its placeholder unfilled instead of being filled with nothing. 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`
@@ -22,6 +22,6 @@ When the unified search formats a hit, the object data handed to the deep-link r
- **THEN** `{case}` resolves to `..%2Fsettings`
#### Scenario: An unfilled placeholder falls back
-- **GIVEN** the `{case}` template and an object with no `case`
+- **GIVEN** the `{case}` template and an object with no `case`, or one whose `case` is `""` or blank
- **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/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
index 4dc5520ebb..ff63bc1ac2 100644
--- 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
@@ -12,7 +12,19 @@ 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.
+reads the seed as current. Any other update SHALL bump the patch version. A
+version carried in `@self.version` SHALL count only for a save that runs
+without RBAC (the import, an administrator, a system caller): a caller judged
+by RBAC SHALL NOT set an object's version, so the seed channel cannot be
+steered from the object API.
+
+The import SHALL compare the shipped version with the version it LAST SHIPPED
+for that seed, which it remembers apart from the object (per app id, never on
+the object), not with the object's own version: every later save of the object
+bumps its patch, so an edited seed would otherwise read as already current and
+a changed shipped seed would never arrive. A seed stored before the import
+remembered anything is compared with its stored version once, and remembered
+at the version the file then ships.
#### Scenario: An upgraded instance receives the shipped case type
@@ -21,6 +33,12 @@ reads the seed as current. Any other update SHALL bump the patch version.
- **THEN** the stored object is updated to the shipped values
- **AND** its `@self.version` is `1.4.7`
+#### Scenario: A seed edited on the instance still receives the next shipped version
+
+- **GIVEN** a seed shipped at `1.4.7`, then saved once on the instance (its own version is now `1.4.8`)
+- **WHEN** the configuration ships it at `1.4.8`
+- **THEN** the stored object is updated to the shipped values, and `1.4.8` is remembered as the shipped version
+
#### Scenario: A seed at the shipped version is left alone
- **GIVEN** an instance holding the seed at the shipped version
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
index d24a34a541..8855234e32 100644
--- 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
@@ -28,6 +28,14 @@ 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.
+Every grant, every revoke and every change of the owning group on a record MUST
+write an audit-trail entry on the hash-chained trail, of its own typed action
+(`grant_added`, `grant_revoked`, `owner_group_changed`): who acted (the caller),
+the record, the principal and permissions of the grant (or the owning group
+before and after), and whether the caller was admitted as owner or as holder of
+the right. A refused request writes none. Failing to write the entry MUST be
+logged at error level and MUST NOT undo the change that was stored.
+
#### 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`
@@ -44,6 +52,10 @@ wildcard `*` admit nobody.
- **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 grant and its revoke are on the trail
+- **GIVEN** the holder of the right adds a read grant for user `reviewer` and then revokes it
+- **THEN** a `grant_added` and a `grant_revoked` entry name the record, `reviewer` and `admittedAs: special-right`
+
#### 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
@@ -77,6 +89,11 @@ kept per group: giving it again replaces the group's entry.
- **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: A change of the list is on the trail
+- **WHEN** an administrator or the owner changes the list under `grant_manage`
+- **THEN** a `settings.updated` entry names the organisation, the list before and the list after, and the acting user
+- **AND** a save that leaves the list as it was writes none
+
#### 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`
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
index bcac30147c..acd067e92e 100644
--- a/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md
+++ b/openspec/changes/stored-decision-tables/specs/flow-decision-tables/spec.md
@@ -13,7 +13,7 @@ OpenRegister MUST ship a core register `decision-tables` with a schema `decision
### 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.
+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`, or when the check for another table with the same `key` cannot run (the save is refused, never waved through), or when whether the object is a decision table cannot be read (a schema or register that cannot be read, as opposed to one that does not exist, which leaves the object alone). 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
@@ -25,9 +25,14 @@ Saving a `decision-table` object MUST be refused with error code `decision-table
- **WHEN** a second table with key `parcel-carrier` is saved
- **THEN** the save MUST be refused naming the first table
+#### Scenario: A key that cannot be checked
+- **GIVEN** a search for tables with the key that fails
+- **WHEN** a table is saved
+- **THEN** the save MUST be refused with `decision-table-invalid`, saying the key could not be checked
+
### 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`.
+`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. When more than one table carries the key (a row written around the save guard, an import), `find()` MUST raise an error naming the tables instead of picking one. 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`
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
index 564ccfb801..952aed09ad 100644
--- 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
@@ -4,7 +4,7 @@
### 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.
+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 writer, bulk or single-object, SHALL NOT write a declared property into a metadata column: when the column a property sanitises to (`_owner`, `_Owner`, `__owner_`, `_authorization`, `_status`, ...) is one ObjectEntity has an attribute for, the property value is not written, so a client value can never replace the value the server stamped there. 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"}`
@@ -17,3 +17,9 @@ A magic-table column whose name starts with `_` SHALL be read as the schema prop
- **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}
+
+#### Scenario: a property named like a metadata column does not overwrite it
+- **GIVEN** a schema declaring properties `_owner`, `_authorization` and `_status`, and an object whose payload carries a value for each
+- **WHEN** the object is prepared for a bulk or single-object write
+- **THEN** the `_owner` column SHALL hold the owner the server stamped, and no client value SHALL reach `_authorization` or `_status`
+- @e2e exclude {write preparation has no screen of its own; covered by MagicBulkHandlerRawAppendTest and MagicMetadataColumnTest}
diff --git a/openspec/specs/ai-mcp/spec.md b/openspec/specs/ai-mcp/spec.md
index c5d5934445..10c9f18ebb 100644
--- a/openspec/specs/ai-mcp/spec.md
+++ b/openspec/specs/ai-mcp/spec.md
@@ -699,7 +699,7 @@ invocation behavior.
### 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.
+`#[McpTool]` MUST accept an optional `annotations` map whose keys are non-empty strings and whose values are scalars, and whose keys MUST NOT be one of the five tool-hint keys the MCP specification reserves in a descriptor's `annotations` object (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`; compared case-insensitively), because the map is forwarded to external clients under that key and a client could trust a hint OpenRegister's own checks contradict. `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)`
@@ -713,6 +713,13 @@ invocation behavior.
- **THEN** the descriptor MUST NOT carry an `annotations` key
@e2e exclude Descriptor-shape assertion with no UI surface; asserted by AttributeToolScannerTest.
+#### Scenario: A reserved MCP hint key is rejected at scan time
+- **GIVEN** a method `#[McpTool(scope: 'update', annotations: ['readOnlyHint' => true])]`
+- **WHEN** `AttributeToolScanner` scans the declaring class
+- **THEN** no tool MUST be registered for that method
+- **AND** a warning MUST be logged naming `annotations` and the offending key
+@e2e exclude Malformed-attribute path 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
diff --git a/openspec/specs/audit-trail-immutable/spec.md b/openspec/specs/audit-trail-immutable/spec.md
index 379c0747a6..3a2f3bb54f 100644
--- a/openspec/specs/audit-trail-immutable/spec.md
+++ b/openspec/specs/audit-trail-immutable/spec.md
@@ -275,7 +275,9 @@ object, and the admin-only index remains the only surface that carries them.
### 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`,
+The per-object history, `GET /api/objects/{register}/{schema}/{id}/audit-trails`
+and its older sibling `GET /api/objects/{register}/{schema}/{id}/logs` (the same
+answer, for the same caller, through the same redaction),
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,
diff --git a/openspec/specs/credential-oauth2-connect/spec.md b/openspec/specs/credential-oauth2-connect/spec.md
index ba494092ae..ab364f129d 100644
--- a/openspec/specs/credential-oauth2-connect/spec.md
+++ b/openspec/specs/credential-oauth2-connect/spec.md
@@ -203,9 +203,9 @@ A failure of this call SHALL NOT undo or alter the connection: the credential st
- **WHEN** the identity call is refused, unreachable, or answers with something that is not JSON
- **THEN** the stored token set is unchanged and the connection remains usable
-### Requirement: A person can connect, see and repair a connection from personal settings
+### Requirement: A person can connect, see and repair a connection from user settings
-OpenRegister's personal settings SHALL offer a "Connect account" action for every catalogue provider that is an OAuth2 token set, SHALL list the caller's existing connections with a status chip reading active, expired or relink needed, and SHALL offer a Reconnect action on a connection that needs one. The panel SHALL never display a token, a client secret, or an authorization code.
+OpenRegister's user settings dialog SHALL offer a "Connect account" action for every catalogue provider that is an OAuth2 token set, SHALL list the caller's existing connections with a status chip reading active, expired or relink needed, and SHALL offer a Reconnect action on a connection that needs one. The panel SHALL never display a token, a client secret, or an authorization code.
`@e2e tests/e2e/credential-oauth2-connect.spec.ts`
diff --git a/openspec/specs/external-recipient-opt-out/spec.md b/openspec/specs/external-recipient-opt-out/spec.md
index be422dc136..47dae82f98 100644
--- a/openspec/specs/external-recipient-opt-out/spec.md
+++ b/openspec/specs/external-recipient-opt-out/spec.md
@@ -125,7 +125,7 @@ A `parties` notification MUST send the rule's resolved `message` as the mail bod
### 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`).
+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`). The referenced object MUST also be an object of the schema the reference property's `$ref` names, read from the schema of the object the hop starts at; a reference to an object of another schema, and a reference property that declares no `$ref` (so names no schema to check against), MUST be refused with `reference-not-found` and nothing sent. 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
@@ -142,6 +142,14 @@ An `x-openregister-notifications` rule MAY address `{kind: "email", field: "`. These verbs SHALL NOT be reachable over HTTP. The user verbs
SHALL keep refusing a missing identity.
+A flow run realised for such a plan (a stage or process task bound to a flow)
+SHALL be queued without the `system:` label as its user, because that label
+names no account and a run that carried it would refuse every scoped step; the
+flow's own attribution decides who the run acts as.
+
#### Scenario: An app's listener creates a plan
- **GIVEN** an object without a plan
diff --git a/openspec/specs/integration-person-lookup/spec.md b/openspec/specs/integration-person-lookup/spec.md
index 119fb55abb..b1f571ed2a 100644
--- a/openspec/specs/integration-person-lookup/spec.md
+++ b/openspec/specs/integration-person-lookup/spec.md
@@ -164,7 +164,7 @@ that both transports are configured on the OpenConnector source.
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 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: 'connector-cannot-withhold-bodies' }`.
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.
@@ -194,7 +194,7 @@ The answer SHALL never hold a name, a BSN, a person object or the address object
### 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.
+`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 `connector-cannot-withhold-bodies`, logged below error level (an older connector is a version gap, not an outage). `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
@@ -207,5 +207,5 @@ The answer SHALL never hold a name, a BSN, a person object or the address object
- 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`
+- THEN the CallService is not called and the router fails with `connector-cannot-withhold-bodies`, without an error log line
- @e2e exclude Backend refusal path, verified by PHPUnit.
diff --git a/openspec/specs/migration-mapping-packs/spec.md b/openspec/specs/migration-mapping-packs/spec.md
index 4aadedd280..da0347ef0b 100644
--- a/openspec/specs/migration-mapping-packs/spec.md
+++ b/openspec/specs/migration-mapping-packs/spec.md
@@ -4,7 +4,7 @@
TBD - created by archiving change migration-mapping-packs. Update Purpose after archive.
## Requirements
### Requirement: The system MUST validate migration pack definitions structurally before storing them
-A migration pack definition SHALL be a JSON document with a slug `id` (lowercase letters/digits/hyphens, unique per instance), a non-empty `name`, a `sourceFormat` from `csv|json|excel`, a strict-semver `version`, a non-empty `fieldMappings` array (each entry with non-empty `source` and `target` strings, optional boolean `required`, optional `transform`), an `idStrategy` (`{type: "generate"}` or `{type: "sourceField", field: ""}`), and optional `defaults` (object) and `skipRows` (positive integers). Transform blocks SHALL declare a `type` from `trim|date|bool-map|concat|lookup|const` and carry that type's required fields (`map` for bool-map/lookup, `fields` for concat, `value` for const). Structurally invalid definitions SHALL be rejected at create/update/import time with HTTP 422, never stored and never deferred to import time.
+A migration pack definition SHALL be a JSON document with a slug `id` (lowercase letters/digits/hyphens, unique per instance), a non-empty `name`, a `sourceFormat` from `csv|json|excel`, a strict-semver `version`, a non-empty `fieldMappings` array (each entry with non-empty `source` and `target` strings, optional boolean `required`, optional `transform`), an `idStrategy` (`{type: "generate"}` or `{type: "sourceField", field: ""}`), and optional `defaults` (object) and `skipRows` (positive integers). Transform blocks SHALL declare a `type` from `trim|date|bool-map|concat|lookup|const` and carry that type's required fields (`map` for bool-map/lookup, `fields` for concat, `value` for const). Structurally invalid definitions SHALL be rejected at create/update/import time with HTTP 422, never stored and never deferred to import time. Every finding the validator returns, and every row error the mapping engine returns (shown in the pack editor's save error and preview, and in an import's error file), SHALL be translated into the user's language through the app's translations; the property names and values inside a message stay as written.
#### Scenario: Valid pack definition is accepted
- **GIVEN** an admin submits a pack with a slug id, name, `sourceFormat: "csv"`, `version: "1.0.0"`, one field mapping with source and target, and `idStrategy: {type: "generate"}`
@@ -78,7 +78,7 @@ The system SHALL expose `/api/migration-packs` REST routes: `index`/`show`/`expo
#### Scenario: Pack JSON export/import round-trips between instances
- **GIVEN** a pack exported as `zgw-zaken-json.json` from one instance
- **WHEN** an admin uploads that file to `POST /api/migration-packs/import` on another instance
-- **THEN** an identical pack is created there; re-importing an updated file with the same `id` updates the existing row instead of erroring
+- **THEN** an identical pack is created there (HTTP 201); re-importing an updated file with the same `id` updates the existing row instead of erroring and answers HTTP 200, so the page can say the stored pack was replaced instead of reporting it as newly imported
#### Scenario: Anonymous callers are rejected everywhere
- **WHEN** an unauthenticated request hits any `/api/migration-packs` route
diff --git a/openspec/specs/notificatie-engine/spec.md b/openspec/specs/notificatie-engine/spec.md
index 98c7ba3685..322e14f117 100644
--- a/openspec/specs/notificatie-engine/spec.md
+++ b/openspec/specs/notificatie-engine/spec.md
@@ -255,7 +255,7 @@ Failed notification deliveries MUST be retried with configurable backoff strateg
### Requirement: Users MUST be able to manage their notification preferences
-Users MUST be able to turn specific schema-declared notifications on or off (and optionally narrow channels) via a personal settings interface, without affecting other users' preferences. Preferences MUST be stored as override-only values in Nextcloud per-user app config under the `openregister` app (NOT in a `NotificationPreference` or `NotificationSubscription` table). When a user has no override for a `(schema, notification-key)` pair, the schema-declared default applies. Independently of the per-`(schema, notification)` override, a user MAY configure a global delivery window (quiet hours) via `NotificationDeliveryWindowService`, exposed through `GET`/`PUT /api/notification-delivery-window`, stored the same override-only way (a distinct per-user app-config key, `notification_delivery_window`) so a user with no configured window keeps today's immediate-delivery behaviour.
+Users MUST be able to turn specific schema-declared notifications on or off (and optionally narrow channels) via the user settings dialog, without affecting other users' preferences. Preferences MUST be stored as override-only values in Nextcloud per-user app config under the `openregister` app (NOT in a `NotificationPreference` or `NotificationSubscription` table). When a user has no override for a `(schema, notification-key)` pair, the schema-declared default applies. Independently of the per-`(schema, notification)` override, a user MAY configure a global delivery window (quiet hours) via `NotificationDeliveryWindowService`, exposed through `GET`/`PUT /api/notification-delivery-window`, stored the same override-only way (a distinct per-user app-config key, `notification_delivery_window`) so a user with no configured window keeps today's immediate-delivery behaviour.
#### Scenario: User disables a specific notification
- GIVEN schema `meldingen` declares an `object_created` notification (default on) to group `behandelaars`
diff --git a/openspec/specs/referential-integrity/spec.md b/openspec/specs/referential-integrity/spec.md
index bd4076669f..fcdf5cc6d6 100644
--- a/openspec/specs/referential-integrity/spec.md
+++ b/openspec/specs/referential-integrity/spec.md
@@ -415,7 +415,7 @@ Referential integrity operations MUST bypass RBAC and multi-tenancy filters when
- **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.
+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 (a `referenceKey` that is empty or the literal `uuid` is a plain uuid reference, to which every action applies, and is read that way by the save and by the delete), 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}`
diff --git a/scripts/l10n/locales/ca.json b/scripts/l10n/locales/ca.json
index aefc9c8ab3..2b44d302bc 100644
--- a/scripts/l10n/locales/ca.json
+++ b/scripts/l10n/locales/ca.json
@@ -167,7 +167,7 @@
"OpenAI Chat Settings": "TERM-SETTINGS",
"Could not change browser notification settings.": "TERM-SETTINGS",
"Notification permission: denied. Re-enable notifications for this site in your browser settings.": "TERM-SETTINGS",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "TERM-SETTINGS",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "TERM-SETTINGS",
"Manage your account settings, security, and personal data.": "TERM-SETTINGS",
"Any favorites and sharing settings for this view": "TERM-SETTINGS",
"They will be retained according to their schema's configured retention period and automatically permanently deleted when the retention period expires. The retention period is configurable per schema and can be found in the schema's settings.": "TERM-SETTINGS",
diff --git a/scripts/l10n/locales/sr.json b/scripts/l10n/locales/sr.json
index 3dd8ac4752..a1b7e5dcc4 100644
--- a/scripts/l10n/locales/sr.json
+++ b/scripts/l10n/locales/sr.json
@@ -96,7 +96,7 @@
"%n register is missing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
"%n registers are missing": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
"A flow runs a series of steps when something happens — an object changes, a schedule fires, or you run it yourself. This list shows every app's flows.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
+ "A VAPID keypair is configured. Users can opt in to browser notifications from their user settings, which are delivered even when the browser tab is closed.": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
"Access (Art 15)": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
"Access results": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
"Accountability": "Transliterated Cyrillic to Serbian Latin. This bundle is 96% Latin (2321 Latin values against 91 Cyrillic); a mixed-script bundle shows the same UI in two alphabets depending on which string the user hits. Serbian is digraphic, so both are valid Serbian — but one bundle must pick one.",
diff --git a/scripts/l10n/locales/sv.json b/scripts/l10n/locales/sv.json
index 4a5f43b441..26e6a44332 100644
--- a/scripts/l10n/locales/sv.json
+++ b/scripts/l10n/locales/sv.json
@@ -4,7 +4,6 @@
"cognates": {
"3": "Numeric literal; identical in every locale.",
"30": "Numeric literal; identical in every locale.",
- "A VAPID keypair is configured. Users can opt in to browser notifications from their personal settings, which are delivered even when the browser tab is closed.": "Technical token / API or config example; not translatable prose.",
"App": "Proper noun / product or brand name; kept as-is in Swedish.",
"Attribute": "Proper noun / product or brand name; kept as-is in Swedish.",
"Available": "Proper noun / product or brand name; kept as-is in Swedish.",
diff --git a/src/components/userSettings/BrowserNotificationsSection.vue b/src/components/userSettings/BrowserNotificationsSection.vue
index 7f4de21b91..8dce70320b 100644
--- a/src/components/userSettings/BrowserNotificationsSection.vue
+++ b/src/components/userSettings/BrowserNotificationsSection.vue
@@ -10,14 +10,12 @@
per Chrome's permission-abuse rules. Shows the current permission state and an
unsupported-browser notice when the Push API is unavailable.
- TODO mount: this repo registers NO PHP Personal settings class (only
- lib/Settings/OpenRegisterAdmin + IntegrationsAdminSettings, both admin). To
- surface this toggle to end users, add an OCP\Settings\ISettings personal
- settings class + a `…` entry in
- appinfo/info.xml and a personal-settings Vue entry point (mirroring
- src/settings.js → #settings) that mounts this component. Until that personal
- mount point exists the component is unmounted; it is fully functional and only
- needs the host shell. See openspec/changes/openregister-web-push-engine.
+ TODO mount: this component is not mounted yet. The per-person settings live in
+ the user settings dialog (src/views/settings/UserSettings.vue, opened from the
+ app navigation and at #user-settings); OpenRegister registers no Nextcloud
+ personal settings page. To surface this toggle to end users, add it as a
+ section of that dialog. It is fully functional and only needs that host.
+ See openspec/changes/openregister-web-push-engine.
@spec openspec/changes/archive/2026-10-05-openregister-web-push-engine/tasks.md#task-5.2
-->
diff --git a/src/components/userSettings/OAuth2ConnectionsSection.vue b/src/components/userSettings/OAuth2ConnectionsSection.vue
index a143986901..a04b847804 100644
--- a/src/components/userSettings/OAuth2ConnectionsSection.vue
+++ b/src/components/userSettings/OAuth2ConnectionsSection.vue
@@ -15,7 +15,7 @@
browser at all: the whole point of the broker is that the secret stays server-side
and OpenRegister makes the outbound call.
- @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
-->
} Resolves once both are loaded.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
async load() {
this.loading = true
@@ -217,7 +217,7 @@ export default {
*
* @return {Promise} Resolves once the browser has been sent onward.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
async connect() {
await this.startFlow({
@@ -233,7 +233,7 @@ export default {
*
* @return {Promise} Resolves once the browser has been sent onward.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
async reconnect(connection) {
await this.startFlow({
@@ -250,7 +250,7 @@ export default {
*
* @return {Promise} Resolves once the list has been reloaded.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
async disconnect(connection) {
this.busy = true
@@ -275,7 +275,7 @@ export default {
*
* @return {Promise} Resolves once the browser has been sent onward.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
async startFlow(payload) {
this.busy = true
@@ -335,7 +335,7 @@ export default {
*
* @return {void}
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
navigateTo(url) {
window.location.href = url
@@ -348,7 +348,7 @@ export default {
*
* @return {string} The handle.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
handleOf(connection) {
return (
@@ -363,7 +363,7 @@ export default {
*
* @return {string} The scopes.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
scopesOf(connection) {
return (connection.scopes ?? []).join(', ')
@@ -376,7 +376,7 @@ export default {
*
* @return {string} The expiry.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
expiryOf(connection) {
if (!connection.expiresAt) {
@@ -394,7 +394,7 @@ export default {
*
* @return {string} The label.
*
- * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-personal-settings
+ * @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-a-person-can-connect-see-and-repair-a-connection-from-user-settings
*/
statusLabel(status) {
const labels = {
diff --git a/src/dialogs/settings/ReimportDescriptorDialog.vue b/src/dialogs/settings/ReimportDescriptorDialog.vue
new file mode 100644
index 0000000000..47d19ee2a8
--- /dev/null
+++ b/src/dialogs/settings/ReimportDescriptorDialog.vue
@@ -0,0 +1,64 @@
+
+
+
+
+ {{
+ t(
+ 'openregister',
+ 'Re-importing {app} takes the authorization of its schemas from the file, replacing any change made on this instance, and may re-import every register of the app, not only {register}.',
+ { app: row.appId, register: row.title },
+ )
+ }}
+
+
+
+ {{ t('openregister', 'Cancel') }}
+
+
+ {{ t('openregister', 'Re-import') }}
+
+
+
+
+
+
diff --git a/src/services/migrationPacks.js b/src/services/migrationPacks.js
index 6ef63c88e7..edeb608a10 100644
--- a/src/services/migrationPacks.js
+++ b/src/services/migrationPacks.js
@@ -123,8 +123,10 @@ export async function deletePack(id) {
/**
* Import a pack from a JSON file. A pack with the same slug is updated.
*
+ * The server answers 201 for a pack it created and 200 for one it replaced.
+ *
* @param {File} file The uploaded file.
- * @return {Promise
diff --git a/src/views/settings/sections/RegisterDescriptors.spec.js b/src/views/settings/sections/RegisterDescriptors.spec.js
new file mode 100644
index 0000000000..e83b37986e
--- /dev/null
+++ b/src/views/settings/sections/RegisterDescriptors.spec.js
@@ -0,0 +1,182 @@
+/**
+ * The Register descriptors panel: a re-import asks first, and when it took
+ * authorization from the file over a local change it says so, naming each block.
+ *
+ * The panel is mounted for real; each @nextcloud/vue component is a
+ *