From 59794316c661d27e0bad484ef9a43fafe5138d7e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:39:13 +0200
Subject: [PATCH 01/99] =?UTF-8?q?fix(review):=20#4580=20eng-f2=20=E2=80=94?=
=?UTF-8?q?=20A=20required=20file=20property=20can=20never=20be=20satisfie?=
=?UTF-8?q?d=20by=20an=20upload=20token?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
validateAll() now excuses a property that carries an upload in the plan's files ('uploaded'), because mapPayload() moves the file out of the validated object. A required file property with a valid token is accepted; without a token it still answers 422 required.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660431
Sweep: saveDraft/promoteDraft/submit all go through validateAll, so one place. DraftActivation does not see form uploads.
Check: phpunit tests/Unit/Service/Form/FormSubmitServiceTest.php -> OK 26 tests (new test errors without the fix); phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Form/FormSubmitService.php | 6 ++++
.../Service/Form/FormSubmitServiceTest.php | 30 +++++++++++++++++++
2 files changed, 36 insertions(+)
diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php
index a7f876b264..1b86410575 100644
--- a/lib/Service/Form/FormSubmitService.php
+++ b/lib/Service/Form/FormSubmitService.php
@@ -648,6 +648,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,
diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php
index 863e05eb30..e4058b15c8 100644
--- a/tests/Unit/Service/Form/FormSubmitServiceTest.php
+++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php
@@ -589,6 +589,36 @@ public function testAnUploadTokenIsClaimedOnlyOnSuccess(): void {
$this->uploads->materialise(formId: 'form-1', token: $token);
}//end testAnUploadTokenIsClaimedOnlyOnSuccess()
+ /**
+ * A required file property is satisfied by its upload token, and still `required` without one.
+ */
+ public function testARequiredFilePropertyIsSatisfiedByAnUploadToken(): void {
+ $case = $this->schemaBySlug['case'];
+ $case->setRequired(['title', 'caseType', 'identifier', 'bijlage']);
+ $path = (string)tempnam(sys_get_temp_dir(), 'or-submit-upload');
+ file_put_contents($path, '%PDF-1.7');
+ $this->temporary[] = $path;
+ $token = $this->uploads->issue(
+ formId: 'form-1',
+ property: 'bijlage',
+ rule: $case->getProperties()['bijlage'],
+ file: ['name' => 'a.pdf', 'type' => 'application/pdf', 'tmp_name' => $path, 'error' => UPLOAD_ERR_OK, 'size' => 8]
+ )['token'];
+ $mapping = ['fields' => [['field' => 'onderwerp', 'property' => 'title'], ['field' => 'bewijs', 'property' => 'bijlage']], 'fixed' => ['caseType' => 'ct']];
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+
+ try {
+ $this->service->submit(destination: $destination, mapping: $mapping, payload: ['onderwerp' => 'a'], scope: 'form-1');
+ $this->fail('A required file property without an upload was accepted.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame(422, $refused->getStatus());
+ $this->assertContains('bijlage', array_map(static fn (array $finding): string => (string)($finding['property'] ?? ''), $refused->getFindings()));
+ }
+
+ $this->service->submit(destination: $destination, mapping: $mapping, payload: ['onderwerp' => 'a', 'bewijs' => ['uploadToken' => $token]], scope: 'form-1');
+ $this->assertSame('a.pdf', $this->saves[0]['files']['bijlage']['name']);
+ }//end testARequiredFilePropertyIsSatisfiedByAnUploadToken()
+
/**
* A token issued for another property is refused, not attached elsewhere.
*/
From 402a14a9a92385feceb56b798aa9d59896402bb9 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:40:05 +0200
Subject: [PATCH 02/99] =?UTF-8?q?fix(review):=20#4580=20eng-f6=20=E2=80=94?=
=?UTF-8?q?=20Review=20policy=20fails=20open=20for=20visibility=20when=20i?=
=?UTF-8?q?t=20cannot=20be=20read?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An unreadable schema now yields a decider-owned policy that also hides others, and a throwing file-to-object lookup in subjectOf() yields the same closed policy instead of the open instance default (also covers the surface slice's duplicate of this finding).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660484
Sweep: both failure branches of ReviewPolicyResolver now share closedPolicy(); no other resolver builds a fail-closed review policy.
Check: phpunit tests/Unit/Service/Decision -> OK 17 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Decision/ReviewPolicyResolver.php | 34 +++++++++++++++----
.../Decision/ReviewPolicyResolverTest.php | 28 +++++++++++++--
2 files changed, 53 insertions(+), 9 deletions(-)
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/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php b/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php
index 5937206ee0..d024fd7e0c 100644
--- a/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php
+++ b/tests/Unit/Service/Decision/ReviewPolicyResolverTest.php
@@ -40,10 +40,11 @@ class ReviewPolicyResolverTest extends TestCase {
*
* @param array|null $schemaConfiguration The schema's configuration, or null when it cannot be read.
* @param array $fileSettings The stored file settings.
+ * @param bool $lookupFails Whether the file-to-object lookup throws.
*
* @return ReviewPolicyResolver The resolver.
*/
- private function resolver(?array $schemaConfiguration, array $fileSettings=[]): ReviewPolicyResolver {
+ private function resolver(?array $schemaConfiguration, array $fileSettings=[], bool $lookupFails=false): ReviewPolicyResolver {
$schemas = $this->createMock(SchemaMapper::class);
if ($schemaConfiguration === null) {
$schemas->method('find')->willThrowException(new RuntimeException('gone'));
@@ -65,7 +66,11 @@ private function resolver(?array $schemaConfiguration, array $fileSettings=[]):
$file = $this->createMock(File::class);
$root = $this->createMock(IRootFolder::class);
- $root->method('getFirstNodeById')->willReturnCallback(static fn (int $id) => ($id === 99 ? $file : null));
+ if ($lookupFails === true) {
+ $root->method('getFirstNodeById')->willThrowException(new RuntimeException('lookup failed'));
+ } else {
+ $root->method('getFirstNodeById')->willReturnCallback(static fn (int $id) => ($id === 99 ? $file : null));
+ }
$object = new ObjectEntity();
$object->setRegister('3');
@@ -137,5 +142,24 @@ public function testAnUnreadableSchemaFailsClosed(): void {
self::assertTrue($policy->isDeciderOwned());
self::assertSame([], $policy->getSupervisors());
+ self::assertTrue($policy->hidesOthers());
}//end testAnUnreadableSchemaFailsClosed()
+
+ /**
+ * A file whose object cannot be looked up is decider-owned and hidden, not the open instance default.
+ *
+ * @return void
+ */
+ public function testAFailedFileLookupFailsClosed(): void {
+ $relation = new EntityRelation();
+ $relation->setId(7);
+ $relation->setFileId(99);
+
+ $resolved = $this->resolver(schemaConfiguration: [], lookupFails: true)->forRelation(relation: $relation);
+
+ self::assertTrue($resolved['policy']->isDeciderOwned());
+ self::assertTrue($resolved['policy']->hidesOthers());
+ self::assertSame([], $resolved['policy']->getSupervisors());
+ self::assertArrayNotHasKey('lookupFailed', $resolved['subject']);
+ }//end testAFailedFileLookupFailsClosed()
}//end class
From 728c921b61da89abbfa5088827ebe5780c218f22 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:40:48 +0200
Subject: [PATCH 03/99] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?=
=?UTF-8?q?=E2=80=94=20Bulk=20save=20overwrites=20another=20user's=20draft?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
applyBulkSafeguards() now asks DraftStatusPolicy::keptFrom() for an existing row when RBAC is enforced and rejects the row with the same 'Permission denied' reason a refused update gives, so a draft stays unannounced. The owner's bulk save still updates it. Tests in Wave12BulkSafeguardsTest (the test fails without the fix).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284
Sweep: keptFrom had one caller (ObjectService::refuseSomebodyElsesDraft); the bulk safeguard is the only other path that resolves an existing row past RBAC.
Check: phpunit Wave12BulkSafeguardsTest + BulkSafeguardSchemaResolutionTest OK; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Object/SaveObjects.php | 22 +++++++
.../Object/Wave12BulkSafeguardsTest.php | 64 +++++++++++++++++++
2 files changed, 86 insertions(+)
diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php
index f2c8d77968..2c582d3aae 100644
--- a/lib/Service/Object/SaveObjects.php
+++ b/lib/Service/Object/SaveObjects.php
@@ -813,6 +813,28 @@ private function applyBulkSafeguards(
continue;
}
+ // Step 2b: a draft is kept to its owner. 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).
+ if ($effectiveRbac === true && $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 3: PermissionHandler check (per row).
if ($effectiveRbac === true) {
$hasPermission = $this->permissionHandler->hasPermission(
diff --git a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
index 3e6f93ae65..5f274f4d2a 100644
--- a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
+++ b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
@@ -326,6 +326,70 @@ public function testBulkUpdateRowAgainstAppendOnlySchemaIsRejected(): void {
$this->assertStringContainsString('appendOnly', $result['invalid'][0]['error']);
}//end testBulkUpdateRowAgainstAppendOnlySchemaIsRejected()
+ // === draft ownership ===
+
+ /**
+ * Bulk save of the uuid of somebody else's draft (schema without draftsVisible) is rejected as a denied permission.
+ */
+ public function testBulkSaveOverSomebodyElsesDraftIsRejected(): void {
+ $this->mockUser('bob', isAdmin: false);
+ $schema = $this->newSchema(1);
+ $register = $this->newRegister(10);
+ // The permission handler would allow it: the hole is that it has no draft rule.
+ $this->permissionHandler->method('hasPermission')->willReturn(true);
+ $this->storeDraft(owner: 'alice');
+
+ $result = $this->initResult(1);
+ $passed = $this->callApplyBulkSafeguards(
+ objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'overwritten']],
+ register: $register,
+ schema: $schema,
+ _rbac: true,
+ _validation: false,
+ result: $result
+ );
+
+ $this->assertSame([], $passed);
+ $this->assertSame(1, $result['statistics']['invalid']);
+ $this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']);
+ }//end testBulkSaveOverSomebodyElsesDraftIsRejected()
+
+ /**
+ * The owner's own bulk save of their draft still goes through.
+ */
+ public function testBulkSaveOverYourOwnDraftPasses(): void {
+ $this->mockUser('alice', isAdmin: false);
+ $schema = $this->newSchema(1);
+ $register = $this->newRegister(10);
+ $this->permissionHandler->method('hasPermission')->willReturn(true);
+ $this->storeDraft(owner: 'alice');
+
+ $result = $this->initResult(1);
+ $passed = $this->callApplyBulkSafeguards(
+ objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'mine']],
+ register: $register,
+ schema: $schema,
+ _rbac: true,
+ _validation: false,
+ result: $result
+ );
+
+ $this->assertCount(1, $passed);
+ $this->assertSame(0, $result['statistics']['invalid']);
+ }//end testBulkSaveOverYourOwnDraftPasses()
+
+ /**
+ * Store a draft of the given owner as the existing row of uuid 2222….
+ */
+ private function storeDraft(string $owner): void {
+ $draft = new \OCA\OpenRegister\Db\ObjectEntity();
+ $draft->setUuid('22222222-2222-2222-2222-222222222222');
+ $draft->setStatus('draft');
+ $draft->setOwner($owner);
+ $this->objectMapper->method('find')->willReturn($draft);
+ $this->objectMapper->method('findObjectsByUuidsInRegisterSchema')->willReturn(['22222222-2222-2222-2222-222222222222' => $draft]);
+ }//end storeDraft()
+
public function testBulkCreateAgainstAppendOnlySchemaIsAllowed(): void {
// appendOnly blocks UPDATE only; INSERT must still succeed.
$this->mockUser('alice', isAdmin: false);
From f67de4564acee13f02a2b5e2f443bee818a2dbf2 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:42:20 +0200
Subject: [PATCH 04/99] =?UTF-8?q?fix(review):=20#4580=20plat-f1=20?=
=?UTF-8?q?=E2=80=94=20Frozen-file=20guard=20ignores=20writes=20made=20thr?=
=?UTF-8?q?ough=20a=20share?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A node outside the openregister home (a sharee's view: /alice/files//x.pdf) is now resolved by file id: only a node owned by the app account is looked up, in the app's home via IRootFolder::getById, and the existing UUID-ancestor + folder proof runs on that twin. A node that does not exist yet stands for its nearest existing ancestor. A home that cannot be searched fails closed with a log line. New constructor argument: IRootFolder (autowired).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660603
Sweep: the listener is the only path-prefix owner test (grep for '/files/' prefix checks in lib/ finds no other frozen-file gate).
Check: phpunit tests/Unit/Listener/FrozenNodeWriteListenerTest.php OK 21 tests; phpcs on the lib file clean. The Newman PUT-as-sharee case from Done-when was not run (no live Nextcloud).
Co-Authored-By: Claude Opus 5.5
---
lib/Listener/FrozenNodeWriteListener.php | 107 ++++++++++++++++--
.../Listener/FrozenNodeWriteListenerTest.php | 102 ++++++++++++++++-
2 files changed, 197 insertions(+), 12 deletions(-)
diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php
index 6546168726..ecf2034819 100644
--- a/lib/Listener/FrozenNodeWriteListener.php
+++ b/lib/Listener/FrozenNodeWriteListener.php
@@ -36,6 +36,7 @@
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\Log\LoggerInterface;
@@ -50,9 +51,14 @@
* 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.
@@ -89,14 +95,16 @@ 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 MagicMapper $objects Resolves the owning object by its UUID.
+ * @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 LoggerInterface $logger,
+ private readonly IRootFolder $rootFolder,
) {
}//end __construct()
@@ -169,11 +177,31 @@ 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;
}
@@ -209,18 +237,75 @@ private function refusalFor(Node $node): ?ObjectStateWriteException {
return $this->guard->refusalFor(object: $object);
}//end refusalFor()
+ /**
+ * 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.
*/
- 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/tests/Unit/Listener/FrozenNodeWriteListenerTest.php b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php
index 364a4d31be..1dab905555 100644
--- a/tests/Unit/Listener/FrozenNodeWriteListenerTest.php
+++ b/tests/Unit/Listener/FrozenNodeWriteListenerTest.php
@@ -35,8 +35,11 @@
use OCP\Files\Events\Node\BeforeNodeWrittenEvent;
use OCP\Files\File;
use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
use OCP\Files\Node;
+use OCP\Files\NotFoundException;
use OCP\HintException;
+use OCP\IUser;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -54,16 +57,50 @@ class FrozenNodeWriteListenerTest extends TestCase {
private LoggerInterface&MockObject $logger;
+ private IRootFolder&MockObject $rootFolder;
+
protected function setUp(): void {
parent::setUp();
$this->objects = $this->createMock(MagicMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
+ $this->rootFolder = $this->createMock(IRootFolder::class);
}//end setUp()
private function listener(): FrozenNodeWriteListener {
- return new FrozenNodeWriteListener(guard: new FileWriteGuard(), objects: $this->objects, logger: $this->logger);
+ return new FrozenNodeWriteListener(guard: new FileWriteGuard(), objects: $this->objects, logger: $this->logger, rootFolder: $this->rootFolder);
}//end listener()
+ /**
+ * The app account's home answers `getById` with the given twins.
+ *
+ * @param array $twins The nodes in the home that carry the id.
+ */
+ private function homeHolds(array $twins): void {
+ $home = $this->createMock(Folder::class);
+ $home->method('getById')->willReturn($twins);
+ $this->rootFolder->method('getUserFolder')->with('openregister')->willReturn($home);
+ }//end homeHolds()
+
+ private function owner(string $uid): IUser&MockObject {
+ $owner = $this->createMock(IUser::class);
+ $owner->method('getUID')->willReturn($uid);
+
+ return $owner;
+ }//end owner()
+
+ /**
+ * The file as the sharee alice sees it: `/alice/files//levering-manifest.pdf`, owned by the app account.
+ */
+ private function fileThroughAShare(string $ownerUid = 'openregister'): File&MockObject {
+ $file = $this->createMock(File::class);
+ $file->method('getName')->willReturn('levering-manifest.pdf');
+ $file->method('getId')->willReturn(9001);
+ $file->method('getPath')->willReturn('/alice/files/Dossier/levering-manifest.pdf');
+ $file->method('getOwner')->willReturn($this->owner($ownerUid));
+
+ return $file;
+ }//end fileThroughAShare()
+
/**
* A folder node with a name, an id and a parent.
*/
@@ -142,6 +179,69 @@ public function testADeleteAndARenameAreAbortedThroughAbortOperation(): void {
$this->listener()->handle(new BeforeNodeRenamedEvent($this->fileInObjectFolder(), $outside));
}//end testADeleteAndARenameAreAbortedThroughAbortOperation()
+ public function testAWriteThroughAShareeMountIsAborted(): void {
+ $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject()]);
+ $this->homeHolds([$this->fileInObjectFolder()]);
+
+ try {
+ $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileThroughAShare()));
+ $this->fail('A write through a share into a frozen object went through');
+ } catch (HintException $e) {
+ $this->assertStringContainsString('frozen by dossiq', $e->getMessage());
+ }
+
+ try {
+ $this->listener()->handle(new BeforeNodeDeletedEvent($this->fileThroughAShare()));
+ $this->fail('A delete through a share of a frozen object went through');
+ } catch (AbortedEventException $e) {
+ $this->assertStringContainsString('frozen by dossiq', $e->getMessage());
+ }
+
+ // A rename of the shared file to somewhere in alice's own tree: the source is the shared file.
+ $own = $this->createMock(File::class);
+ $own->method('getPath')->willReturn('/alice/files/mine.pdf');
+ $own->method('getOwner')->willReturn($this->owner('alice'));
+
+ $this->expectException(AbortedEventException::class);
+ $this->listener()->handle(new BeforeNodeRenamedEvent($this->fileThroughAShare(), $own));
+ }//end testAWriteThroughAShareeMountIsAborted()
+
+ public function testACreateThroughAShareeMountResolvesItsNearestExistingAncestor(): void {
+ $this->objects->method('findAcrossAllSources')->willReturn(['object' => $this->frozenObject()]);
+ // The shared folder, as the home holds it: `/openregister/files/Woo Register/`.
+ $files = $this->folder('files', 2, '/openregister/files', null);
+ $register = $this->folder('Woo Register', 3, '/openregister/files/Woo Register', $files);
+ $objectFolder = $this->folder(self::UUID, self::FOLDER_ID, '/openregister/files/Woo Register/' . self::UUID, $register);
+ $this->homeHolds([$objectFolder]);
+
+ $shared = $this->createMock(Folder::class);
+ $shared->method('getId')->willReturn(self::FOLDER_ID);
+ $shared->method('getOwner')->willReturn($this->owner('openregister'));
+ $new = $this->createMock(File::class);
+ $new->method('getPath')->willReturn('/alice/files/Dossier/new.pdf');
+ $new->method('getOwner')->willThrowException(new NotFoundException('not stored yet'));
+ $new->method('getParent')->willReturn($shared);
+
+ $this->expectException(HintException::class);
+ $this->listener()->handle(new BeforeNodeCreatedEvent($new));
+ }//end testACreateThroughAShareeMountResolvesItsNearestExistingAncestor()
+
+ public function testASharedNodeOfAnotherOwnerIsIgnoredWithoutALookup(): void {
+ $this->objects->expects($this->never())->method('findAcrossAllSources');
+ $this->rootFolder->expects($this->never())->method('getUserFolder');
+
+ $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileThroughAShare(ownerUid: 'bob')));
+ $this->addToAssertionCount(1);
+ }//end testASharedNodeOfAnotherOwnerIsIgnoredWithoutALookup()
+
+ public function testAShareeNodeThatCannotBePlacedInTheHomeIsRefused(): void {
+ $this->rootFolder->method('getUserFolder')->willThrowException(new \RuntimeException('home unavailable'));
+ $this->logger->expects($this->once())->method('warning')->with($this->stringContains('could not be placed'), $this->anything());
+
+ $this->expectException(HintException::class);
+ $this->listener()->handle(new BeforeNodeWrittenEvent($this->fileThroughAShare()));
+ }//end testAShareeNodeThatCannotBePlacedInTheHomeIsRefused()
+
public function testAnUnfrozenObjectFolderAcceptsTheWrite(): void {
$object = $this->frozenObject();
$object->setFrozen(null);
From f1f9c1d83add977ebb0e781499e87a1e6e757648 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:42:58 +0200
Subject: [PATCH 05/99] =?UTF-8?q?fix(review):=20#4580=20plat-f2=20?=
=?UTF-8?q?=E2=80=94=20Any=20admin-UI=20file-settings=20save=20clears=20th?=
=?UTF-8?q?e=20instance=20review=20default?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
updateFileSettingsOnly() writes anonymisationReview only when the request carries the key; without it the stored declaration is kept, and an explicit null still clears it. The test that asserted null after an empty save now asserts the kept value, plus the explicit-null clear. The Vue form is left alone: the server keeps the stored value, so the form need not send it.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660645
Sweep: ReviewPolicy::SETTINGS_KEY is written nowhere else (ReviewPolicyResolver only reads it).
Check: phpunit tests/Unit/Service/Settings/FileSettingsHandlerTest.php OK 20 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Settings/FileSettingsHandler.php | 19 ++++++++++---
.../Settings/FileSettingsHandlerTest.php | 27 +++++++++++++++++--
2 files changed, 41 insertions(+), 5 deletions(-)
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/tests/Unit/Service/Settings/FileSettingsHandlerTest.php b/tests/Unit/Service/Settings/FileSettingsHandlerTest.php
index 553a807daa..f88880c442 100644
--- a/tests/Unit/Service/Settings/FileSettingsHandlerTest.php
+++ b/tests/Unit/Service/Settings/FileSettingsHandlerTest.php
@@ -355,10 +355,33 @@ public function testUpdateFileSettingsStoresCorrectJson(): void {
* @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
*/
public function testTheReviewDefaultIsKeptAndChecked(): void {
+ $stored = '';
+ $this->appConfig->method('setValueString')->willReturnCallback(
+ function (string $app, string $key, string $value) use (&$stored): bool {
+ if ($key === 'fileManagement') {
+ $stored = $value;
+ }
+
+ return true;
+ }
+ );
+ $this->appConfig->method('getValueString')->willReturnCallback(
+ static function (string $app, string $key, string $default = '') use (&$stored): string {
+ return ($key === 'fileManagement' ? $stored : $default);
+ }
+ );
+
+ $kept = ['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => false];
$result = $this->handler->updateFileSettingsOnly(['anonymisationReview' => ['ownership' => 'decider', 'supervisors' => ['woo-coordinator']]]);
- $this->assertSame(['ownership' => 'decider', 'supervisors' => ['woo-coordinator'], 'hideOthers' => false], $result['anonymisationReview']);
+ $this->assertSame($kept, $result['anonymisationReview']);
+
+ // The admin form never sends the key: a save without it keeps the stored default.
+ $this->assertSame($kept, $this->handler->updateFileSettingsOnly(['chunkSize' => 500])['anonymisationReview']);
+ $this->assertSame($kept, $this->handler->getFileSettingsOnly()['anonymisationReview']);
- $this->assertNull($this->handler->updateFileSettingsOnly([])['anonymisationReview']);
+ // An explicit null clears it.
+ $this->assertNull($this->handler->updateFileSettingsOnly(['anonymisationReview' => null])['anonymisationReview']);
+ $this->assertNull($this->handler->getFileSettingsOnly()['anonymisationReview']);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('decidr');
From 02b92226c4f8aad853d37bb9866b2e2f37f2f923 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:46:01 +0200
Subject: [PATCH 06/99] =?UTF-8?q?fix(review):=20#4580=20plat-f3=20?=
=?UTF-8?q?=E2=80=94=20Deferred-chunk=20limit=20is=2032000,=20but=20NC=203?=
=?UTF-8?q?2=20and=2033=20accept=204000?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The chunk budget now follows the running Nextcloud: ListenerDeferralService::getArgumentLimit() reads OC\BackgroundJob\JobList::MAX_ARGUMENT_JSON_LENGTH when the server defines it (34 and later: 32000) and uses 4000 otherwise, which is what NC 32 and 33 enforce. Picking by constant rather than a fixed 4000 keeps 35 at full chunks; the 4000 fallback is the safe side everywhere. The limit can be injected through the constructor (optional, autowired as null). An entry that alone exceeds the limit is enqueued without its oldData and the reduction logged at warning level (the 'updated' notification is still sent, only calculatedChange is skipped); an entry still too large is dropped and named as before. MAX_ARGUMENT_LENGTH is replaced by ARGUMENT_LENGTH_NC32/NC34 (no other user). DeferredChunksFitTheJobLimitTest runs at 4000 and at 32000 and covers the reduction; the 'too large for any job' test now uses an entry whose size is not oldData.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660676
Sweep: the openspec change deferred-listener-batches-stay-under-the-job-limit (proposal + spec requirement) restated 32,000 and is updated to the runtime limit and the reduction.
Check: phpunit tests/Unit/Service/Deferral + ObjectCleanupJobTest OK 34 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
.../Deferral/ListenerDeferralService.php | 93 ++++++++++++--
.../proposal.md | 5 +-
.../specs/event-driven-architecture/spec.md | 10 +-
.../DeferredChunksFitTheJobLimitTest.php | 120 +++++++++++++++---
4 files changed, 195 insertions(+), 33 deletions(-)
diff --git a/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php
index 0a7db1e081..a76845dd9c 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,14 @@ 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` (the notification is
+ * still sent, and the log names the reduction); 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 +345,37 @@ 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. Without it the
+ // 'updated' notification is still sent; only the comparison of old
+ // against new is 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',
+ 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 +385,7 @@ private function chunksUnderLimit(string $jobClass, array $entries): array {
'jobClass' => $jobClass,
'uuid' => (string)($entry['uuid'] ?? ''),
'length' => $length,
+ 'limit' => $this->getArgumentLimit(),
]
);
continue;
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..292ccb060f 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 update notification is still sent) 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..b28f54cbbf 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; 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 keeps its notification
+- 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`, the log names its uuid, and the notification is still sent
+- @e2e exclude pinned by `DeferredChunksFitTheJobLimitTest::testAnOversizedUpdateLosesItsOldDataNotItsNotification`
+
#### 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/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php
index fdb75289c4..ebbcd3214a 100644
--- a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php
+++ b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php
@@ -5,9 +5,10 @@
*
* Drives AnnotationNotificationListener, the caller whose update entries
* carry the object's previous data, into the real ListenerDeferralService,
- * with a job list that refuses an argument over 32,000 characters the way
- * Nextcloud's does. Before this change one cron run lost eleven chunks of 25
- * update notifications that way (zuiddrecht live test, 14 October 2026).
+ * with a job list that refuses an argument over the limit the way
+ * Nextcloud's does: 4,000 characters on Nextcloud 32 and 33, 32,000 from 34.
+ * Before this change one cron run lost eleven chunks of 25 update
+ * notifications that way (zuiddrecht live test, 14 October 2026).
*
* @category Tests
* @package OCA\OpenRegister\Tests\Unit\Service\Deferral
@@ -75,6 +76,13 @@ class DeferredChunksFitTheJobLimitTest extends TestCase {
*/
private array $errors = [];
+ /**
+ * What was logged at warning level.
+ *
+ * @var array}>
+ */
+ private array $warnings = [];
+
/**
* Reset the shutdown spy.
*
@@ -88,15 +96,17 @@ protected function setUp(): void {
/**
* The real deferral service on a job list that enforces the limit.
*
+ * @param int $limit The job argument limit of the simulated Nextcloud.
+ *
* @return ListenerDeferralService
*/
- private function deferral(): ListenerDeferralService {
+ private function deferral(int $limit = ListenerDeferralService::ARGUMENT_LENGTH_NC34): ListenerDeferralService {
$jobList = $this->createMock(IJobList::class);
$jobList->method('add')->willReturnCallback(
- function (string $job, mixed $argument): void {
+ function (string $job, mixed $argument) use ($limit): void {
// The check Nextcloud's JobList::add() makes.
- if (strlen((string)json_encode($argument)) > 32000) {
- throw new InvalidArgumentException("Background job arguments can't exceed 32000 characters (json encoded)");
+ if (strlen((string)json_encode($argument)) > $limit) {
+ throw new InvalidArgumentException("Background job arguments can't exceed " . $limit . ' characters (json encoded)');
}
$this->enqueued[] = $argument;
@@ -109,6 +119,11 @@ function (string $message, array $context = []): void {
$this->errors[] = [$message, $context];
}
);
+ $logger->method('warning')->willReturnCallback(
+ function (string $message, array $context = []): void {
+ $this->warnings[] = [$message, $context];
+ }
+ );
return new ListenerDeferralService(
userSession: $this->createMock(IUserSession::class),
@@ -116,6 +131,7 @@ function (string $message, array $context = []): void {
jobList: $jobList,
appConfig: $this->createMock(IAppConfig::class),
logger: $logger,
+ argumentLimit: $limit,
);
}//end deferral()
@@ -186,57 +202,125 @@ private function enqueuedUuids(): array {
}//end enqueuedUuids()
/**
- * Twenty-five updates of cases of about 2,000 characters each, one
- * listener chunk, all reach the job list.
+ * The limits of the Nextcloud versions this app supports, with an entry size that needs spreading at each.
+ *
+ * @return array Limit and description length.
+ */
+ public static function limits(): array {
+ return [
+ 'Nextcloud 34 and later: 32,000' => [32000, 2000],
+ 'Nextcloud 32 and 33: 4,000' => [4000, 500],
+ ];
+ }//end limits()
+
+ /**
+ * Twenty-five updates of large cases, one listener chunk, all reach the
+ * job list, whichever limit the running Nextcloud has.
+ *
+ * @param int $limit The job argument limit.
+ * @param int $length The length of each case's description.
*
* @return void
*
+ * @dataProvider limits
+ *
* @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 testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes(): void {
- $deferral = $this->deferral();
+ public function testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes(int $limit, int $length): void {
+ $deferral = $this->deferral(limit: $limit);
$listener = $this->listener(deferral: $deferral);
$expected = [];
for ($i = 1; $i <= 25; $i++) {
$uuid = 'case-' . $i;
$expected[] = $uuid;
- $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: $uuid, length: 2000), $this->caseOf(uuid: $uuid, length: 2000)));
+ $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: $uuid, length: $length), $this->caseOf(uuid: $uuid, length: $length)));
}
$deferral->flushAll();
self::assertSame([], $this->errors, 'nothing was refused');
+ self::assertSame([], $this->warnings, 'nothing had to be reduced');
self::assertSame($expected, $this->enqueuedUuids(), 'every update, once, in order');
self::assertGreaterThan(1, count($this->enqueued), 'more than one job');
foreach ($this->enqueued as $argument) {
- self::assertLessThanOrEqual(ListenerDeferralService::MAX_ARGUMENT_LENGTH, strlen((string)json_encode($argument)));
+ self::assertLessThanOrEqual($limit, strlen((string)json_encode($argument)));
}
self::assertSame([AnnotationNotificationDispatchJob::class], array_values(array_unique($this->jobClasses)));
}//end testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes()
/**
- * One update too large for any job is named in the log; the others in
- * its chunk still go.
+ * An update whose previous data alone passes the limit is enqueued without
+ * it: the notification is not lost, and the log names the reduction.
*
* @return void
*
* @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 testAnEntryTooLargeForAnyJobIsNamedAndTheRestGo(): void {
- $deferral = $this->deferral();
+ public function testAnOversizedUpdateLosesItsOldDataNotItsNotification(): void {
+ $deferral = $this->deferral(limit: ListenerDeferralService::ARGUMENT_LENGTH_NC32);
$listener = $this->listener(deferral: $deferral);
$listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'small-1', length: 100), $this->caseOf(uuid: 'small-1', length: 100)));
- $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'huge', length: 40000), $this->caseOf(uuid: 'huge', length: 40000)));
+ $listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'big', length: 5000), $this->caseOf(uuid: 'big', length: 5000)));
$listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'small-2', length: 100), $this->caseOf(uuid: 'small-2', length: 100)));
$deferral->flushAll();
+ self::assertSame([], $this->errors, 'nothing was dropped');
+ self::assertSame(['small-1', 'big', 'small-2'], $this->enqueuedUuids());
+ self::assertCount(1, $this->warnings);
+ self::assertSame('big', $this->warnings[0][1]['uuid']);
+ foreach ($this->enqueued as $argument) {
+ foreach ($argument['entries'] as $entry) {
+ self::assertSame($entry['uuid'] !== 'big', array_key_exists('oldData', $entry) && $entry['oldData'] !== null);
+ }
+ }
+ }//end testAnOversizedUpdateLosesItsOldDataNotItsNotification()
+
+ /**
+ * One entry too large for any job, whatever it carries, is named in the
+ * log; the others in its chunk still go.
+ *
+ * @return void
+ *
+ * @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 testAnEntryTooLargeForAnyJobIsNamedAndTheRestGo(): void {
+ $deferral = $this->deferral();
+ $deferral->defer(jobClass: AnnotationNotificationDispatchJob::class, entry: ['uuid' => 'small-1', 'trigger' => 'updated']);
+ $deferral->defer(jobClass: AnnotationNotificationDispatchJob::class, entry: ['uuid' => 'huge', 'trigger' => 'updated', 'payload' => str_repeat('x', 40000)]);
+ $deferral->defer(jobClass: AnnotationNotificationDispatchJob::class, entry: ['uuid' => 'small-2', 'trigger' => 'updated']);
+
+ $deferral->flushAll();
+
self::assertSame(['small-1', 'small-2'], $this->enqueuedUuids());
self::assertCount(1, $this->errors);
self::assertSame('huge', $this->errors[0][1]['uuid']);
}//end testAnEntryTooLargeForAnyJobIsNamedAndTheRestGo()
+ /**
+ * Without an injected limit the service reads the running server's, and falls back to 4,000.
+ *
+ * @return void
+ */
+ public function testTheLimitComesFromTheServerWhenItNamesOne(): void {
+ $service = new ListenerDeferralService(
+ userSession: $this->createMock(IUserSession::class),
+ organisation: $this->createMock(OrganisationService::class),
+ jobList: $this->createMock(IJobList::class),
+ appConfig: $this->createMock(IAppConfig::class),
+ logger: $this->createMock(LoggerInterface::class),
+ );
+
+ $serverConstant = 'OC\\BackgroundJob\\JobList::MAX_ARGUMENT_JSON_LENGTH';
+ $expected = ListenerDeferralService::ARGUMENT_LENGTH_NC32;
+ if (defined($serverConstant) === true) {
+ $expected = constant($serverConstant);
+ }
+
+ self::assertSame($expected, $service->getArgumentLimit());
+ }//end testTheLimitComesFromTheServerWhenItNamesOne()
+
/**
* Small entries still go as one job per chunk.
*
From 227bc52ed7bceab19d6388a11f077e93257c983d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:46:53 +0200
Subject: [PATCH 07/99] =?UTF-8?q?fix(review):=20#4580=20eng-f1=20=E2=80=94?=
=?UTF-8?q?=20Upload=20endpoint=20ignores=20the=20form's=20audience?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FormsController::upload() applies submit()'s audience rule (shared audienceRefuses()/signInRequired() helpers): an anonymous visitor of a form that is not public gets 401 before any rule lookup or write. FormUploadStore also gets a 20 MiB default cap for a file property that declares no maxSize (surface slice's duplicate of this finding); a declared limit, larger or smaller, is kept.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660403
Sweep: FormUploadStore::issue has one caller (FormsController::upload).
Check: phpunit FormsControllerTest + FormUploadStoreTest OK 27 tests; phpcs on the lib files clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Controller/FormsController.php | 34 +++++++++++++++++--
lib/Service/Form/FormUploadStore.php | 17 +++++++++-
tests/Unit/Controller/FormsControllerTest.php | 18 ++++++++++
.../Unit/Service/Form/FormUploadStoreTest.php | 15 ++++++++
4 files changed, 80 insertions(+), 4 deletions(-)
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/Service/Form/FormUploadStore.php b/lib/Service/Form/FormUploadStore.php
index df4b29257e..da945869f6 100644
--- a/lib/Service/Form/FormUploadStore.php
+++ b/lib/Service/Form/FormUploadStore.php
@@ -60,6 +60,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 +117,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',
diff --git a/tests/Unit/Controller/FormsControllerTest.php b/tests/Unit/Controller/FormsControllerTest.php
index c50b68685d..35da89e797 100644
--- a/tests/Unit/Controller/FormsControllerTest.php
+++ b/tests/Unit/Controller/FormsControllerTest.php
@@ -284,6 +284,24 @@ public function testAnUploadAnswersAToken(): void {
$this->assertSame(['token' => 'abc', 'expiresAt' => 1], $response->getData());
}//end testAnUploadAnswersAToken()
+ /**
+ * An anonymous visitor of a sign-in-only form gets a 401 and no token; a signed-in one gets through.
+ */
+ public function testAnAnonymousUploadToASignInOnlyFormIs401(): void {
+ $this->params = ['property' => 'bijlage'];
+ $this->request->method('getUploadedFile')->willReturn(['name' => 'a.pdf', 'tmp_name' => '/tmp/x', 'error' => 0, 'size' => 5]);
+ $this->resolver->method('resolve')->willReturn($this->form(audience: 'authenticated'));
+ $schema = new Schema();
+ $schema->setProperties(['bijlage' => ['type' => 'file']]);
+ $this->schemas->method('find')->willReturn($schema);
+ $this->uploads->expects($this->once())->method('issue')->willReturn(['token' => 'abc', 'expiresAt' => 1]);
+
+ $this->assertSame(401, $this->controller->upload(formId: 'form-1')->getStatus());
+
+ $this->session->method('getUser')->willReturn($this->createMock(IUser::class));
+ $this->assertSame(201, $this->controller->upload(formId: 'form-1')->getStatus());
+ }//end testAnAnonymousUploadToASignInOnlyFormIs401()
+
/**
* An upload for a property no destination of the form has is a 422, and no token.
*/
diff --git a/tests/Unit/Service/Form/FormUploadStoreTest.php b/tests/Unit/Service/Form/FormUploadStoreTest.php
index 9dbbb64107..f1baae7d22 100644
--- a/tests/Unit/Service/Form/FormUploadStoreTest.php
+++ b/tests/Unit/Service/Form/FormUploadStoreTest.php
@@ -111,6 +111,21 @@ public function testAnOversizedFileIsRefused(): void {
$this->assertSame([], ($this->appData->folders[FormUploadStore::FOLDER] ?? null)?->files ?? []);
}//end testAnOversizedFileIsRefused()
+ /**
+ * A property without a size rule still has a cap: the endpoint is public.
+ */
+ public function testAPropertyWithoutAMaxSizeHasADefaultCap(): void {
+ try {
+ $this->store->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('x', size: FormUploadStore::DEFAULT_MAX_BYTES + 1));
+ $this->fail('A file over the default cap was given a token.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame('file-too-large', $refused->getFindings()[0]['code']);
+ }
+
+ $issued = $this->store->issue(formId: 'form-1', property: 'bijlage', rule: ['type' => 'file'], file: $this->upload('x', size: 1024));
+ $this->assertNotSame('', $issued['token']);
+ }//end testAPropertyWithoutAMaxSizeHasADefaultCap()
+
/**
* A type the property does not allow is refused; an items rule of an array property counts.
*/
From 94854b289f0a00799ea921b717cdfe6890349a77 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:49:36 +0200
Subject: [PATCH 08/99] =?UTF-8?q?fix(review):=20#4580=20eng-f3=20=E2=80=94?=
=?UTF-8?q?=20Idempotency=20key=20is=20neither=20subject-bound=20nor=20rac?=
=?UTF-8?q?e-safe?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The stored answer is now bound to the submitter (user id, scope value or anonymous marker) and a canonical hash of the payload: another subject, or the same key with another payload, gets a 409 that carries nothing of the first answer. A key is reserved with an exclusive ILockingProvider lock for the length of its submit (acquire, find, write, remember, release in a finally), so a parallel retry answers 409 and creates no second object; the lock is released after a refusal too. FormIdempotencyStore gets ILockingProvider and IL10N (autowired), find()/remember() take the binding, and an entry without a binding (older than this change, at most 24 h) matches nobody. Tests use a small in-memory MemoryLockingProvider. docs/features/form-destination.md gets the 409 row and the binding sentence (same sentence as the finding's).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660446
Sweep: FormIdempotencyStore has one caller (FormSubmitService::submitAll) plus the purge job, which is unchanged; the idempotency header is read only in FormsController::submit.
Check: phpunit tests/Unit/Service/Form + FormUploadPurgeJobTest + FormsControllerTest OK 106 tests; phpcs on the two lib files clean. The two-concurrent-requests Done-when is covered through a simulated parallel request inside the save, not a real parallel run.
Co-Authored-By: Claude Opus 5.5
---
docs/features/form-destination.md | 5 +-
lib/Service/Form/FormIdempotencyStore.php | 138 ++++++++++++++++--
lib/Service/Form/FormSubmitService.php | 66 +++++++--
.../Form/Fakes/MemoryLockingProvider.php | 79 ++++++++++
.../Service/Form/FormIdempotencyStoreTest.php | 80 ++++++++--
.../Service/Form/FormSubmitServiceTest.php | 89 ++++++++++-
6 files changed, 425 insertions(+), 32 deletions(-)
create mode 100644 tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php
diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md
index c4869fa631..62cc522fe8 100644
--- a/docs/features/form-destination.md
+++ b/docs/features/form-destination.md
@@ -81,10 +81,13 @@ 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. 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/lib/Service/Form/FormIdempotencyStore.php b/lib/Service/Form/FormIdempotencyStore.php
index 7bdd3fef3d..0e4b6d2cfa 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,88 @@ 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 (form id, or the caller's own).
+ * @param string $scope The form scope.
* @param string $key The Idempotency-Key.
*
+ * @return void
+ *
+ * @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
+ */
+ 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 +167,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 +185,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 +237,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 +245,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 1b86410575..63965d9c07 100644
--- a/lib/Service/Form/FormSubmitService.php
+++ b/lib/Service/Form/FormSubmitService.php
@@ -156,7 +156,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
*/
@@ -249,7 +249,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 +263,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.
diff --git a/tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php b/tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php
new file mode 100644
index 0000000000..bf1795305b
--- /dev/null
+++ b/tests/Unit/Service/Form/Fakes/MemoryLockingProvider.php
@@ -0,0 +1,79 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Service\Form\Fakes;
+
+use OCP\Lock\ILockingProvider;
+use OCP\Lock\LockedException;
+
+/**
+ * Exclusive locks by path; a second acquire of a held path is a LockedException.
+ */
+class MemoryLockingProvider implements ILockingProvider {
+
+ /**
+ * Paths held exclusively.
+ *
+ * @var array
+ */
+ public array $held = [];
+
+ /**
+ * Whether a path is locked.
+ *
+ * @param string $path The path.
+ * @param int $type The lock type.
+ */
+ public function isLocked(string $path, int $type): bool {
+ return isset($this->held[$path]);
+ }//end isLocked()
+
+ /**
+ * Take a lock, or fail when it is held.
+ *
+ * @param string $path The path.
+ * @param int $type The lock type.
+ * @param string|null $readablePath The readable path.
+ */
+ public function acquireLock(string $path, int $type, ?string $readablePath = null): void {
+ if (isset($this->held[$path]) === true) {
+ throw new LockedException($path);
+ }
+
+ $this->held[$path] = true;
+ }//end acquireLock()
+
+ /**
+ * Release a lock.
+ *
+ * @param string $path The path.
+ * @param int $type The lock type.
+ */
+ public function releaseLock(string $path, int $type): void {
+ unset($this->held[$path]);
+ }//end releaseLock()
+
+ /**
+ * Change a lock's type (unused).
+ *
+ * @param string $path The path.
+ * @param int $targetType The type.
+ */
+ public function changeLock(string $path, int $targetType): void {
+ }//end changeLock()
+
+ /**
+ * Release every lock.
+ */
+ public function releaseAll(): void {
+ $this->held = [];
+ }//end releaseAll()
+}//end class
diff --git a/tests/Unit/Service/Form/FormIdempotencyStoreTest.php b/tests/Unit/Service/Form/FormIdempotencyStoreTest.php
index 1e149a94a6..5cc4eb9632 100644
--- a/tests/Unit/Service/Form/FormIdempotencyStoreTest.php
+++ b/tests/Unit/Service/Form/FormIdempotencyStoreTest.php
@@ -14,8 +14,11 @@
namespace OCA\OpenRegister\Tests\Unit\Service\Form;
use OCA\OpenRegister\Service\Form\FormIdempotencyStore;
+use OCA\OpenRegister\Exception\FormSubmitRefusedException;
use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData;
+use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryLockingProvider;
use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IL10N;
use PHPUnit\Framework\TestCase;
/**
@@ -31,18 +34,23 @@ class FormIdempotencyStoreTest extends TestCase {
private FormIdempotencyStore $store;
+ private MemoryLockingProvider $locks;
+
protected function setUp(): void {
$this->appData = new MemoryAppData();
$time = $this->createMock(ITimeFactory::class);
$time->method('getTime')->willReturnCallback(fn (): int => $this->now);
- $this->store = new FormIdempotencyStore(appData: $this->appData, time: $time);
+ $this->locks = new MemoryLockingProvider();
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnArgument(0);
+ $this->store = new FormIdempotencyStore(appData: $this->appData, time: $time, locks: $this->locks, l10n: $l10n);
}//end setUp()
/**
* Nothing remembered: nothing found.
*/
public function testAnUnknownKeyFindsNothing(): void {
- $this->assertNull($this->store->find(scope: 'form-1', key: 'k1'));
+ $this->assertNull($this->store->find(scope: 'form-1', key: 'k1', binding: 'b'));
}//end testAnUnknownKeyFindsNothing()
/**
@@ -50,20 +58,20 @@ public function testAnUnknownKeyFindsNothing(): void {
*/
public function testARememberedAnswerIsFoundWithinTheWindow(): void {
$answer = ['reference' => '2026-0412', 'id' => 'uuid-7'];
- $this->store->remember(scope: 'form-1', key: 'k1', response: $answer);
+ $this->store->remember(scope: 'form-1', key: 'k1', response: $answer, binding: 'b');
$this->now += 86_399;
- $this->assertSame($answer, $this->store->find(scope: 'form-1', key: 'k1'));
+ $this->assertSame($answer, $this->store->find(scope: 'form-1', key: 'k1', binding: 'b'));
}//end testARememberedAnswerIsFoundWithinTheWindow()
/**
* After 24 hours the key is free again, and the expired entry is gone.
*/
public function testAnExpiredAnswerIsNotFound(): void {
- $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7']);
+ $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7'], binding: 'b');
$this->now += 86_401;
- $this->assertNull($this->store->find(scope: 'form-1', key: 'k1'));
+ $this->assertNull($this->store->find(scope: 'form-1', key: 'k1', binding: 'b'));
$this->assertSame([], $this->appData->folders[FormIdempotencyStore::FOLDER]->files);
}//end testAnExpiredAnswerIsNotFound()
@@ -71,22 +79,72 @@ public function testAnExpiredAnswerIsNotFound(): void {
* The same key on another form is another submit.
*/
public function testKeysAreScopedPerForm(): void {
- $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7']);
+ $this->store->remember(scope: 'form-1', key: 'k1', response: ['id' => 'uuid-7'], binding: 'b');
- $this->assertNull($this->store->find(scope: 'form-2', key: 'k1'));
+ $this->assertNull($this->store->find(scope: 'form-2', key: 'k1', binding: 'b'));
}//end testKeysAreScopedPerForm()
+ /**
+ * The same key from another subject or with another payload does not get the first answer.
+ */
+ public function testAnotherBindingGetsNoAnswerOnlyAConflict(): void {
+ $mine = $this->store->bindingFor(subject: 'user:alice', payload: ['title' => 'a', 'n' => ['x' => 1, 'y' => 2]]);
+ $this->store->remember(scope: 'form-1', key: 'k1', response: ['reference' => 'secret'], binding: $mine);
+
+ // The same subject and an equal payload (keys in another order) is a repeat.
+ $again = $this->store->bindingFor(subject: 'user:alice', payload: ['n' => ['y' => 2, 'x' => 1], 'title' => 'a']);
+ $this->assertSame(['reference' => 'secret'], $this->store->find(scope: 'form-1', key: 'k1', binding: $again));
+
+ foreach (
+ [
+ $this->store->bindingFor(subject: 'user:bob', payload: ['title' => 'a', 'n' => ['x' => 1, 'y' => 2]]),
+ $this->store->bindingFor(subject: 'anonymous', payload: ['title' => 'a', 'n' => ['x' => 1, 'y' => 2]]),
+ $this->store->bindingFor(subject: 'user:alice', payload: ['title' => 'changed']),
+ ] as $other
+ ) {
+ try {
+ $this->store->find(scope: 'form-1', key: 'k1', binding: $other);
+ $this->fail('A different binding received the first answer.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame(409, $refused->getStatus());
+ $this->assertStringNotContainsString('secret', $refused->getMessage());
+ }
+ }
+ }//end testAnotherBindingGetsNoAnswerOnlyAConflict()
+
+ /**
+ * A key being worked on is refused to a second request until it is released.
+ */
+ public function testAReservedKeyRefusesASecondRequestUntilReleased(): void {
+ $this->store->reserve(scope: 'form-1', key: 'k1');
+
+ try {
+ $this->store->reserve(scope: 'form-1', key: 'k1');
+ $this->fail('A key in use was reserved twice.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame(409, $refused->getStatus());
+ }
+
+ // Another key and another form are not held up.
+ $this->store->reserve(scope: 'form-1', key: 'k2');
+ $this->store->reserve(scope: 'form-2', key: 'k1');
+
+ $this->store->release(scope: 'form-1', key: 'k1');
+ $this->store->reserve(scope: 'form-1', key: 'k1');
+ $this->addToAssertionCount(1);
+ }//end testAReservedKeyRefusesASecondRequestUntilReleased()
+
/**
* The purge deletes only expired entries and counts them.
*/
public function testThePurgeCountsWhatItDeleted(): void {
- $this->store->remember(scope: 'f', key: 'old', response: ['id' => 'a']);
+ $this->store->remember(scope: 'f', key: 'old', response: ['id' => 'a'], binding: 'b');
$this->now += 50_000;
- $this->store->remember(scope: 'f', key: 'new', response: ['id' => 'b']);
+ $this->store->remember(scope: 'f', key: 'new', response: ['id' => 'b'], binding: 'b');
$this->now += 40_000;
$this->assertSame(1, $this->store->purge());
- $this->assertSame(['id' => 'b'], $this->store->find(scope: 'f', key: 'new'));
+ $this->assertSame(['id' => 'b'], $this->store->find(scope: 'f', key: 'new', binding: 'b'));
$this->assertSame(0, $this->store->purge());
}//end testThePurgeCountsWhatItDeleted()
diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php
index e4058b15c8..42f7edc8e6 100644
--- a/tests/Unit/Service/Form/FormSubmitServiceTest.php
+++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php
@@ -32,6 +32,7 @@
use OCA\OpenRegister\Service\Object\ValidateObject;
use OCA\OpenRegister\Service\ObjectService;
use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData;
+use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryLockingProvider;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\Event;
@@ -137,6 +138,18 @@ class FormSubmitServiceTest extends TestCase {
*/
private array $dispatched = [];
+ /**
+ * The locks the idempotency store holds.
+ */
+ private MemoryLockingProvider $locks;
+
+ /**
+ * Run inside the next save, to simulate a parallel request.
+ *
+ * @var callable|null
+ */
+ private $duringSave = null;
+
protected function setUp(): void {
$this->schemaBySlug = [
'case' => $this->schema(
@@ -231,6 +244,7 @@ function (): string {
);
$appData = new MemoryAppData();
+ $this->locks = new MemoryLockingProvider();
$this->uploads = new FormUploadStore(appData: $appData, time: $time, temp: $temp, rules: new FilePropertyRules(), l10n: $l10n);
$this->service = new FormSubmitService(
@@ -239,7 +253,7 @@ function (): string {
validator: $validateObject,
objects: $this->objects,
findings: new FormPayloadFindings(),
- keys: new FormIdempotencyStore(appData: $appData, time: $time),
+ keys: new FormIdempotencyStore(appData: $appData, time: $time, locks: $this->locks, l10n: $l10n),
uploads: $this->uploads,
l10n: $l10n,
logger: $this->createMock(LoggerInterface::class),
@@ -330,6 +344,12 @@ private function fakeSave(
?string $_owner = null,
): ObjectEntity {
$this->assertInstanceOf(Schema::class, $schema);
+ if ($this->duringSave !== null) {
+ $hook = $this->duringSave;
+ $this->duringSave = null;
+ $hook();
+ }
+
$this->saves[] = ['object' => $object, 'schema' => $schema->getSlug(), 'rbac' => $_rbac, 'user' => $currentUser, 'unowned' => $_unowned, 'files' => $uploadedFiles, 'uuid' => $uuid, 'owner' => $_owner];
if (isset($this->refuseSave[$schema->getSlug()]) === true) {
throw $this->refuseSave[$schema->getSlug()];
@@ -457,6 +477,73 @@ public function testARepeatedIdempotencyKeyRepeatsTheAnswer(): void {
$this->assertCount(2, $this->saves);
}//end testARepeatedIdempotencyKeyRepeatsTheAnswer()
+ /**
+ * The key is client-chosen: another user, or another payload, with the same key never gets the first answer.
+ */
+ public function testAnIdempotencyKeyDoesNotHandTheAnswerToSomebodyElse(): void {
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+ $alice = $this->createMock(IUser::class);
+ $alice->method('getUID')->willReturn('alice');
+ $bob = $this->createMock(IUser::class);
+ $bob->method('getUID')->willReturn('bob');
+ $payload = ['title' => 'a', 'caseType' => 'b'];
+
+ $first = $this->service->submit(destination: $destination, mapping: null, payload: $payload, subject: $alice, idempotencyKey: 'k1', scope: 'form-1');
+
+ foreach ([[$bob, $payload], [null, $payload], [$alice, ['title' => 'other', 'caseType' => 'b']]] as [$subject, $body]) {
+ try {
+ $this->service->submit(destination: $destination, mapping: null, payload: $body, subject: $subject, idempotencyKey: 'k1', scope: 'form-1');
+ $this->fail('The key handed over the first answer.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame(409, $refused->getStatus());
+ $this->assertStringNotContainsString((string)$first['reference'], $refused->getMessage());
+ }
+ }
+
+ $this->assertCount(1, $this->saves);
+ $this->assertSame($first, $this->service->submit(destination: $destination, mapping: null, payload: $payload, subject: $alice, idempotencyKey: 'k1', scope: 'form-1'));
+ }//end testAnIdempotencyKeyDoesNotHandTheAnswerToSomebodyElse()
+
+ /**
+ * Two submits in flight with one key create one object; the key is free again afterwards.
+ */
+ public function testTwoConcurrentSubmitsWithOneKeyCreateOneObject(): void {
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+ $payload = ['title' => 'a', 'caseType' => 'b'];
+ $status = null;
+ // While the first submit is writing, the same request arrives again.
+ $this->duringSave = function () use ($destination, $payload, &$status): void {
+ try {
+ $this->service->submit(destination: $destination, mapping: null, payload: $payload, idempotencyKey: 'k1', scope: 'form-1');
+ } catch (FormSubmitRefusedException $refused) {
+ $status = $refused->getStatus();
+ }
+ };
+
+ $first = $this->service->submit(destination: $destination, mapping: null, payload: $payload, idempotencyKey: 'k1', scope: 'form-1');
+
+ $this->assertSame(409, $status);
+ $this->assertCount(1, $this->saves);
+ $this->assertSame([], $this->locks->held);
+ $this->assertSame($first, $this->service->submit(destination: $destination, mapping: null, payload: $payload, idempotencyKey: 'k1', scope: 'form-1'));
+ }//end testTwoConcurrentSubmitsWithOneKeyCreateOneObject()
+
+ /**
+ * A refused submit releases its key, so the corrected retry is not locked out.
+ */
+ public function testARefusedSubmitReleasesItsKey(): void {
+ try {
+ $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a'], idempotencyKey: 'k1', scope: 'form-1');
+ $this->fail('An incomplete submit was accepted.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame(422, $refused->getStatus());
+ }
+
+ $this->assertSame([], $this->locks->held);
+ $this->service->submit(destination: ['register' => 'dossiq', 'schema' => 'case'], mapping: null, payload: ['title' => 'a', 'caseType' => 'b'], idempotencyKey: 'k1', scope: 'form-1');
+ $this->assertCount(1, $this->saves);
+ }//end testARefusedSubmitReleasesItsKey()
+
/**
* Every write is validated before the first: an invalid second write means no write at all.
*/
From f990f3dd525b4c6390d584c96b9948f2e5b241c1 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:50:15 +0200
Subject: [PATCH 09/99] =?UTF-8?q?fix(review):=20#4580=20eng-f5=20=E2=80=94?=
=?UTF-8?q?=20Draft=20cleanup=20deletes=20without=20re-checking=20the=20dr?=
=?UTF-8?q?aft?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
remove() now reads the object again (ObjectService::find, RBAC off, no render, no audit read) and deletes only while it is still a draft last changed before the cutoff the list was made with; a draft sent or touched since is counted as refused and logged at info level, an object that cannot be read again is left alone. The cutoff is passed through walk() and removeWithin(). DraftExpiryTest gets a find() fake and a test with a draft sent after the list.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660461
Sweep: DraftExpiry::remove has one caller; DraftExpiryJob and DraftsExpiredCommand only call sweep()/preview().
DraftExpiryJobTest gets the same find() fake (it builds DraftExpiry itself).
Check: phpunit tests/Unit/BackgroundJob + DraftsExpiredCommandTest + DraftExpiryTest OK 338 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Object/DraftExpiry.php | 49 ++++++++++++---
.../Unit/BackgroundJob/DraftExpiryJobTest.php | 4 ++
tests/Unit/Service/Object/DraftExpiryTest.php | 61 +++++++++++++++++++
3 files changed, 106 insertions(+), 8 deletions(-)
diff --git a/lib/Service/Object/DraftExpiry.php b/lib/Service/Object/DraftExpiry.php
index da142535b4..48b279a56c 100644
--- a/lib/Service/Object/DraftExpiry.php
+++ b/lib/Service/Object/DraftExpiry.php
@@ -173,12 +173,19 @@ private function walk(DateTimeImmutable $now, int $budget): array {
}
$counts['schemas']++;
- $expired = $this->expiredIn(register: $register, schema: $schema, cutoff: $now->modify('-' . $days . ' days'));
+ $cutoff = $now->modify('-' . $days . ' days');
+ $expired = $this->expiredIn(register: $register, schema: $schema, cutoff: $cutoff);
if ($expired === []) {
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
+ );
$row['lifetimeDays'] = $days;
$counts['perSchema'][] = $row;
foreach (['expired', 'removed', 'refused', 'deferred'] as $key) {
@@ -211,11 +218,12 @@ 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.
*
* @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): array {
$row = [
'register' => (string)($register->getSlug() ?? $register->getId()),
'schema' => (string)($schema->getSlug() ?? $schema->getId()),
@@ -232,7 +240,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;
}
@@ -351,14 +359,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/tests/Unit/BackgroundJob/DraftExpiryJobTest.php b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php
index 4bcfb43a3a..3fc71be3d4 100644
--- a/tests/Unit/BackgroundJob/DraftExpiryJobTest.php
+++ b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php
@@ -155,6 +155,10 @@ public function testTheDeclaredJobRemovesAnExpiredDraft(): void {
$objects->method('searchObjects')->willReturnCallback(
static fn (array $query = []): array => $query['@self']['schema'] === '1' ? [$expired, $fresh] : []
);
+ // The sweep reads each object again just before deleting it.
+ $objects->method('find')->willReturnCallback(
+ static fn (string|int $id): ?ObjectEntity => (array_values(array_filter([$expired, $fresh], static fn (ObjectEntity $entity): bool => $entity->getUuid() === (string)$id))[0] ?? null)
+ );
$objects->method('deleteObject')->willReturnCallback(
function (string $uuid) use (&$deleted, &$actors): bool {
$deleted[] = $uuid;
diff --git a/tests/Unit/Service/Object/DraftExpiryTest.php b/tests/Unit/Service/Object/DraftExpiryTest.php
index d9b2dd45c2..800c76802c 100644
--- a/tests/Unit/Service/Object/DraftExpiryTest.php
+++ b/tests/Unit/Service/Object/DraftExpiryTest.php
@@ -25,6 +25,7 @@
use OCA\OpenRegister\Service\Object\DraftExpiry;
use OCA\OpenRegister\Service\Object\DraftRemovalRecord;
use OCA\OpenRegister\Service\ObjectService;
+use OCP\AppFramework\Db\DoesNotExistException;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use RuntimeException;
@@ -85,6 +86,20 @@ class DraftExpiryTest extends TestCase {
*/
private array $schemas = [];
+ /**
+ * Uuids read again just before their delete.
+ *
+ * @var array
+ */
+ private array $finds = [];
+
+ /**
+ * What the second read answers for a uuid, instead of the listed object (a draft sent in between).
+ *
+ * @var array
+ */
+ private array $replaced = [];
+
/**
* Registers the mapper lists.
*
@@ -111,6 +126,24 @@ function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): ar
return array_slice(($this->stored[$key] ?? []), (int)$query['_offset'], (int)$query['_limit']);
}
);
+ $objects->method('find')->willReturnCallback(
+ function (string|int $id, ...$arguments): ?ObjectEntity {
+ $this->finds[] = (string)$id;
+ if (isset($this->replaced[(string)$id]) === true) {
+ return $this->replaced[(string)$id];
+ }
+
+ foreach ($this->stored as $list) {
+ foreach ($list as $entity) {
+ if ($entity instanceof ObjectEntity && $entity->getUuid() === (string)$id) {
+ return $entity;
+ }
+ }
+ }
+
+ throw new DoesNotExistException('gone');
+ }
+ );
$objects->method('deleteObject')->willReturnCallback(
function (string $uuid, $register = null, $schema = null, bool $_rbac = true, bool $_multitenancy = true, bool $_retentionSweep = false, $currentUser = null, bool $permanent = false): bool {
$this->deletes[] = [
@@ -442,6 +475,34 @@ public function testARefusedDeleteIsCountedAndTheSweepCarriesOn(): void {
$this->assertSame(['[DraftExpiry] An expired draft was not removed'], $this->warnings);
}//end testARefusedDeleteIsCountedAndTheSweepCarriesOn()
+ /**
+ * A draft the owner sent between the list and the delete is not deleted; a draft touched since is not either.
+ */
+ public function testADraftSentAfterTheListIsNotDeleted(): void {
+ $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]);
+ $this->register(id: 10, slug: 'service', schemaIds: [1]);
+ $this->object(where: '10/1', uuid: 'sent', status: 'draft', updated: '2026-09-01T00:00:00+00:00');
+ $this->object(where: '10/1', uuid: 'touched', status: 'draft', updated: '2026-09-01T00:00:00+00:00');
+ $this->object(where: '10/1', uuid: 'stale', status: 'draft', updated: '2026-09-01T00:00:00+00:00');
+
+ $sent = new ObjectEntity();
+ $sent->setUuid('sent');
+ $sent->setStatus('active');
+ $sent->setUpdated(new DateTime('2026-10-12T02:59:00+00:00'));
+ $this->replaced['sent'] = $sent;
+ $touched = new ObjectEntity();
+ $touched->setUuid('touched');
+ $touched->setStatus('draft');
+ $touched->setUpdated(new DateTime('2026-10-12T02:59:00+00:00'));
+ $this->replaced['touched'] = $touched;
+
+ $counts = $this->expiry->sweep(now: $this->now);
+
+ $this->assertSame(['sent', 'touched', 'stale'], $this->finds);
+ $this->assertSame(['stale'], array_column($this->deletes, 'uuid'));
+ $this->assertSame(['schemas' => 1, 'expired' => 3, 'removed' => 1, 'refused' => 2, 'deferred' => 0], $this->totals(counts: $counts));
+ }//end testADraftSentAfterTheListIsNotDeleted()
+
/**
* Every page is read before the first delete, so no draft is skipped by a shifting page.
*/
From 33f3e3fb5701ce69e83323d6e8d9d6d241e23dff Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:51:15 +0200
Subject: [PATCH 10/99] =?UTF-8?q?fix(review):=20#4580=20eng-f7=20=E2=80=94?=
=?UTF-8?q?=20Dependent=20form=20reported=20unpublished=20when=20nothing?=
=?UTF-8?q?=20was=20unpublished?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
unpublish() answers 'unpublish-unavailable' (and logs a warning) for a FormDependent registered without a closure, instead of falling through to notify the author and answer 'unpublished'. The outcome is already listed per form in the schema save response ('affectedForms'), so the save surfaces it. FormDependentsCheckTest covers the null closure.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660498
Sweep: FormDependent::$unpublish is read only in FormDependentsCheck::unpublish.
Check: phpunit tests/Unit/Service/Form/FormDependentsCheckTest.php OK 5 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Form/FormDependentsCheck.php | 17 +++++++---
.../Service/Form/FormDependentsCheckTest.php | 31 +++++++++++++++++++
2 files changed, 44 insertions(+), 4 deletions(-)
diff --git a/lib/Service/Form/FormDependentsCheck.php b/lib/Service/Form/FormDependentsCheck.php
index 321dc195a8..28304408a4 100644
--- a/lib/Service/Form/FormDependentsCheck.php
+++ b/lib/Service/Form/FormDependentsCheck.php
@@ -151,13 +151,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/tests/Unit/Service/Form/FormDependentsCheckTest.php b/tests/Unit/Service/Form/FormDependentsCheckTest.php
index ae80ae64c0..8dbc87d721 100644
--- a/tests/Unit/Service/Form/FormDependentsCheckTest.php
+++ b/tests/Unit/Service/Form/FormDependentsCheckTest.php
@@ -62,6 +62,11 @@ class FormDependentsCheckTest extends TestCase {
private FormDependentsCheck $check;
+ /**
+ * Whether the owning app also registers a form without an unpublish closure.
+ */
+ private bool $withClosureless = false;
+
protected function setUp(): void {
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnCallback(
@@ -97,6 +102,17 @@ function (Event $event): void {
}
)
);
+ if ($this->withClosureless === true) {
+ $event->addForm(
+ new FormDependent(
+ id: 'form-c',
+ app: 'pipelinq',
+ title: 'Zonder sluiter',
+ author: 'carol',
+ mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title']]]
+ )
+ );
+ }
}
);
@@ -191,6 +207,21 @@ public function testANewRequiredPropertyUnpublishesTheFormThatLacksIt(): void {
$this->assertSame('Contact opnemen', $this->sent[0]['parameters']['formTitle']);
}//end testANewRequiredPropertyUnpublishesTheFormThatLacksIt()
+ /**
+ * A form its owning app registered without an unpublish closure is not reported unpublished, and its author is not told so.
+ */
+ public function testAFormWithoutAnUnpublishClosureIsNotReportedUnpublished(): void {
+ $this->withClosureless = true;
+ $assessment = $this->check->assess(schema: $this->ticketWithRequiredPriority());
+
+ $applied = $this->check->apply(assessment: $assessment);
+
+ $outcomes = array_column($applied, 'outcome', 'id');
+ $this->assertSame('unpublish-unavailable', $outcomes['form-c']);
+ $this->assertSame('unpublished', $outcomes['form-a']);
+ $this->assertSame(['alice'], array_column($this->sent, 'user'), 'only the author of a form that was unpublished is told');
+ }//end testAFormWithoutAnUnpublishClosureIsNotReportedUnpublished()
+
/**
* An owning app set to refuse blocks the schema save, and nothing is unpublished.
*/
From 1f4eafbd73fd38568a36f0a96e3f5667811772b5 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:52:04 +0200
Subject: [PATCH 11/99] =?UTF-8?q?fix(review):=20#4580=20surf-f6=20?=
=?UTF-8?q?=E2=80=94=20Dependent-form=20re-check=20runs=20on=20one=20schem?=
=?UTF-8?q?a=20write=20path=20only=20(logging=20part)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
formDependentsCheck() now logs at warning level when the container cannot build the check (it used to return null in silence, which skipped an owning app's refuse setting without a trace), and the lookup happens only for an update that carries properties or required. Test: testAFailingDependentFormCheckIsLogged.
Not done here: running the check on the other schema write paths (configuration importer, SchemaImportController, SchemaTool, SchemasToolProvider, SchemaService), because what 'refuse' means for a write that has no response to refuse is the author's call. See the hand-back note.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660903
Check: phpunit tests/Unit/Controller/SchemasControllerTest.php OK 83 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Controller/SchemasController.php | 22 +++++++++++++++++--
.../Unit/Controller/SchemasControllerTest.php | 21 ++++++++++++++++++
2 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php
index b49609331a..5afa4fd5fe 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()
diff --git a/tests/Unit/Controller/SchemasControllerTest.php b/tests/Unit/Controller/SchemasControllerTest.php
index 033c8cfe92..3211b22722 100644
--- a/tests/Unit/Controller/SchemasControllerTest.php
+++ b/tests/Unit/Controller/SchemasControllerTest.php
@@ -369,6 +369,27 @@ public function testUpdateListsTheFormsItUnpublished(): void {
$this->assertSame([['id' => 'form-a', 'outcome' => 'unpublished']], $result->getData()['affectedForms']);
}//end testUpdateListsTheFormsItUnpublished()
+ /**
+ * A dependent-form check that cannot be built is logged, not read as "no forms affected".
+ *
+ * @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 testAFailingDependentFormCheckIsLogged(): void {
+ $schema = $this->createRealSchema(1, 'Ticket');
+ $this->schemaMapper->method('find')->willReturn($schema);
+ $this->schemaMapper->method('updateFromArray')->willReturn($schema);
+ $this->request->method('getParams')->willReturn(['required' => ['title', 'priority']]);
+ $this->schemaVersioningService->method('classify')->willReturn(new \OCA\OpenRegister\Service\Schema\SchemaChangeSet([], 'none', 'none'));
+ $this->formCheck = null;
+ $this->logger->expects($this->atLeastOnce())->method('warning')
+ ->with($this->stringContains('dependent-form check could not be built'), $this->anything());
+
+ $result = $this->controller->update(1);
+
+ $this->assertSame(200, $result->getStatus());
+ $this->assertArrayNotHasKey('affectedForms', $result->getData() instanceof Schema ? [] : (array)$result->getData());
+ }//end testAFailingDependentFormCheckIsLogged()
+
public function testPatchDelegatesToUpdate(): void {
$schema = $this->createRealSchema(1, 'Patched');
From 03e338eb67ce4c3072bf3484dedf173c27679349 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 17:52:43 +0200
Subject: [PATCH 12/99] =?UTF-8?q?fix(review):=20#4580=20data-f4=20?=
=?UTF-8?q?=E2=80=94=20Forced=20re-import=20deletes=20local=20authorizatio?=
=?UTF-8?q?n=20the=20file=20lacks?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Under an administrator's decision only the authorization roots the incoming definition carries are decided; a block only the instance holds keeps its normal state (local is preserved and reported) instead of settling as UPSTREAM and being dropped, which left the schema default-open. New GuardedDescriptorMergeTest case under a ShippedAuthorizationDecision; the replace-when-shipped-has-it cases stay green (the test fails with the old roots($live, $incoming)).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660343
Sweep: AuthorizationBlocks::roots() has this one caller.
Check: phpunit tests/Unit/Service/ShippedBaseline + RegisterDescriptorServiceTest OK 63 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
.../GuardedDescriptorMerge.php | 7 +++--
.../GuardedDescriptorMergeTest.php | 28 +++++++++++++++++++
2 files changed, 33 insertions(+), 2 deletions(-)
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/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php b/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php
index 9fc2f4aa58..c40e117378 100644
--- a/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php
+++ b/tests/Unit/Service/ShippedBaseline/GuardedDescriptorMergeTest.php
@@ -25,6 +25,7 @@
use OCA\OpenRegister\Service\ShippedBaseline\DescriptorParts;
use OCA\OpenRegister\Service\ShippedBaseline\DivergenceComparator;
use OCA\OpenRegister\Service\ShippedBaseline\GuardedDescriptorMerge;
+use OCA\OpenRegister\Service\ShippedBaseline\ShippedAuthorizationDecision;
use PHPUnit\Framework\TestCase;
/**
@@ -513,4 +514,31 @@ public function testAPropertyAuthorizationIsItsOwnBlock(): void {
$this->assertSame(['properties.bsn.authorization'], array_column($result['conflicts'], 'path'));
$this->assertSame('string', $result['merged']['properties']['bsn']['type']);
}//end testAPropertyAuthorizationIsItsOwnBlock()
+
+ /**
+ * Under an administrator's decision, a local block the shipped definition does not declare is kept, not dropped.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/reimport-real-register-configuration/specs/register-descriptor-admin/spec.md
+ */
+ public function testADecisionKeepsALocalBlockTheShippedDefinitionLacks(): void {
+ $parts = new DescriptorParts();
+ $decision = new ShippedAuthorizationDecision();
+ $merge = new GuardedDescriptorMerge(parts: $parts, comparator: new DivergenceComparator(parts: $parts), decision: $decision);
+ $local = ['read' => ['staff'], 'update' => ['staff']];
+
+ $result = $decision->run(
+ via: 'import',
+ operation: static fn (): array => $merge->merge(
+ baseline: ['title' => 'T'],
+ live: ['title' => 'T', 'authorization' => $local],
+ incoming: ['title' => 'T2']
+ )
+ );
+
+ $this->assertSame('T2', $result['merged']['title']);
+ $this->assertEquals($local, $result['merged']['authorization']);
+ $this->assertSame([], $result['replacedLocal']);
+ }//end testADecisionKeepsALocalBlockTheShippedDefinitionLacks()
}//end class
From 363e6075c7124637ccc0bdfbb1ea3be5e831989a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:04:24 +0200
Subject: [PATCH 13/99] =?UTF-8?q?fix(review):=20#4580=20data-f3=20?=
=?UTF-8?q?=E2=80=94=20RBAC=20draft=20predicate=20ignores=20unhealed=20=5F?=
=?UTF-8?q?status?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
MagicRbacHandler now takes the MagicMetadataColumnHealer (built first in MagicMapper::initializeHandlers) and an optional bare table name; reachableRowSqlFor() leaves the (_status IS NULL OR _status <> 'draft') term out when the table is known to lack _status, so a non-admin on a degraded table no longer fails with an unknown-column error. The table name is threaded through applyRbacFilters, buildRbacConditionsSql, buildRbacRowPredicateSql, buildRbacPredicateForAlias and buildRbacSqlTerms (all optional; a caller that names no table keeps the predicate, which is the narrower and safe answer) and passed by every caller that knows it: MagicSearchHandler (list, UNION conditions, single-object reads), the three MagicMapper single-object and scan sites, RelatedRowQueryApplier (the related table) and AggregationRunner (via MagicMapper::rbacRowPredicateSql(register:)). New test: testADegradedTableGetsNoDraftPredicate (missing: no _status, owner admit kept; healthy table and unnamed table: predicate kept).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660319
Check: phpunit tests/Unit/Db tests/Unit/Service/Aggregation tests/Unit/Service/Query: 2407 tests, 49 errors, all 'PDOException: could not find driver' or 'OC\DB\Schema\Table is not loadable' (no database driver / Nextcloud server classes in this environment), none from the touched code; MagicRbacDraftVisibilityTest OK; phpcs on the touched lib files: no errors.
Co-Authored-By: Claude Opus 5.5
---
lib/Db/MagicMapper.php | 47 ++++++++++------
lib/Db/MagicMapper/MagicRbacHandler.php | 56 +++++++++++++++----
lib/Db/MagicMapper/MagicSearchHandler.php | 30 +++++++---
lib/Service/Aggregation/AggregationRunner.php | 2 +-
lib/Service/Query/RelatedRowQueryApplier.php | 6 +-
.../MagicRbacDraftVisibilityTest.php | 35 +++++++++++-
6 files changed, 136 insertions(+), 40 deletions(-)
diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php
index e99068690b..b28f3db2c3 100644
--- a/lib/Db/MagicMapper.php
+++ b/lib/Db/MagicMapper.php
@@ -554,6 +554,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,
@@ -561,7 +571,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(
@@ -579,13 +590,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,
@@ -6437,7 +6441,8 @@ public function findInRegisterSchemaTable(
schema: $schema,
_rbac: $_rbac,
_multitenancy: $_multitenancy,
- registerId: $register->getId()
+ registerId: $register->getId(),
+ tableName: $tableName
);
}
@@ -6794,7 +6799,8 @@ public function findAcrossAllMagicTables(
schema: $schema,
_rbac: $_rbac,
_multitenancy: $_multitenancy,
- registerId: $register?->getId()
+ registerId: $register?->getId(),
+ tableName: $tableName
);
$accessResult = $accessQb->executeQuery();
@@ -7670,7 +7676,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;
@@ -7925,16 +7932,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()
/**
@@ -10154,7 +10168,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();
diff --git a/lib/Db/MagicMapper/MagicRbacHandler.php b/lib/Db/MagicMapper/MagicRbacHandler.php
index 5aeafe974d..f94304ebc7 100644
--- a/lib/Db/MagicMapper/MagicRbacHandler.php
+++ b/lib/Db/MagicMapper/MagicRbacHandler.php
@@ -130,6 +130,7 @@ 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.
*/
public function __construct(
private readonly IUserSession $userSession,
@@ -141,6 +142,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 +330,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 +344,7 @@ private function reachableRowSqlFor(
string $action,
?Schema $schema = null,
string $columnPrefix = '',
+ ?string $tableName = null,
): string {
$reachable = $this->objectScope()->notPrivateOrGrantedSql(
authColumn: $columnName,
@@ -354,14 +358,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 +528,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 +543,7 @@ public function applyRbacFilters(
IQueryBuilder $qb,
Schema $schema,
string $action = 'read',
+ ?string $tableName = null,
): void {
$user = $this->userSession->getUser();
$userId = $user?->getUID();
@@ -594,7 +621,8 @@ public function applyRbacFilters(
userId: $userId,
action: $action,
schema: $schema,
- columnPrefix: 't.'
+ columnPrefix: 't.',
+ tableName: $tableName
)
);
@@ -1632,6 +1660,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 +1668,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 +1678,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 +1707,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 +1716,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 +1742,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 +1750,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 +1797,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 +1873,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 +1885,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 +1907,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 68a16893d7..a6435a43c9 100644
--- a/lib/Db/MagicMapper/MagicSearchHandler.php
+++ b/lib/Db/MagicMapper/MagicSearchHandler.php
@@ -436,6 +436,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
*
@@ -447,6 +449,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,
@@ -463,7 +466,8 @@ public function applyAccessControlToQuery(
_rbac: $_rbac,
_multitenancy: $resolvedMultitenancy,
multitenancyExplicit: false,
- registerId: $registerId
+ registerId: $registerId,
+ tableName: $tableName
);
}//end applyAccessControlToQuery()
@@ -526,7 +530,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.
@@ -797,7 +802,8 @@ public function buildWhereConditionsSql(
schema: $schema,
rbac: $_rbac,
connection: $connection,
- registerId: $registerId
+ registerId: $registerId,
+ tableName: $tableName
)
);
@@ -1047,6 +1053,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.
*
@@ -1057,7 +1064,8 @@ private function boundaryConditionsSql(
Schema $schema,
mixed $rbac,
mixed $connection,
- ?int $registerId
+ ?int $registerId,
+ ?string $tableName = null
): array {
$conditions = [];
@@ -1092,7 +1100,7 @@ private function boundaryConditionsSql(
}
if ($rbac === true) {
- $rbacCondition = $this->buildRbacConditionSql(schema: $schema);
+ $rbacCondition = $this->buildRbacConditionSql(schema: $schema, tableName: $tableName);
if ($rbacCondition !== null) {
$conditions[] = $rbacCondition;
}
@@ -1361,12 +1369,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.
@@ -2477,6 +2486,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
*
@@ -2489,6 +2499,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
@@ -2530,7 +2541,8 @@ private function applyAccessControlFilters(
$this->rbacHandler->applyRbacFilters(
qb: $qb,
schema: $schema,
- action: 'read'
+ action: 'read',
+ tableName: $tableName
);
}
}//end applyAccessControlFilters()
diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php
index 21b181592b..f814328c5a 100644
--- a/lib/Service/Aggregation/AggregationRunner.php
+++ b/lib/Service/Aggregation/AggregationRunner.php
@@ -2640,7 +2640,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/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/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php
index 8dc85d0b98..8663fdbea1 100644
--- a/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php
+++ b/tests/Unit/Db/MagicMapper/MagicRbacDraftVisibilityTest.php
@@ -13,6 +13,7 @@
namespace Unit\Db\MagicMapper;
+use OCA\OpenRegister\Db\MagicMapper\MagicMetadataColumnHealer;
use OCA\OpenRegister\Db\MagicMapper\MagicRbacHandler;
use OCA\OpenRegister\Db\MagicMapper\RbacResolvers;
use OCA\OpenRegister\Db\Schema;
@@ -47,7 +48,7 @@ class MagicRbacDraftVisibilityTest extends TestCase {
*
* @param array $groups The caller's groups.
*/
- private function handlerFor(string $userId, array $groups = []): MagicRbacHandler {
+ private function handlerFor(string $userId, array $groups = [], ?MagicMetadataColumnHealer $healer = null): MagicRbacHandler {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$userSession = $this->createMock(IUserSession::class);
@@ -63,7 +64,9 @@ private function handlerFor(string $userId, array $groups = []): MagicRbacHandle
$this->createMock(ConditionMatcher::class),
$this->createMock(ContainerInterface::class),
new NullLogger(),
- new RbacResolvers(objectScopeResolver: null, objectGrantResolver: null, denyResolver: new DenyResolver(new DenyEntryMatcher()))
+ new RbacResolvers(objectScopeResolver: null, objectGrantResolver: null, denyResolver: new DenyResolver(new DenyEntryMatcher())),
+ null,
+ $healer
);
}//end handlerFor()
@@ -103,6 +106,34 @@ public function testAnOpenSchemaStillHidesOthersDrafts(): void {
$this->assertStringContainsString("_status <> 'draft'", $predicate);
}//end testAnOpenSchemaStillHidesOthersDrafts()
+ /**
+ * A table that could not be given its `_status` column cannot hold a draft: the predicate leaves it out, and keeps it for a healthy 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
+ */
+ public function testADegradedTableGetsNoDraftPredicate(): void {
+ $healer = $this->createMock(MagicMetadataColumnHealer::class);
+ $healer->method('missingFor')->willReturnCallback(
+ static fn (string $table): array => ($table === 'openregister_table_1_2' ? ['_status'] : [])
+ );
+ $handler = $this->handlerFor('alice', ['behandelaars'], $healer);
+ $schema = $this->schema(['read' => ['behandelaars']]);
+
+ $degraded = (string)$handler->buildRbacRowPredicateSql(schema: $schema, tableName: 'openregister_table_1_2');
+ $this->assertStringNotContainsString('_status', $degraded);
+ $this->assertStringContainsString("_owner = 'alice'", $degraded);
+
+ $healthy = (string)$handler->buildRbacRowPredicateSql(schema: $schema, tableName: 'openregister_table_1_3');
+ $this->assertStringContainsString("(_status IS NULL OR _status <> 'draft')", $healthy);
+
+ // A caller that names no table keeps the predicate: narrower, so safe.
+ $unnamed = (string)$handler->buildRbacRowPredicateSql(schema: $schema);
+ $this->assertStringContainsString('_status', $unnamed);
+
+ $aliased = $handler->buildRbacPredicateForAlias(schema: $schema, alias: 'rel0', tableName: 'openregister_table_1_2');
+ $this->assertStringNotContainsString('_status', $aliased);
+ }//end testADegradedTableGetsNoDraftPredicate()
+
/**
* A schema with draftsVisible shows drafts to everyone who may read; an admin is never restricted.
*/
From d4aa263863ae244aef7446a55b1eea3c5eda34b1 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:06:58 +0200
Subject: [PATCH 14/99] =?UTF-8?q?fix(review):=20#4580=20data-f5=20?=
=?UTF-8?q?=E2=80=94=20Aggregation=20fast=20path=20counts=20drafts=20the?=
=?UTF-8?q?=20list=20hides?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The native aggregation path adds DraftListLens's clause for the mode a query-less list uses (EXCLUDE, or NONE for a schema with draftsListed: false) next to the archive predicate: others' drafts are out of the count, the signed-in caller's own stay in through a bound owner placeholder (kept in step with the organisation bindings that follow), and the clause is left out of a table known to lack _status. This also holds for an administrator, whose RBAC predicate is null. New tests assert the generated WHERE and bindings for nobody, a user, draftsListed false and a degraded table.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660359
Check: phpunit tests/Unit/Service/Aggregation: 276 tests, 21 errors ('PDOException: could not find driver', identical at HEAD) and no other failure; phpcs on the lib file clean. The 'count over a table with one draft and one active row returns 1' Done-when needs a database and was not run (SQL-level assertions stand in for it).
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Aggregation/AggregationRunner.php | 29 +++++++
.../AggregationRunnerArchiveDefaultTest.php | 75 ++++++++++++++++++-
2 files changed, 100 insertions(+), 4 deletions(-)
diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php
index f814328c5a..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.
diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php
index 797763a142..e9e5f4c490 100644
--- a/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php
+++ b/tests/Unit/Service/Aggregation/AggregationRunnerArchiveDefaultTest.php
@@ -173,6 +173,68 @@ public function testTheSoftDeletePredicateIsStillThere(): void {
$this->assertStringContainsString('_deleted IS NULL', (string)$captured['sql']);
}//end testTheSoftDeletePredicateIsStillThere()
+ /**
+ * A count leaves drafts out like the list does: the clause is there for nobody, and with the caller's own drafts admitted for a signed-in user.
+ *
+ * @return void
+ */
+ public function testTheCountAgreesWithTheListAboutDrafts(): void {
+ $this->wirePlatform(platform: $this->createMock(MySQLPlatform::class));
+ $captured = $this->captureSql();
+
+ $this->makeRunner()->runAdhoc(register: $this->makeRegister(), schema: $this->makeSchema(), query: $this->dayBucketQuery());
+ $this->assertStringContainsString("(_status IS NULL OR _status <> 'draft')", (string)$captured['sql']);
+ $this->assertStringNotContainsString('_owner = ?', (string)$captured['sql']);
+ // The first binding is the organisation's: no owner term for nobody.
+ $this->assertSame('__no_active_org__', $captured['bindings'][0]);
+ }//end testTheCountAgreesWithTheListAboutDrafts()
+
+ /**
+ * A signed-in caller's own drafts stay in the count (the list shows them), through a binding; draftsListed: false drops them as the list does.
+ *
+ * @return void
+ */
+ public function testTheCallersOwnDraftsFollowTheSchemasListSetting(): void {
+ $this->wirePlatform(platform: $this->createMock(MySQLPlatform::class));
+ $captured = $this->captureSql();
+ $user = $this->createMock(\OCP\IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+
+ $this->makeRunner(session: $session)->runAdhoc(register: $this->makeRegister(), schema: $this->makeSchema(), query: $this->dayBucketQuery());
+ $this->assertStringContainsString("(_status IS NULL OR _status <> 'draft') OR _owner = ?", (string)$captured['sql']);
+ // The placeholders and the bindings stay in step: the draft clause comes before the organisation one.
+ $this->assertSame(['alice', '__no_active_org__'], array_slice($captured['bindings'], 0, 2));
+
+ $captured['sql'] = null;
+ $captured['bindings'] = [];
+ $schema = $this->makeSchema();
+ $schema->setConfiguration(['draftsListed' => false]);
+ $this->makeRunner(session: $session)->runAdhoc(register: $this->makeRegister(), schema: $schema, query: $this->dayBucketQuery());
+ $this->assertStringContainsString("_status <> 'draft'", (string)$captured['sql']);
+ $this->assertStringNotContainsString('_owner = ?', (string)$captured['sql']);
+ $this->assertNotContains('alice', $captured['bindings']);
+ }//end testTheCallersOwnDraftsFollowTheSchemasListSetting()
+
+ /**
+ * A table that could not be given its `_status` column cannot hold a draft: the clause is left out.
+ *
+ * @return void
+ */
+ public function testADegradedTableGetsNoDraftClause(): void {
+ $this->wirePlatform(platform: $this->createMock(MySQLPlatform::class));
+ $captured = $this->captureSql();
+ $healer = $this->createMock(\OCA\OpenRegister\Db\MagicMapper\MagicMetadataColumnHealer::class);
+ $healer->method('missingFor')->willReturn(['_status']);
+ $this->magicMapper->method('getMetadataColumnHealer')->willReturn($healer);
+
+ $this->makeRunner()->runAdhoc(register: $this->makeRegister(), schema: $this->makeSchema(), query: $this->dayBucketQuery());
+
+ $this->assertNotNull($captured['sql']);
+ $this->assertStringNotContainsString('_status', (string)$captured['sql']);
+ }//end testADegradedTableGetsNoDraftClause()
+
// -----------------------------------------------------------------------
// Helpers.
// -----------------------------------------------------------------------
@@ -203,10 +265,15 @@ private function dayBucketQuery(): AggregationQuery {
* @return \ArrayObject Mutable container with an `sql` key.
*/
private function captureSql(): \ArrayObject {
- $captured = new \ArrayObject(['sql' => null]);
+ $captured = new \ArrayObject(['sql' => null, 'bindings' => []]);
$stmt = $this->createMock(IPreparedStatement::class);
$result = $this->createMock(IResult::class);
- $stmt->method('execute')->willReturn($result);
+ $stmt->method('execute')->willReturnCallback(
+ function (?array $params = null) use ($result, $captured) {
+ $captured['bindings'] = ($params ?? []);
+ return $result;
+ }
+ );
$stmt->method('fetch')->willReturn(false);
$this->db->method('prepare')->willReturnCallback(
@@ -261,7 +328,7 @@ private function makeRegister(): Register {
*
* @return AggregationRunner The runner.
*/
- private function makeRunner(): AggregationRunner {
+ private function makeRunner(?IUserSession $session = null): AggregationRunner {
return new AggregationRunner(
magicMapper: $this->magicMapper,
registerMapper: $this->registerMapper,
@@ -270,7 +337,7 @@ private function makeRunner(): AggregationRunner {
db: $this->db,
cache: $this->cache,
permissionHandler: $this->permissionHandler,
- userSession: $this->userSession,
+ userSession: ($session ?? $this->userSession),
organisationService: $this->organisationService,
organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'),
translationHandler: $this->createMock(TranslationHandler::class),
From a33e49b49f16a07d2521d64bc8d6004a5b89ffcd Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:07:39 +0200
Subject: [PATCH 15/99] =?UTF-8?q?fix(review):=20#4580=20plat-f5=20?=
=?UTF-8?q?=E2=80=94=20A=20later=20listener's=20allow()=20overrides=20an?=
=?UTF-8?q?=20earlier=20refusal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DecisionChangeRequestedEvent keeps a refused flag: once refuse() was called, allow() does nothing, so the registration order of two apps' listeners cannot turn a veto into an allow. testAnAllowClearsTheReason is replaced by a sticky-refusal test covering both orders; the supervisor-may-be-refused and listener-may-allow cases are untouched and green.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660718
Sweep: DecisionVisibility's event is deny-by-default and allow-only (order independent); no other vetoable event in lib/Event has an allow() that clears a refusal.
Check: phpunit tests/Unit/Service/Decision + tests/Unit/Event OK 245 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Event/DecisionChangeRequestedEvent.php | 17 +++++++++++++++-
.../Decision/DecisionOwnershipTest.php | 20 +++++++++++++------
2 files changed, 30 insertions(+), 7 deletions(-)
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/tests/Unit/Service/Decision/DecisionOwnershipTest.php b/tests/Unit/Service/Decision/DecisionOwnershipTest.php
index 193cd6788c..908186f577 100644
--- a/tests/Unit/Service/Decision/DecisionOwnershipTest.php
+++ b/tests/Unit/Service/Decision/DecisionOwnershipTest.php
@@ -200,19 +200,27 @@ public function testAFailingListenerRefuses(): void {
}//end testAFailingListenerRefuses()
/**
- * An allow after a refusal clears the reason.
+ * A refusal is sticky: a later allow() cannot undo it, so listener order decides nothing.
*
* @return void
*/
- public function testAnAllowClearsTheReason(): void {
+ public function testARefusalIsStickyWhateverTheListenerOrder(): void {
$event = new DecisionChangeRequestedEvent(decision: $this->decision(), actorId: 'x', policy: $this->decider(), subject: [], allowed: false);
- $event->refuse(reason: 'first');
+ $event->refuse(reason: 'case closed');
$event->allow();
- self::assertTrue($event->isAllowed());
- self::assertNull($event->getReason());
+ self::assertFalse($event->isAllowed());
+ self::assertSame('case closed', $event->getReason());
+
+ // Allow first, then refuse: the same answer.
+ $other = new DecisionChangeRequestedEvent(decision: $this->decision(), actorId: 'x', policy: $this->decider(), subject: [], allowed: false);
+ $other->allow();
+ self::assertTrue($other->isAllowed());
+ $other->refuse(reason: 'case closed');
+ self::assertFalse($other->isAllowed());
+
self::assertSame('x', $event->getActorId());
self::assertSame('d-1', $event->getDecision()->getUuid());
self::assertTrue($event->getPolicy()->isDeciderOwned());
- }//end testAnAllowClearsTheReason()
+ }//end testARefusalIsStickyWhateverTheListenerOrder()
}//end class
From e19745bc962569b05be64a34162a393d3c8d156b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:08:45 +0200
Subject: [PATCH 16/99] =?UTF-8?q?fix(review):=20#4580=20aud-p1=20=E2=80=94?=
=?UTF-8?q?=20The=20"may=20not=20let=20itself=20in"=20check=20ignores=20re?=
=?UTF-8?q?mote=20principals?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GrantManageGuard::letsItselfIn() takes the remote principal being named and refuses it outright for a holder admitted by the special right alone (a federated cloud id or remote group cannot be proved not to be the caller); ObjectSharingService passes remote and remote_group grantees. The selfGrants data provider of GrantManageSpecialRightTest gains a remote and a remote_group case. The owner and the administrator are unaffected.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660923
Sweep: letsItselfIn has two callers; ObjectOwnershipService names only a group (remote is not applicable there).
Check: phpunit tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php OK 17 tests; phpcs on the two lib files clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Rbac/GrantManageGuard.php | 7 ++++++-
lib/Service/Rbac/ObjectSharingService.php | 3 ++-
tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php | 2 ++
3 files changed, 10 insertions(+), 2 deletions(-)
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/ObjectSharingService.php b/lib/Service/Rbac/ObjectSharingService.php
index 7e3faa59e0..62c44df064 100644
--- a/lib/Service/Rbac/ObjectSharingService.php
+++ b/lib/Service/Rbac/ObjectSharingService.php
@@ -706,7 +706,8 @@ 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(
diff --git a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php
index 1a4f67f398..6f516d6aca 100644
--- a/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php
+++ b/tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php
@@ -314,6 +314,8 @@ public static function selfGrants(): array {
'its own account, padded' => ['user', ' service-account '],
'the group that holds the right' => ['group', self::SERVICE_GROUP],
'another group it is in' => ['group', 'jobs'],
+ 'a federated cloud id' => ['remote', 'service-account@other.example'],
+ 'a remote group' => ['remote_group', 'staff@other.example'],
];
}//end selfGrants()
From d0d2725b327995d489660120abe06284d0ad88dc Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:09:52 +0200
Subject: [PATCH 17/99] =?UTF-8?q?fix(review):=20#4580=20aud-p4=20=E2=80=94?=
=?UTF-8?q?=20Guard=20failure=20imports=20shipped=20authorization=20unguar?=
=?UTF-8?q?ded?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the guard throws, the import still finishes but now writes the incoming definition with the instance's own authorization blocks put back (the schema's, and each property the instance already holds; a block the instance lacks is not added, a property the instance does not hold brings its shipped block), and logs it. Authorization blocks the merge took from the app because only the app changed them are now recorded on the audit trail (configuration.conflict.decided with decision applied-shipped, once per block root, skipping roots already recorded for an explicit decision); recordDecision() gets a decision argument for it. New tests: a throwing baseline read keeps the instance authorization, and an app-only authorization change is audited.
Thread: verdict https://github.com/ConductionNL/openregister/pull/4580#pullrequestreview-5535345548 (this finding has no inline thread)
Check: phpunit tests/Unit/Service/ShippedBaseline + RegisterDescriptorServiceTest OK; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
.../ShippedConfigurationGuard.php | 91 +++++++++++++++++--
.../ShippedConfigurationGuardTest.php | 61 ++++++++++++-
2 files changed, 144 insertions(+), 8 deletions(-)
diff --git a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php
index d3e2f82238..71256c8156 100644
--- a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php
+++ b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php
@@ -163,6 +163,7 @@ 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(
@@ -199,15 +200,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 +220,79 @@ public function guardSchemaUpdate(
}//end try
}//end guardSchemaUpdate()
+ /**
+ * 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 = $incoming;
+ if (array_key_exists('authorization', $live) === true) {
+ $definition['authorization'] = $live['authorization'];
+ } else {
+ unset($definition['authorization']);
+ }
+
+ $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;
+ }
+
+ if (array_key_exists('authorization', $liveProperties[$name]) === true) {
+ $definition['properties'][$name]['authorization'] = $liveProperties[$name]['authorization'];
+ } else {
+ unset($definition['properties'][$name]['authorization']);
+ }
+ }
+
+ return $definition;
+ }//end keepingLiveAuthorization()
+
+ /**
+ * 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 +598,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/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php b/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php
index 05a311d9ab..5b7309f18b 100644
--- a/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php
+++ b/tests/Unit/Service/ShippedBaseline/ShippedConfigurationGuardTest.php
@@ -60,14 +60,19 @@ class ShippedConfigurationGuardTest extends TestCase {
* @param array|null $baseline The stored baseline definition, or null for none.
* @param IUserSession|null $session The session.
* @param ShippedAuthorizationDecision|null $decision The administrator's decision, when one is in effect.
+ * @param bool $readFails Whether reading the baseline throws, so the guard cannot run.
*
* @return ShippedConfigurationGuard The guard.
*/
- private function guard(?array $baseline, ?IUserSession $session = null, ?ShippedAuthorizationDecision $decision = null): ShippedConfigurationGuard {
+ private function guard(?array $baseline, ?IUserSession $session = null, ?ShippedAuthorizationDecision $decision = null, bool $readFails = false): ShippedConfigurationGuard {
$store = $this->createMock(ShippedBaselineStore::class);
$store->method('schemaSubject')->willReturnCallback(
static fn (string $slug): string => ('schema:' . $slug)
);
+ if ($readFails === true) {
+ $store->method('read')->willThrowException(new \RuntimeException('baseline store down'));
+ }
+
$store->method('read')->willReturn(
($baseline === null ? null : [
'definition' => $baseline,
@@ -578,4 +583,58 @@ public function testAnAdministratorsReimportTakesTheShippedAuthorization(): void
$this->assertSame([['schema' => 'portalAccount', 'path' => 'authorization']], $decision->replaced());
$this->assertNull($decision->via(), 'the decision ends with the operation');
}//end testAnAdministratorsReimportTakesTheShippedAuthorization()
+
+ /**
+ * A guard that cannot run still lets the upgrade finish, but never hands the instance's authorization to the shipped one.
+ *
+ * @return void
+ */
+ public function testAGuardThatCannotRunKeepsTheInstanceAuthorization(): void {
+ $live = [
+ 'title' => 'T',
+ 'authorization' => ['read' => ['staff']],
+ 'properties' => [
+ 'bsn' => ['type' => 'string', 'authorization' => ['read' => ['staff']]],
+ 'name' => ['type' => 'string'],
+ ],
+ ];
+ $incoming = [
+ 'title' => 'T2',
+ 'authorization' => ['read' => ['authenticated']],
+ 'properties' => [
+ 'bsn' => ['type' => 'string', 'authorization' => ['read' => ['authenticated']]],
+ 'name' => ['type' => 'string', 'authorization' => ['read' => ['authenticated']]],
+ 'added' => ['type' => 'string', 'authorization' => ['read' => ['shipped']]],
+ ],
+ ];
+
+ $result = $this->guard(baseline: null, readFails: true)->guardSchemaUpdate(slug: 'person', live: $live, incoming: $incoming, app: 'dossiq', appVersion: '1.3.0');
+
+ $this->assertFalse($result['guarded']);
+ $this->assertNull($result['baseline']);
+ $this->assertSame('T2', $result['definition']['title'], 'the rest of the upgrade still lands');
+ $this->assertSame(['read' => ['staff']], $result['definition']['authorization']);
+ $this->assertSame(['read' => ['staff']], $result['definition']['properties']['bsn']['authorization']);
+ $this->assertArrayNotHasKey('authorization', $result['definition']['properties']['name']);
+ $this->assertSame(['read' => ['shipped']], $result['definition']['properties']['added']['authorization'], 'a property the instance lacks brings its shipped block');
+ }//end testAGuardThatCannotRunKeepsTheInstanceAuthorization()
+
+ /**
+ * An authorization block taken because only the app changed it is on the audit trail.
+ *
+ * @return void
+ */
+ public function testAnAuthorizationBlockTakenFromTheAppIsAudited(): void {
+ $block = ['read' => ['authenticated']];
+ $result = $this->guard(baseline: ['authorization' => $block])->guardSchemaUpdate(
+ slug: 'portalAccount',
+ live: ['authorization' => $block],
+ incoming: ['authorization' => ['read' => ['portaliq-medewerkers']]],
+ app: 'portaliq',
+ appVersion: '0.98.0'
+ );
+
+ $this->assertSame(['read' => ['portaliq-medewerkers']], $result['definition']['authorization']);
+ $this->assertSame([ShippedConfigurationGuard::ACTION_DECIDED], $this->actions);
+ }//end testAnAuthorizationBlockTakenFromTheAppIsAudited()
}//end class
From 5faeef9582317db56d386a73154e9731d89e8734 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:14:02 +0200
Subject: [PATCH 18/99] =?UTF-8?q?fix(review):=20#4580=20surf-f4=20?=
=?UTF-8?q?=E2=80=94=20Copies=20still=20send=20users=20to=20the=20removed?=
=?UTF-8?q?=20personal=20settings=20form?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The relink notification now links to the OpenRegister app page with #user-settings (as the OAuth callback does) and says 'user settings'; the admin hint in PushNotificationsConfiguration.vue, its en/nl l10n keys (nl: gebruikersinstellingen), the credential-oauth2-connect requirement heading and text, the notificatie-engine requirement sentence, and the stale header comments in BrowserNotificationsSection.vue and OAuth2Endpoints.php are reworded to the user settings dialog. Other languages' l10n entries keep the old key until Transifex syncs. New NotifierTest case asserts the link.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660858
Sweep: git grep -i 'personal settings' over lib, src and openspec/specs leaves only comments that say OpenRegister is NOT in Nextcloud's personal settings (CredentialOauth2Controller, App.vue, UserSettings.vue).
Check: phpunit tests/Unit/Notification/NotifierTest.php OK 22 tests; l10n/en.json and nl.json parse. The anchored code is spec text and strings only, no eslint run was needed beyond the one-line template change.
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 2 +-
l10n/en.json | 2 +-
l10n/nl.js | 2 +-
l10n/nl.json | 2 +-
lib/Notification/Notifier.php | 8 ++---
lib/Service/Credential/OAuth2Endpoints.php | 2 +-
.../specs/credential-oauth2-connect/spec.md | 4 +--
openspec/specs/notificatie-engine/spec.md | 2 +-
.../BrowserNotificationsSection.vue | 14 ++++-----
.../PushNotificationsConfiguration.vue | 2 +-
tests/Unit/Notification/NotifierTest.php | 31 +++++++++++++++++++
11 files changed, 49 insertions(+), 22 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index f827348952..a9d551312e 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:",
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/nl.js b/l10n/nl.js
index 54ebbc735e..1382c6c28a 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",
diff --git a/l10n/nl.json b/l10n/nl.json
index f4e28ff085..15211fdcbd 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",
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/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/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/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/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/views/settings/sections/PushNotificationsConfiguration.vue b/src/views/settings/sections/PushNotificationsConfiguration.vue
index f2a6c4044e..eceaf51611 100644
--- a/src/views/settings/sections/PushNotificationsConfiguration.vue
+++ b/src/views/settings/sections/PushNotificationsConfiguration.vue
@@ -104,7 +104,7 @@
{{
t(
'openregister',
- '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.',
)
}}
diff --git a/tests/Unit/Notification/NotifierTest.php b/tests/Unit/Notification/NotifierTest.php
index 28ee399af6..a2df03d7bf 100644
--- a/tests/Unit/Notification/NotifierTest.php
+++ b/tests/Unit/Notification/NotifierTest.php
@@ -109,6 +109,37 @@ public function testPrepareConfigurationUpdateAvailable(): void {
}
}
+ /**
+ * The relink notification opens OpenRegister's own page at the user settings dialog, not Nextcloud's personal settings.
+ */
+ public function testTheRelinkNotificationLinksToTheUserSettingsDialog(): void {
+ $notification = $this->createMock(INotification::class);
+ $notification->method('getApp')->willReturn('openregister');
+ $notification->method('getSubject')->willReturn('credential_relink_needed');
+ $notification->method('getSubjectParameters')->willReturn(['provider' => 'Exact']);
+ $notification->method('setParsedSubject')->willReturnSelf();
+ $notification->method('setParsedMessage')->willReturnSelf();
+ $notification->method('setIcon')->willReturnSelf();
+ $link = null;
+ $notification->method('setLink')->willReturnCallback(
+ function (string $value) use (&$link, $notification): INotification {
+ $link = $value;
+ return $notification;
+ }
+ );
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(static fn (string $text, array $args = []): string => vsprintf($text, $args));
+ $this->factory->method('get')->willReturn($l10n);
+ $this->urlGenerator->method('imagePath')->willReturn('/apps/openregister/img/app.svg');
+ $this->urlGenerator->method('linkToRouteAbsolute')
+ ->with('openregister.dashboard.page')
+ ->willReturn('https://example.com/apps/openregister/');
+
+ $this->notifier->prepare($notification, 'en');
+
+ $this->assertSame('https://example.com/apps/openregister/#user-settings', $link);
+ }//end testTheRelinkNotificationLinksToTheUserSettingsDialog()
+
public function testPrepareConfigurationUpdateWithoutConfigId(): void {
$notification = $this->createMock(INotification::class);
$notification->method('getApp')->willReturn('openregister');
From 4e900c4582b01e724a4b500a557eea3982a5b1d1 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:19:07 +0200
Subject: [PATCH 19/99] =?UTF-8?q?fix(review):=20#4580=20surf-f1=20?=
=?UTF-8?q?=E2=80=94=20Object=20GET=20/logs=20bypasses=20the=20audit-histo?=
=?UTF-8?q?ry=20redaction?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ObjectsController::logs() answers a non-admin through ObjectHistoryForReaders::page() (the same read check, doelbinding guard and redaction as the audit-trails route: 404 for an object the caller may not read, no session, request or ipAddress, no property the caller may not see); an administrator keeps the stored history as before. The page keeps the results/total/page/pages/limit/offset shape; the legacy next/prev links are not produced on the reader path. The requirement in audit-trail-immutable/spec.md now names both URLs. The 17 existing testLogs* cases of ObjectsControllerTest, which exercise the stored-history path, now run as an administrator. New ObjectsControllerLogsRedactionTest covers delegation, the 404 and the administrator path; the redaction itself is covered by AuditTrailObjectHistoryForReadersTest.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660832
Check: phpunit tests/Unit/Controller/ObjectsControllerTest.php + ObjectsControllerLogsRedactionTest.php OK 279 tests; tests/Unit/Controller overall: only 'could not find driver' errors (no database driver here); phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Controller/ObjectsController.php | 52 +++++++
openspec/specs/audit-trail-immutable/spec.md | 4 +-
.../ObjectsControllerLogsRedactionTest.php | 142 ++++++++++++++++++
.../Unit/Controller/ObjectsControllerTest.php | 34 +++++
4 files changed, 231 insertions(+), 1 deletion(-)
create mode 100644 tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php
diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php
index a9983c8c9c..6b4678228a 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,48 @@ 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 = $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 === null ? null : (string)$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/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/tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php b/tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php
new file mode 100644
index 0000000000..495f6cca9c
--- /dev/null
+++ b/tests/Unit/Controller/ObjectsControllerLogsRedactionTest.php
@@ -0,0 +1,142 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @spec openspec/specs/audit-trail-immutable/spec.md#requirement-the-history-of-one-object-is-readable-by-whoever-may-read-that-object
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Controller;
+
+use OCA\OpenRegister\Controller\ObjectsController;
+use OCA\OpenRegister\Db\AuditTrailMapper;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\Audit\ObjectHistoryForReaders;
+use OCA\OpenRegister\Service\ExportService;
+use OCA\OpenRegister\Service\ImportService;
+use OCA\OpenRegister\Service\ObjectService;
+use OCA\OpenRegister\Service\WebhookService;
+use OCP\App\IAppManager;
+use OCP\IAppConfig;
+use OCP\IGroupManager;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * The legacy /logs route no longer returns the raw audit rows to a reader who is not an administrator.
+ */
+class ObjectsControllerLogsRedactionTest extends TestCase {
+
+ private ObjectsController $controller;
+
+ private ObjectHistoryForReaders&MockObject $history;
+
+ private ObjectService&MockObject $objectService;
+
+ private IGroupManager&MockObject $groupManager;
+
+ /**
+ * Wire the controller around a mocked reader history.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->history = $this->createMock(ObjectHistoryForReaders::class);
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willReturnCallback(
+ fn (string $id): ?object => ($id === ObjectHistoryForReaders::class ? $this->history : null)
+ );
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $this->groupManager = $this->createMock(IGroupManager::class);
+ $request = $this->createMock(IRequest::class);
+ $request->method('getParams')->willReturn(['limit' => 5, '_search' => 'x']);
+ $this->objectService = $this->createMock(ObjectService::class);
+
+ $this->controller = new ObjectsController(
+ 'openregister',
+ $request,
+ $this->createMock(IAppConfig::class),
+ $this->createMock(IAppManager::class),
+ $container,
+ $this->createMock(RegisterMapper::class),
+ $this->createMock(SchemaMapper::class),
+ $this->createMock(AuditTrailMapper::class),
+ $this->objectService,
+ $session,
+ $this->groupManager,
+ $this->createMock(ExportService::class),
+ $this->createMock(ImportService::class),
+ $this->createMock(WebhookService::class),
+ $this->createMock(LoggerInterface::class)
+ );
+ }//end setUp()
+
+ /**
+ * A non-admin gets the redacted page the reader history builds, and the raw log read is never made.
+ *
+ * @return void
+ */
+ public function testANonAdminGetsTheRedactedHistory(): void {
+ $this->groupManager->method('getUserGroupIds')->willReturn(['staff']);
+ $page = ['results' => [['action' => 'update', 'changed' => ['title' => ['old' => 'a', 'new' => 'b']]]], 'total' => 1, 'page' => null, 'pages' => 1.0, 'limit' => 5, 'offset' => null];
+ $this->history->expects($this->once())->method('page')
+ ->with('reg', 'sch', 'obj-1', $this->callback(static fn (array $params): bool => $params['limit'] === 5 && $params['search'] === 'x' && $params['filters'] === []))
+ ->willReturn($page);
+ $this->objectService->expects($this->never())->method('getLogs');
+
+ $response = $this->controller->logs(id: 'obj-1', register: 'reg', schema: 'sch', objectService: $this->objectService);
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame($page, $response->getData());
+ $this->assertArrayNotHasKey('ipAddress', $response->getData()['results'][0]);
+ }//end testANonAdminGetsTheRedactedHistory()
+
+ /**
+ * An object the caller may not read answers 404, like the audit-trails route and the object GET.
+ *
+ * @return void
+ */
+ public function testAnUnreadableObjectIs404(): void {
+ $this->groupManager->method('getUserGroupIds')->willReturn(['staff']);
+ $this->history->method('page')->willReturn(null);
+ $this->objectService->expects($this->never())->method('getLogs');
+
+ $response = $this->controller->logs(id: 'obj-1', register: 'reg', schema: 'sch', objectService: $this->objectService);
+
+ $this->assertSame(404, $response->getStatus());
+ }//end testAnUnreadableObjectIs404()
+
+ /**
+ * An administrator still reads the stored history as before: the reader history is not asked.
+ *
+ * @return void
+ */
+ public function testAnAdministratorKeepsTheStoredHistory(): void {
+ $this->groupManager->method('getUserGroupIds')->willReturn(['admin']);
+ $this->history->expects($this->never())->method('page');
+ $this->objectService->method('setRegister')->willReturnSelf();
+ $this->objectService->method('setSchema')->willReturnSelf();
+ $this->objectService->method('find')->willReturn(null);
+
+ $response = $this->controller->logs(id: 'obj-1', register: 'reg', schema: 'sch', objectService: $this->objectService);
+
+ $this->assertSame(404, $response->getStatus());
+ }//end testAnAdministratorKeepsTheStoredHistory()
+}//end class
diff --git a/tests/Unit/Controller/ObjectsControllerTest.php b/tests/Unit/Controller/ObjectsControllerTest.php
index 10456b4da8..93c5ea60b1 100644
--- a/tests/Unit/Controller/ObjectsControllerTest.php
+++ b/tests/Unit/Controller/ObjectsControllerTest.php
@@ -993,6 +993,8 @@ public function testUsedThrowsWhenNotFound(): void {
// --- logs() tests ---
public function testLogsReturns404WhenObjectNotFound(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->request->method('getParams')->willReturn([]);
$this->request->method('getRequestUri')->willReturn('/api/objects/1/2/uuid-123/logs');
@@ -1009,6 +1011,8 @@ public function testLogsReturns404WhenObjectNotFound(): void {
}
public function testLogsThrowsWhenFindFails(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->request->method('getParams')->willReturn([]);
$this->objectService->method('setSchema')->willReturnSelf();
@@ -2092,6 +2096,8 @@ public function testObjectsReturns404WhenRegisterNotFoundViaQueryParam(): void {
* Test logs() returns 404 when register does not match (with message key check).
*/
public function testLogsReturns404WhenRegisterDoesNotMatchWithMessage(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('99'); // different register
@@ -2118,6 +2124,8 @@ public function testLogsReturns404WhenRegisterDoesNotMatchWithMessage(): void {
* Test logs() returns 404 when schema does not match (with message content).
*/
public function testLogsReturns404WhenSchemaDoesNotMatchWithMessage(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('1');
@@ -2444,6 +2452,8 @@ public function testUnlockReturns404WhenObjectNotFound(): void {
// =========================================================================
public function testLogsReturnsPaginatedLogsOnSuccess(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('1');
@@ -3461,6 +3471,8 @@ public function testGetObjectVectorizationCountWithViews(): void {
// =========================================================================
public function testLogsThrowsWhenRegisterNotFound(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->request->method('getParams')->willReturn([]);
$this->objectService->method('setRegister')
@@ -3471,6 +3483,8 @@ public function testLogsThrowsWhenRegisterNotFound(): void {
}
public function testLogsThrowsWhenSchemaNotFound(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->request->method('getParams')->willReturn([]);
$this->objectService->method('setRegister')->willReturnSelf();
@@ -4627,6 +4641,8 @@ public function testIndexHandlesObjectEntityResultsInEmptyStripping(): void {
// =========================================================================
public function testLogsMatchesSchemaBySlug(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->markTestSkipped(self::SCHEMA_SHAPE_SKIP);
// Use a mock to return an array from getSchema (real entity is typed ?string)
$objectEntity = $this->getMockBuilder(\OCA\OpenRegister\Db\ObjectEntity::class)
@@ -4666,6 +4682,8 @@ public function testLogsMatchesSchemaBySlug(): void {
* @return void
*/
public function testLogsReturns200WhenSchemaMatchesAsString(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('1');
@@ -4688,6 +4706,8 @@ public function testLogsReturns200WhenSchemaMatchesAsString(): void {
}//end testLogsReturns200WhenSchemaMatchesAsString()
public function testLogsReturns404WhenSchemaDoesNotMatch(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('1');
@@ -4711,6 +4731,8 @@ public function testLogsReturns404WhenSchemaDoesNotMatch(): void {
}
public function testLogsReturns404WhenRegisterDoesNotMatch(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('99'); // different register
@@ -4736,6 +4758,8 @@ public function testLogsReturns404WhenRegisterDoesNotMatch(): void {
// =========================================================================
public function testLogsMatchesSchemaAsObject(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->markTestSkipped(self::SCHEMA_SHAPE_SKIP);
$schemaObj = new \stdClass();
$schemaObj->id = '2';
@@ -4771,6 +4795,8 @@ public function testLogsMatchesSchemaAsObject(): void {
// =========================================================================
public function testLogsMatchesSchemaAsObjectById(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->markTestSkipped(self::SCHEMA_SHAPE_SKIP);
$schemaObj = new \stdClass();
$schemaObj->id = '2';
@@ -4805,6 +4831,8 @@ public function testLogsMatchesSchemaAsObjectById(): void {
// =========================================================================
public function testLogsMatchesSchemaAsArrayById(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->markTestSkipped(self::SCHEMA_SHAPE_SKIP);
// Use a mock to return an array from getSchema (real entity is typed ?string)
$objectEntity = $this->getMockBuilder(\OCA\OpenRegister\Db\ObjectEntity::class)
@@ -4836,6 +4864,8 @@ public function testLogsMatchesSchemaAsArrayById(): void {
// =========================================================================
public function testLogsWithPaginationParams(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('1');
@@ -5378,6 +5408,8 @@ public function testContractsWithNonUnderscoreOffsetParam(): void {
// =========================================================================
public function testLogsReturns404OnDbException(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$this->request->method('getParams')->willReturn([]);
$this->objectService->method('setSchema')->willReturnSelf();
@@ -5397,6 +5429,8 @@ public function testLogsReturns404OnDbException(): void {
// =========================================================================
public function testLogsWithLegacyParams(): void {
+ // The stored history is the administrator's; a reader gets the redacted one (ObjectsControllerLogsRedactionTest).
+ $this->setupAdminUser();
$objectEntity = new \OCA\OpenRegister\Db\ObjectEntity();
$objectEntity->setUuid('uuid-123');
$objectEntity->setRegister('1');
From c967b2d6bdd9522f30225754d5429d3cdd056539 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:20:50 +0200
Subject: [PATCH 20/99] =?UTF-8?q?fix(review):=20#4580=20eng-f8=20=E2=80=94?=
=?UTF-8?q?=20Task=20wake=20still=20overwrites=20another=20listener's=20si?=
=?UTF-8?q?gnal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FlowRunService::signal() merges the new payload over the signal still waiting on the suspended run (array_merge: keys of both survive, a key both set takes the later value) instead of assigning it outright, so the FlowTaskBridge outcome bag and another listener's payload no longer depend on listener order. A consumed signal is still removed by persistResult(), so nothing from an earlier question reaches the merge. FlowRunServiceTest gets a two-signal case.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660510
Sweep: signal() is the only writer of the signal context key (RegistryStepDispatcher only reads and unsets it).
Check: phpunit tests/Unit/Service/Flow OK 1525 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Flow/FlowRunService.php | 14 +++++++++++++
.../Unit/Service/Flow/FlowRunServiceTest.php | 21 +++++++++++++++++++
2 files changed, 35 insertions(+)
diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php
index f7da616e9d..c763a69040 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);
diff --git a/tests/Unit/Service/Flow/FlowRunServiceTest.php b/tests/Unit/Service/Flow/FlowRunServiceTest.php
index e3f22b93e2..3cd8c2a271 100644
--- a/tests/Unit/Service/Flow/FlowRunServiceTest.php
+++ b/tests/Unit/Service/Flow/FlowRunServiceTest.php
@@ -475,6 +475,27 @@ public function testAReAskLoopThatReceivesTheAnswerTerminates(): void {
$this->assertSame(1, $this->doneBranch->calls);
}//end testAReAskLoopThatReceivesTheAnswerTerminates()
+ /**
+ * Two signals on one suspended run keep the keys of both, whichever came last.
+ *
+ * @return void
+ */
+ public function testTwoSignalsOnASuspendedRunAreMerged(): void {
+ $flow = ['id' => 'f1', 'nodes' => [['id' => 'first', 'type' => 'test.ask']], 'edges' => []];
+ $run = $this->service->queue('f1', user: 'alice');
+ $run = $this->service->execute($run, $flow, new RunSubject());
+ $this->assertSame(FlowRun::STATUS_SUSPENDED, $run->getStatus());
+
+ $run = $this->service->signal($run, ['decision' => 'approved', 'taskId' => 'task-1']);
+ $run = $this->service->signal($run, ['completedBy' => 'alice', 'decision' => 'approved-later']);
+
+ $this->assertNotNull($run);
+ $signal = $run->getContext()[FlowRunService::SIGNAL_CONTEXT_KEY];
+ $this->assertSame('task-1', $signal['taskId'], 'the first listener\'s key survives');
+ $this->assertSame('alice', $signal['completedBy'], 'the second listener\'s key is added');
+ $this->assertSame('approved-later', $signal['decision'], 'a key both set takes the later value');
+ }//end testTwoSignalsOnASuspendedRunAreMerged()
+
/**
* THE RESUME ANSWER IS SCOPED TO THE NODE THAT SUSPENDED — through the
* worker path. `signal()` then `execute()` is exactly what the resume
From 1dfdcd386f9b9d16bc2b59b0274c1f18e0d6952d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:24:04 +0200
Subject: [PATCH 21/99] =?UTF-8?q?fix(review):=20#4580=20eng-f9=20=E2=80=94?=
=?UTF-8?q?=20Decision=20ownership=20check=20is=20skipped=20in=20two=20cas?=
=?UTF-8?q?es?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
decide() now treats a relation that names a decision whose record cannot be found as owned by nobody but a supervisor (a synthetic decision with no decider goes through DecisionOwnership::refusal, so a supervisor, an administrator or the owning app's listener still gets through and a reviewer is refused), and links the new decision with a compare-and-swap: EntityRelationMapper::linkDecision() gains expectedUuid and guarded arguments, and a guarded link writes only while the stored link is still the one read, otherwise it throws DecisionConflictException, which decide() rolls back and answers as decision-owned. Two reviewers taking a first decision at once can no longer both link theirs. New cases in DecisionOwnershipTest: dangling link refused for a reviewer and allowed for a supervisor, and a lost link race refused.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660525
Check: phpunit tests/Unit/Controller/DecisionOwnershipTest + EntityRelationsControllerTest + tests/Unit/Service/Decision + EntityRelationMapperUpdateDecisionMetadataTest OK 57 tests; phpcs on the three lib files clean. The guarded SQL update itself needs a database and was not run here.
Co-Authored-By: Claude Opus 5.5
---
lib/Db/EntityRelationMapper.php | 27 +++++++++++-
lib/Exception/DecisionConflictException.php | 31 +++++++++++++
.../Decision/RelationDecisionService.php | 33 +++++++++++---
.../Unit/Controller/DecisionOwnershipTest.php | 43 ++++++++++++++++++-
4 files changed, 126 insertions(+), 8 deletions(-)
create mode 100644 lib/Exception/DecisionConflictException.php
diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php
index c6b37cbd16..761d6e8b81 100644
--- a/lib/Db/EntityRelationMapper.php
+++ b/lib/Db/EntityRelationMapper.php
@@ -23,6 +23,7 @@
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\IQueryBuilder;
@@ -948,14 +949,38 @@ 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
*/
- 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)));
+ if ($expectedUuid === null || $expectedUuid === '') {
+ $qb->andWhere($qb->expr()->orX($qb->expr()->isNull('decision_uuid'), $qb->expr()->eq('decision_uuid', $qb->createNamedParameter(''))));
+ } else {
+ $qb->andWhere($qb->expr()->eq('decision_uuid', $qb->createNamedParameter($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);
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/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php
index 3e132599a0..8ca3b3f8ad 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;
@@ -91,7 +92,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,10 +103,21 @@ 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) {
+ $linkedUuid = $relation->getDecisionUuid();
+ $current = $this->decisions->find(uuid: $linkedUuid);
+ $owned = $current;
+ if ($owned === null && $linkedUuid !== null && $linkedUuid !== '') {
+ // The relation names a decision whose record cannot be found.
+ // DecisionVisibility hides that state; here it belongs to nobody
+ // but a supervisor, so a reviewer cannot take it over by the gap.
+ $owned = new Decision();
+ $owned->setUuid($linkedUuid);
+ $owned->setDecidedBy('');
+ }
+
+ if ($owned !== null) {
$resolved = $this->policies->forRelation(relation: $relation);
- $refusal = $this->ownership->refusal(decision: $current, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']);
+ $refusal = $this->ownership->refusal(decision: $owned, actorId: $actorId, policy: $resolved['policy'], subject: $resolved['subject']);
if ($refusal !== null) {
throw new DecisionOwnedException(refusal: $refusal);
}
@@ -140,8 +152,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;
diff --git a/tests/Unit/Controller/DecisionOwnershipTest.php b/tests/Unit/Controller/DecisionOwnershipTest.php
index f98caf9e62..9b2c2dae0d 100644
--- a/tests/Unit/Controller/DecisionOwnershipTest.php
+++ b/tests/Unit/Controller/DecisionOwnershipTest.php
@@ -95,6 +95,13 @@ class DecisionOwnershipTest extends TestCase {
*/
private array $groups = ['coord' => ['woo-coordinator'], 'root' => ['admin']];
+ /**
+ * Whether the guarded link finds the link already taken, as a concurrent first decision would.
+ *
+ * @var bool
+ */
+ private bool $linkConflict = false;
+
/**
* Set up a relation every reviewer may write.
*
@@ -141,7 +148,11 @@ static function (EntityRelation $relation, array $fields): EntityRelation {
}
);
$relations->method('linkDecision')->willReturnCallback(
- static function (EntityRelation $relation, string $decisionUuid): EntityRelation {
+ function (EntityRelation $relation, string $decisionUuid, ?string $expectedUuid = null, bool $guarded = false): EntityRelation {
+ if ($guarded === true && $this->linkConflict === true) {
+ throw new \OCA\OpenRegister\Exception\DecisionConflictException('taken');
+ }
+
$relation->setDecisionUuid($decisionUuid);
return $relation;
}
@@ -162,7 +173,7 @@ static function (EntityRelation $relation, string $decisionUuid): EntityRelation
$groups->method('isInGroup')->willReturnCallback(fn (string $id, string $gid): bool => in_array($gid, ($this->groups[$id] ?? []), true));
$decisionMapper = $this->createMock(DecisionMapper::class);
- $decisionMapper->method('findByUuid')->willReturnCallback(fn (string $uuid): Decision => $this->decisions[$uuid]);
+ $decisionMapper->method('findByUuid')->willReturnCallback(fn (string $uuid): Decision => ($this->decisions[$uuid] ?? throw new \OCP\AppFramework\Db\DoesNotExistException('gone')));
$decisionMapper->method('insert')->willReturnCallback(
function (Decision $decision): Decision {
$decision->setUuid('d-'.(count($this->decisions) + 1));
@@ -304,6 +315,34 @@ public function testAnAdministratorMay(): void {
*
* @return void
*/
+ public function testADanglingDecisionLinkIsRefusedForAReviewer(): void {
+ $this->schemaConfiguration = ['x-openregister-review' => ['ownership' => 'decider', 'supervisors' => ['woo-coordinator']]];
+ // The relation names a decision whose record is gone.
+ $this->relation->setDecisionUuid('d-gone');
+
+ $response = $this->controllerAs('alice', ['bases' => ['A']])->update(7);
+ $this->assertSame(403, $response->getStatus());
+ $this->assertSame('decision-owned', $response->getData()['error']);
+ $this->assertEmpty($this->decisions);
+
+ // A supervisor may take it over.
+ $this->assertSame(200, $this->controllerAs('coord', ['bases' => ['A']])->update(7)->getStatus());
+ }//end testADanglingDecisionLinkIsRefusedForAReviewer()
+
+ /**
+ * The link is guarded: when somebody else linked a decision first, the second is refused and nothing is recorded as linked.
+ */
+ public function testASecondConcurrentFirstDecisionIsRefused(): void {
+ $this->schemaConfiguration = ['x-openregister-review' => ['ownership' => 'decider']];
+ $this->linkConflict = true;
+
+ $response = $this->controllerAs('alice', ['bases' => ['A']])->update(7);
+
+ $this->assertSame(403, $response->getStatus());
+ $this->assertSame('decision-owned', $response->getData()['error']);
+ $this->assertNull($this->relation->getDecisionUuid());
+ }//end testASecondConcurrentFirstDecisionIsRefused()
+
public function testAnUndecidedRelationIsOpen(): void {
$response = $this->controllerAs(uid: 'p.bakker', body: ['skipAnonymization' => false])->update(7);
From 09d5d985fd2f375f90a82f7a8d2db05a9def1e8a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:24:44 +0200
Subject: [PATCH 22/99] =?UTF-8?q?fix(review):=20#4580=20eng-f10=20?=
=?UTF-8?q?=E2=80=94=20Failure=20to=20arm=20a=20statutory=20expiry=20timer?=
=?UTF-8?q?=20is=20only=20logged?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CasePlanItemTimers::arm() now logs at error level and rethrows when the timer of a statutory (wettelijk) deadline is refused, so onEntered() raises it instead of letting the item sit active past a legal term with nothing to end it; advisory and service-norm timers stay a warning and a 0. New test: a statutory arm failure is raised, a service-norm one is not. Note for the author: CaseItemTimerListener::handle() calls onEntered() from an event listener, and Nextcloud's event dispatcher logs and swallows a listener's exception, so whether the transition itself fails depends on the caller of the transition; a visible marker on the item would need a schema field and is not added here.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660540
Check: phpunit tests/Unit/Service/Case + tests/Unit/Listener OK 608 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Case/CasePlanItemTimers.php | 17 +++++++++++++++
.../Service/Case/CasePlanItemTimersTest.php | 21 +++++++++++++++++++
2 files changed, 38 insertions(+)
diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php
index 34ce0f3b86..56cfc12947 100644
--- a/lib/Service/Case/CasePlanItemTimers.php
+++ b/lib/Service/Case/CasePlanItemTimers.php
@@ -85,6 +85,8 @@ public function __construct(
*
* @return int How many timers were armed.
*
+ * @throws Throwable When the timer of a statutory (wettelijk) expiry could not be armed; any other refusal is logged.
+ *
* @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
*/
public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $now = null): int {
@@ -171,6 +173,8 @@ public function itemToTerminate(FlowTimerFiredEvent $event): ?string {
*
* @return boolean True when armed.
*
+ * @throws Throwable When the timer of a statutory (wettelijk) deadline could not be armed.
+ *
* @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
*/
private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadline, ?string $actor, DateTimeImmutable $now): bool {
@@ -215,6 +219,19 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin
return true;
} catch (Throwable $failure) {
+ if ($config['legalEffect'] === FlowTimer::LEGAL_WETTELIJK) {
+ // A statutory expiry that is not armed is a term that never
+ // runs: the item would stay active past a legal deadline with
+ // nothing to end it. That is not a warning to log and move on
+ // from; it is raised to the caller of the transition.
+ $this->logger->error(
+ '[CasePlanItemTimers] The timer of a statutory plan-item deadline could not be armed: ' . $failure->getMessage(),
+ ['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure]
+ );
+
+ throw $failure;
+ }
+
$this->logger->warning(
'[CasePlanItemTimers] A plan-item timer was refused; the transition stands: ' . $failure->getMessage(),
['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure]
diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php
index 8df6af8a82..40084031d0 100644
--- a/tests/Unit/Service/Case/CasePlanItemTimersTest.php
+++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php
@@ -162,6 +162,27 @@ static function (array $config) use (&$configs): FlowTimer {
$this->assertSame(0, $this->service()->onEntered(item: $far, actor: null, now: $now));
}//end testEdgesNeverBlockTheTransition()
+ /**
+ * A refused timer of a statutory expiry is raised, not logged away; a service-norm or advisory one stays a warning.
+ *
+ * @return void
+ */
+ public function testARefusedStatutoryTimerIsRaised(): void {
+ $now = new DateTimeImmutable('2026-10-10T10:00:00+00:00');
+ $statutory = CaseFixtures::row(id: 9, key: 'stat', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE);
+ $statutory->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00'));
+ $statutory->setPlanSettings(['statutory' => ['stat']]);
+ $norm = CaseFixtures::row(id: 10, key: 'norm', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE);
+ $norm->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00'));
+ $this->timers->method('arm')->willThrowException(new RuntimeException('calendar unknown'));
+
+ $this->assertSame(0, $this->service()->onEntered(item: $norm, actor: null, now: $now), 'a service norm stays a warning');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('calendar unknown');
+ $this->service()->onEntered(item: $statutory, actor: null, now: $now);
+ }//end testARefusedStatutoryTimerIsRaised()
+
/**
* Reaching a terminal state cancels the item's open timers; a failure to
* cancel is logged, never thrown.
From 71f4796dc41aaa29d22de216b9fd94a80bbdaaa0 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:25:32 +0200
Subject: [PATCH 23/99] =?UTF-8?q?fix(review):=20#4580=20plat-f4=20?=
=?UTF-8?q?=E2=80=94=20Aborted=20delete=20or=20rename=20reads=20as=20succe?=
=?UTF-8?q?ss=20to=20internal=20callers=20(delete=20part)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DeleteFileHandler::deleteFile() reads the node's id before delete() and asks the root folder for it afterwards; a node that is still there (Nextcloud ignores a listener's abortOperation() through the Node API, as the frozen-object guard uses) is logged and reported false instead of true, so FileService::deleteFile() callers such as the example-set remover and the merge no longer believe a file is gone that is not. New test: delete() that leaves the node behind answers false. The rename/move handlers are not changed here (left to the author, see the hand-back note), and the ExampleSetRemoveLiveTest case from Done-when was not added.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660694
Check: phpunit tests/Unit/Service/File OK 489 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/File/DeleteFileHandler.php | 14 ++++++++++++++
tests/Unit/Service/File/DeleteFileHandlerTest.php | 13 +++++++++++++
2 files changed, 27 insertions(+)
diff --git a/lib/Service/File/DeleteFileHandler.php b/lib/Service/File/DeleteFileHandler.php
index 16cbb58ef7..abb85e0c50 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,18 @@ 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/tests/Unit/Service/File/DeleteFileHandlerTest.php b/tests/Unit/Service/File/DeleteFileHandlerTest.php
index 3f3f506627..bf69af2d5f 100644
--- a/tests/Unit/Service/File/DeleteFileHandlerTest.php
+++ b/tests/Unit/Service/File/DeleteFileHandlerTest.php
@@ -123,6 +123,19 @@ public function testDeleteRefusedWithoutDeletePermission(): void {
// deleteFile - a Node is deleted as a Node
// =========================================================================
+ /**
+ * A delete that returns without removing the node (an aborted operation) is reported as not deleted.
+ */
+ public function testADeleteThatLeavesTheNodeBehindIsReportedFalse(): void {
+ $file = $this->createMock(File::class);
+ $file->method('getId')->willReturn(42);
+ $file->method('isDeletable')->willReturn(true);
+ $file->expects($this->once())->method('delete');
+ $this->rootFolder->method('getFirstNodeById')->with(42)->willReturn($file);
+
+ $this->assertFalse($this->handler->deleteFile(file: $file, object: $this->createMock(ObjectEntity::class)));
+ }//end testADeleteThatLeavesTheNodeBehindIsReportedFalse()
+
/**
* A Node argument MUST be deleted directly, never cast to string.
*
From 224da09c4850e0f09e7846dfab93f7c7a3074616 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:27:01 +0200
Subject: [PATCH 24/99] =?UTF-8?q?fix(review):=20#4580=20eng-f3=20=E2=80=94?=
=?UTF-8?q?=20@spec=20on=20FormIdempotencyStore::release?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The spec-coverage gate flagged the new release() method; it carries the same @spec as reserve() and the rest of the store.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660446
Check: bash scripts/run-hydra-gates.sh --scope-to-diff --base b9e5d8df -> re-run after this commit (see hand-back).
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Form/FormIdempotencyStore.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/lib/Service/Form/FormIdempotencyStore.php b/lib/Service/Form/FormIdempotencyStore.php
index 0e4b6d2cfa..987a68ab92 100644
--- a/lib/Service/Form/FormIdempotencyStore.php
+++ b/lib/Service/Form/FormIdempotencyStore.php
@@ -129,6 +129,8 @@ public function reserve(string $scope, string $key): void {
* @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 {
From 50bee16d8fbc22d659ada5256f79bd94ef4f603d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:46:17 +0200
Subject: [PATCH 25/99] =?UTF-8?q?fix(review):=20#4580=20eng-f10=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20do=20not=20rethrow=20from=20the=20liste?=
=?UTF-8?q?ner=20path?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CasePlanStateMachine::flushEvents() catches Throwable around every dispatchTyped after the transition is committed, so the rethrow changed only a log line. It is reverted: arm() returns false again, and the error-level log now says the item is active WITHOUT its statutory expiry timer, naming the plan item, the case object and the legal effect. No schema field is added; a visible marker on the item is for the author.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660540
Check: phpunit tests/Unit/Service/Case OK 108 tests; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Case/CasePlanItemTimers.php | 25 +++++++++++--------
.../Service/Case/CasePlanItemTimersTest.php | 25 ++++++++++++-------
2 files changed, 30 insertions(+), 20 deletions(-)
diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php
index 56cfc12947..0542899c0f 100644
--- a/lib/Service/Case/CasePlanItemTimers.php
+++ b/lib/Service/Case/CasePlanItemTimers.php
@@ -85,8 +85,6 @@ public function __construct(
*
* @return int How many timers were armed.
*
- * @throws Throwable When the timer of a statutory (wettelijk) expiry could not be armed; any other refusal is logged.
- *
* @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
*/
public function onEntered(CaseItem $item, ?string $actor, ?DateTimeInterface $now = null): int {
@@ -173,8 +171,6 @@ public function itemToTerminate(FlowTimerFiredEvent $event): ?string {
*
* @return boolean True when armed.
*
- * @throws Throwable When the timer of a statutory (wettelijk) deadline could not be armed.
- *
* @spec openspec/specs/flow-cases/spec.md#requirement-plan-item-deadlines-run-on-the-shared-clock
*/
private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadline, ?string $actor, DateTimeImmutable $now): bool {
@@ -220,16 +216,23 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin
return true;
} catch (Throwable $failure) {
if ($config['legalEffect'] === FlowTimer::LEGAL_WETTELIJK) {
- // A statutory expiry that is not armed is a term that never
- // runs: the item would stay active past a legal deadline with
- // nothing to end it. That is not a warning to log and move on
- // from; it is raised to the caller of the transition.
+ // The transition is already committed and the caller swallows an
+ // exception raised from here (CasePlanStateMachine::flushEvents()),
+ // so this cannot fail it. It can only say loudly that the item is
+ // active without the timer that enforces its statutory expiry.
$this->logger->error(
- '[CasePlanItemTimers] The timer of a statutory plan-item deadline could not be armed: ' . $failure->getMessage(),
- ['item' => $item->getUuid(), 'purpose' => $purpose, 'exception' => $failure]
+ sprintf(
+ '[CasePlanItemTimers] Plan item %s of case object %s is active WITHOUT its statutory (%s) %s timer: the term will not run and nothing will end the item. %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]
);
- throw $failure;
+ return false;
}
$this->logger->warning(
diff --git a/tests/Unit/Service/Case/CasePlanItemTimersTest.php b/tests/Unit/Service/Case/CasePlanItemTimersTest.php
index 40084031d0..9e43b680c2 100644
--- a/tests/Unit/Service/Case/CasePlanItemTimersTest.php
+++ b/tests/Unit/Service/Case/CasePlanItemTimersTest.php
@@ -163,25 +163,32 @@ static function (array $config) use (&$configs): FlowTimer {
}//end testEdgesNeverBlockTheTransition()
/**
- * A refused timer of a statutory expiry is raised, not logged away; a service-norm or advisory one stays a warning.
+ * A refused timer of a statutory expiry is logged at error level naming case, item and legal effect; the transition stands.
*
* @return void
*/
- public function testARefusedStatutoryTimerIsRaised(): void {
+ public function testARefusedStatutoryTimerIsLoggedLoudly(): void {
$now = new DateTimeImmutable('2026-10-10T10:00:00+00:00');
$statutory = CaseFixtures::row(id: 9, key: 'stat', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE);
$statutory->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00'));
$statutory->setPlanSettings(['statutory' => ['stat']]);
- $norm = CaseFixtures::row(id: 10, key: 'norm', type: CaseItem::TYPE_HUMAN_TASK, state: CaseItem::STATE_ACTIVE);
- $norm->setExpiresAt(new DateTime('2026-10-20T10:00:00+00:00'));
$this->timers->method('arm')->willThrowException(new RuntimeException('calendar unknown'));
+ $errors = [];
+ $logger = $this->createMock(\Psr\Log\LoggerInterface::class);
+ $logger->method('error')->willReturnCallback(
+ static function (string $message, array $context = []) use (&$errors): void {
+ $errors[] = [$message, $context];
+ }
+ );
+ $service = new CasePlanItemTimers(timers: $this->timers, logger: $logger);
- $this->assertSame(0, $this->service()->onEntered(item: $norm, actor: null, now: $now), 'a service norm stays a warning');
+ $this->assertSame(0, $service->onEntered(item: $statutory, actor: null, now: $now));
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('calendar unknown');
- $this->service()->onEntered(item: $statutory, actor: null, now: $now);
- }//end testARefusedStatutoryTimerIsRaised()
+ $this->assertCount(1, $errors);
+ $this->assertStringContainsString('WITHOUT its statutory', $errors[0][0]);
+ $this->assertStringContainsString('stat', $errors[0][0]);
+ $this->assertSame('wettelijk', $errors[0][1]['legalEffect']);
+ }//end testARefusedStatutoryTimerIsLoggedLoudly()
/**
* Reaching a terminal state cancels the item's open timers; a failure to
From 2d0dbae3fb0340131d9a2b769603c16e4dde4c87 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:48:02 +0200
Subject: [PATCH 26/99] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20bulk=20and=20single=20delete=20keep=20a?=
=?UTF-8?q?nother=20user's=20draft?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PermissionHandler::filterUuidsForPermissions() now asks DraftStatusPolicy::keptFrom() before the delete permission, so another user's draft is left out of a bulk delete like an object that does not exist. ObjectService::deleteObject() asks it too (RBAC on) after the object is found and before the handler runs, answering DoesNotExistException, as the save path does; the read lookup already hid it through the RBAC predicate, this is the explicit rule. Tests: bulk filter (fails at HEAD) and single delete.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284
Check: phpunit PermissionHandlerBulkDeleteLookupTest, ObjectServiceBulkDeleteTest, ObjectServiceDraftDeleteTest and tests/Unit/Service/Object OK; phpcs on both lib files clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Object/PermissionHandler.php | 7 +++
lib/Service/ObjectService.php | 11 ++++
.../PermissionHandlerBulkDeleteLookupTest.php | 50 +++++++++++++++++++
.../Service/ObjectServiceDraftDeleteTest.php | 20 ++++++++
4 files changed, 88 insertions(+)
diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php
index afff4b12e4..9bab97ed3e 100644
--- a/lib/Service/Object/PermissionHandler.php
+++ b/lib/Service/Object/PermissionHandler.php
@@ -1862,6 +1862,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 ((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/ObjectService.php b/lib/Service/ObjectService.php
index 59780d6005..1a7c774005 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.
+ if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null) {
+ $deleter = ($actingUserId ?? $this->userSession->getUser()?->getUID());
+ if ((new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleter) === true) {
+ throw new \OCP\AppFramework\Db\DoesNotExistException('Object not found');
+ }
+ }
+
return $this->deleteHandler->deleteObject(
register: $this->currentRegister,
schema: $this->currentSchema,
diff --git a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php
index a5cd550a0d..b469da4978 100644
--- a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php
+++ b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php
@@ -167,4 +167,54 @@ public function testUnresolvableUuidIsDropped(): void {
$this->assertSame(['uuid-alpha'], $filtered);
}//end testUnresolvableUuidIsDropped()
+
+ /**
+ * A bulk delete leaves somebody else's draft out, like an object that does not exist, and keeps the caller's own.
+ *
+ * @return void
+ */
+ public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void {
+ $bobsDraft = $this->entity('draft-bob');
+ $bobsDraft->setStatus('draft');
+ $bobsDraft->setOwner('bob');
+ $bobsDraft->setSchema('5');
+ $alicesDraft = $this->entity('draft-alice');
+ $alicesDraft->setStatus('draft');
+ $alicesDraft->setOwner('alice');
+ $alicesDraft->setSchema('5');
+ $record = $this->entity('record');
+ $record->setStatus('active');
+ $record->setOwner('alice');
+ $record->setSchema('5');
+
+ $mapper = $this->createMock(MagicMapper::class);
+ $mapper->method('findMultipleAcrossAllMagicTables')->willReturn([$bobsDraft, $alicesDraft, $record]);
+ $schemas = $this->createMock(SchemaMapper::class);
+ $schemas->method('find')->willReturn(new \OCA\OpenRegister\Db\Schema());
+ $user = $this->createMock(\OCP\IUser::class);
+ $user->method('getUID')->willReturn('bob');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willThrowException(new \RuntimeException('no organisation service'));
+ $handler = new PermissionHandler(
+ userSession: $session,
+ userManager: $this->createMock(IUserManager::class),
+ groupManager: $this->createMock(IGroupManager::class),
+ schemaMapper: $schemas,
+ objectEntityMapper: $mapper,
+ conditionMatcher: $this->createMock(ConditionMatcher::class),
+ appConfig: $this->createMock(IAppConfig::class),
+ logger: $this->createMock(LoggerInterface::class),
+ container: $container,
+ );
+
+ // The schema's own delete rule is not what this asks (it would need a real user and groups);
+ // run inside the system context so hasPermission() says yes and only the draft rule decides.
+ $filtered = \OCA\OpenRegister\Service\SystemOperationContext::run(
+ static fn (): array => $handler->filterUuidsForPermissions(uuids: ['draft-bob', 'draft-alice', 'record'], _rbac: true, _multitenancy: false)
+ );
+
+ $this->assertSame(['draft-bob', 'record'], $filtered, "alice's draft is left out; bob's own draft and an ordinary record stay");
+ }//end testSomebodyElsesDraftIsFilteredOutOfABulkDelete()
}//end class
diff --git a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php
index 4f79a3350b..8c15fc1a89 100644
--- a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php
+++ b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php
@@ -337,6 +337,26 @@ public function testADraftIsDeletedForGoodOnAnySchema(): void {
$this->assertSame([true], $asked);
}//end testADraftIsDeletedForGoodOnAnySchema()
+ /**
+ * Somebody else's draft is not deleted through the single-object path, and the handler is never asked.
+ *
+ * @return void
+ */
+ public function testSomebodyElsesDraftIsNotDeleted(): void {
+ [$register, $schema] = $this->scope(archival: false);
+ $this->stored(status: 'draft');
+ $asked = [];
+ $this->recordHandler(asked: $asked);
+
+ $this->expectException(\OCP\AppFramework\Db\DoesNotExistException::class);
+ try {
+ // The stored draft belongs to 'agent'; the caller (no session user) is not that owner.
+ $this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false);
+ } finally {
+ $this->assertSame([], $asked);
+ }
+ }//end testSomebodyElsesDraftIsNotDeleted()
+
/**
* Control: an ordinary object's delete stays the soft delete it was.
*
From c5354845b3fba42eb7c16e1393632adc0c4928a5 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:48:19 +0200
Subject: [PATCH 27/99] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20draft=20guard=20before=20the=20appendOn?=
=?UTF-8?q?ly=20check?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The draft guard (Step 2b) now runs above the appendOnly gate in applyBulkSafeguards(), so a non-owner of a draft on an appendOnly schema gets the same 'Permission denied' as everywhere else instead of an appendOnly message that confirms the uuid names an existing row. New test.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284
Check: phpunit Wave12BulkSafeguardsTest + BulkSafeguardSchemaResolutionTest OK; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Object/SaveObjects.php | 26 ++++++++++---------
.../Object/Wave12BulkSafeguardsTest.php | 24 +++++++++++++++++
2 files changed, 38 insertions(+), 12 deletions(-)
diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php
index 2c582d3aae..826aaf54f8 100644
--- a/lib/Service/Object/SaveObjects.php
+++ b/lib/Service/Object/SaveObjects.php
@@ -802,18 +802,9 @@ private function applyBulkSafeguards(
existingByUuid: $existingByUuid
);
- // Step 4: appendOnly UPDATE → reject.
- if ($action === 'update' && $rowSchema->isAppendOnly() === true) {
- $this->recordSafeguardRejection(
- object: $sanitised,
- reason: 'Schema is appendOnly; UPDATE rejected for row UUID ' . ((string)($uuid ?? '?')),
- result: $result,
- index: $rowIndex
- );
- continue;
- }
-
- // Step 2b: a draft is kept to its owner. The existing row was found
+ // 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
@@ -835,6 +826,17 @@ private function applyBulkSafeguards(
}
}
+ // Step 4: appendOnly UPDATE → reject.
+ if ($action === 'update' && $rowSchema->isAppendOnly() === true) {
+ $this->recordSafeguardRejection(
+ object: $sanitised,
+ reason: 'Schema is appendOnly; UPDATE rejected for row UUID ' . ((string)($uuid ?? '?')),
+ result: $result,
+ index: $rowIndex
+ );
+ continue;
+ }
+
// Step 3: PermissionHandler check (per row).
if ($effectiveRbac === true) {
$hasPermission = $this->permissionHandler->hasPermission(
diff --git a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
index 5f274f4d2a..225cf031e0 100644
--- a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
+++ b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
@@ -354,6 +354,30 @@ public function testBulkSaveOverSomebodyElsesDraftIsRejected(): void {
$this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']);
}//end testBulkSaveOverSomebodyElsesDraftIsRejected()
+ /**
+ * On an appendOnly schema the draft rule answers first: a non-owner is not told by the appendOnly message that the uuid exists.
+ */
+ public function testAppendOnlyDoesNotRevealSomebodyElsesDraft(): void {
+ $this->mockUser('bob', isAdmin: false);
+ $schema = $this->newSchema(1, null, appendOnly: true);
+ $register = $this->newRegister(10);
+ $this->permissionHandler->method('hasPermission')->willReturn(true);
+ $this->storeDraft(owner: 'alice');
+
+ $result = $this->initResult(1);
+ $this->callApplyBulkSafeguards(
+ objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'probe']],
+ register: $register,
+ schema: $schema,
+ _rbac: true,
+ _validation: false,
+ result: $result
+ );
+
+ $this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']);
+ $this->assertStringNotContainsString('appendOnly', $result['invalid'][0]['error']);
+ }//end testAppendOnlyDoesNotRevealSomebodyElsesDraft()
+
/**
* The owner's own bulk save of their draft still goes through.
*/
From fb4be1d92748ad90f44709dbca9d7eaf1722869d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:49:41 +0200
Subject: [PATCH 28/99] =?UTF-8?q?fix(review):=20#4580=20plat-f3=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20say=20what=20an=20oversized=20entry=20l?=
=?UTF-8?q?oses?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Dropping oldData for an entry that alone exceeds the job limit keeps the entry but skips the conditioned updated rules and the calculatedChange rules for that object (they fail closed without the previous data). The warning now says so, the docblock and comments no longer claim the notification is still sent, and the proposal, spec requirement and scenario of deferred-listener-batches-stay-under-the-job-limit are corrected; the test was renamed accordingly and asserts the log text.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660676
Check: phpunit tests/Unit/Service/Deferral OK; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Deferral/ListenerDeferralService.php | 16 +++++++++-------
.../proposal.md | 2 +-
.../specs/event-driven-architecture/spec.md | 8 ++++----
.../DeferredChunksFitTheJobLimitTest.php | 5 +++--
4 files changed, 17 insertions(+), 14 deletions(-)
diff --git a/lib/Service/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php
index a76845dd9c..ea96b09ba8 100644
--- a/lib/Service/Deferral/ListenerDeferralService.php
+++ b/lib/Service/Deferral/ListenerDeferralService.php
@@ -308,9 +308,11 @@ public function flushAll(): void {
* 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` (the notification is
- * still sent, and the log names the reduction); only an entry that is
- * still too large after that is dropped, and it is named in the log.
+ * 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.
*
@@ -353,15 +355,15 @@ private function chunksUnderLimit(string $jobClass, array $entries): array {
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. Without it the
- // 'updated' notification is still sent; only the comparison of old
- // against new is skipped.
+ // 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',
+ 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__,
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 292ccb060f..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
@@ -17,7 +17,7 @@ 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 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 update notification is still sent) and the reduction is logged at warning level.
+- 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 b28f54cbbf..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,7 +4,7 @@
### 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 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; 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.
+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
@@ -12,11 +12,11 @@ The listener deferral service SHALL enqueue a buffered chunk as one or more back
- 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 keeps its notification
+#### 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`, the log names its uuid, and the notification is still sent
-- @e2e exclude pinned by `DeferredChunksFitTheJobLimitTest::testAnOversizedUpdateLosesItsOldDataNotItsNotification`
+- 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
diff --git a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php
index ebbcd3214a..43bc59d13d 100644
--- a/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php
+++ b/tests/Unit/Service/Deferral/DeferredChunksFitTheJobLimitTest.php
@@ -257,7 +257,7 @@ public function testAChunkOfLargeUpdatesIsSpreadOverJobsTheListTakes(int $limit,
*
* @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 testAnOversizedUpdateLosesItsOldDataNotItsNotification(): void {
+ public function testAnOversizedUpdateIsKeptWithoutItsOldData(): void {
$deferral = $this->deferral(limit: ListenerDeferralService::ARGUMENT_LENGTH_NC32);
$listener = $this->listener(deferral: $deferral);
$listener->handle(new ObjectUpdatedEvent($this->caseOf(uuid: 'small-1', length: 100), $this->caseOf(uuid: 'small-1', length: 100)));
@@ -270,12 +270,13 @@ public function testAnOversizedUpdateLosesItsOldDataNotItsNotification(): void {
self::assertSame(['small-1', 'big', 'small-2'], $this->enqueuedUuids());
self::assertCount(1, $this->warnings);
self::assertSame('big', $this->warnings[0][1]['uuid']);
+ self::assertStringContainsString('calculatedChange rules are skipped', $this->warnings[0][0]);
foreach ($this->enqueued as $argument) {
foreach ($argument['entries'] as $entry) {
self::assertSame($entry['uuid'] !== 'big', array_key_exists('oldData', $entry) && $entry['oldData'] !== null);
}
}
- }//end testAnOversizedUpdateLosesItsOldDataNotItsNotification()
+ }//end testAnOversizedUpdateIsKeptWithoutItsOldData()
/**
* One entry too large for any job, whatever it carries, is named in the
From 59f9acaccba57565a3ca2ac61b68033775b364d4 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:49:41 +0200
Subject: [PATCH 29/99] =?UTF-8?q?fix(review):=20#4580=20plat-f3=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20contain=20a=20refused=20webhook=20job?=
=?UTF-8?q?=20argument?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WebhookService::dispatchEvent() enqueues WebhookDeliveryJob with the whole event payload as the job argument, the same 4000 (NC 32 and 33) or 32000 (34 and later) limit. A refusal from IJobList::add() was uncaught: it aborted the webhooks after it in the loop and surfaced as an exception in the event listener, where it is only a log line. Each add is now guarded: the refusal is logged at error level with the webhook id, event, payload length and message, and the loop goes on. That delivery is still lost (no redesign of webhook delivery; trimming or a payload reference is the author's call). Test: a refused first webhook does not stop the second.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660676
Check: phpunit WebhookServiceTest, WebhookAsyncDispatchTest and WebhookEventListenerTest OK; phpcs on the lib file clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/WebhookService.php | 38 +++++++++++++++++------
tests/Unit/Service/WebhookServiceTest.php | 20 ++++++++++++
2 files changed, 49 insertions(+), 9 deletions(-)
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/tests/Unit/Service/WebhookServiceTest.php b/tests/Unit/Service/WebhookServiceTest.php
index 1950ec53bf..59da0fff22 100644
--- a/tests/Unit/Service/WebhookServiceTest.php
+++ b/tests/Unit/Service/WebhookServiceTest.php
@@ -676,6 +676,26 @@ public function testDispatchEventEnqueuesDeliveryJobPerWebhookAndDoesNotDeliverS
$this->assertSame(2, $enqueued[1][1]['webhook_id']);
}
+ public function testARefusedJobArgumentDoesNotStopTheOtherWebhooks(): void {
+ $event = $this->createMock(\OCP\EventDispatcher\Event::class);
+ $this->webhookMapper->method('findForEvent')->willReturn([$this->createTestWebhook(id: 1), $this->createTestWebhook(id: 2)]);
+
+ $enqueued = [];
+ $this->jobList->method('add')->willReturnCallback(function (string $job, array $arg) use (&$enqueued) {
+ if ($arg['webhook_id'] === 1) {
+ throw new \InvalidArgumentException("Background job arguments can't exceed 4000 characters (json encoded)");
+ }
+
+ $enqueued[] = $arg['webhook_id'];
+ });
+ $this->logger->expects($this->atLeastOnce())->method('error')
+ ->with($this->stringContains('could not be enqueued'), $this->callback(static fn (array $context): bool => $context['webhook_id'] === 1));
+
+ $this->service->dispatchEvent($event, 'SomeEvent', ['test' => str_repeat('x', 5000)]);
+
+ $this->assertSame([2], $enqueued, 'the refusal is contained to the one delivery');
+ }
+
// ─── deliverWebhook tests ────────────────────────────────────────
public function testDeliverWebhookReturnsFalseWhenDisabled(): void {
From 6703ff76a5bddc12dcfe44d49745a58695be9602 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:49:57 +0200
Subject: [PATCH 30/99] =?UTF-8?q?fix(review):=20#4580=20eng-f3=20=E2=80=94?=
=?UTF-8?q?=20pre-push:=20scenario=20and=20docs=20follow=20the=20new=20key?=
=?UTF-8?q?=20behaviour?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The idempotency scenario in form-destination-validator now says same subject and same payload; two new scenarios state the 409 for another subject or payload and for a parallel retry. docs/features/form-destination.md states that the parallel-retry protection relies on Nextcloud file locking (filelocking.enabled); without it only the sequential repeat is answered from the stored answer.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660446
Check: documentation only; no test run.
Co-Authored-By: Claude Opus 5.5
---
docs/features/form-destination.md | 5 ++++-
.../specs/form-destination/spec.md | 14 +++++++++++++-
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/docs/features/form-destination.md b/docs/features/form-destination.md
index 62cc522fe8..bf9242ea69 100644
--- a/docs/features/form-destination.md
+++ b/docs/features/form-destination.md
@@ -87,7 +87,10 @@ OpenRegister object (`status: published`, `destination` or `writes`,
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. When one
+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/openspec/changes/form-destination-validator/specs/form-destination/spec.md b/openspec/changes/form-destination-validator/specs/form-destination/spec.md
index af1e91a99f..8de3bdc0ff 100644
--- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md
+++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md
@@ -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.
From 9420851e65f035512b0342c3f3c700f6b3e44f16 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:49:57 +0200
Subject: [PATCH 31/99] =?UTF-8?q?fix(review):=20#4580=20surf-f4=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20federated-configuration=20doc=20says=20?=
=?UTF-8?q?user=20settings?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
docs/federated-configuration.md still sent readers to the Configuration store pane in personal settings; it says user settings now. Other languages' l10n entries stay for Transifex.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660858
Check: documentation only; git grep -i 'personal settings' -- docs finds nothing.
Co-Authored-By: Claude Opus 5.5
---
docs/federated-configuration.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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.
From 902c28b14cbf57fc3850857f0f686884a18476df Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:52:23 +0200
Subject: [PATCH 32/99] =?UTF-8?q?fix(review):=20#4580=20surf-f1=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20phpcs=20inline=20if=20in=20logsForReade?=
=?UTF-8?q?r?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the inline ternary phpcs refuses in ObjectsController::logsForReader() with an if; no behaviour change.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660832
Check: phpunit ObjectsControllerTest + ObjectsControllerLogsRedactionTest OK; phpcs on all touched lib files now clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Controller/ObjectsController.php | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php
index 6b4678228a..9a7b8b425a 100644
--- a/lib/Controller/ObjectsController.php
+++ b/lib/Controller/ObjectsController.php
@@ -4894,7 +4894,10 @@ public function logs(string $id, string $register, string $schema, ObjectService
*/
private function logsForReader(string $register, string $schema, string $id): JSONResponse {
$config = $this->getConfig(_register: $register, _schema: $schema);
- $search = $config['_search'];
+ $search = null;
+ if ($config['_search'] !== null) {
+ $search = (string)$config['_search'];
+ }
try {
$page = $this->container->get(ObjectHistoryForReaders::class)->page(
@@ -4907,7 +4910,7 @@ private function logsForReader(string $register, string $schema, string $id): JS
'page' => $config['page'],
'filters' => [],
'sort' => [],
- 'search' => ($search === null ? null : (string)$search),
+ 'search' => $search,
]
);
} catch (PurposeRefusedException $refusal) {
From d942f683a97004d052053da58dc0bebca91e0db2 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Thu, 15 Oct 2026 18:56:45 +0200
Subject: [PATCH 33/99] =?UTF-8?q?fix(review):=20#4580=20data-f1=20?=
=?UTF-8?q?=E2=80=94=20pre-push:=20admins=20pass=20the=20draft=20guards=20?=
=?UTF-8?q?like=20the=20read=20path?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The three draft guards (bulk delete filter in PermissionHandler, ObjectService::deleteObject, SaveObjects bulk-save safeguard) no longer refuse an administrator: an administrator reads every draft (MagicRbacHandler bypass) and the single save already lets one through, so they use the same group check (the 'admin' group id) the handlers already use. A non-owner non-admin is still refused. Tests: admin passes the bulk delete filter, deleteObject and the bulk save; a non-owner non-admin is still kept; and the owner deletes their own draft through ObjectService::deleteObject.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4284660284
Check: phpunit tests/Unit/Service/Object + ObjectServiceDraftDeleteTest + ObjectServiceBulkDeleteTest + BulkSafeguardSchemaResolutionTest OK 2747 tests; phpcs on the three lib files clean.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Object/PermissionHandler.php | 7 +-
lib/Service/Object/SaveObjects.php | 3 +-
lib/Service/ObjectService.php | 9 ++-
.../PermissionHandlerBulkDeleteLookupTest.php | 33 ++++++--
.../Object/Wave12BulkSafeguardsTest.php | 24 ++++++
.../Service/ObjectServiceDraftDeleteTest.php | 78 ++++++++++++++++++-
6 files changed, 142 insertions(+), 12 deletions(-)
diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php
index 9bab97ed3e..1018f2b9de 100644
--- a/lib/Service/Object/PermissionHandler.php
+++ b/lib/Service/Object/PermissionHandler.php
@@ -1829,6 +1829,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
@@ -1865,7 +1870,7 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult
// 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 ((new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $userId) === true) {
+ if ($callerIsAdmin === false && (new DraftStatusPolicy())->keptFrom(object: $object, schema: $schema, userId: $userId) === true) {
continue;
}
diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php
index 826aaf54f8..b66c3acb98 100644
--- a/lib/Service/Object/SaveObjects.php
+++ b/lib/Service/Object/SaveObjects.php
@@ -809,7 +809,8 @@ private function applyBulkSafeguards(
// 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).
- if ($effectiveRbac === true && $existingEntity !== null) {
+ // 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,
diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php
index 1a7c774005..b09fe96135 100644
--- a/lib/Service/ObjectService.php
+++ b/lib/Service/ObjectService.php
@@ -3217,8 +3217,13 @@ public function deleteObject(
// read lookup above let through. Answered as not found, like the
// save path (refuseSomebodyElsesDraft), so it is not announced.
if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null) {
- $deleter = ($actingUserId ?? $this->userSession->getUser()?->getUID());
- if ((new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleter) === true) {
+ $deleterUser = ($currentUser ?? $this->userSession->getUser());
+ $deleterIsAdmin = ($deleterUser !== null
+ && in_array('admin', $this->groupManager->getUserGroupIds($deleterUser), true) === true);
+ // An administrator reads every draft, so is not kept from one.
+ if ($deleterIsAdmin === false
+ && (new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleterUser?->getUID()) === true
+ ) {
throw new \OCP\AppFramework\Db\DoesNotExistException('Object not found');
}
}
diff --git a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php
index b469da4978..7ba3e61bb4 100644
--- a/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php
+++ b/tests/Unit/Service/Object/PermissionHandlerBulkDeleteLookupTest.php
@@ -174,6 +174,27 @@ public function testUnresolvableUuidIsDropped(): void {
* @return void
*/
public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void {
+ $this->assertSame(['draft-bob', 'record'], $this->bulkDeleteFilterAs(uid: 'bob', groups: ['staff']), "alice's draft is left out; bob's own draft and an ordinary record stay");
+ }//end testSomebodyElsesDraftIsFilteredOutOfABulkDelete()
+
+ /**
+ * An administrator is not kept from another user's draft in a bulk delete, as on the read path.
+ *
+ * @return void
+ */
+ public function testAnAdministratorKeepsEveryDraftInABulkDelete(): void {
+ $this->assertSame(['draft-bob', 'draft-alice', 'record'], $this->bulkDeleteFilterAs(uid: 'root', groups: ['admin']));
+ }//end testAnAdministratorKeepsEveryDraftInABulkDelete()
+
+ /**
+ * Run the bulk-delete filter over bob's draft, alice's draft and a record, as a user in the given groups.
+ *
+ * @param string $uid The caller.
+ * @param array $groups The caller's groups.
+ *
+ * @return array The uuids that survive.
+ */
+ private function bulkDeleteFilterAs(string $uid, array $groups): array {
$bobsDraft = $this->entity('draft-bob');
$bobsDraft->setStatus('draft');
$bobsDraft->setOwner('bob');
@@ -192,15 +213,17 @@ public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void {
$schemas = $this->createMock(SchemaMapper::class);
$schemas->method('find')->willReturn(new \OCA\OpenRegister\Db\Schema());
$user = $this->createMock(\OCP\IUser::class);
- $user->method('getUID')->willReturn('bob');
+ $user->method('getUID')->willReturn($uid);
$session = $this->createMock(IUserSession::class);
$session->method('getUser')->willReturn($user);
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('getUserGroupIds')->willReturn($groups);
$container = $this->createMock(ContainerInterface::class);
$container->method('get')->willThrowException(new \RuntimeException('no organisation service'));
$handler = new PermissionHandler(
userSession: $session,
userManager: $this->createMock(IUserManager::class),
- groupManager: $this->createMock(IGroupManager::class),
+ groupManager: $groupManager,
schemaMapper: $schemas,
objectEntityMapper: $mapper,
conditionMatcher: $this->createMock(ConditionMatcher::class),
@@ -211,10 +234,8 @@ public function testSomebodyElsesDraftIsFilteredOutOfABulkDelete(): void {
// The schema's own delete rule is not what this asks (it would need a real user and groups);
// run inside the system context so hasPermission() says yes and only the draft rule decides.
- $filtered = \OCA\OpenRegister\Service\SystemOperationContext::run(
+ return \OCA\OpenRegister\Service\SystemOperationContext::run(
static fn (): array => $handler->filterUuidsForPermissions(uuids: ['draft-bob', 'draft-alice', 'record'], _rbac: true, _multitenancy: false)
);
-
- $this->assertSame(['draft-bob', 'record'], $filtered, "alice's draft is left out; bob's own draft and an ordinary record stay");
- }//end testSomebodyElsesDraftIsFilteredOutOfABulkDelete()
+ }//end bulkDeleteFilterAs()
}//end class
diff --git a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
index 225cf031e0..f2c77ce40a 100644
--- a/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
+++ b/tests/Unit/Service/Object/Wave12BulkSafeguardsTest.php
@@ -354,6 +354,30 @@ public function testBulkSaveOverSomebodyElsesDraftIsRejected(): void {
$this->assertStringContainsString('Permission denied', $result['invalid'][0]['error']);
}//end testBulkSaveOverSomebodyElsesDraftIsRejected()
+ /**
+ * An administrator is not kept from another user's draft by the bulk save, as on the read path.
+ */
+ public function testAnAdministratorMayBulkSaveOverSomebodyElsesDraft(): void {
+ $this->mockUser('root', isAdmin: true);
+ $schema = $this->newSchema(1);
+ $register = $this->newRegister(10);
+ $this->permissionHandler->method('hasPermission')->willReturn(true);
+ $this->storeDraft(owner: 'alice');
+
+ $result = $this->initResult(1);
+ $passed = $this->callApplyBulkSafeguards(
+ objects: [['@self' => ['uuid' => '22222222-2222-2222-2222-222222222222'], 'title' => 'admin edit']],
+ register: $register,
+ schema: $schema,
+ _rbac: true,
+ _validation: false,
+ result: $result
+ );
+
+ $this->assertCount(1, $passed);
+ $this->assertSame(0, $result['statistics']['invalid']);
+ }//end testAnAdministratorMayBulkSaveOverSomebodyElsesDraft()
+
/**
* On an appendOnly schema the draft rule answers first: a non-owner is not told by the appendOnly message that the uuid exists.
*/
diff --git a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php
index 8c15fc1a89..2da28606af 100644
--- a/tests/Unit/Service/ObjectServiceDraftDeleteTest.php
+++ b/tests/Unit/Service/ObjectServiceDraftDeleteTest.php
@@ -110,6 +110,10 @@ class ObjectServiceDraftDeleteTest extends TestCase {
/** @var MockObject&DateTimeNormalizer */
private MockObject $dateTimeNormalizer;
+ private IUserSession&\PHPUnit\Framework\MockObject\MockObject $userSession;
+
+ private IGroupManager&\PHPUnit\Framework\MockObject\MockObject $groupManager;
+
/**
* Set up fresh service + mocks before each test.
*
@@ -118,6 +122,8 @@ class ObjectServiceDraftDeleteTest extends TestCase {
protected function setUp(): void {
parent::setUp();
+ $this->userSession = $this->createMock(IUserSession::class);
+ $this->groupManager = $this->createMock(IGroupManager::class);
$this->saveHandler = $this->createMock(SaveObject::class);
$this->renderHandler = $this->createMock(RenderObject::class);
$this->deleteHandler = $this->createMock(DeleteObject::class);
@@ -179,9 +185,9 @@ static function (array $obj, mixed $schema, ?string $uuid, ?int $register): arra
$this->createMock(ViewMapper::class),
$this->objectMapper,
$this->createMock(FileService::class),
- $this->createMock(IUserSession::class),
+ $this->userSession,
$this->createMock(SearchTrailService::class),
- $this->createMock(IGroupManager::class),
+ $this->groupManager,
$this->createMock(IUserManager::class),
$this->createMock(OrganisationService::class),
$this->createMock(LoggerInterface::class),
@@ -357,6 +363,74 @@ public function testSomebodyElsesDraftIsNotDeleted(): void {
}
}//end testSomebodyElsesDraftIsNotDeleted()
+ /**
+ * Sign in as a user in the given groups.
+ *
+ * @param string $uid The user id.
+ * @param array $groups The user's groups.
+ *
+ * @return void
+ */
+ private function signIn(string $uid, array $groups): void {
+ $user = $this->createMock(\OCP\IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ $this->userSession->method('getUser')->willReturn($user);
+ $this->groupManager->method('getUserGroupIds')->willReturn($groups);
+ }//end signIn()
+
+ /**
+ * The owner deletes their own draft, for good.
+ *
+ * @return void
+ */
+ public function testTheOwnerMayDeleteTheirOwnDraft(): void {
+ [$register, $schema] = $this->scope(archival: false);
+ $this->stored(status: 'draft');
+ $asked = [];
+ $this->recordHandler(asked: $asked);
+
+ // The stored draft belongs to 'agent'.
+ $this->signIn(uid: 'agent', groups: ['staff']);
+ $this->assertTrue($this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false));
+ $this->assertSame([true], $asked, "the owner's own draft is deleted for good");
+ }//end testTheOwnerMayDeleteTheirOwnDraft()
+
+ /**
+ * An administrator is not kept from another user's draft, as on the read path.
+ *
+ * @return void
+ */
+ public function testAnAdministratorMayDeleteSomebodyElsesDraft(): void {
+ [$register, $schema] = $this->scope(archival: false);
+ $this->stored(status: 'draft');
+ $asked = [];
+ $this->recordHandler(asked: $asked);
+
+ $this->signIn(uid: 'root', groups: ['admin']);
+ $this->assertTrue($this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false));
+ $this->assertSame([true], $asked);
+ }//end testAnAdministratorMayDeleteSomebodyElsesDraft()
+
+ /**
+ * A non-owner who is not an administrator is still kept from the draft.
+ *
+ * @return void
+ */
+ public function testANonOwnerNonAdministratorIsStillKept(): void {
+ [$register, $schema] = $this->scope(archival: false);
+ $this->stored(status: 'draft');
+ $asked = [];
+ $this->recordHandler(asked: $asked);
+
+ $this->signIn(uid: 'mallory', groups: ['staff']);
+ $this->expectException(\OCP\AppFramework\Db\DoesNotExistException::class);
+ try {
+ $this->service->deleteObject(uuid: 'draft-1', register: $register, schema: $schema, _rbac: true, _multitenancy: false);
+ } finally {
+ $this->assertSame([], $asked);
+ }
+ }//end testANonOwnerNonAdministratorIsStillKept()
+
/**
* Control: an ordinary object's delete stays the soft delete it was.
*
From 42248e3a529e079b26410c15385a8d5e112eb90e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 10:33:48 +0200
Subject: [PATCH 34/99] fix(l10n): user-settings wording of the VAPID hint in
the other 34 locales
The admin hint now says 'user settings' (surf-f4); en and nl were updated, the other catalogues still carried the old key, so test:l10n:parity failed on a missing key in every locale. Key and value renamed in both l10n/.js and l10n/.json, with the stale justification records adjusted in scripts/l10n/locales (sv translated, ca/sr renamed).
Co-Authored-By: Claude Opus 5.5
---
l10n/be.js | 2 +-
l10n/be.json | 2 +-
l10n/bg.js | 2 +-
l10n/bg.json | 2 +-
l10n/bs.js | 2 +-
l10n/bs.json | 2 +-
l10n/ca.js | 2 +-
l10n/ca.json | 2 +-
l10n/cs.js | 2 +-
l10n/cs.json | 2 +-
l10n/da.js | 2 +-
l10n/da.json | 2 +-
l10n/de.js | 2 +-
l10n/de.json | 2 +-
l10n/el.js | 2 +-
l10n/el.json | 2 +-
l10n/es.js | 2 +-
l10n/es.json | 2 +-
l10n/et.js | 2 +-
l10n/et.json | 2 +-
l10n/fi.js | 2 +-
l10n/fi.json | 2 +-
l10n/fr.js | 2 +-
l10n/fr.json | 2 +-
l10n/ga.js | 2 +-
l10n/ga.json | 2 +-
l10n/hr.js | 2 +-
l10n/hr.json | 2 +-
l10n/hu.js | 2 +-
l10n/hu.json | 2 +-
l10n/is.js | 2 +-
l10n/is.json | 2 +-
l10n/it.js | 2 +-
l10n/it.json | 2 +-
l10n/lb.js | 2 +-
l10n/lb.json | 2 +-
l10n/lt.js | 2 +-
l10n/lt.json | 2 +-
l10n/lv.js | 2 +-
l10n/lv.json | 2 +-
l10n/mk.js | 2 +-
l10n/mk.json | 2 +-
l10n/mt.js | 2 +-
l10n/mt.json | 2 +-
l10n/nb.js | 2 +-
l10n/nb.json | 2 +-
l10n/pl.js | 2 +-
l10n/pl.json | 2 +-
l10n/pt.js | 2 +-
l10n/pt.json | 2 +-
l10n/rm.js | 2 +-
l10n/rm.json | 2 +-
l10n/ro.js | 2 +-
l10n/ro.json | 2 +-
l10n/ru.js | 2 +-
l10n/ru.json | 2 +-
l10n/sk.js | 2 +-
l10n/sk.json | 2 +-
l10n/sl.js | 2 +-
l10n/sl.json | 2 +-
l10n/sq.js | 2 +-
l10n/sq.json | 2 +-
l10n/sr.js | 2 +-
l10n/sr.json | 2 +-
l10n/sv.js | 2 +-
l10n/sv.json | 2 +-
l10n/tr.js | 2 +-
l10n/tr.json | 2 +-
l10n/uk.js | 2 +-
l10n/uk.json | 2 +-
scripts/l10n/locales/ca.json | 2 +-
scripts/l10n/locales/sr.json | 2 +-
scripts/l10n/locales/sv.json | 1 -
73 files changed, 72 insertions(+), 73 deletions(-)
diff --git a/l10n/be.js b/l10n/be.js
index bb6a8a0854..0139d5024f 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": "ИИ-чат",
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..adb6e4a8b3 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 чат",
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..d6483bc4cf 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",
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..799c7bd2d5 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",
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..eb8eb302db 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",
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..4822f40342 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",
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..ee7304ef90 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",
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..2f956f6139 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",
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/es.js b/l10n/es.js
index 38f1bb0cef..21575bb408 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",
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..4f64298677 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",
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..4a04130562 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",
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..e8ea4fd6d4 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",
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..c074ce8145 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",
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..58563390c8 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",
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..65d9f18b0f 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",
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..82b641a511 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",
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..60f7a2fa5f 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",
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..b5004f658d 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",
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..8644d76724 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",
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..9ee0d32d4d 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",
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..03388d6193 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 разговор",
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..6b9085d7e2 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",
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..84cb4d74fa 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",
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/pl.js b/l10n/pl.js
index f91f12fb6e..1083c25184 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",
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..b4cb5262d6 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",
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..b42f54bc0d 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",
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..11de284a33 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",
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..5ad93db803 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": "ИИ-чат",
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..c3b84480ae 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",
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..4f57519c3d 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",
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..875113b161 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",
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..de93620eaa 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",
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..115b703a74 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",
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..627cb0a106 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",
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..075118dac8 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": "ИИ-чат",
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/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.",
From 6d71d17cd1ff455a54db05a3a1f57ff4c99e353e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 11:09:06 +0200
Subject: [PATCH 35/99] fix(quality): phpcs line length and phpmd findings in
the review fixes
Wrap the five lines over 150 characters, import-alias the DoesNotExistException the draft-delete guard throws, and bring the phpmd findings of the review fixes under their thresholds: RelationDecisionService::decide() and FrozenNodeWriteListener::refusalFor() lose their ownership and owner-lookup steps to private helpers, EntityRelationMapper::linkDecision() and ShippedConfigurationGuard lose their else branches and logging block to helpers. Suppressions with a reason only where the repo does the same for comparable code (DI constructor parameter count, class complexity, method count, coupling, a flag naming where a walk starts).
Co-Authored-By: Claude Opus 5.5
---
lib/Db/EntityRelationMapper.php | 33 ++++++--
lib/Db/MagicMapper/MagicRbacHandler.php | 3 +
lib/Listener/FrozenNodeWriteListener.php | 28 ++++++-
lib/Service/Case/CasePlanItemTimers.php | 11 ++-
.../Decision/RelationDecisionService.php | 56 +++++++++-----
.../Deferral/ListenerDeferralService.php | 4 +-
lib/Service/File/DeleteFileHandler.php | 3 +-
lib/Service/Form/FormSubmitService.php | 2 +
lib/Service/ObjectService.php | 5 +-
.../ShippedConfigurationGuard.php | 76 +++++++++++++------
10 files changed, 166 insertions(+), 55 deletions(-)
diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php
index 761d6e8b81..17e3a0d613 100644
--- a/lib/Db/EntityRelationMapper.php
+++ b/lib/Db/EntityRelationMapper.php
@@ -26,6 +26,7 @@
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;
@@ -963,18 +964,22 @@ private function emitDecisionMetadataAuditEntry(
* @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, ?string $expectedUuid = null, bool $guarded = false): 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)));
- if ($expectedUuid === null || $expectedUuid === '') {
- $qb->andWhere($qb->expr()->orX($qb->expr()->isNull('decision_uuid'), $qb->expr()->eq('decision_uuid', $qb->createNamedParameter(''))));
- } else {
- $qb->andWhere($qb->expr()->eq('decision_uuid', $qb->createNamedParameter($expectedUuid)));
- }
+ $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.');
@@ -985,4 +990,20 @@ public function linkDecision(EntityRelation $relation, string $decisionUuid, ?st
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/MagicRbacHandler.php b/lib/Db/MagicMapper/MagicRbacHandler.php
index f94304ebc7..175e8b2e54 100644
--- a/lib/Db/MagicMapper/MagicRbacHandler.php
+++ b/lib/Db/MagicMapper/MagicRbacHandler.php
@@ -131,6 +131,9 @@ class MagicRbacHandler {
* 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,
diff --git a/lib/Listener/FrozenNodeWriteListener.php b/lib/Listener/FrozenNodeWriteListener.php
index ecf2034819..6e3ef1122d 100644
--- a/lib/Listener/FrozenNodeWriteListener.php
+++ b/lib/Listener/FrozenNodeWriteListener.php
@@ -206,6 +206,28 @@ private function refusalFor(Node $node): ?ObjectStateWriteException {
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(
identifier: $candidate['uuid'],
@@ -234,8 +256,8 @@ 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.
@@ -297,6 +319,8 @@ private function homeNodeOf(Node $node, string $home): ?array {
* @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, bool $includeSelf = false): ?array {
$current = $node;
diff --git a/lib/Service/Case/CasePlanItemTimers.php b/lib/Service/Case/CasePlanItemTimers.php
index 0542899c0f..7fc7534864 100644
--- a/lib/Service/Case/CasePlanItemTimers.php
+++ b/lib/Service/Case/CasePlanItemTimers.php
@@ -222,14 +222,21 @@ private function arm(CaseItem $item, string $purpose, DateTimeInterface $deadlin
// active without the timer that enforces its statutory expiry.
$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 and nothing will end the item. %s',
+ '[CasePlanItemTimers] Plan item %s of case object %s is active WITHOUT its statutory (%s) %s timer: '
+ . 'the term will not run and nothing will end the item. %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]
+ [
+ 'item' => $item->getUuid(),
+ 'case' => $item->getObjectUuid(),
+ 'legalEffect' => FlowTimer::LEGAL_WETTELIJK,
+ 'purpose' => $purpose,
+ 'exception' => $failure,
+ ]
);
return false;
diff --git a/lib/Service/Decision/RelationDecisionService.php b/lib/Service/Decision/RelationDecisionService.php
index 8ca3b3f8ad..67c59137ca 100644
--- a/lib/Service/Decision/RelationDecisionService.php
+++ b/lib/Service/Decision/RelationDecisionService.php
@@ -41,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 {
@@ -105,23 +108,7 @@ public function decide(EntityRelation $relation, array $body, IUser $actor): arr
$actorId = $actor->getUID();
$linkedUuid = $relation->getDecisionUuid();
$current = $this->decisions->find(uuid: $linkedUuid);
- $owned = $current;
- if ($owned === null && $linkedUuid !== null && $linkedUuid !== '') {
- // The relation names a decision whose record cannot be found.
- // DecisionVisibility hides that state; here it belongs to nobody
- // but a supervisor, so a reviewer cannot take it over by the gap.
- $owned = new Decision();
- $owned->setUuid($linkedUuid);
- $owned->setDecidedBy('');
- }
-
- if ($owned !== null) {
- $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);
- }
- }
+ $this->assertMayChange(relation: $relation, current: $current, linkedUuid: $linkedUuid, actorId: $actorId);
$before = [$relation->getBases(), $relation->getSkipAnonymization()];
@@ -173,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/Deferral/ListenerDeferralService.php b/lib/Service/Deferral/ListenerDeferralService.php
index ea96b09ba8..699b774983 100644
--- a/lib/Service/Deferral/ListenerDeferralService.php
+++ b/lib/Service/Deferral/ListenerDeferralService.php
@@ -363,7 +363,9 @@ private function chunksUnderLimit(string $jobClass, array $entries): array {
$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',
+ 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__,
diff --git a/lib/Service/File/DeleteFileHandler.php b/lib/Service/File/DeleteFileHandler.php
index abb85e0c50..12b499fc31 100644
--- a/lib/Service/File/DeleteFileHandler.php
+++ b/lib/Service/File/DeleteFileHandler.php
@@ -143,7 +143,8 @@ public function deleteFile(Node|string|int $file, ?ObjectEntity $object = null):
// 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).',
+ 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;
diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php
index 63965d9c07..e1504614c2 100644
--- a/lib/Service/Form/FormSubmitService.php
+++ b/lib/Service/Form/FormSubmitService.php
@@ -71,6 +71,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.
diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php
index b09fe96135..854d40c736 100644
--- a/lib/Service/ObjectService.php
+++ b/lib/Service/ObjectService.php
@@ -3221,10 +3221,11 @@ public function deleteObject(
$deleterIsAdmin = ($deleterUser !== null
&& in_array('admin', $this->groupManager->getUserGroupIds($deleterUser), true) === true);
// An administrator reads every draft, so is not kept from one.
+ $deleterUid = $deleterUser?->getUID();
if ($deleterIsAdmin === false
- && (new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleterUser?->getUID()) === true
+ && (new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleterUid) === true
) {
- throw new \OCP\AppFramework\Db\DoesNotExistException('Object not found');
+ throw new OcpDoesNotExistException('Object not found');
}
}
diff --git a/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php b/lib/Service/ShippedBaseline/ShippedConfigurationGuard.php
index 71256c8156..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,7 +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));
+ $this->recordAppliedAuthorization(
+ slug: $slug,
+ app: $app,
+ applied: $result['applied'],
+ recorded: array_merge($result['replacedLocal'], $decisions)
+ );
if ($record === true) {
$this->baselines->record(
@@ -174,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);
@@ -220,6 +217,27 @@ 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.
*
@@ -234,12 +252,7 @@ public function guardSchemaUpdate(
* @return array The definition to write.
*/
private function keepingLiveAuthorization(array $live, array $incoming): array {
- $definition = $incoming;
- if (array_key_exists('authorization', $live) === true) {
- $definition['authorization'] = $live['authorization'];
- } else {
- unset($definition['authorization']);
- }
+ $definition = $this->withAuthorizationOf(target: $incoming, live: $live);
$liveProperties = ($live['properties'] ?? null);
if (is_array($liveProperties) === false || is_array($definition['properties'] ?? null) === false) {
@@ -251,16 +264,31 @@ private function keepingLiveAuthorization(array $live, array $incoming): array {
continue;
}
- if (array_key_exists('authorization', $liveProperties[$name]) === true) {
- $definition['properties'][$name]['authorization'] = $liveProperties[$name]['authorization'];
- } else {
- unset($definition['properties'][$name]['authorization']);
- }
+ $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.
*
From ca4dddafe450bc2fc3ebebf36e671b28cdbcbd45 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 11:09:06 +0200
Subject: [PATCH 36/99] test(aggregation): give the SQLite fixtures the _status
column the draft lens names
The native aggregation path now adds the draft clause (data-f5), which names _status; real magic tables carry that column, the hand-made SQLite fixtures of four native-path tests did not, so the native query failed and every one fell back to the PHP path.
Co-Authored-By: Claude Opus 5.5
---
.../Aggregation/AggregationJoinAndCompositeGroupByTest.php | 4 ++--
.../Aggregation/AggregationRunnerMultiFieldGroupByTest.php | 2 +-
.../Service/Aggregation/AggregationRunnerMultiMetricTest.php | 2 +-
.../Aggregation/AggregationRunnerMultiValueFilterTest.php | 4 ++--
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php
index 1cf31e676c..d5880113f3 100644
--- a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php
+++ b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php
@@ -1585,7 +1585,7 @@ private function seedSqlite(): PDO {
$pdo->exec(
'CREATE TABLE "oc_register_1_commitment_line" (
"_deleted" TEXT,
- "_archived" TEXT,
+ "_archived" TEXT, "_status" TEXT,
"_organisation" TEXT,
"programme" TEXT,
"cost_centre" TEXT,
@@ -1595,7 +1595,7 @@ private function seedSqlite(): PDO {
$pdo->exec(
'CREATE TABLE "oc_register_1_commitment_budget" (
"_deleted" TEXT,
- "_archived" TEXT,
+ "_archived" TEXT, "_status" TEXT,
"_organisation" TEXT,
"programme_code" TEXT,
"authorised_amount" INTEGER
diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php
index 12f9eb7989..13ef39ee53 100644
--- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php
+++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php
@@ -317,7 +317,7 @@ private function seedSqlite(): PDO {
$pdo->exec(
'CREATE TABLE "oc_register_1_schema_ap_tx" (
"_deleted" TEXT,
- "_archived" TEXT,
+ "_archived" TEXT, "_status" TEXT,
"_organisation" TEXT,
"vendor_id" TEXT,
"due_date_bucket" TEXT,
diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php
index 3751f244d6..0a195211a1 100644
--- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php
+++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php
@@ -325,7 +325,7 @@ public function testOneElementMetricsListBehavesLikeLegacySingleMetric(): void {
private function makeNativeRunner(): AggregationRunner {
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
- $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_organisation" TEXT, "status" TEXT, "amount" INTEGER)');
+ $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "status" TEXT, "amount" INTEGER)');
$insert = $pdo->prepare('INSERT INTO "oc_register_1_schema_x" ("_deleted", "_organisation", "status", "amount") VALUES (NULL, ?, ?, ?)');
foreach ($this->dataset() as $row) {
$insert->execute(['__no_active_org__', $row['status'], $row['amount']]);
diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php
index a90303e60e..325a26491a 100644
--- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php
+++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php
@@ -451,13 +451,13 @@ private function makeNativeRunner(array $dataset, mixed &$capturedSql, ?Schema $
$isTags = ($schema !== null && $schema->getSlug() === 'tagged-items');
if ($isTags === true) {
- $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_organisation" TEXT, "id" INTEGER, "tags" TEXT)');
+ $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "id" INTEGER, "tags" TEXT)');
$insert = $pdo->prepare('INSERT INTO "oc_register_1_schema_x" ("_deleted", "_organisation", "id", "tags") VALUES (NULL, ?, ?, ?)');
foreach ($dataset as $row) {
$insert->execute(['__no_active_org__', $row['id'], json_encode($row['tags'])]);
}
} else {
- $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_organisation" TEXT, "status" TEXT, "region" TEXT, "amount" INTEGER)');
+ $pdo->exec('CREATE TABLE "oc_register_1_schema_x" ("_deleted" TEXT, "_archived" TEXT, "_status" TEXT, "_organisation" TEXT, "status" TEXT, "region" TEXT, "amount" INTEGER)');
$insert = $pdo->prepare('INSERT INTO "oc_register_1_schema_x" ("_deleted", "_organisation", "status", "region", "amount") VALUES (NULL, ?, ?, ?, ?)');
foreach ($dataset as $row) {
$insert->execute(['__no_active_org__', $row['status'], ($row['region'] ?? null), $row['amount']]);
From db13f88c035d673c795e826ba50b53b54bce621c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 11:09:06 +0200
Subject: [PATCH 37/99] fix(spec): follow the renamed oauth2-connect
requirement in its @spec anchors
The requirement heading now ends 'from user settings' (surf-f4); the @spec tags in OAuth2ConnectionsSection.vue and the e2e spec still pointed at the personal-settings slug, which gate 46 reports as 13 unresolved anchors.
Co-Authored-By: Claude Opus 5.5
---
.../userSettings/OAuth2ConnectionsSection.vue | 24 +++++++++----------
tests/e2e/credential-oauth2-connect.spec.ts | 2 +-
2 files changed, 13 insertions(+), 13 deletions(-)
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/tests/e2e/credential-oauth2-connect.spec.ts b/tests/e2e/credential-oauth2-connect.spec.ts
index 84baa627d5..ea3c10e519 100644
--- a/tests/e2e/credential-oauth2-connect.spec.ts
+++ b/tests/e2e/credential-oauth2-connect.spec.ts
@@ -30,7 +30,7 @@
* @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-re-authorisation-overrides-the-same-credential
* @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-disconnecting-revokes-upstream-where-it-can-and-disables-locally
* @spec openspec/specs/credential-oauth2-connect/spec.md#requirement-bluesky-is-its-own-client-and-mastodon-registers-per-instance
- * @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
*/
import { expect, test } from '@playwright/test'
From 3a395d87bd19fa72b7257dc3ee52eeb599e39f19 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:41:52 +0200
Subject: [PATCH 38/99] =?UTF-8?q?fix(review):=20#4580=20flow-run=20?=
=?UTF-8?q?=E2=80=94=20direct-invoke=20caller=20config=20is=20limited=20to?=
=?UTF-8?q?=20the=20node's=20declared=20keys?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
executeNode() merged the caller's config over the published step's config with caller keys winning and no vocabulary check, so a caller who may read the subject could set any key of an opted-in node. Only keys the node declares (IFlowNodeConfigKeys::configKeys(), else the keys of its IFlowNodeConfigForm) are taken now; a node declaring neither takes nothing from the caller. Spec scenario and two unit tests added.
Thread: discussion r4284660550 (lib/Service/Flow/FlowRunService.php:1090)
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Flow/FlowRunService.php | 53 ++++++++++++++++++-
.../specs/flow-run-node/spec.md | 11 ++++
.../Flow/FlowRunServiceExecuteNodeTest.php | 51 +++++++++++++++++-
3 files changed, 112 insertions(+), 3 deletions(-)
diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php
index c763a69040..72f4b2d294 100644
--- a/lib/Service/Flow/FlowRunService.php
+++ b/lib/Service/Flow/FlowRunService.php
@@ -1101,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);
@@ -1149,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/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/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php b/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php
index 5d4ee4144a..2ad81c0cac 100644
--- a/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php
+++ b/tests/Unit/Service/Flow/FlowRunServiceExecuteNodeTest.php
@@ -17,6 +17,7 @@
use OCA\OpenRegister\Service\Flow\FlowRunService;
use OCA\OpenRegister\Service\Flow\FlowSuspension;
use OCA\OpenRegister\Service\Flow\IFlowNode;
+use OCA\OpenRegister\Service\Flow\IFlowNodeConfigKeys;
use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent;
use OCA\OpenRegister\Tests\Unit\Service\Flow\PublishedVersionDouble;
use OCP\EventDispatcher\Event;
@@ -77,6 +78,13 @@ public function execute(array $items, array $config, array $context): array {
}
}
+/** A counting node that declares the config keys it reads, so a caller may only set those. */
+class KeyedCountingNode extends CountingNode implements IFlowNodeConfigKeys {
+ public function configKeys(): array {
+ return ['templateId', 'folder'];
+ }
+}
+
/** Always throws, to exercise the FAILED path. */
class ExplodingNode implements IFlowNode {
public function getId(): string {
@@ -147,7 +155,7 @@ class FlowRunServiceExecuteNodeTest extends TestCase {
private FlowRunMapper $mapper;
private FlowRunService $service;
- private CountingNode $target;
+ private KeyedCountingNode $target;
private CountingNode $downstream;
protected function setUp(): void {
@@ -157,7 +165,7 @@ protected function setUp(): void {
$this->mapper->method('insert')->willReturnArgument(0);
$this->mapper->method('update')->willReturnArgument(0);
- $this->target = new CountingNode('test.target');
+ $this->target = new KeyedCountingNode('test.target');
$this->downstream = new CountingNode('test.downstream');
$exploding = new ExplodingNode();
$suspending = new SuspendingNode();
@@ -338,4 +346,43 @@ public function testTheCallersConfigOverlaysThePublishedStep(): void {
$this->assertSame(['templateId' => 'picked', 'folder' => 'Besluiten'], $this->target->lastConfig);
}//end testTheCallersConfigOverlaysThePublishedStep()
+
+ /**
+ * A caller key the node does not declare never reaches it: the author's
+ * published value stays, and the extra key is dropped.
+ *
+ * @return void
+ */
+ public function testACallerKeyOutsideTheNodesVocabularyDoesNotReachIt(): void {
+ $run = $this->service->queue(flowId: 'f1', trigger: FlowRunService::TRIGGER_DIRECT_NODE, user: 'alice');
+
+ $this->service->executeNode(
+ run: $run,
+ flow: $this->flowDoc(),
+ subject: new ExecuteNodeSubject(),
+ nodeId: 'target',
+ config: ['templateId' => 'picked', 'webhookUrl' => 'https://example.invalid/', 'folder' => 'Elders']
+ );
+
+ $this->assertSame(['templateId' => 'picked', 'folder' => 'Elders'], $this->target->lastConfig);
+ }//end testACallerKeyOutsideTheNodesVocabularyDoesNotReachIt()
+
+ /**
+ * A node that declares no config vocabulary takes nothing from the caller.
+ *
+ * @return void
+ */
+ public function testANodeThatDeclaresNoKeysTakesNothingFromTheCaller(): void {
+ $run = $this->service->queue(flowId: 'f1', trigger: FlowRunService::TRIGGER_DIRECT_NODE, user: 'alice');
+
+ $this->service->executeNode(
+ run: $run,
+ flow: $this->flowDoc(),
+ subject: new ExecuteNodeSubject(),
+ nodeId: 'downstream',
+ config: ['anything' => 'x']
+ );
+
+ $this->assertSame([], $this->downstream->lastConfig);
+ }//end testANodeThatDeclaresNoKeysTakesNothingFromTheCaller()
}//end class
From 84686f454ef4ab1a84dce9334ec1cdd63b1c4d00 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:47:34 +0200
Subject: [PATCH 39/99] =?UTF-8?q?fix(review):=20#4580=20r4293205541=20?=
=?UTF-8?q?=E2=80=94=20hasPermission()=20keeps=20another=20user's=20draft?=
=?UTF-8?q?=20from=20every=20action?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The draft rule lived in the SQL read predicate only, so a check that is handed the stored object (the readable audit trail, bulk save, a transition, a file) answered yes for a draft the object API hides. PermissionHandler::hasPermission() now asks DraftStatusPolicy::keptFrom() when it is given an object and refuses every action when the draft is kept from the caller. Same exemptions as the read path: an administrator, RBAC off, a system operation, and the command line without a user pass; the owner passes; a schema with draftsVisible shows its drafts; an anonymous web caller is kept. The /api/audit-trails/readable list therefore no longer returns the create/update rows (with their changed values) of somebody else's draft.
Tests: PermissionHandlerDraftRuleTest (red without the rule), two cases in ReadableAuditTrailListerTest over the real handler (bob gets nothing, alice gets her draft's rows).
Threads: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205541 (and the same root cause as r4284660284, r4293205529)
Refs #4666
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Object/PermissionHandler.php | 65 +++++
.../Audit/ReadableAuditTrailListerTest.php | 90 ++++++
.../Object/PermissionHandlerDraftRuleTest.php | 275 ++++++++++++++++++
3 files changed, 430 insertions(+)
create mode 100644 tests/Unit/Service/Object/PermissionHandlerDraftRuleTest.php
diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php
index 1018f2b9de..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.
*
diff --git a/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php b/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php
index 24cf5370c5..bf8431a47d 100644
--- a/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php
+++ b/tests/Unit/Service/Audit/ReadableAuditTrailListerTest.php
@@ -35,8 +35,16 @@
use OCA\OpenRegister\Db\Schema;
use OCA\OpenRegister\Db\SchemaMapper;
use OCA\OpenRegister\Service\Audit\ReadableAuditTrailLister;
+use OCA\OpenRegister\Service\ConditionMatcher;
use OCA\OpenRegister\Service\Object\PermissionHandler;
+use OCP\IAppConfig;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
/**
* @covers \OCA\OpenRegister\Service\Audit\ReadableAuditTrailLister
@@ -184,6 +192,88 @@ public function testAnUnreadableObjectsEntryIsAbsent(): void {
$this->assertCount(1, $page['results']);
}//end testAnUnreadableObjectsEntryIsAbsent()
+ /**
+ * A lister over the REAL permission handler, for one caller and one draft owned by somebody else.
+ *
+ * @param string $caller The signed-in user.
+ *
+ * @return array The uuids of the entries the caller gets back.
+ */
+ private function draftEntriesFor(string $caller): array {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($caller);
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $userManager = $this->createMock(IUserManager::class);
+ $userManager->method('get')->willReturn($user);
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('getUserGroupIds')->willReturn(['staff']);
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willThrowException(new \RuntimeException('not available'));
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueBool')->willReturnCallback(
+ static fn (string $app, string $key, bool $default=false): bool => $default
+ );
+
+ // The schema lets every signed-in user read: only the draft rule can refuse.
+ $schema = new Schema();
+ $schema->setId(7);
+ $schema->setAuthorization(['read' => ['authenticated']]);
+ $schemaMapper = $this->createMock(SchemaMapper::class);
+ $schemaMapper->method('find')->willReturn($schema);
+
+ $draft = $this->object(uuid: 'obj-draft');
+ $draft->setStatus('draft');
+ $draft->setOwner('alice');
+
+ $objectMapper = $this->createMock(MagicMapper::class);
+ $objectMapper->method('findMultipleAcrossAllMagicTables')->willReturn([$draft]);
+ $auditMapper = $this->createMock(AuditTrailMapper::class);
+ $auditMapper->method('findAll')->willReturnCallback(
+ fn (?int $limit = null, ?int $offset = null): array => (($offset !== null && $offset > 0) ? [] : [$this->entry(objectUuid: 'obj-draft', action: 'create')])
+ );
+
+ $lister = new ReadableAuditTrailLister(
+ auditTrailMapper: $auditMapper,
+ objectMapper: $objectMapper,
+ schemaMapper: $schemaMapper,
+ permissionHandler: new PermissionHandler(
+ userSession: $session,
+ userManager: $userManager,
+ groupManager: $groupManager,
+ schemaMapper: $schemaMapper,
+ objectEntityMapper: $objectMapper,
+ conditionMatcher: $this->createMock(ConditionMatcher::class),
+ appConfig: $appConfig,
+ logger: $this->createMock(LoggerInterface::class),
+ container: $container,
+ )
+ );
+
+ return array_column($lister->page(userId: $caller, limit: 20)['results'], 'objectUuid');
+ }//end draftEntriesFor()
+
+ /**
+ * What a user typed into a draft is not in the readable trail of anybody else (the draft rule of hasPermission()).
+ *
+ * Goes red without the draft rule: the schema lets bob read, and the entry
+ * holds the draft's values in its `changed` column.
+ *
+ * @return void
+ */
+ public function testSomebodyElsesDraftHasNoEntriesInTheTrail(): void {
+ $this->assertSame([], $this->draftEntriesFor(caller: 'bob'), "bob got the audit entries of alice's draft.");
+ }//end testSomebodyElsesDraftHasNoEntriesInTheTrail()
+
+ /**
+ * The owner still reads the entries of their own draft.
+ *
+ * @return void
+ */
+ public function testTheOwnerStillGetsTheEntriesOfTheirDraft(): void {
+ $this->assertSame(['obj-draft'], $this->draftEntriesFor(caller: 'alice'));
+ }//end testTheOwnerStillGetsTheEntriesOfTheirDraft()
+
/**
* An anonymous caller gets nothing, and the mapper is never asked.
*
diff --git a/tests/Unit/Service/Object/PermissionHandlerDraftRuleTest.php b/tests/Unit/Service/Object/PermissionHandlerDraftRuleTest.php
new file mode 100644
index 0000000000..c55dd620e2
--- /dev/null
+++ b/tests/Unit/Service/Object/PermissionHandlerDraftRuleTest.php
@@ -0,0 +1,275 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Test
+ * @package OCA\OpenRegister\Tests\Unit\Service\Object
+ *
+ * @author Conduction Development Team
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @version GIT:
+ *
+ * @link https://OpenRegister.app
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Service\Object;
+
+use OCA\OpenRegister\Db\MagicMapper;
+use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\ConditionMatcher;
+use OCA\OpenRegister\Service\Object\PermissionHandler;
+use OCA\OpenRegister\Service\SystemOperationContext;
+use OCP\IAppConfig;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Locks the draft rule of PermissionHandler::hasPermission().
+ */
+class PermissionHandlerDraftRuleTest extends TestCase {
+
+ /**
+ * Build a handler for a caller.
+ *
+ * @param string|null $uid The signed-in user, null for nobody.
+ * @param array $groups The caller's groups.
+ * @param bool $commandLine Whether the handler reads as the command line.
+ *
+ * @return PermissionHandler The handler under test.
+ */
+ private function handlerFor(?string $uid, array $groups=['staff'], bool $commandLine=false): PermissionHandler {
+ $user = null;
+ if ($uid !== null) {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ }
+
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $userManager = $this->createMock(IUserManager::class);
+ $userManager->method('get')->willReturn($user);
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('getUserGroupIds')->willReturn($groups);
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willThrowException(new \RuntimeException('not available'));
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueBool')->willReturnCallback(
+ static fn (string $app, string $key, bool $default=false): bool => ($key === 'rbac.inherit_from_public_default' || $default)
+ );
+
+ return new class (
+ $session,
+ $userManager,
+ $groupManager,
+ $this->createMock(SchemaMapper::class),
+ $this->createMock(MagicMapper::class),
+ $this->createMock(ConditionMatcher::class),
+ $appConfig,
+ $this->createMock(LoggerInterface::class),
+ $container,
+ $commandLine
+ ) extends PermissionHandler {
+ /**
+ * @param mixed $session The session.
+ * @param mixed $userManager The user manager.
+ * @param mixed $groupManager The group manager.
+ * @param mixed $schemaMapper The schema mapper.
+ * @param mixed $mapper The object mapper.
+ * @param mixed $matcher The condition matcher.
+ * @param mixed $appConfig The app config.
+ * @param mixed $logger The logger.
+ * @param mixed $container The container.
+ * @param bool $commandLine Whether to read as the command line.
+ */
+ public function __construct(
+ $session,
+ $userManager,
+ $groupManager,
+ $schemaMapper,
+ $mapper,
+ $matcher,
+ $appConfig,
+ $logger,
+ $container,
+ private readonly bool $commandLine
+ ) {
+ parent::__construct($session, $userManager, $groupManager, $schemaMapper, $mapper, $matcher, $appConfig, $logger, $container);
+ }
+
+ /**
+ * @return bool
+ */
+ protected function isCommandLine(): bool {
+ return $this->commandLine;
+ }
+ };
+ }//end handlerFor()
+
+ /**
+ * A schema whose rules let anybody do anything, so only the draft rule can refuse.
+ *
+ * @param bool $draftsVisible Whether the schema shows its drafts to others.
+ *
+ * @return Schema The schema.
+ */
+ private function schema(bool $draftsVisible=false): Schema {
+ $schema = new Schema();
+ $schema->setId(5);
+ $schema->setAuthorization(
+ [
+ 'read' => ['authenticated', 'public'],
+ 'update' => ['authenticated', 'public'],
+ 'delete' => ['authenticated', 'public'],
+ ]
+ );
+ $schema->setConfiguration(['draftsVisible' => $draftsVisible]);
+
+ return $schema;
+ }//end schema()
+
+ /**
+ * A stored object.
+ *
+ * @param string $status The lifecycle status.
+ * @param string $owner The owner.
+ *
+ * @return ObjectEntity The object.
+ */
+ private function object(string $status, string $owner='alice'): ObjectEntity {
+ $object = new ObjectEntity();
+ $object->setUuid('11111111-1111-4111-8111-111111111111');
+ $object->setStatus($status);
+ $object->setOwner($owner);
+ $object->setSchema('5');
+
+ return $object;
+ }//end object()
+
+ /**
+ * Every action on somebody else's draft is refused for a non-owner, and passes for the same object once it is not a draft.
+ *
+ * Fails without the draft rule: the schema's rules grant every verb to a
+ * signed-in user.
+ *
+ * @return void
+ */
+ public function testSomebodyElsesDraftIsRefusedForEveryAction(): void {
+ $handler = $this->handlerFor(uid: 'bob');
+ foreach (['read', 'update', 'delete'] as $action) {
+ $this->assertFalse(
+ $handler->hasPermission(schema: $this->schema(), action: $action, objectOwner: 'alice', object: $this->object(status: 'draft')),
+ "bob may not $action alice's draft"
+ );
+ $this->assertTrue(
+ $handler->hasPermission(schema: $this->schema(), action: $action, objectOwner: 'alice', object: $this->object(status: 'active')),
+ "bob may $action alice's definitive object"
+ );
+ }
+ }//end testSomebodyElsesDraftIsRefusedForEveryAction()
+
+ /**
+ * The explicit user id is the subject, not the session's user.
+ *
+ * @return void
+ */
+ public function testTheExplicitUserIdIsTheSubject(): void {
+ $handler = $this->handlerFor(uid: 'alice');
+ $this->assertFalse(
+ $handler->hasPermission(schema: $this->schema(), action: 'read', userId: 'bob', objectOwner: 'alice', object: $this->object(status: 'draft'))
+ );
+ }//end testTheExplicitUserIdIsTheSubject()
+
+ /**
+ * The owner keeps their own draft.
+ *
+ * @return void
+ */
+ public function testTheOwnerKeepsTheirDraft(): void {
+ $handler = $this->handlerFor(uid: 'alice');
+ $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $this->object(status: 'draft')));
+ $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'delete', objectOwner: 'alice', object: $this->object(status: 'draft')));
+ }//end testTheOwnerKeepsTheirDraft()
+
+ /**
+ * An administrator reads every draft, as on the read path.
+ *
+ * @return void
+ */
+ public function testAnAdministratorIsNotKeptFromADraft(): void {
+ $handler = $this->handlerFor(uid: 'root', groups: ['admin']);
+ $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $this->object(status: 'draft')));
+ }//end testAnAdministratorIsNotKeptFromADraft()
+
+ /**
+ * A schema that sets draftsVisible lets others reach its drafts.
+ *
+ * @return void
+ */
+ public function testASchemaWithVisibleDraftsLetsOthersIn(): void {
+ $handler = $this->handlerFor(uid: 'bob');
+ $this->assertTrue($handler->hasPermission(schema: $this->schema(draftsVisible: true), action: 'read', objectOwner: 'alice', object: $this->object(status: 'draft')));
+ }//end testASchemaWithVisibleDraftsLetsOthersIn()
+
+ /**
+ * A check without an object is not a draft question: the rule asks only when it is handed the stored object.
+ *
+ * @return void
+ */
+ public function testACheckWithoutAnObjectIsUnchanged(): void {
+ $handler = $this->handlerFor(uid: 'bob');
+ $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice'));
+ }//end testACheckWithoutAnObjectIsUnchanged()
+
+ /**
+ * RBAC off and a system operation are not kept from a draft: their caller judges them.
+ *
+ * @return void
+ */
+ public function testRbacOffAndTheSystemContextPass(): void {
+ $handler = $this->handlerFor(uid: 'bob');
+ $draft = $this->object(status: 'draft');
+ $this->assertTrue($handler->hasPermission(schema: $this->schema(), action: 'update', objectOwner: 'alice', _rbac: false, object: $draft));
+ $this->assertTrue(
+ SystemOperationContext::run(fn (): bool => $handler->hasPermission(schema: $this->schema(), action: 'update', objectOwner: 'alice', object: $draft))
+ );
+ }//end testRbacOffAndTheSystemContextPass()
+
+ /**
+ * An anonymous web caller is kept from a draft, and the command line without a user is the system.
+ *
+ * @return void
+ */
+ public function testAnAnonymousWebCallerIsKeptAndTheCommandLineIsNot(): void {
+ $draft = $this->object(status: 'draft');
+ $this->assertFalse(
+ $this->handlerFor(uid: null, commandLine: false)->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $draft)
+ );
+ $this->assertTrue(
+ $this->handlerFor(uid: null, commandLine: true)->hasPermission(schema: $this->schema(), action: 'read', objectOwner: 'alice', object: $draft)
+ );
+ }//end testAnAnonymousWebCallerIsKeptAndTheCommandLineIsNot()
+}//end class
From 5165a2b2534f5421c67d11fe56f3e8ead004b15b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:50:18 +0200
Subject: [PATCH 40/99] =?UTF-8?q?fix(review):=20#4580=20r4293205529=20?=
=?UTF-8?q?=E2=80=94=20bulk=20delete=20follows=20the=20draft=20delete=20ru?=
=?UTF-8?q?les?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
deleteObjects() ran the delete handler without the draft rules deleteObject() has. A draft is now removed permanently (its files and its one draft.removed audit entry, no tombstone of the unsent input), and it does not meet the archival gate, so its owner can bulk-discard it on an archival schema. Per row the loop also asks again whether the draft is kept from the caller (another user's draft is skipped as OBJECT_NOT_PERMITTED, an administrator is not kept), so it no longer leans on the one permission filter. The check is one private method shared with deleteObject().
Tests: ObjectServiceBulkDeleteTest: another user's draft is skipped and never reaches the handler, the owner's is deleted permanent on an archival schema, an administrator may delete any, a record stays a soft delete. The first two go red with the new branches removed.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205529
Refs #4665
Co-Authored-By: Claude Opus 5.5
---
lib/Service/ObjectService.php | 74 +++++++---
.../Service/ObjectServiceBulkDeleteTest.php | 130 ++++++++++++++++++
2 files changed, 188 insertions(+), 16 deletions(-)
diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php
index 854d40c736..5c38287657 100644
--- a/lib/Service/ObjectService.php
+++ b/lib/Service/ObjectService.php
@@ -3216,17 +3216,10 @@ public function deleteObject(
// 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.
- if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null) {
- $deleterUser = ($currentUser ?? $this->userSession->getUser());
- $deleterIsAdmin = ($deleterUser !== null
- && in_array('admin', $this->groupManager->getUserGroupIds($deleterUser), true) === true);
- // An administrator reads every draft, so is not kept from one.
- $deleterUid = $deleterUser?->getUID();
- if ($deleterIsAdmin === false
- && (new DraftStatusPolicy())->keptFrom(object: $objectToDelete, schema: $this->currentSchema, userId: $deleterUid) === true
- ) {
- throw new OcpDoesNotExistException('Object not found');
- }
+ if ($_rbac === true && $objectToDelete !== null && $this->currentSchema !== null
+ && $this->draftKeptFromCaller(object: $objectToDelete, schema: $this->currentSchema, user: ($currentUser ?? $this->userSession->getUser())) === true
+ ) {
+ throw new OcpDoesNotExistException('Object not found');
}
return $this->deleteHandler->deleteObject(
@@ -3357,6 +3350,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.
*
@@ -5574,10 +5591,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) {
@@ -5592,6 +5611,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.
@@ -5652,14 +5672,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,
@@ -5667,7 +5708,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/tests/Unit/Service/ObjectServiceBulkDeleteTest.php b/tests/Unit/Service/ObjectServiceBulkDeleteTest.php
index bec2568713..95233f429d 100644
--- a/tests/Unit/Service/ObjectServiceBulkDeleteTest.php
+++ b/tests/Unit/Service/ObjectServiceBulkDeleteTest.php
@@ -54,6 +54,9 @@
use OCA\OpenRegister\Service\Object\DeleteObject;
use OCA\OpenRegister\Service\Object\PermissionHandler;
use OCA\OpenRegister\Service\ObjectService;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -300,4 +303,131 @@ public function testRowOnANonArchivalSchemaIsNotRefused(): void {
$this->assertSame(['uuid-archival'], $result['deleted_uuids']);
$this->assertSame([], $result['skipped_uuids']);
}//end testRowOnANonArchivalSchemaIsNotRefused()
+
+ /**
+ * Sign a user in on the service under test.
+ *
+ * @param string $uid The user.
+ * @param array $groups The user's groups.
+ *
+ * @return void
+ */
+ private function signIn(string $uid, array $groups=['staff']): void {
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn($uid);
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $groupManager = $this->createMock(IGroupManager::class);
+ $groupManager->method('getUserGroupIds')->willReturn($groups);
+ $this->inject('userSession', $session);
+ $this->inject('groupManager', $groupManager);
+ }//end signIn()
+
+ /**
+ * A stored draft of a user.
+ *
+ * @param string $uuid The uuid.
+ * @param string $owner The owner.
+ * @param int $schemaId The schema.
+ * @param array $configuration The schema's configuration.
+ *
+ * @return ObjectEntity The draft.
+ */
+ private function givenDraft(string $uuid, string $owner, int $schemaId, array $configuration=[]): ObjectEntity {
+ $draft = $this->givenObject(uuid: $uuid, schemaId: $schemaId, configuration: $configuration);
+ $draft->setStatus('draft');
+ $draft->setOwner($owner);
+
+ return $draft;
+ }//end givenDraft()
+
+ /**
+ * Bulk-deleting somebody else's draft skips it and never reaches the delete handler.
+ *
+ * The permission filter is doubled to let it through here, so this is the
+ * loop's own answer; it goes red with the loop check removed.
+ *
+ * @return void
+ */
+ public function testSomebodyElsesDraftIsSkippedByTheLoop(): void {
+ $draft = $this->givenDraft(uuid: 'uuid-draft', owner: 'alice', schemaId: 11);
+ $this->buildService([$draft]);
+ $this->signIn(uid: 'bob');
+
+ $this->deleteHandler->expects($this->never())->method('deleteObject');
+
+ $result = $this->service->deleteObjects(['uuid-draft']);
+
+ $this->assertSame([], $result['deleted_uuids']);
+ $this->assertSame(['uuid-draft'], $result['skipped_uuids']);
+ $this->assertSame('OBJECT_NOT_PERMITTED', $result['skipped_reasons']['uuid-draft']['error'] ?? null);
+ }//end testSomebodyElsesDraftIsSkippedByTheLoop()
+
+ /**
+ * The owner bulk-discards their own draft for good, also on an archival schema.
+ *
+ * A draft is not a record: the archival gate does not apply, and the delete
+ * is permanent, so no tombstone of the unsent input stays and the handler
+ * writes the one `draft.removed` entry. Goes red without the permanent flag.
+ *
+ * @return void
+ */
+ public function testTheOwnersDraftIsRemovedPermanentlyOnAnArchivalSchema(): void {
+ $draft = $this->givenDraft(uuid: 'uuid-draft', owner: 'alice', schemaId: 12, configuration: self::ARCHIVAL_CONFIGURATION);
+ $this->buildService([$draft]);
+ $this->signIn(uid: 'alice');
+
+ $permanentFlags = [];
+ $this->deleteHandler->method('deleteObject')->willReturnCallback(
+ static function (mixed ...$args) use (&$permanentFlags): bool {
+ $permanentFlags[] = ($args[8] ?? null);
+
+ return true;
+ }
+ );
+
+ $result = $this->service->deleteObjects(['uuid-draft']);
+
+ $this->assertSame(['uuid-draft'], $result['deleted_uuids']);
+ $this->assertSame([true], $permanentFlags, 'a draft is deleted permanently');
+ }//end testTheOwnersDraftIsRemovedPermanentlyOnAnArchivalSchema()
+
+ /**
+ * An administrator bulk-deletes another user's draft, as on the read path.
+ *
+ * @return void
+ */
+ public function testAnAdministratorMayBulkDeleteAnyDraft(): void {
+ $draft = $this->givenDraft(uuid: 'uuid-draft', owner: 'alice', schemaId: 11);
+ $this->buildService([$draft]);
+ $this->signIn(uid: 'root', groups: ['admin']);
+
+ $this->deleteHandler->expects($this->once())->method('deleteObject')->willReturn(true);
+
+ $this->assertSame(['uuid-draft'], $this->service->deleteObjects(['uuid-draft'])['deleted_uuids']);
+ }//end testAnAdministratorMayBulkDeleteAnyDraft()
+
+ /**
+ * A definitive object is still deleted softly, and still refused on an archival schema.
+ *
+ * @return void
+ */
+ public function testARecordIsNotDeletedPermanently(): void {
+ $record = $this->givenObject(uuid: 'uuid-record', schemaId: 11);
+ $record->setStatus('active');
+ $this->buildService([$record]);
+
+ $permanentFlags = [];
+ $this->deleteHandler->method('deleteObject')->willReturnCallback(
+ static function (mixed ...$args) use (&$permanentFlags): bool {
+ $permanentFlags[] = ($args[8] ?? null);
+
+ return true;
+ }
+ );
+
+ $this->service->deleteObjects(['uuid-record']);
+
+ $this->assertSame([false], $permanentFlags);
+ }//end testARecordIsNotDeletedPermanently()
}//end class
From 36a808be57fe6cdb9e513624704d4fec10e7111c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:50:39 +0200
Subject: [PATCH 41/99] =?UTF-8?q?fix(review):=20#4580=20notify=20=E2=80=94?=
=?UTF-8?q?=20an=20email=20recipient=20reference=20must=20name=20an=20obje?=
=?UTF-8?q?ct=20of=20the=20schema=20its=20property=20points=20at?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The referenced object was read as the system by id and only held to the triggering object's organisation, so a person who can write the reference property could aim it at any same-organisation object that carries the named field. Every hop is now held to the schema the reference property's $ref resolves to (read from the schema of the object the hop starts at); another schema, or a property without a $ref, yields reference-not-found and nothing is sent. The dispatcher hands the rule's schema to the notifier. Spec REQ-ERO-007 and tests updated.
Thread: discussion r4284660586 (lib/Service/Notification/ReferencedAddressNotifier.php:308)
Co-Authored-By: Claude Opus 5.5
---
.../AnnotationNotificationDispatcher.php | 6 +-
.../ReferencedAddressNotifier.php | 106 +++++++++--
.../specs/external-recipient-opt-out/spec.md | 10 +-
.../ReferencedAddressNotifierTest.php | 166 +++++++++++++++++-
4 files changed, 267 insertions(+), 21 deletions(-)
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/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: "objects->method('find')->willReturnCallback(
fn (int|string $id): ?ObjectEntity => ($this->store[(string)$id] ?? null)
);
+ $this->schemas = $this->createMock(SchemaMapper::class);
+ $this->schemas->method('find')->willReturnCallback(
+ fn (int|string $id): Schema => $this->schema(['next' => ['type' => 'string', '$ref' => '#/components/schemas/supplier']])
+ );
$this->email = $this->createMock(EmailSender::class);
$this->email->method('sendToAddress')->willReturnCallback(
function (string $address, string $displayName, string $subject, string $body, ?array $unsubscribe = null): string {
@@ -87,6 +113,10 @@ function (string $address, string $displayName, string $subject, string $body, ?
return EmailSender::OUTCOME_DISPATCHED;
}
);
+ $this->refs = $this->createMock(RelationCascadeHandler::class);
+ $this->refs->method('resolveSchemaReference')->willReturnCallback(
+ static fn (string $reference): ?string => ($reference === '#/components/schemas/supplier' ? self::SUPPLIER_SCHEMA : null)
+ );
$this->optOut = $this->createMock(OptOutAuthority::class);
$this->optOut->method('withLink')->willReturnCallback(
static fn (string $body, ?array $unsubscribe): string => $body . (($unsubscribe['url'] ?? '') !== '' ? "\n\nstop: " . $unsubscribe['url'] : '')
@@ -106,22 +136,55 @@ private function notifier(): ReferencedAddressNotifier {
objects: $this->objects,
email: $this->email,
optOut: $this->optOut,
- logger: $this->createMock(LoggerInterface::class)
+ logger: $this->createMock(LoggerInterface::class),
+ schemas: $this->schemas,
+ refs: $this->refs
);
}//end notifier()
+ /**
+ * A schema with the given properties.
+ *
+ * @param array $properties Its properties.
+ *
+ * @return Schema
+ */
+ private function schema(array $properties): Schema {
+ $schema = new Schema();
+ $schema->setProperties($properties);
+ return $schema;
+ }//end schema()
+
+ /**
+ * The triggering object's schema: every property named here is a reference to a supplier.
+ *
+ * @param array $properties The reference properties.
+ *
+ * @return Schema
+ */
+ private function referencing(array $properties): Schema {
+ $declared = [];
+ foreach ($properties as $property) {
+ $declared[$property] = ['type' => 'string', '$ref' => '#/components/schemas/supplier'];
+ }
+
+ return $this->schema($declared);
+ }//end referencing()
+
/**
* A stored object.
*
* @param string $uuid Its uuid.
* @param array $data Its data.
* @param string|null $organisation Its organisation.
+ * @param string $schema Its schema id.
*
* @return ObjectEntity
*/
- private function object(string $uuid, array $data, ?string $organisation = 'org-1'): ObjectEntity {
+ private function object(string $uuid, array $data, ?string $organisation = 'org-1', string $schema = self::SUPPLIER_SCHEMA): ObjectEntity {
$object = new ObjectEntity();
$object->setUuid($uuid);
+ $object->setSchema($schema);
$object->setObject($data);
$object->setOrganisation($organisation);
return $object;
@@ -162,7 +225,8 @@ public function testTheAddressIsReadThroughTheReferenceAndMailed(): void {
recipientsSpec: [['kind' => 'email', 'field' => 'supplierRef.contactEmail']],
subject: 'Nieuw bericht',
body: 'U heeft een nieuw bericht.',
- category: 'service'
+ category: 'service',
+ schema: $this->referencing(['supplierRef'])
);
$this->assertSame([['field' => 'supplierRef.contactEmail', 'outcome' => EmailSender::OUTCOME_DISPATCHED]], $outcomes);
@@ -185,7 +249,10 @@ public function testAPlainFieldAndAnObjectShapedReferenceBothResolve(): void {
$notifier = $this->notifier();
$this->assertSame('a@example.org', $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'email')['address']);
- $this->assertSame('b@example.org', $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'supplier.contactEmail')['address']);
+ $this->assertSame(
+ 'b@example.org',
+ $notifier->resolveAddress(object: $object, data: $object->getObject(), path: 'supplier.contactEmail', schema: $this->referencing(['supplier']))['address']
+ );
}//end testAPlainFieldAndAnObjectShapedReferenceBothResolve()
/**
@@ -199,6 +266,7 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void {
$this->store['sup-bad'] = $this->object('sup-bad', ['contactEmail' => 'not an address']);
$object = $this->object('m', ['empty' => '', 'missingRef' => 'gone', 'foreign' => 'sup-other', 'bad' => 'sup-bad']);
$notifier = $this->notifier();
+ $schema = $this->referencing(['empty', 'missingRef', 'foreign', 'bad', 'ref', 'glob']);
$cases = [
'empty.contactEmail' => ReferencedAddressNotifier::REASON_NO_REFERENCE,
@@ -208,7 +276,7 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void {
'nothing' => ReferencedAddressNotifier::REASON_NO_REFERENCE,
];
foreach ($cases as $path => $reason) {
- $resolved = $notifier->resolveAddress(object: $object, data: $object->getObject(), path: $path);
+ $resolved = $notifier->resolveAddress(object: $object, data: $object->getObject(), path: $path, schema: $schema);
$this->assertNull($resolved['address'], $path);
$this->assertSame($reason, $resolved['reason'], $path);
}
@@ -217,8 +285,8 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void {
// any object may read a referenced object that has no organisation.
$this->store['global'] = $this->object('global', ['contactEmail' => 'g@example.org'], null);
$untenanted = $this->object('u', ['ref' => 'sup-other', 'glob' => 'global'], null);
- $this->assertSame(ReferencedAddressNotifier::REASON_OTHER_TENANT, $notifier->resolveAddress(object: $untenanted, data: $untenanted->getObject(), path: 'ref.contactEmail')['reason']);
- $this->assertSame('g@example.org', $notifier->resolveAddress(object: $object, data: ['glob' => 'global'], path: 'glob.contactEmail')['address']);
+ $this->assertSame(ReferencedAddressNotifier::REASON_OTHER_TENANT, $notifier->resolveAddress(object: $untenanted, data: $untenanted->getObject(), path: 'ref.contactEmail', schema: $schema)['reason']);
+ $this->assertSame('g@example.org', $notifier->resolveAddress(object: $object, data: ['glob' => 'global'], path: 'glob.contactEmail', schema: $schema)['address']);
$outcomes = $notifier->notify(
object: $object,
@@ -226,7 +294,8 @@ public function testAnUnresolvableAddressIsNamedAndNothingIsSent(): void {
recipientsSpec: [['kind' => 'email', 'field' => 'foreign.contactEmail']],
subject: 's',
body: 'b',
- category: 'service'
+ category: 'service',
+ schema: $schema
);
$this->assertSame([['field' => 'foreign.contactEmail', 'outcome' => ReferencedAddressNotifier::REASON_OTHER_TENANT]], $outcomes);
$this->assertSame([], $this->sent);
@@ -258,7 +327,8 @@ public function testIntegriqRefusalsAreRespected(): void {
],
subject: 's',
body: 'b',
- category: 'service'
+ category: 'service',
+ schema: $this->referencing(['refA', 'refB'])
);
$this->assertSame(
@@ -284,4 +354,82 @@ public function testARuleWithoutEmailEntriesDoesNothing(): void {
$this->notifier()->notify(object: $object, data: [], recipientsSpec: [['kind' => 'users', 'users' => ['a']]], subject: 's', body: 'b', category: 'service')
);
}//end testARuleWithoutEmailEntriesDoesNothing()
+
+ /**
+ * A reference to an object of another schema than the property points at
+ * is refused as not found, and nothing is sent: the reference value is
+ * data the person saving the triggering object controls, so it must not
+ * reach any same-organisation object that happens to carry the field.
+ *
+ * @return void
+ */
+ public function testAReferenceToAnObjectOfAnotherSchemaIsRefusedAndNothingIsSent(): void {
+ $this->optOut->method('ask')->willReturnCallback(
+ function (string $channel, string $category, array $addresses): array {
+ $this->assertSame([], $addresses, 'no address of the wrong object may be put to integriq');
+ return [];
+ }
+ );
+ $this->store['staff-1'] = $this->object('staff-1', ['contactEmail' => 'staff@example.org'], 'org-1', '99');
+ $message = $this->object('msg-1', ['supplierRef' => 'staff-1']);
+
+ $outcomes = $this->notifier()->notify(
+ object: $message,
+ data: $message->getObject(),
+ recipientsSpec: [['kind' => 'email', 'field' => 'supplierRef.contactEmail']],
+ subject: 's',
+ body: 'b',
+ category: 'service',
+ schema: $this->referencing(['supplierRef'])
+ );
+
+ $this->assertSame([['field' => 'supplierRef.contactEmail', 'outcome' => ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND]], $outcomes);
+ $this->assertSame([], $this->sent);
+ }//end testAReferenceToAnObjectOfAnotherSchemaIsRefusedAndNothingIsSent()
+
+ /**
+ * A reference property that declares no `$ref` (or no schema is known)
+ * names nothing to check against, so it is not followed.
+ *
+ * @return void
+ */
+ public function testAReferencePropertyWithoutARefIsNotFollowed(): void {
+ $this->store['sup-1'] = $this->object('sup-1', ['contactEmail' => 'info@bouw.example']);
+ $message = $this->object('msg-1', ['supplierRef' => 'sup-1']);
+ $notifier = $this->notifier();
+
+ $plain = $this->schema(['supplierRef' => ['type' => 'string', 'format' => 'uuid']]);
+ $this->assertSame(
+ ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND,
+ $notifier->resolveAddress(object: $message, data: $message->getObject(), path: 'supplierRef.contactEmail', schema: $plain)['reason']
+ );
+ $this->assertSame(
+ ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND,
+ $notifier->resolveAddress(object: $message, data: $message->getObject(), path: 'supplierRef.contactEmail')['reason']
+ );
+ $this->assertNull($notifier->resolveAddress(object: $message, data: $message->getObject(), path: 'supplierRef.contactEmail', schema: $plain)['address']);
+ }//end testAReferencePropertyWithoutARefIsNotFollowed()
+
+ /**
+ * Every hop of a longer path is held to the schema its own property
+ * declares, read from the schema of the object reached.
+ *
+ * @return void
+ */
+ public function testEveryHopOfALongerPathIsCheckedAgainstItsOwnSchema(): void {
+ $this->store['sup-1'] = $this->object('sup-1', ['next' => 'sup-2']);
+ $this->store['sup-2'] = $this->object('sup-2', ['contactEmail' => 'deep@example.org']);
+ $this->store['other'] = $this->object('other', ['contactEmail' => 'staff@example.org'], 'org-1', '99');
+ $notifier = $this->notifier();
+ $schema = $this->referencing(['first']);
+
+ $ok = $this->object('m-1', ['first' => 'sup-1']);
+ $this->assertSame('deep@example.org', $notifier->resolveAddress(object: $ok, data: $ok->getObject(), path: 'first.next.contactEmail', schema: $schema)['address']);
+
+ $this->store['sup-1'] = $this->object('sup-1', ['next' => 'other']);
+ $this->assertSame(
+ ReferencedAddressNotifier::REASON_REFERENCE_NOT_FOUND,
+ $notifier->resolveAddress(object: $ok, data: $ok->getObject(), path: 'first.next.contactEmail', schema: $schema)['reason']
+ );
+ }//end testEveryHopOfALongerPathIsCheckedAgainstItsOwnSchema()
}//end class
From 33891b25d2746b2d273c177ed997932f6c2286c0 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:52:15 +0200
Subject: [PATCH 42/99] =?UTF-8?q?fix(review):=20#4580=20mcp=20=E2=80=94=20?=
=?UTF-8?q?#[McpTool]=20annotations=20may=20not=20carry=20MCP's=20reserved?=
=?UTF-8?q?=20hint=20keys?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The free-form annotations map is forwarded under `annotations`, the key tools/list hands to external MCP clients for tool hints, so `readOnlyHint` on a write tool could be trusted by a client that auto-approves read-only tools. malformedAnnotation() now also refuses readOnlyHint, destructiveHint, idempotentHint, openWorldHint and title (case-insensitive); the tool is skipped and logged like any malformed map. Spec REQ-ATTR-007 and AttributeToolScannerTest updated.
Thread: discussion r4284660809 (lib/Mcp/AttributeToolScanner.php:303)
Co-Authored-By: Claude Opus 5.5
---
lib/Mcp/AttributeToolScanner.php | 43 +++++++++++++++++--
openspec/specs/ai-mcp/spec.md | 9 +++-
tests/Unit/Mcp/AttributeToolScannerTest.php | 21 ++++++++-
.../Mcp/Fixtures/AnnotationFixtureService.php | 8 +++-
4 files changed, 74 insertions(+), 7 deletions(-)
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/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/tests/Unit/Mcp/AttributeToolScannerTest.php b/tests/Unit/Mcp/AttributeToolScannerTest.php
index e9c585c8cf..aae5ce1726 100644
--- a/tests/Unit/Mcp/AttributeToolScannerTest.php
+++ b/tests/Unit/Mcp/AttributeToolScannerTest.php
@@ -356,7 +356,7 @@ public function testUndeclaredAnnotationsStayOmitted(): void {
}//end testUndeclaredAnnotationsStayOmitted()
public function testMalformedAnnotationsAreRejectedAndLogged(): void {
- $this->logger->expects($this->once())
+ $this->logger->expects($this->exactly(2))
->method('warning')
->with($this->stringContains('unrecognised `annotations`'));
@@ -367,4 +367,23 @@ public function testMalformedAnnotationsAreRejectedAndLogged(): void {
$this->assertArrayHasKey('reassignCase', $descriptors);
}//end testMalformedAnnotationsAreRejectedAndLogged()
+
+ public function testAReservedMcpHintKeyInAnnotationsIsRejectedAndLogged(): void {
+ $logged = [];
+ $this->logger->method('warning')->willReturnCallback(
+ function (string $message, array $context = []) use (&$logged): void {
+ $logged[] = ['message' => $message, 'context' => $context];
+ }
+ );
+
+ $descriptors = $this->scanAnnotationFixture();
+
+ $this->assertArrayNotHasKey('hintClaimingTool', $descriptors);
+ $this->assertArrayHasKey('fileCase', $descriptors);
+ $named = array_values(array_filter($logged, static fn (array $entry): bool => (($entry['context']['method'] ?? '') === 'hintClaimingTool')));
+ $this->assertCount(1, $named);
+ $this->assertStringContainsString('unrecognised `annotations`', $named[0]['message']);
+ $this->assertSame('readOnlyHint', $named[0]['context']['annotations']);
+
+ }//end testAReservedMcpHintKeyInAnnotationsIsRejectedAndLogged()
}//end class
diff --git a/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php b/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php
index 3d79b70bfe..0908e0cd21 100644
--- a/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php
+++ b/tests/Unit/Mcp/Fixtures/AnnotationFixtureService.php
@@ -4,7 +4,8 @@
* Test fixture only: attributed methods exercising the optional
* `annotations` map on `#[McpTool]` (REQ-ATTR-007). One method declares a
* mark, one declares none (an empty map is never forwarded), and one
- * declares a nested value (rejected at scan time).
+ * declares a nested value, and one declares a reserved MCP hint key (both
+ * rejected at scan time).
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
@@ -44,4 +45,9 @@ public function reassignCase(string $id): array {
public function badAnnotationTool(string $id): array {
return ['id' => $id];
}//end badAnnotationTool()
+
+ #[McpTool(scope: 'update', annotations: ['readOnlyHint' => true])]
+ public function hintClaimingTool(string $id): array {
+ return ['id' => $id];
+ }//end hintClaimingTool()
}//end class
From 8e630d49ea70af9037b5eec87db1d6b14e5605bd Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:52:31 +0200
Subject: [PATCH 43/99] =?UTF-8?q?fix(review):=20#4580=20tree=20=E2=80=94?=
=?UTF-8?q?=20redact=20fields,=20bound=20the=20level=20read,=20report=20a?=
=?UTF-8?q?=20failed=20access=20check,=20hide=20the=20topmost=20parent,=20?=
=?UTF-8?q?honour=20doelbinding?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Threads (PR #4580, rjzondervan 2026-10-16):
- r4293205945 ObjectTreeService:479: the tree 'fields' are read from entities that went through RenderObject::redactWriteOnlyFromRows (writeOnly, property authorization.read, decryption), on copies, for root, ancestors and descendants.
- r4293205975 :437: MagicMapper::readableUuidsOrNull() tells 'could not check' from 'none readable'; the tree answers truncatedBy 'unreadable' for the former.
- r4293205987 :302: HierarchyDescender::childrenOf() takes a limit; a level is read with at most cap-so-far+1 rows and a wider level answers truncatedBy 'cap' without being fetched. tree() gets a user rate limit.
- r4293205995 :390: the topmost ancestor names a parent only when the caller may read it.
- r4293205960 ObjectRelationsController:507: tree(), graph() and exportGraph() run PurposeGuard::enforce() after the read check.
Spec: object-tree-read gains REQ-OTR-003 (fields) and REQ-OTR-004 (doelbinding) and the cap, unreadable and hidden-parent scenarios.
Co-Authored-By: Claude Opus 5.5
---
lib/Controller/ObjectRelationsController.php | 75 +++++++
lib/Db/MagicMapper.php | 25 ++-
lib/Service/Rbac/HierarchyDescender.php | 20 +-
lib/Service/Relation/ObjectTreeService.php | 208 +++++++++++++-----
.../specs/referential-integrity/spec.md | 51 +++++
.../ObjectRelationsControllerTreeTest.php | 69 +++++-
.../Relation/ObjectTreeServiceTest.php | 190 +++++++++++++++-
7 files changed, 572 insertions(+), 66 deletions(-)
diff --git a/lib/Controller/ObjectRelationsController.php b/lib/Controller/ObjectRelationsController.php
index 0942f0628b..d712bbb3b3 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;
@@ -81,6 +86,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 +484,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 +515,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 +581,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 +624,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/Db/MagicMapper.php b/lib/Db/MagicMapper.php
index af1604392d..d495c870df 100644
--- a/lib/Db/MagicMapper.php
+++ b/lib/Db/MagicMapper.php
@@ -10142,6 +10142,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 [];
@@ -10198,11 +10219,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/Service/Rbac/HierarchyDescender.php b/lib/Service/Rbac/HierarchyDescender.php
index a092684e27..7ba3a0958d 100644
--- a/lib/Service/Rbac/HierarchyDescender.php
+++ b/lib/Service/Rbac/HierarchyDescender.php
@@ -195,16 +195,23 @@ 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 [];
}
@@ -220,6 +227,11 @@ public function childrenOf(string $table, string $parentColumn, array $parentUui
)
);
+ if ($limit !== null) {
+ $qb->andWhere($qb->expr()->neq('_uuid', $parentColumn));
+ $qb->setMaxResults($limit - count($children));
+ }
+
$result = $qb->executeQuery();
foreach ($result->fetchAll() as $row) {
$childUuid = (string)($row['_uuid'] ?? '');
@@ -235,6 +247,10 @@ public function childrenOf(string $table, string $parentColumn, array $parentUui
}
$result->closeCursor();
+
+ if ($limit !== null && count($children) >= $limit) {
+ break;
+ }
}//end foreach
return $children;
diff --git a/lib/Service/Relation/ObjectTreeService.php b/lib/Service/Relation/ObjectTreeService.php
index fd2d6fb5d4..5bbdc858b2 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;
@@ -105,6 +106,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 +116,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 +168,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 +200,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 +301,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 +352,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 +395,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 +426,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 +506,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 +541,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/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/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php b/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php
index 11e2659e26..3545196add 100644
--- a/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php
+++ b/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php
@@ -24,6 +24,11 @@
use OCA\OpenRegister\Controller\ObjectRelationsController;
use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\Schema;
+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;
@@ -55,6 +60,26 @@ class ObjectRelationsControllerTreeTest extends TestCase {
*/
private ObjectReadAccess&MockObject $access;
+ /**
+ * The doelbinding guard double.
+ *
+ * @var PurposeGuard&MockObject
+ */
+ private PurposeGuard&MockObject $purposeGuard;
+
+ /**
+ * An object as the access check hands it over: it knows its register and schema.
+ *
+ * @return ObjectEntity The object.
+ */
+ private function object(): ObjectEntity {
+ $object = new ObjectEntity();
+ $object->setRegister('1');
+ $object->setSchema('2');
+
+ return $object;
+ }//end object()
+
/**
* Build the controller over a set of query parameters.
*
@@ -71,6 +96,10 @@ private function controller(array $params): ObjectRelationsController {
$this->trees = $this->createMock(ObjectTreeService::class);
$this->access = $this->createMock(ObjectReadAccess::class);
$this->access->method('notReadable')->willReturn(new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404));
+ $this->access->method('schemaOf')->willReturn(new Schema());
+ $this->purposeGuard = $this->createMock(PurposeGuard::class);
+ $registerMapper = $this->createMock(RegisterMapper::class);
+ $registerMapper->method('find')->willReturn(new Register());
return new ObjectRelationsController(
appName: 'openregister',
@@ -80,7 +109,9 @@ private function controller(array $params): ObjectRelationsController {
objectService: $this->createMock(ObjectService::class),
exportGate: $this->createMock(ExportGate::class),
access: $this->access,
- trees: $this->trees
+ trees: $this->trees,
+ purposeGuard: $this->purposeGuard,
+ registerMapper: $registerMapper
);
}//end controller()
@@ -104,7 +135,7 @@ public function testAnUnreadableRootIsNotFoundAndNotWalked(): void {
*/
public function testAnUnknownDirectionIsRefused(): void {
$controller = $this->controller(params: ['direction' => 'sideways']);
- $this->access->method('readable')->willReturn(new ObjectEntity());
+ $this->access->method('readable')->willReturn($this->object());
$this->trees->expects($this->never())->method('tree');
$this->assertSame(400, $controller->tree(register: 'r', schema: 's', id: 'x')->getStatus());
@@ -117,7 +148,7 @@ public function testAnUnknownDirectionIsRefused(): void {
*/
public function testTheQueryReachesTheWalk(): void {
$controller = $this->controller(params: ['direction' => 'both', 'depth' => '2', 'fields' => 'kvkNumber, tradeName']);
- $root = new ObjectEntity();
+ $root = $this->object();
$this->access->method('readable')->willReturn($root);
$this->trees->expects($this->once())->method('tree')
->with($root, 'both', 2, ['kvkNumber', 'tradeName'])
@@ -129,6 +160,36 @@ public function testTheQueryReachesTheWalk(): void {
$this->assertSame(['nodes' => []], $response->getData());
}//end testTheQueryReachesTheWalk()
+ /**
+ * A schema that asks for a declared purpose refuses the tree like the object GET, and nothing is walked.
+ *
+ * @return void
+ */
+ public function testARefusedPurposeAnswersItsStatusAndNothingIsWalked(): void {
+ $controller = $this->controller(params: []);
+ $this->access->method('readable')->willReturn($this->object());
+ $this->purposeGuard->expects($this->once())->method('enforce')
+ ->willThrowException(new PurposeRefusedException(rule: PurposeRefusedException::RULE_MISSING, reason: 'A purpose is required.'));
+ $this->trees->expects($this->never())->method('tree');
+
+ $response = $controller->tree(register: 'r', schema: 's', id: 'x');
+
+ $this->assertSame(403, $response->getStatus());
+ }//end testARefusedPurposeAnswersItsStatusAndNothingIsWalked()
+
+ /**
+ * The purpose check runs after the read check: an unreadable root never reaches the guard.
+ *
+ * @return void
+ */
+ public function testAnUnreadableRootNeverReachesThePurposeGuard(): void {
+ $controller = $this->controller(params: []);
+ $this->access->method('readable')->willReturn(null);
+ $this->purposeGuard->expects($this->never())->method('enforce');
+
+ $this->assertSame(404, $controller->tree(register: 'r', schema: 's', id: 'x')->getStatus());
+ }//end testAnUnreadableRootNeverReachesThePurposeGuard()
+
/**
* Without a depth the walk gets null, which means the schema's own bound.
*
@@ -136,7 +197,7 @@ public function testTheQueryReachesTheWalk(): void {
*/
public function testNoDepthMeansTheDeclaredBound(): void {
$controller = $this->controller(params: []);
- $this->access->method('readable')->willReturn(new ObjectEntity());
+ $this->access->method('readable')->willReturn($this->object());
$this->trees->expects($this->once())->method('tree')
->with($this->anything(), 'descendants', null, [])
->willReturn([]);
diff --git a/tests/Unit/Service/Relation/ObjectTreeServiceTest.php b/tests/Unit/Service/Relation/ObjectTreeServiceTest.php
index 9f20cc8830..494ebce562 100644
--- a/tests/Unit/Service/Relation/ObjectTreeServiceTest.php
+++ b/tests/Unit/Service/Relation/ObjectTreeServiceTest.php
@@ -35,6 +35,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\PartyAnnotationValidator;
use OCA\OpenRegister\Service\Rbac\HierarchyAnnotationValidator;
use OCA\OpenRegister\Service\Rbac\HierarchyDescender;
@@ -60,6 +61,27 @@
*/
class ObjectTreeServiceTest extends TestCase {
+ /**
+ * The `limit` every `childrenOf` call of the last built service was given.
+ *
+ * @var array
+ */
+ private array $askedLimits = [];
+
+ /**
+ * Whether the access check of the last built service fails instead of answering.
+ *
+ * @var boolean
+ */
+ private bool $accessFails = false;
+
+ /**
+ * The redaction the service is given, or null for one that changes nothing.
+ *
+ * @var RenderObject|null
+ */
+ private ?RenderObject $renderer = null;
+
/**
* An organisation schema fragment, as a register file declares it.
*
@@ -136,9 +158,10 @@ private function service(array $parentOf, ?array $readable = null, ?array $fragm
->onlyMethods(['childrenOf'])
->getMock();
$descender->method('childrenOf')->willReturnCallback(
- function (string $table, string $parentColumn, array $parentUuids) use ($parentOf): array {
+ function (string $table, string $parentColumn, array $parentUuids, ?int $limit = null) use ($parentOf): array {
$this->assertSame('openregister_table_1_2', $table);
$this->assertSame('parent_organisation', $parentColumn);
+ $this->askedLimits[] = $limit;
$children = [];
foreach ($parentOf as $child => $parent) {
if ($parent !== null && $child !== $parent && in_array($parent, $parentUuids, true) === true) {
@@ -146,13 +169,13 @@ function (string $table, string $parentColumn, array $parentUuids) use ($parentO
}
}
- return $children;
+ return ($limit === null ? $children : array_slice($children, 0, $limit, true));
}
);
$objectMapper = $this->createMock(MagicMapper::class);
- $objectMapper->method('filterReadableUuids')->willReturnCallback(
- static fn (int $registerId, int $schemaId, array $uuids): array => array_values(
+ $objectMapper->method('readableUuidsOrNull')->willReturnCallback(
+ fn (int $registerId, int $schemaId, array $uuids): ?array => ($this->accessFails === true) ? null : array_values(
array_filter($uuids, static fn (string $uuid): bool => $readable === null || in_array($uuid, $readable, true))
)
);
@@ -197,6 +220,7 @@ function (Register $register, Schema $schema, array $uuids) use ($parentOf): arr
registerMapper: $registerMapper,
schemaMapper: $schemaMapper,
graphs: $graphs,
+ renderer: ($this->renderer ?? $this->createMock(RenderObject::class)),
logger: $logger
);
}//end service()
@@ -204,18 +228,24 @@ function (Register $register, Schema $schema, array $uuids) use ($parentOf): arr
/**
* One organisation object.
*
- * @param string $uuid The uuid.
- * @param string|null $parent The parent uuid.
+ * @param string $uuid The uuid.
+ * @param string|null $parent The parent uuid.
+ * @param array $extra Further properties.
*
* @return ObjectEntity The object.
*/
- private function organisation(string $uuid, ?string $parent): ObjectEntity {
+ private function organisation(string $uuid, ?string $parent, array $extra = []): ObjectEntity {
$entity = new ObjectEntity();
$entity->setUuid($uuid);
$entity->setName('Org '.$uuid);
$entity->setRegister('1');
$entity->setSchema('2');
- $entity->setObject(['tradeName' => 'Org '.$uuid, 'kvkNumber' => '1000000'.strlen($uuid), 'parentOrganisation' => $parent]);
+ $entity->setObject(
+ array_merge(
+ ['tradeName' => 'Org '.$uuid, 'kvkNumber' => '1000000'.strlen($uuid), 'parentOrganisation' => $parent],
+ $extra
+ )
+ );
return $entity;
}//end organisation()
@@ -402,4 +432,148 @@ public function testTheHierarchyDeclarationIsReadToo(): void {
$this->assertSame(1, $tree['maxDepth']);
$this->assertSame(['holding' => 0, 'sub-a' => 1, 'sub-b' => 1], self::levels(tree: $tree));
}//end testTheHierarchyDeclarationIsReadToo()
+
+ /**
+ * The `fields` of a node never carry a write-only, a property-restricted or an encrypted value.
+ *
+ * The service hands every entity it reads to the same redaction every other
+ * read of an object goes through, and reads the properties from what comes
+ * back; the objects the mapper loaded are left as they were.
+ *
+ * @return void
+ */
+ public function testFieldsNeverReturnWhatTheCallerMayNotRead(): void {
+ $redactions = 0;
+ $renderer = $this->createMock(RenderObject::class);
+ $renderer->method('redactWriteOnlyFromRows')->willReturnCallback(
+ function (array &$rows, bool $_rbac = true) use (&$redactions): void {
+ foreach ($rows as $row) {
+ $redactions++;
+ $data = $row->getObject();
+ unset($data['secret'], $data['restricted']);
+ $row->setObject($data);
+ }
+ }
+ );
+ $this->renderer = $renderer;
+
+ $root = $this->organisation(uuid: 'sub-a', parent: 'holding', extra: ['secret' => 's3cret', 'restricted' => 'no', 'open' => 'yes']);
+ $tree = $this->service(parentOf: self::group())->tree(
+ root: $root,
+ direction: ObjectTreeService::DIRECTION_BOTH,
+ fields: ['secret', 'restricted', 'open']
+ );
+
+ $this->assertGreaterThanOrEqual(3, $redactions);
+ $this->assertCount(3, $tree['nodes']);
+ foreach ($tree['nodes'] as $node) {
+ $this->assertNull($node['properties']['secret'], 'node '.$node['uuid']);
+ $this->assertNull($node['properties']['restricted'], 'node '.$node['uuid']);
+ }
+
+ $this->assertSame('yes', $tree['nodes'][1]['properties']['open']);
+ $this->assertSame('s3cret', $root->getObject()['secret'], 'The entity the caller passed in is not changed.');
+ }//end testFieldsNeverReturnWhatTheCallerMayNotRead()
+
+ /**
+ * A request that names no field redacts nothing: there is nothing to carry.
+ *
+ * @return void
+ */
+ public function testWithoutFieldsNothingIsRedacted(): void {
+ $renderer = $this->createMock(RenderObject::class);
+ $renderer->expects($this->never())->method('redactWriteOnlyFromRows');
+ $this->renderer = $renderer;
+
+ $tree = $this->service(parentOf: self::group())->tree(root: $this->organisation(uuid: 'holding', parent: null));
+
+ $this->assertCount(4, $tree['nodes']);
+ }//end testWithoutFieldsNothingIsRedacted()
+
+ /**
+ * A failing access check is an incomplete answer, not a complete one without those nodes.
+ *
+ * @return void
+ */
+ public function testAFailingAccessCheckReadsAsUnreadableNotAsComplete(): void {
+ $this->accessFails = true;
+ $service = $this->service(parentOf: self::group());
+
+ $down = $service->tree(root: $this->organisation(uuid: 'holding', parent: null));
+ $this->assertSame(['holding' => 0], self::levels(tree: $down));
+ $this->assertTrue($down['truncated']);
+ $this->assertSame(ObjectTreeService::TRUNCATED_UNREADABLE, $down['truncatedBy']);
+
+ $up = $service->tree(root: $this->organisation(uuid: 'sub-a', parent: 'holding'), direction: ObjectTreeService::DIRECTION_ANCESTORS);
+ $this->assertSame(['sub-a' => 0], self::levels(tree: $up));
+ $this->assertTrue($up['truncated']);
+ $this->assertSame(ObjectTreeService::TRUNCATED_UNREADABLE, $up['truncatedBy']);
+ }//end testAFailingAccessCheckReadsAsUnreadableNotAsComplete()
+
+ /**
+ * The node cap bounds the read of a level, not only the answer.
+ *
+ * @return void
+ */
+ public function testTheCapBoundsTheRowsAskedFor(): void {
+ $wide = ['holding' => null];
+ for ($i = 1; $i <= 50; $i++) {
+ $wide['child-'.$i] = 'holding';
+ }
+
+ $tree = $this->service(parentOf: $wide, cap: 10)->tree(root: $this->organisation(uuid: 'holding', parent: null));
+
+ $this->assertSame(ObjectTreeService::TRUNCATED_CAP, $tree['truncatedBy']);
+ $this->assertSame(['holding' => 0], self::levels(tree: $tree));
+ $this->assertNotEmpty($this->askedLimits);
+ foreach ($this->askedLimits as $limit) {
+ $this->assertNotNull($limit);
+ $this->assertLessThanOrEqual(11, $limit);
+ }
+ }//end testTheCapBoundsTheRowsAskedFor()
+
+ /**
+ * A level exactly as wide as the cap allows is answered whole.
+ *
+ * @return void
+ */
+ public function testALevelThatFitsTheCapIsNotTruncated(): void {
+ $tree = $this->service(parentOf: self::group(), cap: 4)->tree(root: $this->organisation(uuid: 'holding', parent: null));
+
+ $this->assertSame(['holding' => 0, 'sub-a' => 1, 'sub-b' => 1, 'sub-a1' => 2], self::levels(tree: $tree));
+ $this->assertFalse($tree['truncated']);
+ }//end testALevelThatFitsTheCapIsNotTruncated()
+
+ /**
+ * The topmost ancestor does not name a parent the caller may not read.
+ *
+ * @return void
+ */
+ public function testTheTopmostAncestorDoesNotNameAHiddenParent(): void {
+ $tree = $this->service(parentOf: self::group(), readable: ['sub-a1', 'sub-a'])->tree(
+ root: $this->organisation(uuid: 'sub-a1', parent: 'sub-a'),
+ direction: ObjectTreeService::DIRECTION_ANCESTORS
+ );
+
+ $this->assertSame(['sub-a' => -1, 'sub-a1' => 0], self::levels(tree: $tree));
+ $this->assertNull($tree['nodes'][0]['parent']);
+ $this->assertFalse($tree['truncated']);
+ }//end testTheTopmostAncestorDoesNotNameAHiddenParent()
+
+ /**
+ * At the depth bound the topmost node names the readable parent just above it.
+ *
+ * @return void
+ */
+ public function testTheAncestorAtTheDepthBoundNamesItsReadableParent(): void {
+ $tree = $this->service(parentOf: self::group())->tree(
+ root: $this->organisation(uuid: 'sub-a1', parent: 'sub-a'),
+ direction: ObjectTreeService::DIRECTION_ANCESTORS,
+ depth: 1
+ );
+
+ $this->assertSame(['sub-a' => -1, 'sub-a1' => 0], self::levels(tree: $tree));
+ $this->assertSame('holding', $tree['nodes'][0]['parent']);
+ $this->assertSame(ObjectTreeService::TRUNCATED_DEPTH, $tree['truncatedBy']);
+ }//end testTheAncestorAtTheDepthBoundNamesItsReadableParent()
}//end class
From 702483366180897c15936c0db5e62233e066b3b3 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:52:48 +0200
Subject: [PATCH 44/99] =?UTF-8?q?fix(review):=20#4580=20r4293205511=20?=
=?UTF-8?q?=E2=80=94=20a=20form=20payload's=20id=20never=20picks=20the=20u?=
=?UTF-8?q?uid=20of=20the=20write?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The save takes a payload `id` (or `@self.id`) as the uuid of an existing object and updates it. pairsOf() dropped only `_` and `@` keys when a write has no mapping, so a submitter's `id` reached the object; a scope-value owner saves with RBAC off, so a portal resident could overwrite another owner's draft, and a fresh submit could be stored over an object the caller cannot read. mapPayload() now drops `id` and `uuid` from an unmapped payload and strips `id` and `@self.id` from the finished object whatever the mapping or fixed values say: a submit never chooses its uuid, the draft id (checked by DraftFinder::own()) is the only way to name an existing object. The form-destination spec states the rule.
Tests: FormSubmitServiceTest, a draft save and a fresh submit, with and without a mapping; both go red with the filters removed.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205511
Refs #4664
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Form/FormSubmitService.php | 18 +++++-
.../specs/form-destination/spec.md | 2 +-
.../Service/Form/FormSubmitServiceTest.php | 62 +++++++++++++++++++
3 files changed, 78 insertions(+), 4 deletions(-)
diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php
index e1504614c2..1a6aa72ccb 100644
--- a/lib/Service/Form/FormSubmitService.php
+++ b/lib/Service/Form/FormSubmitService.php
@@ -580,7 +580,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.
@@ -623,11 +624,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.
@@ -639,7 +650,8 @@ private function pairsOf(?array $mapping, array $payload): array {
if ($mapping === null) {
foreach ($payload as $name => $value) {
$name = (string)$name;
- if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) {
+ // `id` and `uuid` name an object, not a property: see mapPayload().
+ if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false && $name !== 'id' && $name !== 'uuid') {
$pairs[$name] = $value;
}
}
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 8de3bdc0ff..3ba303d5d3 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
diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php
index 42f7edc8e6..eba3a147c8 100644
--- a/tests/Unit/Service/Form/FormSubmitServiceTest.php
+++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php
@@ -1018,4 +1018,66 @@ public function testAUsersDraftReadsBackThroughTheSubject(): void {
$this->assertSame('Van Alice', $this->service->readDraft(destination: $destination, draftId: $draft['id'], subject: $alice)['data']['title']);
$this->assertSame($draft['id'], $this->service->findDraft(destination: $destination, subject: $alice)['id'] ?? null);
}//end testAUsersDraftReadsBackThroughTheSubject()
+
+ /**
+ * A draft save never takes its uuid from the payload, so it cannot write over another owner's draft.
+ *
+ * The save takes a payload `id` as the uuid of an existing object and
+ * updates it, and a scope-value owner saves with RBAC off. Goes red with
+ * the id filter removed: the second save then carries `id` and the stored
+ * draft of subject 1 is the one the fake updates.
+ *
+ * @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 testAPayloadIdNeverPicksTheUuidOfADraftSave(): void {
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+ $victim = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Van subject 1'], subject: null, owner: DraftOwner::portalSubject(subjectRef: 'subject-1'));
+
+ $attacker = $this->service->saveDraft(
+ destination: $destination,
+ mapping: null,
+ payload: ['title' => 'Overschreven', 'id' => $victim['id'], 'uuid' => $victim['id']],
+ subject: null,
+ owner: DraftOwner::portalSubject(subjectRef: 'subject-2')
+ );
+
+ $this->assertNotSame($victim['id'], $attacker['id'], 'The second save made its own object.');
+ $this->assertNull($this->saves[1]['uuid']);
+ $this->assertArrayNotHasKey('id', $this->saves[1]['object']);
+ $this->assertArrayNotHasKey('uuid', $this->saves[1]['object']);
+ $this->assertSame('Van subject 1', $this->stored[$victim['id']]->getObject()['title'], 'The other draft is unchanged.');
+ $this->assertSame('portal:subject-1', $this->stored[$victim['id']]->getOwner());
+ }//end testAPayloadIdNeverPicksTheUuidOfADraftSave()
+
+ /**
+ * A fresh submit never takes its uuid from the payload, with or without a mapping.
+ *
+ * A mapping that points a field at the property `id`, and a fixed
+ * `@self.id`, are held to the same rule: the submit never names an
+ * existing object, the draft id is the only way to.
+ *
+ * @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 testAPayloadIdNeverPicksTheUuidOfASubmit(): void {
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+ $this->service->submit(destination: $destination, mapping: null, payload: ['title' => 'a', 'caseType' => 'b', 'id' => 'uuid-existing'], subject: null);
+ $this->service->submit(
+ destination: $destination,
+ mapping: [
+ 'fields' => [['field' => 'mail', 'property' => 'id'], ['field' => 'onderwerp', 'property' => 'title']],
+ 'fixed' => ['caseType' => 'b', '@self' => ['id' => 'uuid-existing', 'owner' => 'x']],
+ ],
+ payload: ['mail' => 'uuid-existing', 'onderwerp' => 'a'],
+ subject: null
+ );
+
+ foreach ($this->saves as $save) {
+ $this->assertNull($save['uuid']);
+ $this->assertArrayNotHasKey('id', $save['object']);
+ $this->assertArrayNotHasKey('id', (array)($save['object']['@self'] ?? []));
+ }
+
+ $this->assertCount(2, $this->saves);
+ $this->assertNotSame('uuid-existing', $this->saves[0]['uuid']);
+ }//end testAPayloadIdNeverPicksTheUuidOfASubmit()
}//end class
From e4118ff494a07e9789e674921dd83f63701dacb4 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 13:58:10 +0200
Subject: [PATCH 45/99] =?UTF-8?q?fix(review):=20#4580=20example-sets=20?=
=?UTF-8?q?=E2=80=94=20organisation=20lookups=20tell=20absent=20from=20fai?=
=?UTF-8?q?led,=20and=20remove=20keeps=20an=20organisation=20somebody=20jo?=
=?UTF-8?q?ined?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ExampleSetInstance::ensure() and delete() read any Throwable from the organisation mapper as "not there" / "already gone": a transient database error could create a second organisation with the same slug, or make remove forget an organisation that stays. Only DoesNotExistException means absent now; ensure() reports any other error without inserting, and delete() reports it as failed so the organisation stays in the record.
delete() also keeps an organisation that has a member other than the accounts the install record holds (a leftover membership of an account that no longer exists does not count): it stays in the record and the remove command names it under "Left in place". The object half of the finding (objects outside the record that carry the organisation uuid) is not checked: OpenRegister has no cross-schema count by organisation.
Threads: discussion r4284660757 (ExampleSetInstance.php:159) and r4293205825 (ExampleSetInstance.php:163)
Co-Authored-By: Claude Opus 5.5
---
lib/Command/ExampleSetRemoveCommand.php | 26 +++++-
lib/Service/ExampleSet/ExampleSetInstance.php | 45 ++++++++--
lib/Service/ExampleSet/ExampleSetRemover.php | 23 ++++-
.../example-sets/specs/example-sets/spec.md | 7 +-
.../ExampleSetFragmentPartsTest.php | 23 +++++
.../ExampleSet/ExampleSetInstanceTest.php | 84 +++++++++++++++++++
6 files changed, 194 insertions(+), 14 deletions(-)
create mode 100644 tests/Unit/Service/ExampleSet/ExampleSetInstanceTest.php
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/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/ExampleSetRemover.php b/lib/Service/ExampleSet/ExampleSetRemover.php
index 266a351fc1..8bad6cecf7 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()
@@ -302,19 +303,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/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index c2fa1fe32e..252d04ca62 100644
--- a/openspec/changes/example-sets/specs/example-sets/spec.md
+++ b/openspec/changes/example-sets/specs/example-sets/spec.md
@@ -223,13 +223,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.
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php b/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php
index 5e0ba5b839..36d06b3188 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php
@@ -276,6 +276,29 @@ public function testTheCastsOrganisationIsCreatedOnceFoundByPlaceholderAndRemove
$this->assertSame(['org-existing'], array_keys($this->organisations));
}
+ public function testAnOrganisationWithAMemberTheSetDidNotAddSurvivesRemoveAndIsNamed(): void {
+ $file = $this->castFile();
+ $file['cast']['openregisterOrganisations'] = [['key' => 'gemeente', 'name' => 'Gemeente Demo', 'slug' => 'demo']];
+ $this->app(app: 'openregister', fragment: $file);
+ $this->alpha(['objects' => [['key' => 'pub-1', 'register' => 'pub', 'schema' => 'publication', 'data' => ['organization' => '{{organisation:demo}}']]]]);
+ $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today());
+ $this->accounts->users['real.person'] = ['displayName' => 'Real Person', 'email' => '', 'password' => 'x'];
+ $this->organisations['org-1']->setUsers(['sanne.devries', 'real.person']);
+
+ $removed = $this->remover->remove(set: 'demo');
+
+ $this->assertSame(0, $removed['removed']['organisations']);
+ $this->assertCount(1, $this->organisations, 'a tenant somebody joined is not deleted');
+ $this->assertSame(['OpenRegister organisation org-1, which has members the set did not add'], $removed['left']);
+ $this->assertSame([], $removed['failed']);
+ $this->assertSame(['org-1'], $this->record->read(set: 'demo')['organisations'], 'it stays in the record');
+
+ unset($this->accounts->users['real.person']);
+ $again = $this->remover->remove(set: 'demo');
+ $this->assertSame(1, $again['removed']['organisations'], 'once only its own accounts and leftovers of deleted ones are members it goes');
+ $this->assertSame([], $this->organisations);
+ }
+
public function testEveryCastMemberHasAStableContactsUid(): void {
$plan = $this->catalogue->gather(set: 'demo');
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetInstanceTest.php b/tests/Unit/Service/ExampleSet/ExampleSetInstanceTest.php
new file mode 100644
index 0000000000..50794a01b0
--- /dev/null
+++ b/tests/Unit/Service/ExampleSet/ExampleSetInstanceTest.php
@@ -0,0 +1,84 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://www.OpenRegister.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Service\ExampleSet;
+
+use OCA\OpenRegister\Db\Organisation;
+use OCA\OpenRegister\Db\OrganisationMapper;
+use OCA\OpenRegister\Service\ExampleSet\ExampleSetInstance;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IConfig;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use RuntimeException;
+
+/**
+ * @covers \OCA\OpenRegister\Service\ExampleSet\ExampleSetInstance
+ */
+class ExampleSetInstanceTest extends TestCase {
+ /** @var OrganisationMapper&MockObject */
+ private $mapper;
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->mapper = $this->createMock(OrganisationMapper::class);
+ }
+
+ private function instance(): ExampleSetInstance {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willReturn($this->mapper);
+
+ return new ExampleSetInstance(container: $container, config: $this->createMock(IConfig::class));
+ }
+
+ public function testALookupThatFailsIsNotReadAsNoOrganisationAndNothingIsInserted(): void {
+ $this->mapper->method('findBySlug')->willThrowException(new RuntimeException('database is gone'));
+ $this->mapper->expects($this->never())->method('insert');
+
+ $result = $this->instance()->ensure(declared: ['name' => 'Gemeente', 'slug' => 'demo', 'description' => '']);
+
+ $this->assertSame(['uuid' => '', 'created' => false, 'error' => 'database is gone'], $result);
+ }
+
+ public function testAnOrganisationThatDoesNotExistIsCreated(): void {
+ $this->mapper->method('findBySlug')->willThrowException(new DoesNotExistException('none'));
+ $saved = new Organisation();
+ $saved->setUuid('org-new');
+ $this->mapper->expects($this->once())->method('insert')->willReturn($saved);
+
+ $result = $this->instance()->ensure(declared: ['name' => 'Gemeente', 'slug' => 'demo', 'description' => '']);
+
+ $this->assertSame(['uuid' => 'org-new', 'created' => true, 'error' => ''], $result);
+ }
+
+ public function testALookupThatFailsIsNotReadAsGoneAndNothingIsForgotten(): void {
+ $this->mapper->method('findByUuid')->willThrowException(new RuntimeException('database is gone'));
+ $this->mapper->expects($this->never())->method('delete');
+
+ $this->assertSame(ExampleSetInstance::FAILED, $this->instance()->delete(uuid: 'org-1'));
+ }
+
+ public function testAnOrganisationThatDoesNotExistIsGone(): void {
+ $this->mapper->method('findByUuid')->willThrowException(new DoesNotExistException('none'));
+
+ $this->assertSame(ExampleSetInstance::DELETED, $this->instance()->delete(uuid: 'org-1'));
+ }
+}
From 27a5257eb5f673cc01aace6a5a74c0998bf8a2d8 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:01:10 +0200
Subject: [PATCH 46/99] =?UTF-8?q?fix(review):=20#4580=20example-sets=20?=
=?UTF-8?q?=E2=80=94=20the=20--check=20text=20names=20the=20register=20imp?=
=?UTF-8?q?ort=20and=20the=20organisations=20it=20creates?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The --check option said it writes nothing, but the check first imports a required register the instance lacks and creates the cast's OpenRegister organisations, and records both (REQ-EXS-009, REQ-EXS-016). The option text and the closing line now say so, and REQ-EXS-004 names organisations beside register imports as the only things the check may write, which resolves the contradiction with REQ-EXS-016 in favour of the placeholders that need them. Not split: the check cannot resolve {{organisation:...}} to a stand-in without a design change.
Threads: discussion r4284660797 (ExampleSetInstallCommand.php:86) and r4293205881 (ExampleSetPrerequisites.php:74)
Co-Authored-By: Claude Opus 5.5
---
lib/Command/ExampleSetInstallCommand.php | 6 +++++-
openspec/changes/example-sets/specs/example-sets/spec.md | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/lib/Command/ExampleSetInstallCommand.php b/lib/Command/ExampleSetInstallCommand.php
index 50e5894e07..00d65eddb4 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',
@@ -181,6 +182,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()
diff --git a/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index 252d04ca62..7d7f7d288e 100644
--- a/openspec/changes/example-sets/specs/example-sets/spec.md
+++ b/openspec/changes/example-sets/specs/example-sets/spec.md
@@ -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
From 525727d3cbad0b3d8706b6c9c1b9d1e65becf232 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:01:12 +0200
Subject: [PATCH 47/99] =?UTF-8?q?fix(review):=20#4580=20bulk=20=E2=80=94?=
=?UTF-8?q?=20a=20property=20named=20like=20a=20metadata=20column=20never?=
=?UTF-8?q?=20overwrites=20it?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thread r4293206007 (MagicBulkHandler:217, rjzondervan blocker). Since #4661 the bulk writer let a declared underscore property through and wrote it to sanitizeColumnName(key) after the server had stamped _owner/_organisation/_status/... on the same row, so a client value for a property named _owner (or _Owner, __owner_) won. MagicMetadataColumn::skippedOnWrite() now also skips a key whose sanitised column is a metadata column (the rule field() already applies on read: the metadata column wins), and the single-object writer (MagicMapper::prepareObjectDataForTable) skips it with a warning. Not done: refusing such a property at schema save (changes what already-saved schemas may declare; see the report).
Co-Authored-By: Claude Opus 5.5
---
lib/Db/MagicMapper.php | 18 +++++++
lib/Db/MagicMapper/MagicBulkHandler.php | 19 ++++++-
lib/Db/MagicMapper/MagicMetadataColumn.php | 49 ++++++++++++++++++-
.../specs/magic-table-reads/spec.md | 8 ++-
.../Unit/Db/MagicBulkHandlerRawAppendTest.php | 29 +++++++++++
.../MagicMapper/MagicMetadataColumnTest.php | 19 +++++++
6 files changed, 139 insertions(+), 3 deletions(-)
diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php
index d495c870df..4d5f739b69 100644
--- a/lib/Db/MagicMapper.php
+++ b/lib/Db/MagicMapper.php
@@ -4690,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).
diff --git a/lib/Db/MagicMapper/MagicBulkHandler.php b/lib/Db/MagicMapper/MagicBulkHandler.php
index 4f92739035..61bdcd4cd8 100644
--- a/lib/Db/MagicMapper/MagicBulkHandler.php
+++ b/lib/Db/MagicMapper/MagicBulkHandler.php
@@ -146,6 +146,7 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe
$prepared = [];
$now = new DateTime();
$columnRoles = new MagicMetadataColumn();
+ $shadowReported = false;
foreach ($objects as $object) {
$preparedObject = [];
@@ -214,7 +215,23 @@ 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) {
+ if ($columnRoles->skippedOnWrite(key: (string)$propertyName, schemaProperties: ($schema->getProperties() ?? [])) === true) {
+ // A declared property that would write a metadata column is
+ // dropped, not written over the value stamped above. Said once
+ // per call, not once per object.
+ if ($shadowReported === false && $columnRoles->shadowsMetadata(key: (string)$propertyName) === true) {
+ $shadowReported = true;
+ $this->logger->warning(
+ message: '[MagicBulkHandler] A schema property is named like a metadata column; its value is not written',
+ context: [
+ 'file' => __FILE__,
+ 'line' => __LINE__,
+ 'propertyName' => (string)$propertyName,
+ 'schema' => $schema->getId(),
+ ]
+ );
+ }
+
continue;
}
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/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/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php b/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php
index e3e89bae44..93bcb616b4 100644
--- a/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php
+++ b/tests/Unit/Db/MagicBulkHandlerRawAppendTest.php
@@ -154,4 +154,33 @@ public function testPurgeExpiredDeletesByRegisterSchemaAndPassedExpiry(): void {
$this->assertSame(4, $purged);
$this->assertSame(['_schema = :p22', '_expires IS NOT NULL', '_expires < NOW()'], $andWheres);
}
+
+ public function testADeclaredPropertyNamedLikeAMetadataColumnNeverReplacesTheStampedValue(): void {
+ $this->schema->setProperties(
+ [
+ '_authorization' => ['type' => 'object'],
+ '_owner' => ['type' => 'string'],
+ '_organisation' => ['type' => 'string'],
+ '_status' => ['type' => 'string'],
+ '_meta' => ['type' => 'string'],
+ ]
+ );
+
+ $row = $this->prepare([
+ '@self' => ['uuid' => 'u-3', 'owner' => 'alice', 'organisation' => 'org-1'],
+ 'object' => [
+ '_authorization' => ['read' => ['everyone']],
+ '_owner' => 'mallory',
+ '_organisation' => 'org-2',
+ '_status' => 'published',
+ '_meta' => 'kept',
+ ],
+ ]);
+
+ $this->assertSame('alice', $row['_owner']);
+ $this->assertSame('org-1', $row['_organisation']);
+ $this->assertArrayNotHasKey('_authorization', $row);
+ $this->assertArrayNotHasKey('_status', $row);
+ $this->assertSame('kept', $row['_meta']);
+ }
}
diff --git a/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php b/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php
index c496c56496..ab2fcb62a9 100644
--- a/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php
+++ b/tests/Unit/Db/MagicMapper/MagicMetadataColumnTest.php
@@ -79,4 +79,23 @@ public function testAttributeNamesFollowObjectEntity(): void {
$this->assertSame('id', (new MagicMetadataColumn())->attribute(field: 'id'));
$this->assertNull((new MagicMetadataColumn())->attribute(field: 'meta'));
}//end testAttributeNamesFollowObjectEntity()
+
+ /**
+ * A declared property whose column is a metadata column is never written.
+ *
+ * The spellings that sanitise to the same column are refused too.
+ *
+ * @return void
+ */
+ public function testAPropertyThatWouldWriteAMetadataColumnIsSkipped(): void {
+ $roles = new MagicMetadataColumn();
+
+ foreach (['_owner', '_organisation', '_authorization', '_status', '_deleted', '_uuid', '_register', '_schema', '_locked', '_frozen', '_archived', '_expires', '_Owner', '__owner', '_owner_', '_schemaVersion'] as $key) {
+ $this->assertTrue($roles->skippedOnWrite(key: $key, schemaProperties: [$key => ['type' => 'string']]), $key);
+ $this->assertTrue($roles->shadowsMetadata(key: $key), $key);
+ }
+
+ $this->assertFalse($roles->shadowsMetadata(key: '_meta'));
+ $this->assertFalse($roles->shadowsMetadata(key: 'owner'));
+ }//end testAPropertyThatWouldWriteAMetadataColumnIsSkipped()
}//end class
From 64de9f659576bea91c3c9e0e56a5ab35baa68a45 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:01:17 +0200
Subject: [PATCH 48/99] =?UTF-8?q?fix(review):=20#4580=20PRRT=5FkwDOMnn3ws6?=
=?UTF-8?q?tJjuF=20=E2=80=94=20a=20lost=20race=20to=20leave=20draft=20anno?=
=?UTF-8?q?unces=20nothing?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two requests on one draft (a double click, a retry) could both pass the "still a draft" read; the second save then loaded the row already active, the status policy returned early because active equals active, and leftDraft() announced ObjectCreatedEvent and ObjectActivatedEvent a second time. The status policy now refuses a save when a promotion is allowed for an object that is no longer a draft (DraftAlreadyLeftException), and both callers (FormSubmitService::promoteDraft and DraftActivation::activate) check after the write that the allowance was used (DraftStatusPolicy::promotionWasUsed) before they announce anything. The form path answers as for a draft that is gone (404), the activation 409 not-a-draft.
Tests: FormSubmitServiceTest and DraftActivationTest (lost race through the real policy, and a save that did not use the allowance), DraftStatusPolicyTest. The fake save in FormSubmitServiceTest now judges a stored object through the real status policy, as SaveObject does.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205548 (first comment of thread PRRT_kwDOMnn3ws6tJjuF)
Co-Authored-By: Claude Opus 5.5
---
lib/Exception/DraftAlreadyLeftException.php | 36 +++++++++
lib/Service/Form/FormSubmitService.php | 11 +++
lib/Service/Object/DraftActivation.php | 23 ++++++
lib/Service/Object/DraftStatusPolicy.php | 31 +++++++-
.../Service/Form/FormSubmitServiceTest.php | 73 +++++++++++++++++++
.../Service/Object/DraftActivationTest.php | 69 +++++++++++++++++-
.../Service/Object/DraftStatusPolicyTest.php | 38 ++++++++++
7 files changed, 279 insertions(+), 2 deletions(-)
create mode 100644 lib/Exception/DraftAlreadyLeftException.php
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/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php
index 1a6aa72ccb..758c55ffd3 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;
@@ -381,6 +382,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();
}
@@ -894,6 +901,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/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/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php
index c750782408..86f7610501 100644
--- a/lib/Service/Object/DraftStatusPolicy.php
+++ b/lib/Service/Object/DraftStatusPolicy.php
@@ -37,6 +37,7 @@
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;
@@ -81,6 +82,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 +118,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 the draft this save was to promote has already left draft (DraftAlreadyLeftException).
*
* @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;
}
diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php
index eba3a147c8..68729a4fcb 100644
--- a/tests/Unit/Service/Form/FormSubmitServiceTest.php
+++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php
@@ -29,6 +29,7 @@
use OCA\OpenRegister\Service\Form\FormSubmitService;
use OCA\OpenRegister\Service\Form\FormUploadStore;
use OCA\OpenRegister\Service\Object\DraftOwner;
+use OCA\OpenRegister\Service\Object\DraftStatusPolicy;
use OCA\OpenRegister\Service\Object\ValidateObject;
use OCA\OpenRegister\Service\ObjectService;
use OCA\OpenRegister\Tests\Unit\Service\Form\Fakes\MemoryAppData;
@@ -68,6 +69,7 @@
* @uses \OCA\OpenRegister\Service\Object\DraftFinder
* @uses \OCA\OpenRegister\Service\Object\DraftOwner
* @uses \OCA\OpenRegister\Service\Object\DraftStatusPolicy
+ * @uses \OCA\OpenRegister\Exception\DraftAlreadyLeftException
*/
class FormSubmitServiceTest extends TestCase {
@@ -150,7 +152,15 @@ class FormSubmitServiceTest extends TestCase {
*/
private $duringSave = null;
+ /**
+ * Whether the fake save skips the real status policy, as a save that never reached it would.
+ *
+ * @var bool
+ */
+ private bool $saveSkipsStatusPolicy = false;
+
protected function setUp(): void {
+ DraftStatusPolicy::resetPromotions();
$this->schemaBySlug = [
'case' => $this->schema(
id: 1,
@@ -364,6 +374,16 @@ private function fakeSave(
$data = (array)$object;
$status = ($data['@self']['status'] ?? null);
unset($data['@self']);
+
+ // The real status policy judges a save of a stored object, as SaveObject
+ // does: a promotion out of draft is allowed once, and an allowance for an
+ // object that is no longer a draft is refused.
+ $storedBefore = ($this->stored[(string)$uuid] ?? null);
+ if ($storedBefore !== null && $status !== null && $this->saveSkipsStatusPolicy === false) {
+ $storedBefore->setId(1);
+ (new DraftStatusPolicy())->resolveStatus(entity: $storedBefore, selfData: ['status' => $status]);
+ }
+
if ($status === 'draft') {
// The fake listener stamps receipt fields only on a received object.
$entity = new ObjectEntity();
@@ -1080,4 +1100,57 @@ public function testAPayloadIdNeverPicksTheUuidOfASubmit(): void {
$this->assertCount(2, $this->saves);
$this->assertNotSame('uuid-existing', $this->saves[0]['uuid']);
}//end testAPayloadIdNeverPicksTheUuidOfASubmit()
+
+ /**
+ * Two requests on one draft: the one that finds it already sent is refused and announces nothing.
+ *
+ * The first request's save leaves draft between the second one's "is it
+ * still a draft" read and its save. The status policy refuses the second
+ * write (an allowance for an object that is no longer a draft), so no
+ * second `ObjectCreatedEvent` goes out.
+ *
+ * @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 function testALostRaceToLeaveDraftAnnouncesNothing(): void {
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+ $alice = $this->user(uid: 'alice');
+ $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Kapvergunning'], subject: $alice);
+ $this->duringSave = function () use ($draft): void {
+ $this->stored[$draft['id']]->setStatus('active');
+ };
+
+ try {
+ $this->service->submit(destination: $destination, mapping: null, payload: ['caseType' => 'ct-1'], subject: $alice, draftId: $draft['id']);
+ $this->fail('The second send of one draft was accepted.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame(404, $refused->getStatus());
+ }
+
+ $this->assertSame([], $this->dispatched, 'Nothing is announced a second time.');
+ }//end testALostRaceToLeaveDraftAnnouncesNothing()
+
+ /**
+ * A save that did not use the promotion allowance does not announce the creation either.
+ *
+ * The save is doubled as one that never reached the status policy and
+ * returned an object that was already `active`; goes red without the
+ * check on the allowance after the write.
+ *
+ * @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 function testASaveThatDidNotUseThePromotionAnnouncesNothing(): void {
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+ $alice = $this->user(uid: 'alice');
+ $draft = $this->service->saveDraft(destination: $destination, mapping: null, payload: ['title' => 'Kapvergunning'], subject: $alice);
+ $this->saveSkipsStatusPolicy = true;
+
+ try {
+ $this->service->submit(destination: $destination, mapping: null, payload: ['caseType' => 'ct-1'], subject: $alice, draftId: $draft['id']);
+ $this->fail('A save that did not leave draft was announced.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame(404, $refused->getStatus());
+ }
+
+ $this->assertSame([], $this->dispatched);
+ }//end testASaveThatDidNotUseThePromotionAnnouncesNothing()
}//end class
diff --git a/tests/Unit/Service/Object/DraftActivationTest.php b/tests/Unit/Service/Object/DraftActivationTest.php
index aa1c47eda7..31a8d62348 100644
--- a/tests/Unit/Service/Object/DraftActivationTest.php
+++ b/tests/Unit/Service/Object/DraftActivationTest.php
@@ -52,6 +52,7 @@
* @uses \OCA\OpenRegister\Exception\DraftActivationRefusedException
* @uses \OCA\OpenRegister\Exception\NotAuthorizedException
* @uses \OCA\OpenRegister\Exception\ValidationException
+ * @uses \OCA\OpenRegister\Exception\DraftAlreadyLeftException
* @uses \OCA\OpenRegister\Event\ObjectActivatedEvent
* @uses \OCA\OpenRegister\Event\ObjectCreatedEvent
* @uses \OCA\OpenRegister\Service\Object\ObjectEventGate
@@ -96,6 +97,20 @@ class DraftActivationTest extends TestCase {
*/
private ?\Throwable $refuseSave = null;
+ /**
+ * Run inside the next save before the status policy judges it, as a parallel request would.
+ *
+ * @var callable|null
+ */
+ private $beforeGate = null;
+
+ /**
+ * Whether the save skips the status policy, as one that never reached it would.
+ *
+ * @var bool
+ */
+ private bool $saveSkipsGate = false;
+
private DraftActivation $activation;
private Register $register;
@@ -232,8 +247,17 @@ private function realGateSave(
unset($data['@self']);
$entity = $this->stored[(string)$uuid];
+ if ($this->beforeGate !== null) {
+ $hook = $this->beforeGate;
+ $this->beforeGate = null;
+ $hook();
+ }
+
// The real gate: throws unless exactly this object was allowed one promotion.
- $entity->setStatus((new DraftStatusPolicy())->resolveStatus(entity: $entity, selfData: $self));
+ if ($this->saveSkipsGate === false) {
+ $entity->setStatus((new DraftStatusPolicy())->resolveStatus(entity: $entity, selfData: $self));
+ }
+
$entity->setObject($data + ['number' => 'T-2026-0001']);
return $entity;
@@ -574,4 +598,47 @@ public function testTheDraftIsReadInItsOwnRegisterAndSchema(): void {
$this->assertSame(['id' => 'd1', 'rbac' => false, 'register' => $this->register, 'schema' => $this->schema], $this->finds[0]);
}//end testTheDraftIsReadInItsOwnRegisterAndSchema()
+
+ /**
+ * Two activations of one draft: the one that finds it already sent announces nothing.
+ *
+ * A parallel request makes the draft definitive between this one's read and
+ * its save. The real policy refuses the save (an allowance for an object
+ * that is no longer a draft): 409, nothing written, no second creation event.
+ *
+ * @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 function testALostRaceToActivateAnnouncesNothing(): void {
+ $this->store(uuid: 'd1', owner: 'agent', status: 'draft', data: ['subject' => 'A', 'channel' => 'phone']);
+ $this->beforeGate = function (): void {
+ $this->stored['d1']->setStatus('active');
+ };
+
+ $refused = $this->refusalOf(draftId: 'd1', owner: DraftOwner::ofUser(user: $this->user(uid: 'agent')));
+
+ $this->assertSame(409, $refused->getStatus());
+ $this->assertSame(DraftActivationRefusedException::NOT_A_DRAFT, $refused->getReason());
+ $this->assertSame([], $this->dispatched);
+ }//end testALostRaceToActivateAnnouncesNothing()
+
+ /**
+ * A save that did not use the promotion allowance does not announce the creation either.
+ *
+ * The save returns an object that was already `active` without the policy
+ * consuming the allowance; goes red without the check after the write.
+ *
+ * @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 function testASaveThatDidNotUseThePromotionAnnouncesNothing(): void {
+ $this->store(uuid: 'd1', owner: 'agent', status: 'draft', data: ['subject' => 'A', 'channel' => 'phone']);
+ $this->saveSkipsGate = true;
+ $this->beforeGate = function (): void {
+ $this->stored['d1']->setStatus('active');
+ };
+
+ $refused = $this->refusalOf(draftId: 'd1', owner: DraftOwner::ofUser(user: $this->user(uid: 'agent')));
+
+ $this->assertSame(DraftActivationRefusedException::NOT_A_DRAFT, $refused->getReason());
+ $this->assertSame([], $this->dispatched);
+ }//end testASaveThatDidNotUseThePromotionAnnouncesNothing()
}//end class
diff --git a/tests/Unit/Service/Object/DraftStatusPolicyTest.php b/tests/Unit/Service/Object/DraftStatusPolicyTest.php
index 4dfa6dde70..8da3ca8b10 100644
--- a/tests/Unit/Service/Object/DraftStatusPolicyTest.php
+++ b/tests/Unit/Service/Object/DraftStatusPolicyTest.php
@@ -26,6 +26,7 @@
* @uses \OCA\OpenRegister\Db\ObjectEntity
* @uses \OCA\OpenRegister\Db\Schema
* @uses \OCA\OpenRegister\Exception\ValidationException
+ * @uses \OCA\OpenRegister\Exception\DraftAlreadyLeftException
* @uses \OCA\OpenRegister\Service\Form\FormDestinationValidator
*/
class DraftStatusPolicyTest extends TestCase {
@@ -226,4 +227,41 @@ public function testTheHiddenDraftClause(): void {
$this->assertSame("(t._status IS NULL OR t._status <> 'draft')", $this->policy->notADraftSql(columnPrefix: 't.'));
$this->assertSame("(_status IS NULL OR _status <> 'draft')", $this->policy->notADraftSql(columnPrefix: ''));
}//end testTheHiddenDraftClause()
+
+ /**
+ * An allowance for an object that is no longer a draft is refused, so a parallel request that lost the race writes nothing.
+ *
+ * @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 function testAnAllowanceForAnObjectThatAlreadyLeftDraftIsRefused(): void {
+ DraftStatusPolicy::allowPromotion(uuid: 'uuid-1');
+
+ $this->expectException(\OCA\OpenRegister\Exception\DraftAlreadyLeftException::class);
+ $this->policy->resolveStatus(entity: $this->entity(status: 'active'), selfData: ['status' => 'active']);
+ }//end testAnAllowanceForAnObjectThatAlreadyLeftDraftIsRefused()
+
+ /**
+ * Control: without an allowance, and for a new object, an unchanged status is no refusal.
+ *
+ * @return void
+ */
+ public function testNoAllowanceNoRefusal(): void {
+ $this->assertSame('active', $this->policy->resolveStatus(entity: $this->entity(status: 'active'), selfData: ['status' => 'active']));
+
+ DraftStatusPolicy::allowPromotion(uuid: 'uuid-1');
+ $this->assertNull($this->policy->resolveStatus(entity: $this->entity(status: null, stored: false), selfData: []));
+ }//end testNoAllowanceNoRefusal()
+
+ /**
+ * The allowance is used by the save that leaves draft, and only then.
+ *
+ * @return void
+ */
+ public function testThePromotionIsUsedBySavingTheDraft(): void {
+ DraftStatusPolicy::allowPromotion(uuid: 'uuid-1');
+ $this->assertFalse(DraftStatusPolicy::promotionWasUsed(uuid: 'uuid-1'));
+
+ $this->assertSame('active', $this->policy->resolveStatus(entity: $this->entity(status: 'draft'), selfData: ['status' => 'active']));
+ $this->assertTrue(DraftStatusPolicy::promotionWasUsed(uuid: 'uuid-1'));
+ }//end testThePromotionIsUsedBySavingTheDraft()
}//end class
From 3bacf69ad28c9ed737d74dd7732d56dadb163a0b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:04:39 +0200
Subject: [PATCH 49/99] =?UTF-8?q?fix(review):=20#4580=20r4293205573=20?=
=?UTF-8?q?=E2=80=94=20a=20draft=20save=20claims=20its=20upload=20tokens?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
saveDraft() mapped upload tokens into files and wrote them onto the draft but never claimed them, so the token stayed redeemable for 24 hours after its bytes were on the draft, and a client resending it attached the same file again. It now claims its tokens after the draft is written, as submitAll() does; a refused draft save keeps them for the retry. The form-destination spec says so.
Tests: FormSubmitServiceTest::testADraftSaveClaimsItsUploadTokens (red without the claim).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205573
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Form/FormSubmitService.php | 4 ++
.../specs/form-destination/spec.md | 2 +-
.../Service/Form/FormSubmitServiceTest.php | 42 +++++++++++++++++++
3 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/lib/Service/Form/FormSubmitService.php b/lib/Service/Form/FormSubmitService.php
index 758c55ffd3..0024b42aed 100644
--- a/lib/Service/Form/FormSubmitService.php
+++ b/lib/Service/Form/FormSubmitService.php
@@ -231,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()
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 3ba303d5d3..d2278d7db6 100644
--- a/openspec/changes/form-destination-validator/specs/form-destination/spec.md
+++ b/openspec/changes/form-destination-validator/specs/form-destination/spec.md
@@ -74,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
diff --git a/tests/Unit/Service/Form/FormSubmitServiceTest.php b/tests/Unit/Service/Form/FormSubmitServiceTest.php
index 68729a4fcb..773f3d2684 100644
--- a/tests/Unit/Service/Form/FormSubmitServiceTest.php
+++ b/tests/Unit/Service/Form/FormSubmitServiceTest.php
@@ -1153,4 +1153,46 @@ public function testASaveThatDidNotUseThePromotionAnnouncesNothing(): void {
$this->assertSame([], $this->dispatched);
}//end testASaveThatDidNotUseThePromotionAnnouncesNothing()
+
+ /**
+ * A draft save claims its upload tokens: the file sits on the draft, so the token cannot be redeemed again.
+ *
+ * A refused draft save keeps the token for the retry, as a refused submit does. Goes red without the claim.
+ *
+ * @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 testADraftSaveClaimsItsUploadTokens(): void {
+ $path = (string)tempnam(sys_get_temp_dir(), 'or-submit-upload');
+ file_put_contents($path, '%PDF-1.7');
+ $this->temporary[] = $path;
+ $token = $this->uploads->issue(
+ formId: 'form-1',
+ property: 'bijlage',
+ rule: $this->schemaBySlug['case']->getProperties()['bijlage'],
+ file: ['name' => 'a.pdf', 'type' => 'application/pdf', 'tmp_name' => $path, 'error' => UPLOAD_ERR_OK, 'size' => 8]
+ )['token'];
+ $destination = ['register' => 'dossiq', 'schema' => 'case'];
+ $payload = ['title' => 'Half af', 'bijlage' => ['uploadToken' => $token]];
+ $alice = $this->user(uid: 'alice');
+
+ // Refused first: the token survives for the retry.
+ $this->refuseSave['case'] = new CustomValidationException(message: 'x', errors: ['title' => 'nope']);
+ try {
+ $this->service->saveDraft(destination: $destination, mapping: null, payload: $payload, subject: $alice, scope: 'form-1');
+ $this->fail('Refused draft save accepted.');
+ } catch (FormSubmitRefusedException) {
+ $this->assertSame('a.pdf', $this->uploads->materialise(formId: 'form-1', token: $token)['name']);
+ }
+
+ unset($this->refuseSave['case']);
+ $this->service->saveDraft(destination: $destination, mapping: null, payload: $payload, subject: $alice, scope: 'form-1');
+ $this->assertSame('a.pdf', $this->saves[1]['files']['bijlage']['name']);
+
+ try {
+ $this->uploads->materialise(formId: 'form-1', token: $token);
+ $this->fail('The token of a saved draft is still redeemable.');
+ } catch (FormSubmitRefusedException $refused) {
+ $this->assertSame('upload-token-unknown', $refused->getFindings()[0]['code'] ?? null);
+ }
+ }//end testADraftSaveClaimsItsUploadTokens()
}//end class
From a066800d872656688803c69efb5df31c2f1df5bf Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:08:16 +0200
Subject: [PATCH 50/99] =?UTF-8?q?fix(review):=20#4580=20oas=20=E2=80=94=20?=
=?UTF-8?q?an=20unresolved=20roles=20block=20reads=20as=20grant-nothing,?=
=?UTF-8?q?=20like=20enforcement?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verdict item without a thread (EffectiveAuthorization, introduced in #4589, rjzondervan 2026-10-16). PermissionHandler::expandRoles() answers a roles block that resolves to nothing with four empty verbs, EffectiveAuthorization::expandRoles() (what the OAS reads) still answered an empty block, i.e. 'no authorization configured'. It now applies the same rule on every return path.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Rbac/EffectiveAuthorization.php | 37 ++++++-
.../Rbac/EffectiveAuthorizationTest.php | 102 ++++++++++++++++++
2 files changed, 135 insertions(+), 4 deletions(-)
create mode 100644 tests/Unit/Service/Rbac/EffectiveAuthorizationTest.php
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/tests/Unit/Service/Rbac/EffectiveAuthorizationTest.php b/tests/Unit/Service/Rbac/EffectiveAuthorizationTest.php
new file mode 100644
index 0000000000..f0b404c596
--- /dev/null
+++ b/tests/Unit/Service/Rbac/EffectiveAuthorizationTest.php
@@ -0,0 +1,102 @@
+
+ *
+ * @category Test
+ * @package OCA\OpenRegister\Tests\Unit\Service\Rbac
+ *
+ * @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/roles-block-that-resolves-to-nothing-grants-nothing/specs/rbac-scopes/spec.md
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Service\Rbac;
+
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Service\Rbac\EffectiveAuthorization;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \OCA\OpenRegister\Service\Rbac\EffectiveAuthorization
+ */
+class EffectiveAuthorizationTest extends TestCase {
+
+ /**
+ * The effective authorization of a schema whose block names roles.
+ *
+ * @param array $block The schema's authorization block.
+ * @param array|null $roles The register's role definitions; null for no register.
+ *
+ * @return array|null The answer.
+ */
+ private function forSchema(array $block, ?array $roles): ?array {
+ $schema = new Schema();
+ $schema->setId(5);
+ $schema->setAuthorization($block);
+
+ $mapper = $this->createMock(RegisterMapper::class);
+ if ($roles === null) {
+ $mapper->method('getFirstRegisterWithSchema')->willReturn(null);
+ } else {
+ $register = new Register();
+ $register->setId(1);
+ $register->setConfiguration(['roles' => $roles]);
+ $mapper->method('getFirstRegisterWithSchema')->willReturn(1);
+ $mapper->method('find')->willReturn($register);
+ }
+
+ return (new EffectiveAuthorization(registerMapper: $mapper))->forSchema(schema: $schema);
+ }//end forSchema()
+
+ /**
+ * A roles block over a register without role definitions grants nobody anything.
+ *
+ * @return void
+ */
+ public function testARolesBlockWithoutRoleDefinitionsGrantsNothing(): void {
+ $expected = ['read' => [], 'create' => [], 'update' => [], 'delete' => []];
+
+ $this->assertSame($expected, $this->forSchema(block: ['roles' => ['viewer' => ['staff']]], roles: []));
+ $this->assertSame($expected, $this->forSchema(block: ['roles' => ['viewer' => ['staff']]], roles: null));
+ }//end testARolesBlockWithoutRoleDefinitionsGrantsNothing()
+
+ /**
+ * A roles block whose every name is unknown grants nobody anything.
+ *
+ * @return void
+ */
+ public function testARolesBlockWithOnlyUnknownRolesGrantsNothing(): void {
+ $answer = $this->forSchema(
+ block: ['roles' => ['ghost' => ['staff']]],
+ roles: [['name' => 'viewer', 'actions' => ['read']]]
+ );
+
+ $this->assertSame(['read' => [], 'create' => [], 'update' => [], 'delete' => []], $answer);
+ }//end testARolesBlockWithOnlyUnknownRolesGrantsNothing()
+
+ /**
+ * A roles block that resolves still expands to the verbs of its roles.
+ *
+ * @return void
+ */
+ public function testARolesBlockThatResolvesIsExpanded(): void {
+ $answer = $this->forSchema(
+ block: ['roles' => ['viewer' => ['staff']]],
+ roles: [['name' => 'viewer', 'actions' => ['read']]]
+ );
+
+ $this->assertSame(['read' => ['staff']], $answer);
+ }//end testARolesBlockThatResolvesIsExpanded()
+}//end class
From 46dad2e0f5a0cbcb6a25cbe3124ffe327a59e7e8 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:06:37 +0200
Subject: [PATCH 51/99] =?UTF-8?q?fix(review):=20#4580=20example-sets=20?=
=?UTF-8?q?=E2=80=94=20remove=20leaves=20a=20changed=20path=20the=20user?=
=?UTF-8?q?=20edited=20since=20the=20install?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ExampleSetChanges::revert() wrote the recorded old value back without checking that the path still held what the set wrote, so a user's later edit on a path the set had touched was overwritten in silence. It now compares the current value at each recorded path with the recorded new value, leaves a path that differs and returns it; the remover names each as "Left in place", as it already does for app config and user preferences. What a change appended is still taken out wherever the list is. REQ-EXS-011 states the rule.
Thread: discussion r4284660744 (lib/Service/ExampleSet/ExampleSetChanges.php:251)
Co-Authored-By: Claude Opus 5.5
---
lib/Service/ExampleSet/ExampleSetChanges.php | 25 +++++++++++++++----
lib/Service/ExampleSet/ExampleSetRemover.php | 10 ++++++--
.../example-sets/specs/example-sets/spec.md | 2 +-
.../ExampleSet/ExampleSetRequirementsTest.php | 22 ++++++++++++++++
4 files changed, 51 insertions(+), 8 deletions(-)
diff --git a/lib/Service/ExampleSet/ExampleSetChanges.php b/lib/Service/ExampleSet/ExampleSetChanges.php
index aace3cf4d2..c8abd5d765 100644
--- a/lib/Service/ExampleSet/ExampleSetChanges.php
+++ b/lib/Service/ExampleSet/ExampleSetChanges.php
@@ -239,24 +239,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 +290,10 @@ 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);
+ $ok = ($why === '');
- return $this->store->update(register: (string) $entry['register'], schema: (string) $entry['schema'], id: (string) $entry['id'], data: $row) === '';
+ return ['ok' => $ok, 'left' => $left];
}//end revert()
/**
diff --git a/lib/Service/ExampleSet/ExampleSetRemover.php b/lib/Service/ExampleSet/ExampleSetRemover.php
index 8bad6cecf7..a0590a3208 100644
--- a/lib/Service/ExampleSet/ExampleSetRemover.php
+++ b/lib/Service/ExampleSet/ExampleSetRemover.php
@@ -148,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);
diff --git a/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index 7d7f7d288e..0962d386e3 100644
--- a/openspec/changes/example-sets/specs/example-sets/spec.md
+++ b/openspec/changes/example-sets/specs/example-sets/spec.md
@@ -158,7 +158,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 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 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
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php b/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php
index 5520ca97cd..7b828ae86e 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php
@@ -166,6 +166,28 @@ public function testAChangeIsMadeOnceProvedAndRevertedExactly(): void {
);
}
+ public function testAPathSomebodyEditedAfterTheInstallIsLeftAndNamedOnRemove(): void {
+ $this->store->offer(register: 'site', schema: 'portal');
+ $portal = $this->store->seed(register: 'site', schema: 'portal', data: ['slug' => 'demo', 'title' => 'Demo', 'authentication' => ['modes' => ['password']]]);
+ $this->app(app: 'openregister', fragment: $this->castFile());
+ $this->app(app: 'site', fragment: $this->signInChange());
+ $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today());
+
+ // A user rewrites the card the set wrote; the appended mode is still the set's.
+ $this->store->rows['site portal'][$portal]['authentication']['modeLabels']['nextcloud'] = ['title' => 'Mijn eigen kaart'];
+ $removed = $this->remover->remove(set: 'demo');
+
+ $this->assertSame([], $removed['failed']);
+ $this->assertSame(1, $removed['removed']['changes']);
+ $this->assertSame(
+ ['change site/sign-in on site portal ('.$portal.'), path authentication.modeLabels.nextcloud, which somebody changed after the install'],
+ $removed['left']
+ );
+ $row = $this->store->get(register: 'site', schema: 'portal', id: $portal);
+ $this->assertSame(['title' => 'Mijn eigen kaart'], $row['authentication']['modeLabels']['nextcloud'], 'the user\'s value stays');
+ $this->assertSame(['password'], $row['authentication']['modes'], 'what the set appended is still taken out');
+ }
+
public function testAChangeThatIsAlreadyThereIsNotRecordedAndSurvivesRemove(): void {
$this->store->offer(register: 'site', schema: 'portal');
$portal = $this->store->seed(
From 28efb9bec748acb500fcc073627b263a0955e0f5 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:14:25 +0200
Subject: [PATCH 52/99] =?UTF-8?q?fix(review):=20#4580=20brp=20=E2=80=94=20?=
=?UTF-8?q?the=20resident-count=20constants=20no=20longer=20split=20DEFAUL?=
=?UTF-8?q?T=5FFIELDS=20from=20its=20docblock?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thread r4293206060 (BrpPersonProvider:109).
Co-Authored-By: Claude Opus 5.5
---
.../Integration/Providers/BrpPersonProvider.php | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
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',
From 109b451d264cca6c3729c8121bd6e2763245dad3 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:17:21 +0200
Subject: [PATCH 53/99] =?UTF-8?q?fix(review):=20#4580=20example-sets=20?=
=?UTF-8?q?=E2=80=94=20a=20change=20or=20lookup=20that=20more=20than=20one?=
=?UTF-8?q?=20object=20answers=20is=20refused?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ExampleSetChanges::target() and ExampleSetInstaller::lookedUp() returned the first row a business-key match found, across every organisation, so on an instance where two organisations hold the same key the set wrote to (and on remove rewrote) or pointed its objects at whichever row came first. Both now collect every matching row through the new ExampleSetStore::matching() (each row once, filtered read first, then the unfiltered scan) and refuse when there is none or more than one, naming the count. REQ-EXS-002 and REQ-EXS-011 say so.
Threads: discussion r4293205793 (ExampleSetChanges.php:332; the lookup site is ExampleSetInstaller.php:573)
Co-Authored-By: Claude Opus 5.5
---
lib/Service/ExampleSet/ExampleSetChanges.php | 47 +++++++-------
.../ExampleSet/ExampleSetInstaller.php | 60 +++++++-----------
lib/Service/ExampleSet/ExampleSetStore.php | 63 +++++++++++++++++++
.../example-sets/specs/example-sets/spec.md | 4 +-
.../ExampleSet/ExampleSetInstallerTest.php | 15 +++++
.../ExampleSet/ExampleSetRequirementsTest.php | 21 +++++++
6 files changed, 149 insertions(+), 61 deletions(-)
diff --git a/lib/Service/ExampleSet/ExampleSetChanges.php b/lib/Service/ExampleSet/ExampleSetChanges.php
index c8abd5d765..2a1f536e6b 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']);
}
@@ -324,39 +335,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 1328011d70..cf2e352b81 100644
--- a/lib/Service/ExampleSet/ExampleSetInstaller.php
+++ b/lib/Service/ExampleSet/ExampleSetInstaller.php
@@ -543,9 +543,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.
@@ -567,14 +568,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);
@@ -582,13 +582,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 ''.
@@ -602,7 +613,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'];
}
}
@@ -610,29 +621,6 @@ 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.
*
diff --git a/lib/Service/ExampleSet/ExampleSetStore.php b/lib/Service/ExampleSet/ExampleSetStore.php
index 58ed18ab76..c12ec64a94 100644
--- a/lib/Service/ExampleSet/ExampleSetStore.php
+++ b/lib/Service/ExampleSet/ExampleSetStore.php
@@ -167,6 +167,69 @@ 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
+ */
+ 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.
*
diff --git a/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index 0962d386e3..f9090e5cd5 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`
@@ -158,7 +158,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 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.
+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
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php b/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php
index 9a6b17b4fd..cd5d21bc10 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php
@@ -86,6 +86,21 @@ public function testInstallWritesTheCastAndEveryFragmentAndProvesIt(): void {
$this->assertStringNotContainsString('generated-', $this->config['example_set_demo'], 'a password is never in the record');
}
+ public function testALookupThatTwoRowsAnswerRefusesTheCheckAndWritesNothing(): void {
+ // Another organisation holds a case type with the same business key.
+ $this->store->seed(register: 'cases', schema: 'caseType', data: ['identifier' => 'woo']);
+ $plan = $this->catalogue->gather(set: 'demo');
+
+ $check = $this->installer->check(plan: $plan, today: $this->today());
+
+ $this->assertCount(1, $check['refusals']);
+ $this->assertStringContainsString('2 cases caseType rows with identifier "woo"; a lookup needs exactly one', $check['refusals'][0]);
+
+ $report = $this->installer->install(plan: $plan, today: $this->today());
+ $this->assertCount(1, $report['refusals']);
+ $this->assertSame(0, $this->store->creates, 'a refused set writes nothing');
+ }
+
public function testASecondInstallWritesNothingAndSaysAlreadyThere(): void {
$plan = $this->catalogue->gather(set: 'demo');
$this->installer->install(plan: $plan, today: $this->today());
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php b/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php
index 7b828ae86e..60dfcc4e7c 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetRequirementsTest.php
@@ -205,6 +205,27 @@ public function testAChangeThatIsAlreadyThereIsNotRecordedAndSurvivesRemove(): v
$this->assertSame(['nextcloud'], $this->store->get(register: 'site', schema: 'portal', id: $portal)['authentication']['modes']);
}
+ public function testAChangeWhoseWhereTwoObjectsAnswerRefusesTheCheckAndWritesNothing(): void {
+ $this->store->offer(register: 'site', schema: 'portal');
+ $first = $this->store->seed(register: 'site', schema: 'portal', data: ['slug' => 'demo', 'authentication' => ['modes' => ['password']]]);
+ $second = $this->store->seed(register: 'site', schema: 'portal', data: ['slug' => 'demo', 'authentication' => ['modes' => ['password']]]);
+ $this->app(app: 'openregister', fragment: $this->castFile());
+ $this->app(app: 'site', fragment: $this->signInChange());
+ $plan = $this->catalogue->gather(set: 'demo');
+
+ $check = $this->installer->check(plan: $plan, today: $this->today());
+ $this->assertSame(
+ ['site/lib/Settings/example-sets/demo.json: change "sign-in" (site portal): 2 site portal rows match {"slug":"demo"}; a change needs exactly one to change'],
+ $check['refusals']
+ );
+
+ $report = $this->installer->install(plan: $plan, today: $this->today());
+ $this->assertCount(1, $report['refusals']);
+ $this->assertSame(0, $this->store->updates);
+ $this->assertSame(['password'], $this->store->get(register: 'site', schema: 'portal', id: $first)['authentication']['modes']);
+ $this->assertSame(['password'], $this->store->get(register: 'site', schema: 'portal', id: $second)['authentication']['modes']);
+ }
+
public function testAChangeWithoutItsObjectRefusesTheCheck(): void {
$this->store->offer(register: 'site', schema: 'portal');
$this->app(app: 'openregister', fragment: $this->castFile());
From faf5b455bb99027b95ecbcc93a2a6d96f3bdab7a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:17:21 +0200
Subject: [PATCH 54/99] =?UTF-8?q?fix(review):=20#4580=20r4293205583=20?=
=?UTF-8?q?=E2=80=94=20a=20required=20generated=20identifier=20no=20longer?=
=?UTF-8?q?=20blocks=20submit=20and=20activation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
serverFilledExcusals() excused a property from `required` for a default, a computation or the serverSet marker, but not for `x-openregister-generated`, which the creating listener fills on create and on the save that leaves draft. A schema that listed its case number in `required` and generated it therefore got a `required` finding on every form submit and every activation, and such a draft could never leave draft. FormDestinationValidator::isServerFilled() is now the one definition (public, static, with the generated annotation), asked by the draft policy's excusals and by the form mapping's required-unmapped check.
Tests: DraftStatusPolicyTest, DraftActivationTest (a draft without the generated required property activates), FormDestinationValidatorTest; the three go red with the new branch removed.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205583
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Form/FormDestinationValidator.php | 19 ++++++++++++---
lib/Service/Object/DraftStatusPolicy.php | 13 ++++-------
.../Form/FormDestinationValidatorTest.php | 19 +++++++++++++++
.../Service/Object/DraftActivationTest.php | 23 +++++++++++++++++++
.../Service/Object/DraftStatusPolicyTest.php | 5 ++--
5 files changed, 66 insertions(+), 13 deletions(-)
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/Object/DraftStatusPolicy.php b/lib/Service/Object/DraftStatusPolicy.php
index 86f7610501..ebb87090fd 100644
--- a/lib/Service/Object/DraftStatusPolicy.php
+++ b/lib/Service/Object/DraftStatusPolicy.php
@@ -232,9 +232,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.
@@ -242,7 +242,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
*/
@@ -253,10 +253,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/tests/Unit/Service/Form/FormDestinationValidatorTest.php b/tests/Unit/Service/Form/FormDestinationValidatorTest.php
index 17a6926681..d25a6f5531 100644
--- a/tests/Unit/Service/Form/FormDestinationValidatorTest.php
+++ b/tests/Unit/Service/Form/FormDestinationValidatorTest.php
@@ -135,6 +135,25 @@ public function testAServerSetDefaultedOrComputedPropertyIsNotReported(): void {
$this->assertSame([], $findings);
}//end testAServerSetDefaultedOrComputedPropertyIsNotReported()
+ /**
+ * A required generated identifier (`x-openregister-generated`) is filled by the server, so it is not reported either.
+ *
+ * @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
+ */
+ public function testAGeneratedIdentifierIsNotReported(): void {
+ $schema = $this->caseSchema(
+ extraProperties: ['caseNumber' => ['type' => 'string', 'x-openregister-generated' => ['sequence' => 'case', 'format' => 'Z-{year}-{seq:4}']]],
+ required: ['title', 'caseNumber']
+ );
+
+ $findings = $this->validator->validate(
+ mapping: ['fields' => [['field' => 'onderwerp', 'property' => 'title', 'type' => 'text', 'maxLength' => 100]]],
+ schema: $schema
+ );
+
+ $this->assertSame([], $findings);
+ }//end testAGeneratedIdentifierIsNotReported()
+
/**
* A property-level `required: true` counts as required too.
*/
diff --git a/tests/Unit/Service/Object/DraftActivationTest.php b/tests/Unit/Service/Object/DraftActivationTest.php
index 31a8d62348..460c6bb524 100644
--- a/tests/Unit/Service/Object/DraftActivationTest.php
+++ b/tests/Unit/Service/Object/DraftActivationTest.php
@@ -641,4 +641,27 @@ public function testASaveThatDidNotUseThePromotionAnnouncesNothing(): void {
$this->assertSame(DraftActivationRefusedException::NOT_A_DRAFT, $refused->getReason());
$this->assertSame([], $this->dispatched);
}//end testASaveThatDidNotUseThePromotionAnnouncesNothing()
+
+ /**
+ * A required property that carries a generated identifier does not keep a draft from leaving draft.
+ *
+ * The listener issues the number on the save that leaves draft, so the
+ * draft cannot hold it; excused like `serverSet`, or such a draft could
+ * never be sent. Goes red without the `x-openregister-generated` excusal.
+ *
+ * @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
+ */
+ public function testAGeneratedRequiredIdentifierDoesNotBlockActivation(): void {
+ $this->schema->setRequired(['subject', 'channel', 'caseNumber']);
+ $this->schema->setProperties(
+ $this->schema->getProperties() + [
+ 'caseNumber' => ['type' => 'string', 'x-openregister-generated' => ['sequence' => 'case', 'format' => 'Z-{year}-{seq:4}']],
+ ]
+ );
+ $this->store(uuid: 'd1', owner: 'agent', status: 'draft', data: ['subject' => 'A', 'channel' => 'phone']);
+
+ $saved = $this->activation->activate(register: $this->register, schema: $this->schema, draftId: 'd1', owner: DraftOwner::ofUser(user: $this->user(uid: 'agent')));
+
+ $this->assertSame('active', $saved->getStatus());
+ }//end testAGeneratedRequiredIdentifierDoesNotBlockActivation()
}//end class
diff --git a/tests/Unit/Service/Object/DraftStatusPolicyTest.php b/tests/Unit/Service/Object/DraftStatusPolicyTest.php
index 8da3ca8b10..498cbf04da 100644
--- a/tests/Unit/Service/Object/DraftStatusPolicyTest.php
+++ b/tests/Unit/Service/Object/DraftStatusPolicyTest.php
@@ -134,15 +134,16 @@ public function testServerFilledPropertiesAreExcusedWhenAbsent(): void {
'total' => ['type' => 'number', 'computed' => ['expression' => 'a + b']],
'number' => ['type' => 'string', 'x-openregister' => ['serverSet' => true]],
'reference' => ['type' => 'string', 'x-openregister' => ['serverSet' => false]],
+ 'caseNumber' => ['type' => 'string', 'x-openregister-generated' => ['sequence' => 'case', 'format' => 'Z-{year}-{seq:4}']],
]
);
$this->assertSame(
- ['priority' => 'server-set', 'total' => 'server-set', 'number' => 'server-set'],
+ ['priority' => 'server-set', 'total' => 'server-set', 'number' => 'server-set', 'caseNumber' => 'server-set'],
$this->policy->serverFilledExcusals(schema: $schema, object: ['title' => 'x'])
);
// A value the caller did supply is judged like any other.
- $this->assertSame(['total' => 'server-set'], $this->policy->serverFilledExcusals(schema: $schema, object: ['priority' => 'high', 'number' => 'T-1']));
+ $this->assertSame(['total' => 'server-set', 'caseNumber' => 'server-set'], $this->policy->serverFilledExcusals(schema: $schema, object: ['priority' => 'high', 'number' => 'T-1']));
}//end testServerFilledPropertiesAreExcusedWhenAbsent()
/**
From 31bed1f5584305d65905e9c1507e440955fd6c96 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:17:47 +0200
Subject: [PATCH 55/99] =?UTF-8?q?fix(review):=20#4580=20brp=20=E2=80=94=20?=
=?UTF-8?q?a=20connector=20without=20withholdBodies=20answers=20its=20own?=
=?UTF-8?q?=20cause,=20not=20an=20outage?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thread r4293206045 (ExternalIntegrationRouter:737). dispatch() asks the CallService::call signature before passing the named argument: an integriq without withholdBodies (beta, main) is not called and the call fails with the new cause connector-cannot-withhold-bodies at info level, instead of an Unknown named parameter error logged at ERROR per call and read as upstream-service-down. Spec integration-person-lookup follows. The integriq version this needs is not stated in the release notes here (see the CHANGELOG entry).
Co-Authored-By: Claude Opus 5.5
---
.../ProviderUnavailableException.php | 4 +-
.../Integration/ExternalIntegrationRouter.php | 42 ++++++++++++++++++-
.../specs/integration-person-lookup/spec.md | 6 +--
.../ExternalIntegrationRouterTest.php | 11 +++--
.../BrpPersonProviderResidentCountTest.php | 12 ++++++
5 files changed, 65 insertions(+), 10 deletions(-)
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/Service/Integration/ExternalIntegrationRouter.php b/lib/Service/Integration/ExternalIntegrationRouter.php
index ca38dcfa93..eba28f852a 100644
--- a/lib/Service/Integration/ExternalIntegrationRouter.php
+++ b/lib/Service/Integration/ExternalIntegrationRouter.php
@@ -706,8 +706,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 +719,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 +728,16 @@ private function dispatch($source, string $method, string $path, array $options)
unset($options['withholdBodies']);
$callService = $this->container->get($this->callServiceClass());
+ if ($withholdBodies === true && $this->canWithholdBodies(callService: $callService) === 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);
@@ -750,6 +763,31 @@ private function dispatch($source, string $method, string $path, array $options)
);
}//end dispatch()
+ /**
+ * Whether the connector's `CallService::call` takes the `withholdBodies` option.
+ *
+ * Older connectors (integriq on beta/main) have no such parameter. Asked of
+ * the method's signature, so the answer is the same for every call and no
+ * call is made to find out.
+ *
+ * @param object $callService The connector's CallService.
+ *
+ * @return bool True when `call` names the parameter or collects named arguments.
+ */
+ private function canWithholdBodies(object $callService): bool {
+ if (method_exists($callService, 'call') === false) {
+ return false;
+ }
+
+ foreach ((new \ReflectionMethod($callService, 'call'))->getParameters() as $parameter) {
+ if ($parameter->getName() === 'withholdBodies' || $parameter->isVariadic() === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end canWithholdBodies()
+
/**
* Extract transport metadata from a CallService response (OpenConnector
* CallLog). Reads ONLY the HTTP status, the OpenConnector-measured
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/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php b/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php
index a5719f648b..197fed6573 100644
--- a/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php
+++ b/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php
@@ -41,6 +41,7 @@
use OCP\AppFramework\Db\DoesNotExistException;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
@@ -365,7 +366,7 @@ function (
*
* @return ExternalIntegrationRouter
*/
- private function routerOn(ObjectService $objectService, ?object $callService = null): ExternalIntegrationRouter {
+ private function routerOn(ObjectService $objectService, ?object $callService = null, ?LoggerInterface $logger = null): ExternalIntegrationRouter {
$appManager = $this->createMock(IAppManager::class);
$appManager->method('isInstalled')->willReturn(true);
$appManager->method('isEnabledForUser')->willReturn(true);
@@ -385,7 +386,7 @@ static function (string $id) use ($objectService, $callService) {
}
);
- return new ExternalIntegrationRouter($appManager, $container, new NullLogger());
+ return new ExternalIntegrationRouter($appManager, $container, ($logger ?? new NullLogger()));
}//end routerOn()
/**
@@ -1014,13 +1015,15 @@ public function testAConnectorThatCannotWithholdBodiesIsNotCalled(): void {
$callService = new FakeCallService(
$this->integriqCallLog(['statusCode' => 200, 'response' => ['statusCode' => 200, 'body' => '{}', 'encoding' => 'UTF-8']])
);
- $router = $this->routerOn($this->objectService(['integriq/brp-haalcentraal' => $source]), $callService);
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->never())->method('error');
+ $router = $this->routerOn($this->objectService(['integriq/brp-haalcentraal' => $source]), $callService, $logger);
try {
$router->callWithMeta(new FakeExternalProvider(source: 'brp-haalcentraal'), 'POST', 'personen', ['withholdBodies' => true]);
$this->fail('Expected ProviderUnavailableException');
} catch (ProviderUnavailableException $e) {
- $this->assertSame(ProviderUnavailableException::CAUSE_UPSTREAM_SERVICE_DOWN, $e->getCause());
+ $this->assertSame(ProviderUnavailableException::CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES, $e->getCause());
}
$this->assertNull($callService->receivedSource);
diff --git a/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php b/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php
index 575765d2a0..27c9a799a3 100644
--- a/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php
+++ b/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php
@@ -207,4 +207,16 @@ public function testAnUnexpectedFailureIsLoggedWithoutTheAddress(): void {
$this->assertCount(1, $this->logged);
$this->assertStringNotContainsString(self::ADDRESS, implode("\n", $this->logged));
}//end testAnUnexpectedFailureIsLoggedWithoutTheAddress()
+
+ public function testAConnectorThatCannotWithholdBodiesAnswersItsOwnCause(): void {
+ $this->router->method('sourceConfiguration')->willReturn(['residentCount' => true]);
+ $this->router->method('callWithMeta')->willThrowException(
+ new ProviderUnavailableException(message: 'old connector', cause: ProviderUnavailableException::CAUSE_CONNECTOR_CANNOT_WITHHOLD_BODIES)
+ );
+
+ $this->assertSame(
+ ['unavailable' => true, 'cause' => 'connector-cannot-withhold-bodies'],
+ $this->provider->countResidentsAtAddressObject(self::ADDRESS)
+ );
+ }//end testAConnectorThatCannotWithholdBodiesAnswersItsOwnCause()
}//end class
From cc7a089c11c006518d686b6ec062d4d293109746 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:18:19 +0200
Subject: [PATCH 56/99] =?UTF-8?q?fix(review):=20#4580=20example-sets=20?=
=?UTF-8?q?=E2=80=94=20remove=20keeps=20a=20generated=20app-config=20key?=
=?UTF-8?q?=20an=20administrator=20replaced?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ExampleSetAppConfig::revert() skipped the changed-since guard for a generated entry and always deleted the key, so an administrator who replaced the generated secret with a real API token lost it on remove, and a key that held an empty value before the install was deleted instead of restored. The record now keeps a SHA-256 fingerprint of the generated value (never the value); revert() returns `left` when the stored value no longer has it, and restores the old (empty) value when the key existed. An entry recorded before fingerprints existed has nothing to compare and is restored as before. REQ-EXS-015 updated.
Thread: discussion r4293205829 (lib/Service/ExampleSet/ExampleSetAppConfig.php:146)
Co-Authored-By: Claude Opus 5.5
---
.../ExampleSet/ExampleSetAppConfig.php | 55 +++++++++++++++++--
.../example-sets/specs/example-sets/spec.md | 2 +-
.../ExampleSetFragmentPartsTest.php | 26 +++++++++
3 files changed, 77 insertions(+), 6 deletions(-)
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/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index f9090e5cd5..80cf399ad0 100644
--- a/openspec/changes/example-sets/specs/example-sets/spec.md
+++ b/openspec/changes/example-sets/specs/example-sets/spec.md
@@ -209,7 +209,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`
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php b/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php
index 36d06b3188..a48cdbab47 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetFragmentPartsTest.php
@@ -197,10 +197,36 @@ public function testASensitiveKeyIsRefusedUnlessTheSetGeneratesIt(): void {
$this->assertTrue($report['ok'], json_encode($report));
$this->assertTrue($this->sensitive['alpha']['secret']);
$this->assertStringNotContainsString($this->appValues['alpha']['secret'], (string) json_encode($report).json_encode($this->config));
+ $this->assertStringNotContainsString($this->appValues['alpha']['secret'], $this->config['example_set_demo'], 'the record holds a fingerprint, never the value');
$this->remover->remove(set: 'demo');
$this->assertArrayNotHasKey('secret', $this->appValues['alpha']);
}
+ public function testAGeneratedKeyAnAdministratorReplacedStaysAndIsNamed(): void {
+ $this->alpha(['appConfig' => [['key' => 'secret', 'generate' => true]]]);
+ $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today());
+
+ // The administrator puts a real token there after the install.
+ $this->appValues['alpha']['secret'] = 'their-real-api-token';
+ $removed = $this->remover->remove(set: 'demo');
+
+ $this->assertSame('their-real-api-token', $this->appValues['alpha']['secret']);
+ $this->assertContains('app config alpha secret, which somebody changed after the install', $removed['left']);
+ $this->assertSame([], $removed['failed']);
+ }
+
+ public function testAGeneratedKeyThatHeldAnEmptyValueGetsItBack(): void {
+ $this->appValues['alpha'] = ['secret' => ''];
+ $this->alpha(['appConfig' => [['key' => 'secret', 'generate' => true]]]);
+ $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today());
+ $this->assertNotSame('', $this->appValues['alpha']['secret']);
+
+ $this->remover->remove(set: 'demo');
+
+ $this->assertArrayHasKey('secret', $this->appValues['alpha'], 'a key that was there stays there');
+ $this->assertSame('', $this->appValues['alpha']['secret']);
+ }
+
public function testTheInstanceAddressFillsAPlaceholderAndIsNeverEmpty(): void {
$this->alpha(['objects' => [['key' => 'pub-1', 'register' => 'pub', 'schema' => 'publication', 'data' => ['url' => '{{instance:baseUrl}}/apps/x']]]]);
$this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today());
From ca621798542c411edff39ee72c074d2040605a5d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:19:25 +0200
Subject: [PATCH 57/99] =?UTF-8?q?fix(review):=20#4580=20files=20=E2=80=94?=
=?UTF-8?q?=20the=20object-folder=20endpoints=20refuse=20a=20frozen=20or?=
=?UTF-8?q?=20archived=20object=20with=20409?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thread r4293206081 (FilesController:315 / ObjectFolderController). ObjectFolderController::ensureChangeable() asks FileWriteGuard::assertWritable() after the access rule (as FilesController does), and ObjectFolderResponder answers ObjectStateWriteException with its body and 409 instead of logging it at error and answering 500. Create, upload, rename and delete on a frozen or archived object answer 409 with state/by/at/reason and touch nothing; a reader without update still gets 403.
Co-Authored-By: Claude Opus 5.5
---
lib/Controller/ObjectFolderController.php | 19 ++++-
lib/Service/File/ObjectFolderResponder.php | 9 +-
.../specs/object-lifecycle/spec.md | 10 ++-
.../Controller/ObjectFolderControllerTest.php | 84 ++++++++++++++++++-
4 files changed, 117 insertions(+), 5 deletions(-)
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/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/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/tests/Unit/Controller/ObjectFolderControllerTest.php b/tests/Unit/Controller/ObjectFolderControllerTest.php
index 1b07f3c5d2..c29ed782b3 100644
--- a/tests/Unit/Controller/ObjectFolderControllerTest.php
+++ b/tests/Unit/Controller/ObjectFolderControllerTest.php
@@ -41,6 +41,7 @@
use OCA\OpenRegister\Service\File\ObjectFolderResponder;
use OCA\OpenRegister\Service\File\ObjectFolderUploads;
use OCA\OpenRegister\Service\FileService;
+use OCA\OpenRegister\Service\Object\FileWriteGuard;
use OCA\OpenRegister\Service\Object\PermissionHandler;
use OCA\OpenRegister\Service\ObjectService;
use OCA\OpenRegister\Service\Rbac\DenyEnforcementMode;
@@ -287,7 +288,8 @@ static function () use ($permissionHandler, $schema, $object): ObjectEntity {
),
$browser,
new ObjectFolderUploads(browser: $browser),
- new ObjectFolderResponder(l10n: $l10n, logger: $logger)
+ new ObjectFolderResponder(l10n: $l10n, logger: $logger),
+ new FileWriteGuard()
);
}//end controllerAs()
@@ -598,4 +600,84 @@ public function testAnUploadWithNoFileIs400(): void {
$this->assertSame(400, $response->getStatus());
}//end testAnUploadWithNoFileIs400()
+
+ /**
+ * Every change to the folder of a frozen or archived object is a 409 naming the state, and nothing is touched.
+ *
+ * @param string $state `frozen` or `archived`.
+ * @param array $marker The marker the object carries.
+ *
+ * @return void
+ *
+ * @dataProvider markedObjects
+ */
+ public function testEveryChangeOnAFrozenOrArchivedObjectIs409(string $state, array $marker): void {
+ if ($state === 'frozen') {
+ $this->object->setFrozen($marker);
+ } else {
+ $this->object->setArchived($marker);
+ }
+
+ $this->fileService->expects($this->never())->method('getObjectFolder');
+ $this->fileService->expects($this->never())->method('addFile');
+ $this->uploading(name: 'nota.txt', content: 'x');
+
+ $controller = $this->controllerAs('henk', ['behandelaars']);
+ $responses = [
+ $controller->create(register: '20', schema: '35', id: self::OBJECT_UUID, name: 'Stukken'),
+ $controller->upload(register: '20', schema: '35', id: self::OBJECT_UUID, path: 'Bijlagen'),
+ $controller->rename(register: '20', schema: '35', id: self::OBJECT_UUID, nodeId: 201, name: 'nieuw.txt'),
+ $controller->destroy(register: '20', schema: '35', id: self::OBJECT_UUID, nodeId: 201),
+ ];
+
+ foreach ($responses as $response) {
+ $this->assertSame(409, $response->getStatus());
+ $this->assertSame($state, $response->getData()['state']);
+ $this->assertSame('alice', $response->getData()['by']);
+ $this->assertSame('2026-10-01T09:00:00+00:00', $response->getData()['at']);
+ $this->assertSame('case closed', $response->getData()['reason']);
+ }
+ }//end testEveryChangeOnAFrozenOrArchivedObjectIs409()
+
+ /**
+ * The markers a frozen and an archived object carry.
+ *
+ * @return array}>
+ */
+ public static function markedObjects(): array {
+ $marker = ['by' => 'alice', 'at' => '2026-10-01T09:00:00+00:00', 'reason' => 'case closed'];
+
+ return [
+ 'frozen' => ['frozen', $marker],
+ 'archived' => ['archived', $marker],
+ ];
+ }//end markedObjects()
+
+ /**
+ * Reading the folder of a frozen object is not a write and still works.
+ *
+ * @return void
+ */
+ public function testAFrozenObjectsFolderCanStillBeListed(): void {
+ $this->object->setFrozen(['by' => 'alice', 'at' => '2026-10-01T09:00:00+00:00']);
+
+ $response = $this->controllerAs('henk', ['behandelaars'])
+ ->index(register: '20', schema: '35', id: self::OBJECT_UUID);
+
+ $this->assertSame(200, $response->getStatus());
+ }//end testAFrozenObjectsFolderCanStillBeListed()
+
+ /**
+ * A caller without update learns nothing of the state: 403, not 409.
+ *
+ * @return void
+ */
+ public function testAReaderOfAFrozenObjectStillGets403(): void {
+ $this->object->setFrozen(['by' => 'alice']);
+
+ $response = $this->controllerAs('bea', ['vergunningen'])
+ ->create(register: '20', schema: '35', id: self::OBJECT_UUID, name: 'Stukken');
+
+ $this->assertSame(403, $response->getStatus());
+ }//end testAReaderOfAFrozenObjectStillGets403()
}//end class
From 6981f684751ee80c4c364e6635fc30679020e018 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:20:49 +0200
Subject: [PATCH 58/99] =?UTF-8?q?fix(review):=20#4580=20example-sets=20?=
=?UTF-8?q?=E2=80=94=20a=20failed=20read=20of=20an=20object=20is=20not=20r?=
=?UTF-8?q?ead=20as=20"it=20is=20gone"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ExampleSetStore::deleteRecorded() caught every Throwable from ObjectService::find() and treated it as an absent row, so remove counted an object it could not read as removed and dropped it from the record; ExampleSetStore::get() returned null for any failure and installFragment() read that as "the instance lost it" (REQ-EXS-005), created the object again and overwrote the recorded id, leaving two copies and forgetting the original (and its files).
Only a DoesNotExistException or a null answer means absent now. deleteRecorded() returns the failure as the reason so the object stays in the record and is named. The new ExampleSetStore::read() hands back the row or the error; get() stays the null-on-any-failure wrapper for the proof; the installer reports a failed read of a kept object as not written and keeps the recorded id. The store test that locked in the old behaviour now throws DoesNotExistException. REQ-EXS-005 and REQ-EXS-007 say so.
Threads: discussion r4293205756 (ExampleSetStore.php:288) and r4293205769 (ExampleSetInstaller.php:652)
Co-Authored-By: Claude Opus 5.5
---
.../ExampleSet/ExampleSetInstaller.php | 24 ++++++---
lib/Service/ExampleSet/ExampleSetStore.php | 53 +++++++++++++++----
.../example-sets/specs/example-sets/spec.md | 4 +-
.../ExampleSet/ExampleSetInstallerTest.php | 24 +++++++++
.../ExampleSet/ExampleSetStoreTest.php | 41 +++++++++++++-
.../ExampleSet/Fake/FakeExampleSetStore.php | 15 ++++++
6 files changed, 143 insertions(+), 18 deletions(-)
diff --git a/lib/Service/ExampleSet/ExampleSetInstaller.php b/lib/Service/ExampleSet/ExampleSetInstaller.php
index cf2e352b81..acce64c7e6 100644
--- a/lib/Service/ExampleSet/ExampleSetInstaller.php
+++ b/lib/Service/ExampleSet/ExampleSetInstaller.php
@@ -133,7 +133,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
@@ -295,7 +295,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(
@@ -624,19 +633,22 @@ private function plannedMatch(array $planned, string $register, string $schema,
/**
* 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/ExampleSetStore.php b/lib/Service/ExampleSet/ExampleSetStore.php
index c12ec64a94..392a3af897 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;
@@ -233,6 +234,9 @@ public function rowMatches(array $row, array $where): bool {
/**
* 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.
@@ -242,24 +246,51 @@ public function rowMatches(array $row, array $where): bool {
* @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.
@@ -345,11 +376,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/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index 80cf399ad0..abb018d20f 100644
--- a/openspec/changes/example-sets/specs/example-sets/spec.md
+++ b/openspec/changes/example-sets/specs/example-sets/spec.md
@@ -94,7 +94,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`
@@ -112,7 +112,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
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php b/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php
index cd5d21bc10..758ead42ce 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetInstallerTest.php
@@ -86,6 +86,30 @@ public function testInstallWritesTheCastAndEveryFragmentAndProvesIt(): void {
$this->assertStringNotContainsString('generated-', $this->config['example_set_demo'], 'a password is never in the record');
}
+ public function testAReadErrorOnAKeptObjectWritesNothingAndKeepsTheRecordedId(): void {
+ $plan = $this->catalogue->gather(set: 'demo');
+ $this->installer->install(plan: $plan, today: $this->today());
+ $before = json_decode($this->config['example_set_demo'], true);
+ $caseId = $before['objects']['cases/case-1']['id'];
+ $creates = $this->store->creates;
+
+ // The database cannot be read right now: the object is on the instance, but the read says nothing.
+ $this->store->unreadable = [$caseId];
+ $report = $this->installer->install(plan: $plan, today: $this->today());
+
+ $this->assertSame($creates, $this->store->creates, 'nothing is written again');
+ $after = json_decode($this->config['example_set_demo'], true);
+ $this->assertSame($caseId, $after['objects']['cases/case-1']['id'], 'the original stays in the record');
+ $this->assertSame($before['objects'], $after['objects']);
+ $this->assertFalse($report['ok']);
+ $this->assertStringContainsString('could not be read (database is gone)', implode("\n", $report['dropped']));
+
+ // Once it can be read again the next install finds it and writes nothing.
+ $this->store->unreadable = [];
+ $this->assertTrue($this->installer->install(plan: $plan, today: $this->today())['ok']);
+ $this->assertSame($creates, $this->store->creates);
+ }
+
public function testALookupThatTwoRowsAnswerRefusesTheCheckAndWritesNothing(): void {
// Another organisation holds a case type with the same business key.
$this->store->seed(register: 'cases', schema: 'caseType', data: ['identifier' => 'woo']);
diff --git a/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php b/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php
index a792c410a6..fbc071948b 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetStoreTest.php
@@ -27,6 +27,7 @@
use OCA\OpenRegister\Service\ExampleSet\ExampleSetStore;
use OCA\OpenRegister\Service\Object\ValidateObject;
use OCA\OpenRegister\Service\ObjectService;
+use OCP\AppFramework\Db\DoesNotExistException;
use Opis\JsonSchema\ValidationResult;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
@@ -142,7 +143,7 @@ public function testARefusalIsReturnedAsTheReasonAndAGoneRowIsGone(): void {
$objects = $this->createMock(ObjectService::class);
$objects->method('find')->willReturnCallback(static function (mixed $id) use ($row): ObjectEntity {
if ($id === 'gone') {
- throw new \RuntimeException('not found');
+ throw new DoesNotExistException('Object gone not found');
}
return $row;
@@ -153,4 +154,42 @@ public function testARefusalIsReturnedAsTheReasonAndAGoneRowIsGone(): void {
$this->assertSame('a relation still points here', $store->deleteRecorded(register: 'pipelinq', schema: 'ticket', id: 'ticket-1'));
$this->assertSame('', $store->deleteRecorded(register: 'pipelinq', schema: 'ticket', id: 'gone'));
}
+
+ /**
+ * A read that fails says nothing about the row: remove must not drop it from the record as gone.
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-remove-deletes-exactly-what-the-install-made-req-exs-007
+ */
+ public function testAReadThatFailsLeavesTheRowInTheRecordAndNamesWhy(): void {
+ $objects = $this->createMock(ObjectService::class);
+ $objects->method('find')->willThrowException(new \RuntimeException('db down'));
+ $objects->expects($this->never())->method('deleteObject');
+
+ $why = $this->storeOver(objects: $objects)->deleteRecorded(register: 'pipelinq', schema: 'ticket', id: 'ticket-1');
+
+ $this->assertNotSame('', $why);
+ $this->assertStringContainsString('db down', $why);
+ }
+
+ /**
+ * A row that is not there reads as absent without an error; one that cannot be read comes back with its error.
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-second-install-writes-nothing-req-exs-005
+ */
+ public function testReadTellsAbsentFromFailed(): void {
+ $objects = $this->createMock(ObjectService::class);
+ $objects->method('find')->willReturnCallback(static function (mixed $id): ?ObjectEntity {
+ return match ($id) {
+ 'absent' => throw new DoesNotExistException('not found'),
+ 'null' => null,
+ default => throw new \RuntimeException('db down'),
+ };
+ });
+ $store = $this->storeOver(objects: $objects);
+
+ $this->assertSame(['row' => null, 'error' => ''], $store->read(register: 'r', schema: 's', id: 'absent'));
+ $this->assertSame(['row' => null, 'error' => ''], $store->read(register: 'r', schema: 's', id: 'null'));
+ $this->assertSame(['row' => null, 'error' => 'db down'], $store->read(register: 'r', schema: 's', id: 'broken'));
+ $this->assertNull($store->get(register: 'r', schema: 's', id: 'broken'));
+ }
}
diff --git a/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php b/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php
index 99bcba8c06..2937790fb5 100644
--- a/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php
+++ b/tests/Unit/Service/ExampleSet/Fake/FakeExampleSetStore.php
@@ -178,10 +178,25 @@ public function find(string $register, string $schema, array $filters): array {
return array_values($this->rows[$register.' '.$schema] ?? []);
}
+ /** @var array Ids whose read fails with an error, as a database that is down would. */
+ public array $unreadable = [];
+
public function get(string $register, string $schema, string $id): ?array {
+ if (in_array($id, $this->unreadable, true) === true) {
+ return null;
+ }
+
return ($this->rows[$register.' '.$schema][$id] ?? null);
}
+ public function read(string $register, string $schema, string $id): array {
+ if (in_array($id, $this->unreadable, true) === true) {
+ return ['row' => null, 'error' => 'database is gone'];
+ }
+
+ return ['row' => ($this->rows[$register.' '.$schema][$id] ?? null), 'error' => ''];
+ }
+
public function create(string $register, string $schema, array $data): array {
if ($this->offers(register: $register, schema: $schema) === false) {
return ['id' => '', 'error' => 'no such schema'];
From 180bee9e161da884e29cd89a4b0d12f8a547ad9e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:24:01 +0200
Subject: [PATCH 59/99] =?UTF-8?q?fix(review):=20#4580=20example-sets=20?=
=?UTF-8?q?=E2=80=94=20a=20reinstall=20no=20longer=20reverts=20a=20user's?=
=?UTF-8?q?=20edit=20of=20a=20kept=20object's=20reference?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
refreshReferences() wrote every reference field whose stored value differed from what the declaration resolves to, so a user who pointed a demo case at another person had that edit reverted by the next install, against REQ-EXS-005 and the "somebody may have changed it since" rule of proven(). The install record now keeps what each object was created with in its reference fields (`refs` on the object's entry), and a refresh is made only while a field still holds what the install last wrote there (the refs value, or the newest `new` of the `#references` change). Any other value, or a record that predates the recorded values, is left as it is and named in the install output ("Left as it is"); it does not fail the install. REQ-EXS-023 states the rule.
Thread: discussion r4293205804 (lib/Service/ExampleSet/ExampleSetInstaller.php:386)
Co-Authored-By: Claude Opus 5.5
---
lib/Command/ExampleSetInstallCommand.php | 4 ++
lib/Service/ExampleSet/ExampleSetChanges.php | 57 ++++++++++++++++---
.../ExampleSet/ExampleSetInstaller.php | 40 ++++++++++++-
lib/Service/ExampleSet/ExampleSetRecord.php | 6 +-
.../example-sets/specs/example-sets/spec.md | 7 ++-
.../ExampleSet/ExampleSetReferencesTest.php | 45 +++++++++++++++
6 files changed, 147 insertions(+), 12 deletions(-)
diff --git a/lib/Command/ExampleSetInstallCommand.php b/lib/Command/ExampleSetInstallCommand.php
index 00d65eddb4..eb63fd9438 100644
--- a/lib/Command/ExampleSetInstallCommand.php
+++ b/lib/Command/ExampleSetInstallCommand.php
@@ -244,6 +244,10 @@ private function printReport(array $report, OutputInterface $output): void {
$output->writeln('Files attached: '.$report['files']);
}
+ 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']];
foreach ($problems as $words => $lines) {
foreach ($lines as $line) {
diff --git a/lib/Service/ExampleSet/ExampleSetChanges.php b/lib/Service/ExampleSet/ExampleSetChanges.php
index 2a1f536e6b..632061e76e 100644
--- a/lib/Service/ExampleSet/ExampleSetChanges.php
+++ b/lib/Service/ExampleSet/ExampleSetChanges.php
@@ -190,31 +190,70 @@ 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(
+ $todo = [];
+ $left = [];
+ foreach ($values as $field => $value) {
+ $held = $this->paths->atPath(row: $row, path: (string) $field);
+ if ($held['found'] === true && $this->same(first: $held['value'], second: $value) === true) {
+ continue;
+ }
+
+ $wrote = array_key_exists($field, $written);
+ if ($held['found'] === true && $wrote === true && $this->same(first: $held['value'], second: $written[$field]) === true) {
+ $todo[$field] = $value;
+ continue;
+ }
+
+ $left[] = (string) $field;
+ }
+
+ if ($todo === []) {
+ return ['error' => '', 'entry' => null, 'left' => $left];
+ }
+
+ $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()
+ /**
+ * Whether two values are the same as stored (type and order of a list included).
+ *
+ * @param mixed $first A value.
+ * @param mixed $second Another.
+ *
+ * @return bool
+ */
+ private function same(mixed $first, mixed $second): bool {
+ return json_encode($first) === json_encode($second);
+ }//end same()
+
/**
* The paths of a made change that the stored object does not hold.
*
diff --git a/lib/Service/ExampleSet/ExampleSetInstaller.php b/lib/Service/ExampleSet/ExampleSetInstaller.php
index acce64c7e6..00d1321132 100644
--- a/lib/Service/ExampleSet/ExampleSetInstaller.php
+++ b/lib/Service/ExampleSet/ExampleSetInstaller.php
@@ -179,6 +179,7 @@ public function install(array $plan, array $adopt=[], string $password='', ?Date
'passwords' => [],
'types' => [],
'dropped' => [],
+ 'left' => [],
'missing' => [],
'lost' => [],
'ok' => false,
@@ -337,6 +338,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);
@@ -391,7 +397,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';
@@ -418,6 +433,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.
*
diff --git a/lib/Service/ExampleSet/ExampleSetRecord.php b/lib/Service/ExampleSet/ExampleSetRecord.php
index 0a510289cf..080dcb43c3 100644
--- a/lib/Service/ExampleSet/ExampleSetRecord.php
+++ b/lib/Service/ExampleSet/ExampleSetRecord.php
@@ -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'];
+ }
}
}
diff --git a/openspec/changes/example-sets/specs/example-sets/spec.md b/openspec/changes/example-sets/specs/example-sets/spec.md
index abb018d20f..c0a6efcc4b 100644
--- a/openspec/changes/example-sets/specs/example-sets/spec.md
+++ b/openspec/changes/example-sets/specs/example-sets/spec.md
@@ -315,13 +315,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/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php b/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php
index 0eba1b8c1e..b2d993292f 100644
--- a/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php
+++ b/tests/Unit/Service/ExampleSet/ExampleSetReferencesTest.php
@@ -120,4 +120,49 @@ public function testAnUntouchedSecondInstallRefreshesNothing(): void {
$this->assertSame($updates, $this->store->updates);
$this->assertSame([], $this->record->read(set: 'demo')['changes']);
}
+
+ /**
+ * A user who pointed a kept object's reference at something else keeps that edit: the next install leaves it and says so.
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023
+ */
+ public function testAUsersEditOfAKeptObjectsReferenceIsLeftAndNamed(): void {
+ $publications = $this->store->ids(register: 'pub', schema: 'publication');
+ $this->store->rows['pub publication'][$publications[0]]['themes'] = ['an-unrelated-id'];
+ $updates = $this->store->updates;
+
+ $report = $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today());
+
+ $this->assertSame(['an-unrelated-id'], $this->store->get(register: 'pub', schema: 'publication', id: $publications[0])['themes']);
+ $this->assertSame($updates, $this->store->updates, 'nothing is written over the edit');
+ $this->assertArrayNotHasKey('alpha/pub-1#references', $report['changes']);
+ $this->assertArrayNotHasKey('alpha/pub-1#references', $this->record->read(set: 'demo')['changes']);
+ $this->assertSame(['lib/Settings/example-sets/demo.json: object "pub-1": field "themes" was changed after the install and is left as it is'], array_map(
+ static fn (string $line): string => preg_replace('#^[^:]*/lib/#', 'lib/', $line),
+ $report['left']
+ ));
+ $this->assertTrue($report['ok'], 'an edit is not a failure');
+ }
+
+ /**
+ * A kept object whose record predates the recorded values is not refreshed over a value nobody can vouch for.
+ *
+ * @spec openspec/changes/example-sets/specs/example-sets/spec.md#requirement-a-kept-object-follows-the-objects-it-references-req-exs-023
+ */
+ public function testAReferenceTheRecordHoldsNoValueForIsLeft(): void {
+ $record = $this->record->read(set: 'demo');
+ foreach ($record['objects'] as $name => $held) {
+ unset($record['objects'][$name]['refs']);
+ }
+
+ $this->record->write(set: 'demo', record: $record);
+ $this->store->undeletable = $this->store->ids(register: 'pub', schema: 'publication');
+ $this->remover->remove(set: 'demo');
+ $this->store->undeletable = [];
+
+ $report = $this->installer->install(plan: $this->catalogue->gather(set: 'demo'), today: $this->today());
+
+ $this->assertCount(2, $report['left']);
+ $this->assertArrayNotHasKey('alpha/pub-1#references', $report['changes']);
+ }
}
From e61ed6c9ef771baa2a13690707d656cbd971c7cc Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:27:44 +0200
Subject: [PATCH 60/99] =?UTF-8?q?fix(review):=20#4580=20r4293205561=20?=
=?UTF-8?q?=E2=80=94=20draft=20expiry=20reads=20expired=20drafts=20only=20?=
=?UTF-8?q?and=20counts=20what=20lies=20beyond=20the=20cap?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
expiredIn() read every draft (no date filter) in PAGE_SIZE pages up to MAX_PAGES and filtered by age in PHP, so on a schema with more than 5,000 drafts the expired ones after the 5,000th row were never read, never counted as expired or deferred, and (with a stable list order) never removed. The store is now asked for the drafts past their lifetime alone (updated before the cutoff, then never updated and created before it, in UTC), so the rows read are expired rows; the PHP check on the object stays. When MAX_PAGES full pages are read, the remainder is counted with countSearchObjects and reported as expired and deferred, so expired = removed + refused + deferred. A list that cannot be read is logged once. The draft-lifetime spec states the read cap and the accounting.
Tests: DraftExpiryTest (the fake store now honours the date filters): young drafts do not fill the window, the remainder beyond the cap is deferred in a preview and in a sweep, the queries carry the cutoff. The first goes red without the date filter.
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205561
Co-Authored-By: Claude Opus 5.5
---
lib/Service/Object/DraftExpiry.php | 131 +++++++++++++++---
.../specs/form-destination/spec.md | 2 +-
tests/Unit/Service/Object/DraftExpiryTest.php | 127 ++++++++++++++++-
3 files changed, 233 insertions(+), 27 deletions(-)
diff --git a/lib/Service/Object/DraftExpiry.php b/lib/Service/Object/DraftExpiry.php
index 48b279a56c..cd38ee63fc 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).
*
@@ -41,6 +44,7 @@
namespace OCA\OpenRegister\Service\Object;
use DateTimeImmutable;
+use DateTimeZone;
use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Db\Register;
use OCA\OpenRegister\Db\RegisterMapper;
@@ -174,8 +178,8 @@ private function walk(DateTimeImmutable $now, int $budget): array {
$counts['schemas']++;
$cutoff = $now->modify('-' . $days . ' days');
- $expired = $this->expiredIn(register: $register, schema: $schema, cutoff: $cutoff);
- if ($expired === []) {
+ [$expired, $unread] = $this->expiredIn(register: $register, schema: $schema, cutoff: $cutoff);
+ if ($expired === [] && $unread === 0) {
continue;
}
@@ -184,7 +188,8 @@ private function walk(DateTimeImmutable $now, int $budget): array {
schema: $schema,
expired: $expired,
budget: ($budget - $counts['removed'] - $counts['refused']),
- cutoff: $cutoff
+ cutoff: $cutoff,
+ unread: $unread
);
$row['lifetimeDays'] = $days;
$counts['perSchema'][] = $row;
@@ -219,19 +224,20 @@ private function holdsDrafts(Schema $schema): bool {
* @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, DateTimeImmutable $cutoff): 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) {
@@ -276,29 +282,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 = $cutoff->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
+ $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
);
@@ -307,26 +363,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.
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/tests/Unit/Service/Object/DraftExpiryTest.php b/tests/Unit/Service/Object/DraftExpiryTest.php
index 800c76802c..a9980de4dd 100644
--- a/tests/Unit/Service/Object/DraftExpiryTest.php
+++ b/tests/Unit/Service/Object/DraftExpiryTest.php
@@ -58,6 +58,13 @@ class DraftExpiryTest extends TestCase {
*/
private array $searches = [];
+ /**
+ * Counts asked for what lies beyond the read cap: the query.
+ *
+ * @var array>
+ */
+ private array $counts = [];
+
/**
* Deletes run: uuid, scope and flags.
*
@@ -123,7 +130,14 @@ function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): ar
throw new RuntimeException('table missing');
}
- return array_slice(($this->stored[$key] ?? []), (int)$query['_offset'], (int)$query['_limit']);
+ return array_slice($this->matching(query: $query), (int)$query['_offset'], (int)$query['_limit']);
+ }
+ );
+ $objects->method('countSearchObjects')->willReturnCallback(
+ function (array $query = [], bool $_rbac = true, bool $_multitenancy = true): int {
+ $this->counts[] = $query;
+
+ return count($this->matching(query: $query));
}
);
$objects->method('find')->willReturnCallback(
@@ -198,6 +212,45 @@ function (string $message): void {
$this->expiry = new DraftExpiry(objects: $objects, registers: $registerMapper, schemas: $schemaMapper, logger: $logger);
}//end setUp()
+ /**
+ * What the store answers for a list query: the stored objects the `@self` date filters let through, in list order.
+ *
+ * Only the two filters the sweep sends are understood: `updated` before a moment, and
+ * `updated` null with `created` before a moment. Whatever else is stored (a non-object)
+ * comes back as it is, for the sweep's own check to turn down.
+ *
+ * @param array $query The list query.
+ *
+ * @return array The matching objects.
+ */
+ private function matching(array $query): array {
+ $self = $query['@self'];
+ $list = ($this->stored[$self['register'] . '/' . $self['schema']] ?? []);
+
+ return array_values(
+ array_filter(
+ $list,
+ static function (mixed $entity) use ($self): bool {
+ if (($entity instanceof ObjectEntity) === false) {
+ return true;
+ }
+
+ $before = static fn (?\DateTime $moment, string $limit): bool => $moment !== null
+ && $moment->getTimestamp() < (new DateTimeImmutable($limit, new \DateTimeZone('UTC')))->getTimestamp();
+ if (is_array($self['updated'] ?? null) === true) {
+ return $before($entity->getUpdated(), $self['updated']['lt']);
+ }
+
+ if (($self['updated'] ?? null) === 'IS NULL') {
+ return $entity->getUpdated() === null && $before($entity->getCreated(), $self['created']['lt']);
+ }
+
+ return true;
+ }
+ )
+ );
+ }//end matching()
+
/**
* A schema, with or without a draft lifetime.
*
@@ -293,7 +346,12 @@ public function testAnExpiredDraftIsRemovedAndAYoungOneStays(): void {
$this->assertFalse($this->deletes[0]['retentionSweep']);
// The list asked for drafts alone, of everybody, in that register and schema.
- $this->assertSame(['register' => '10', 'schema' => '1'], $this->searches[0]['query']['@self']);
+ $this->assertSame('10', $this->searches[0]['query']['@self']['register']);
+ $this->assertSame('1', $this->searches[0]['query']['@self']['schema']);
+ // ... and only the expired ones: last changed before the cutoff (seven days before the sweep, in UTC), then the never-changed ones.
+ $this->assertSame(['lt' => '2026-10-05 03:00:00'], $this->searches[0]['query']['@self']['updated']);
+ $this->assertSame('IS NULL', $this->searches[1]['query']['@self']['updated']);
+ $this->assertSame(['lt' => '2026-10-05 03:00:00'], $this->searches[1]['query']['@self']['created']);
$this->assertSame('true', $this->searches[0]['query']['_drafts']);
$this->assertFalse($this->searches[0]['rbac']);
$this->assertFalse($this->searches[0]['multitenancy']);
@@ -516,10 +574,12 @@ public function testEveryPageIsReadBeforeTheFirstDelete(): void {
$counts = $this->expiry->sweep(now: $this->now);
$this->assertSame(DraftExpiry::PAGE_SIZE + 5, $counts['removed']);
- $this->assertCount(2, $this->searches);
+ // Two pages of expired drafts, then the one read for drafts that were never changed.
+ $this->assertCount(3, $this->searches);
$this->assertSame(0, $this->searches[0]['query']['_offset']);
$this->assertSame(DraftExpiry::PAGE_SIZE, $this->searches[1]['query']['_offset']);
$this->assertSame(DraftExpiry::PAGE_SIZE, $this->searches[1]['query']['_limit']);
+ $this->assertSame([], $this->counts, 'Nothing lies beyond the cap, so nothing is counted.');
}//end testEveryPageIsReadBeforeTheFirstDelete()
/**
@@ -542,4 +602,65 @@ public function testAnUnreadableSchemaOrListIsSkipped(): void {
$this->warnings
);
}//end testAnUnreadableSchemaOrListIsSkipped()
+
+ /**
+ * Young drafts never hide an expired one: the store is asked for expired drafts alone, so the read cap holds expired rows only.
+ *
+ * Goes red without the date filter in the query: the first MAX_PAGES x PAGE_SIZE
+ * rows are then the young drafts and the expired ones behind them are never read.
+ */
+ public function testYoungDraftsDoNotFillTheReadWindow(): void {
+ $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]);
+ $this->register(id: 10, slug: 'service', schemaIds: [1]);
+ for ($i = 0; $i < (DraftExpiry::MAX_PAGES * DraftExpiry::PAGE_SIZE); $i++) {
+ $this->object(where: '10/1', uuid: 'young-' . $i, status: 'draft', updated: '2026-10-11T00:00:00+00:00');
+ }
+
+ $this->object(where: '10/1', uuid: 'old-1', status: 'draft', updated: '2026-09-01T00:00:00+00:00');
+ $this->object(where: '10/1', uuid: 'old-2', status: 'draft', updated: '2026-09-02T00:00:00+00:00');
+
+ $counts = $this->expiry->sweep(now: $this->now);
+
+ $this->assertSame(['old-1', 'old-2'], array_column($this->deletes, 'uuid'));
+ $this->assertSame(['schemas' => 1, 'expired' => 2, 'removed' => 2, 'refused' => 0, 'deferred' => 0], $this->totals(counts: $counts));
+ }//end testYoungDraftsDoNotFillTheReadWindow()
+
+ /**
+ * Expired drafts beyond the read cap are counted as deferred, not missing from the counts (decision 228).
+ *
+ * The cap is MAX_PAGES full pages per register and schema. A preview (nothing removed) shows
+ * the whole picture: what was read, and what lies beyond it.
+ */
+ public function testExpiredDraftsBeyondTheReadCapAreCountedAsDeferred(): void {
+ $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]);
+ $this->register(id: 10, slug: 'service', schemaIds: [1]);
+ $cap = (DraftExpiry::MAX_PAGES * DraftExpiry::PAGE_SIZE);
+ for ($i = 0; $i < ($cap + 20); $i++) {
+ $this->object(where: '10/1', uuid: 'old-' . $i, status: 'draft', updated: '2026-09-01T00:00:00+00:00');
+ }
+
+ $counts = $this->expiry->preview(now: $this->now);
+
+ $this->assertSame(['schemas' => 1, 'expired' => ($cap + 20), 'removed' => 0, 'refused' => 0, 'deferred' => ($cap + 20)], $this->totals(counts: $counts));
+ $this->assertCount(DraftExpiry::MAX_PAGES, $this->searches, 'The read stops at the cap; the second list is not read, its rows are counted.');
+ $this->assertCount(2, $this->counts, 'The remainder of both lists is counted.');
+ }//end testExpiredDraftsBeyondTheReadCapAreCountedAsDeferred()
+
+ /**
+ * A sweep removes what it read and counts what is left beyond the cap as deferred.
+ */
+ public function testASweepCountsWhatIsLeftBeyondTheCapAsDeferred(): void {
+ $this->schema(id: 1, slug: 'ticket', configuration: ['draftLifetimeDays' => 7]);
+ $this->register(id: 10, slug: 'service', schemaIds: [1]);
+ $cap = (DraftExpiry::MAX_PAGES * DraftExpiry::PAGE_SIZE);
+ for ($i = 0; $i < ($cap + 20); $i++) {
+ $this->object(where: '10/1', uuid: 'old-' . $i, status: 'draft', updated: '2026-09-01T00:00:00+00:00');
+ }
+
+ $counts = $this->expiry->sweep(now: $this->now);
+
+ $this->assertSame(DraftExpiry::MAX_REMOVALS, $counts['removed']);
+ $this->assertSame($cap + 20, $counts['expired']);
+ $this->assertSame(($cap + 20) - DraftExpiry::MAX_REMOVALS, $counts['deferred'], 'Nothing is missing from the counts: expired = removed + refused + deferred.');
+ }//end testASweepCountsWhatIsLeftBeyondTheCapAsDeferred()
}//end class
From 8516405adfb14a6fb53191157857190676a2d426 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:27:44 +0200
Subject: [PATCH 61/99] =?UTF-8?q?fix(review):=20#4580=20r4293205599=20?=
=?UTF-8?q?=E2=80=94=20draft=20sweep=20counts=20are=20logged=20where=20the?=
=?UTF-8?q?=20default=20log=20level=20shows=20them?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DraftExpiryJob logged its per-schema lines and its totals at info, which Nextcloud's default loglevel (warning) does not write, so after the first run (which removes drafts that were kept until then) an administrator had no record of how many. A line that reports removed or refused drafts is now logged at warning, as DeferredObjectEventJob does for the same reason; a counted zero stays at info. FormUploadPurgeJob keeps its routine hourly count at info.
Tests: DraftExpiryJobTest (warning for a removal and for a refusal, info for the zero).
Thread: https://github.com/ConductionNL/openregister/pull/4580#discussion_r4293205599
Co-Authored-By: Claude Opus 5.5
---
lib/BackgroundJob/DraftExpiryJob.php | 34 +++++++++++++----
.../Unit/BackgroundJob/DraftExpiryJobTest.php | 38 ++++++++++++++++---
2 files changed, 59 insertions(+), 13 deletions(-)
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/tests/Unit/BackgroundJob/DraftExpiryJobTest.php b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php
index 3fc71be3d4..4172886959 100644
--- a/tests/Unit/BackgroundJob/DraftExpiryJobTest.php
+++ b/tests/Unit/BackgroundJob/DraftExpiryJobTest.php
@@ -179,12 +179,16 @@ static function (ObjectEntity $object, string $action, array $context = [], ?str
// One line for the schema that lost a draft, then the totals (decision 228).
$logged = [];
+ $levels = [];
$logger = $this->createMock(LoggerInterface::class);
- $logger->method('info')->willReturnCallback(
- static function (string $message, array $context = []) use (&$logged): void {
- $logged[] = [$message, $context];
- }
- );
+ foreach (['info', 'warning'] as $level) {
+ $logger->method($level)->willReturnCallback(
+ static function (string $message, array $context = []) use (&$logged, &$levels, $level): void {
+ $logged[] = [$message, $context];
+ $levels[] = $level;
+ }
+ );
+ }
$job = $this->build(
class: $this->declaredJobClass(),
@@ -210,6 +214,8 @@ class: $this->declaredJobClass(),
// The second schema sets nothing, so it gets 30 days: looked at, nothing expired, no line of its own.
$this->assertStringContainsString('swept drafts', $logged[1][0]);
$this->assertSame(['schemas' => 2, 'expired' => 1, 'removed' => 1, 'refused' => 0, 'deferred' => 0], $logged[1][1]);
+ // A sweep that removed a draft is logged where the default log level (warning) shows it.
+ $this->assertSame(['warning', 'warning'], $levels);
}//end testTheDeclaredJobRemovesAnExpiredDraft()
/**
@@ -249,4 +255,26 @@ class: $this->declaredJobClass(),
$this->expectException(RuntimeException::class);
$this->runJob(job: $job);
}//end testAFailingSweepSurfaces()
+
+ /**
+ * Drafts that were removed or refused are logged at warning, so the default log level shows them; a zero stays at info.
+ */
+ public function testARefusalIsLoggedWhereTheDefaultLevelShowsIt(): void {
+ $expiry = $this->createMock(DraftExpiry::class);
+ $expiry->method('sweep')->willReturn(
+ [
+ 'schemas' => 1, 'expired' => 2, 'removed' => 0, 'refused' => 2, 'deferred' => 0,
+ 'perSchema' => [['register' => 'service', 'schema' => 'ticket', 'lifetimeDays' => 7, 'expired' => 2, 'removed' => 0, 'refused' => 2, 'deferred' => 0]],
+ ]
+ );
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->exactly(2))->method('warning');
+ $logger->expects($this->never())->method('info');
+
+ $job = $this->build(
+ class: $this->declaredJobClass(),
+ services: [ITimeFactory::class => $this->clock(), LoggerInterface::class => $logger, DraftExpiry::class => $expiry]
+ );
+ $this->runJob(job: $job);
+ }//end testARefusalIsLoggedWhereTheDefaultLevelShowsIt()
}//end class
From 82af93ab558a37efd2c50e2bdc24b218c80d4e3c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Fri, 16 Oct 2026 14:28:04 +0200
Subject: [PATCH 62/99] =?UTF-8?q?fix(review):=20#4580=20files=20=E2=80=94?=
=?UTF-8?q?=20a=20delete=20that=20did=20not=20happen=20is=20not=20reported?=
=?UTF-8?q?=20as=20a=20move,=20a=20merge=20transfer=20or=20a=20batch=20del?=
=?UTF-8?q?ete?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thread r4284660694 (FrozenNodeWriteListener:123, the part left for the author). DeleteFileHandler already answers false for a file that is still there after delete() (71f4796); the callers that ignored it now read it: FileService::moveFile() takes the copy back and throws when the source could not be removed, MergeHandler reports a transfer or delete whose source survives as failed (and takes the copy back), and a batch delete counts it as failed. Rename and move through the Node API need no change: Node::move() throws NotPermittedException when the listener aborts the rename (view->rename returns false), so no caller reads it as success.
Co-Authored-By: Claude Opus 5.5
---
lib/Service/File/FileBatchHandler.php | 7 +-
lib/Service/FileService.php | 18 ++-
lib/Service/Object/MergeHandler.php | 18 ++-
.../Service/File/FileBatchHandlerTest.php | 17 +++
.../Unit/Service/FileServiceMoveFileTest.php | 104 +++++++++++++
.../Object/MergeHandlerFileTransferTest.php | 144 ++++++++++++++++++
6 files changed, 300 insertions(+), 8 deletions(-)
create mode 100644 tests/Unit/Service/FileServiceMoveFileTest.php
create mode 100644 tests/Unit/Service/Object/MergeHandlerFileTransferTest.php
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/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/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/tests/Unit/Service/File/FileBatchHandlerTest.php b/tests/Unit/Service/File/FileBatchHandlerTest.php
index ba767a7462..c934027d69 100644
--- a/tests/Unit/Service/File/FileBatchHandlerTest.php
+++ b/tests/Unit/Service/File/FileBatchHandlerTest.php
@@ -128,4 +128,21 @@ public function testBatchEmptyFileIds(): void {
$this->handler->executeBatch($object, 'publish', []);
}
+
+ /**
+ * A delete the file service reports as not done is a failed item of the batch.
+ */
+ public function testBatchDeleteOfAFileThatIsStillThereFails(): void {
+ $object = $this->createObjectEntity();
+
+ $this->fileService
+ ->method('deleteFile')
+ ->willReturnCallback(static fn ($file, $obj): bool => $file !== 43);
+
+ $result = $this->handler->executeBatch($object, 'delete', [42, 43]);
+
+ $this->assertEquals(1, $result['summary']['succeeded']);
+ $this->assertEquals(1, $result['summary']['failed']);
+ $this->assertFalse($result['results'][1]['success']);
+ }
}
diff --git a/tests/Unit/Service/FileServiceMoveFileTest.php b/tests/Unit/Service/FileServiceMoveFileTest.php
new file mode 100644
index 0000000000..7dcfb81a93
--- /dev/null
+++ b/tests/Unit/Service/FileServiceMoveFileTest.php
@@ -0,0 +1,104 @@
+
+ *
+ * @category Test
+ * @package OCA\OpenRegister\Tests\Unit\Service
+ *
+ * @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/object-archive-state/specs/object-lifecycle/spec.md#requirement-file-writes-honour-the-frozen-and-archived-marker-req-oas-007
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Tests\Unit\Service;
+
+use Exception;
+use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Service\File\FileLockHandler;
+use OCA\OpenRegister\Service\FileService;
+use OCP\Files\File;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use ReflectionProperty;
+
+/**
+ * @covers \OCA\OpenRegister\Service\FileService::moveFile
+ */
+class FileServiceMoveFileTest extends TestCase {
+
+ /**
+ * A file service whose copy and delete are doubles, so only the move rule runs.
+ *
+ * @return FileService&MockObject The service.
+ */
+ private function service(): FileService&MockObject {
+ $service = $this->getMockBuilder(FileService::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['copyFile', 'deleteFile'])
+ ->getMock();
+
+ foreach (['fileLockHandler' => $this->createMock(FileLockHandler::class), 'logger' => $this->createMock(LoggerInterface::class)] as $name => $value) {
+ $property = new ReflectionProperty(FileService::class, $name);
+ $property->setValue($service, $value);
+ }
+
+ return $service;
+ }//end service()
+
+ /**
+ * A source that could not be deleted is not moved: the copy is taken back and the caller told.
+ *
+ * @return void
+ */
+ public function testASourceThatSurvivesItsDeleteIsNotMoved(): void {
+ $copy = $this->createMock(File::class);
+ $source = new ObjectEntity();
+ $target = new ObjectEntity();
+
+ $service = $this->service();
+ $service->method('copyFile')->willReturn($copy);
+ $deleted = [];
+ $service->method('deleteFile')->willReturnCallback(
+ function ($file) use (&$deleted, $copy): bool {
+ $deleted[] = $file;
+
+ return $file === $copy;
+ }
+ );
+
+ try {
+ $service->moveFile(sourceObject: $source, fileId: 7, targetObject: $target);
+ $this->fail('Expected the move to fail');
+ } catch (Exception $e) {
+ $this->assertStringContainsString('could not be removed from the source object', $e->getMessage());
+ }
+
+ $this->assertSame([7, $copy], $deleted);
+ }//end testASourceThatSurvivesItsDeleteIsNotMoved()
+
+ /**
+ * Control: a source that is deleted makes the copy the moved file.
+ *
+ * @return void
+ */
+ public function testADeletedSourceIsAMove(): void {
+ $copy = $this->createMock(File::class);
+
+ $service = $this->service();
+ $service->method('copyFile')->willReturn($copy);
+ $service->expects($this->once())->method('deleteFile')->with(7)->willReturn(true);
+
+ $this->assertSame($copy, $service->moveFile(sourceObject: new ObjectEntity(), fileId: 7, targetObject: new ObjectEntity()));
+ }//end testADeletedSourceIsAMove()
+}//end class
diff --git a/tests/Unit/Service/Object/MergeHandlerFileTransferTest.php b/tests/Unit/Service/Object/MergeHandlerFileTransferTest.php
new file mode 100644
index 0000000000..7a8d841fd0
--- /dev/null
+++ b/tests/Unit/Service/Object/MergeHandlerFileTransferTest.php
@@ -0,0 +1,144 @@
+
+ *
+ * @category Test
+ * @package OCA\OpenRegister\Tests\Unit\Service\Object
+ *
+ * @author Conduction Development Team