From 4216abdadd9e2da938ef6a8fa08d92e653fb39f8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 20:16:13 +0000
Subject: [PATCH 001/139] chore(release): 1.1.5-unstable.20260820201450 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 48ce161288..de3c3c123d 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820125943
+ 1.1.5-unstable.20260820201450
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 45b77b10cf..4dcac8d2bd 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820125943",
+ "version": "1.1.5-unstable.20260820201450",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 0e35aea2616f654ecf26757294a07804bf87d600 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 20:46:33 +0000
Subject: [PATCH 002/139] chore(release): 1.1.5-unstable.20260820204506 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index de3c3c123d..0e3ae79892 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820201450
+ 1.1.5-unstable.20260820204506
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 4dcac8d2bd..d81fbdc831 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820201450",
+ "version": "1.1.5-unstable.20260820204506",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 159d2f25d93b3b777a285b5505b3211289b5f694 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 21:05:46 +0000
Subject: [PATCH 003/139] chore(release): 1.1.5-unstable.20260820210405 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 0e3ae79892..ec76a9aee1 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820204506
+ 1.1.5-unstable.20260820210405
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index d81fbdc831..0380f98809 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820204506",
+ "version": "1.1.5-unstable.20260820210405",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From d3c316be9ab30b72f3dbb9ac9a2135152c93fcf0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 21:28:30 +0000
Subject: [PATCH 004/139] chore(release): 1.1.5-unstable.20260820212658 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index ec76a9aee1..16f4be501c 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820210405
+ 1.1.5-unstable.20260820212658
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 0380f98809..30c62aaead 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820210405",
+ "version": "1.1.5-unstable.20260820212658",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 5094e5f0cfb71a1886ad5be87f8c8131f16790f0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 21:45:18 +0000
Subject: [PATCH 005/139] chore(release): 1.1.5-unstable.20260820214342 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 16f4be501c..88009df0c0 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820212658
+ 1.1.5-unstable.20260820214342
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 30c62aaead..586c75c0dd 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820212658",
+ "version": "1.1.5-unstable.20260820214342",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From a05f2e5427d6fcb50b9b024a0b86259a346d0cf8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 22:17:31 +0000
Subject: [PATCH 006/139] chore(release): 1.1.5-unstable.20260820221600 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 88009df0c0..1568dca4b1 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820214342
+ 1.1.5-unstable.20260820221600
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 586c75c0dd..57dc4b4397 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820214342",
+ "version": "1.1.5-unstable.20260820221600",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From e2f4abf8801a55c3457285490955ee3acb833045 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 22:37:10 +0000
Subject: [PATCH 007/139] chore(release): 1.1.5-unstable.20260820223544 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 1568dca4b1..748b50fbd7 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820221600
+ 1.1.5-unstable.20260820223544
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 57dc4b4397..088d1a3d1f 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820221600",
+ "version": "1.1.5-unstable.20260820223544",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 4bbd5a3d200112656f2b00cd26202a68aa26bab2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 22:49:52 +0000
Subject: [PATCH 008/139] chore(release): 1.1.5-unstable.20260820224828 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 748b50fbd7..cd75e834eb 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820223544
+ 1.1.5-unstable.20260820224828
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 088d1a3d1f..37db9ffcd0 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820223544",
+ "version": "1.1.5-unstable.20260820224828",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 26fa9c72485eb0efe05010ed0695673188d81c04 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 23:11:58 +0000
Subject: [PATCH 009/139] chore(release): 1.1.5-unstable.20260820231029 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index cd75e834eb..20e3444fb7 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820224828
+ 1.1.5-unstable.20260820231029
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 37db9ffcd0..be8ae992ae 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820224828",
+ "version": "1.1.5-unstable.20260820231029",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From b969dac9e52ec7f3c0a62bb2879a3c93ca3250e2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 23:40:46 +0000
Subject: [PATCH 010/139] chore(release): 1.1.5-unstable.20260820233856 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 20e3444fb7..74c7496bb7 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820231029
+ 1.1.5-unstable.20260820233856
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index be8ae992ae..1c8ca56623 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820231029",
+ "version": "1.1.5-unstable.20260820233856",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 0a633272e66c96d283559e4bf95a0dc718ee49b6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 23:58:51 +0000
Subject: [PATCH 011/139] chore(release): 1.1.5-unstable.20260820235724 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 74c7496bb7..1e79140bf2 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820233856
+ 1.1.5-unstable.20260820235724
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 1c8ca56623..6664570e94 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820233856",
+ "version": "1.1.5-unstable.20260820235724",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 178143fffbadf9650a4323f65e1f4e6db83aec62 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 00:14:37 +0000
Subject: [PATCH 012/139] chore(release): 1.1.5-unstable.20260821001315 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 1e79140bf2..f1da15bd3d 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260820235724
+ 1.1.5-unstable.20260821001315
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 6664570e94..c60932a80e 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260820235724",
+ "version": "1.1.5-unstable.20260821001315",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 6037d35391636aad9e01a0ccd8aaada913421a60 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 00:28:27 +0000
Subject: [PATCH 013/139] chore(release): 1.1.5-unstable.20260821002700 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index f1da15bd3d..68fbc9d0e0 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821001315
+ 1.1.5-unstable.20260821002700
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index c60932a80e..873490b8da 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821001315",
+ "version": "1.1.5-unstable.20260821002700",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From b78ab4a94518e3ae59b65a95908ae6a93eb2fb88 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 00:51:13 +0000
Subject: [PATCH 014/139] chore(release): 1.1.5-unstable.20260821004947 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 68fbc9d0e0..708995a21e 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821002700
+ 1.1.5-unstable.20260821004947
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 873490b8da..86a6e3accd 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821002700",
+ "version": "1.1.5-unstable.20260821004947",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 6de7465e79d5f818c7b7f83f7184e98925c6ecc7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 01:02:11 +0000
Subject: [PATCH 015/139] chore(release): 1.1.5-unstable.20260821010031 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 708995a21e..0a6d32471a 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821004947
+ 1.1.5-unstable.20260821010031
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 86a6e3accd..26ac14b772 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821004947",
+ "version": "1.1.5-unstable.20260821010031",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 42ae2b0c40d5031bfaeb7bd0b9556e8b356d3437 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 01:14:37 +0000
Subject: [PATCH 016/139] chore(release): 1.1.5-unstable.20260821011328 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 0a6d32471a..d1ca86a844 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821010031
+ 1.1.5-unstable.20260821011328
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 26ac14b772..7d28e52a11 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821010031",
+ "version": "1.1.5-unstable.20260821011328",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 36ff52761a1eae308b46f481512cc651341ca686 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 01:29:41 +0000
Subject: [PATCH 017/139] chore(release): 1.1.5-unstable.20260821012759 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index d1ca86a844..c78837a610 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821011328
+ 1.1.5-unstable.20260821012759
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 7d28e52a11..c7f32a0f62 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821011328",
+ "version": "1.1.5-unstable.20260821012759",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 42f4b56e6acc99fa9d0c6076afaf203ddc70984b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 01:45:12 +0000
Subject: [PATCH 018/139] chore(release): 1.1.5-unstable.20260821014409 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index c78837a610..afed981813 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821012759
+ 1.1.5-unstable.20260821014409
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index c7f32a0f62..02ba03477d 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821012759",
+ "version": "1.1.5-unstable.20260821014409",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 83fdb6ecb903853dea5cfb7d50193ddc405235fa Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 01:57:32 +0000
Subject: [PATCH 019/139] chore(release): 1.1.5-unstable.20260821015601 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index afed981813..d0d183c746 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821014409
+ 1.1.5-unstable.20260821015601
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 02ba03477d..1cb6cd46f8 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821014409",
+ "version": "1.1.5-unstable.20260821015601",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 38da6ebcfb330efcdbd219742cff37cfec3958fc Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 02:09:53 +0000
Subject: [PATCH 020/139] chore(release): 1.1.5-unstable.20260821020821 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index d0d183c746..22dff66ce2 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821015601
+ 1.1.5-unstable.20260821020821
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 1cb6cd46f8..9adac16dde 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821015601",
+ "version": "1.1.5-unstable.20260821020821",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 0dd6517c04a5f94018b6aa4101c4131aa2d51f68 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 02:21:58 +0000
Subject: [PATCH 021/139] chore(release): 1.1.5-unstable.20260821022028 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 22dff66ce2..a9bac03fe7 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821020821
+ 1.1.5-unstable.20260821022028
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 9adac16dde..43d64b3422 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821020821",
+ "version": "1.1.5-unstable.20260821022028",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From c1c0d79eb501c7fa47d50d1f4a06b6059a488aed Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 02:43:58 +0000
Subject: [PATCH 022/139] chore(release): 1.1.5-unstable.20260821024225 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index a9bac03fe7..96c37ff419 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821022028
+ 1.1.5-unstable.20260821024225
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 43d64b3422..7b5210864b 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821022028",
+ "version": "1.1.5-unstable.20260821024225",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 4685f7c704a278c2944d1597b9b3c23b867ae5b2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 02:54:14 +0000
Subject: [PATCH 023/139] chore(release): 1.1.5-unstable.20260821025248 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 96c37ff419..bd64576e3c 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821024225
+ 1.1.5-unstable.20260821025248
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 7b5210864b..075d7037f0 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821024225",
+ "version": "1.1.5-unstable.20260821025248",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 5110f05bc87053efbd9b447efd137946236969ff Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 03:05:46 +0000
Subject: [PATCH 024/139] chore(release): 1.1.5-unstable.20260821030422 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index bd64576e3c..0b2ed66cd7 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821025248
+ 1.1.5-unstable.20260821030422
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 075d7037f0..7f7fdd274e 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821025248",
+ "version": "1.1.5-unstable.20260821030422",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From ecd463891bfa177abc21d44f5a5eb793252bfce5 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 03:18:32 +0000
Subject: [PATCH 025/139] chore(release): 1.1.5-unstable.20260821031704 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 0b2ed66cd7..812c4cbe4c 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821030422
+ 1.1.5-unstable.20260821031704
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 7f7fdd274e..333709b5f2 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821030422",
+ "version": "1.1.5-unstable.20260821031704",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From c6acb87fb0b72714930eb751900f2d58da382fe0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 03:31:01 +0000
Subject: [PATCH 026/139] chore(release): 1.1.5-unstable.20260821032933 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 812c4cbe4c..6f90a201a9 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821031704
+ 1.1.5-unstable.20260821032933
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 333709b5f2..bd289821d1 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821031704",
+ "version": "1.1.5-unstable.20260821032933",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 0c0d7fca1c63120a462e88a97f7c2d7fffc1bb23 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 03:44:01 +0000
Subject: [PATCH 027/139] chore(release): 1.1.5-unstable.20260821034229 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6f90a201a9..e87efb6b5f 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821032933
+ 1.1.5-unstable.20260821034229
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index bd289821d1..53d9eb7785 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821032933",
+ "version": "1.1.5-unstable.20260821034229",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From de55c8c337c7f18d09cf2191272d25a9db28d159 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 03:53:42 +0000
Subject: [PATCH 028/139] chore(release): 1.1.5-unstable.20260821035214 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index e87efb6b5f..f1e056bfbe 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821034229
+ 1.1.5-unstable.20260821035214
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 53d9eb7785..ddedc4e2c5 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821034229",
+ "version": "1.1.5-unstable.20260821035214",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 9ccab4a2e5a98be61b2566a928e4aaf8ab4fedab Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 04:07:29 +0000
Subject: [PATCH 029/139] chore(release): 1.1.5-unstable.20260821040545 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index f1e056bfbe..52b91a200e 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821035214
+ 1.1.5-unstable.20260821040545
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index ddedc4e2c5..5c6cba1816 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821035214",
+ "version": "1.1.5-unstable.20260821040545",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 36033f78152389ce5adf4925cd618e438649e073 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 04:18:15 +0000
Subject: [PATCH 030/139] chore(release): 1.1.5-unstable.20260821041645 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 52b91a200e..cced82f9ad 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821040545
+ 1.1.5-unstable.20260821041645
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 5c6cba1816..95cdd6ae01 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821040545",
+ "version": "1.1.5-unstable.20260821041645",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 47c9ade65cd4ce6480671cca27bed7269e903f39 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 04:29:56 +0000
Subject: [PATCH 031/139] chore(release): 1.1.5-unstable.20260821042831 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index cced82f9ad..470dad282e 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821041645
+ 1.1.5-unstable.20260821042831
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 95cdd6ae01..3bdf6a21d8 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821041645",
+ "version": "1.1.5-unstable.20260821042831",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From b8c3919963a24c2aa4352fbbbc1a074b267b6873 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 04:40:46 +0000
Subject: [PATCH 032/139] chore(release): 1.1.5-unstable.20260821043916 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 470dad282e..b38132e27a 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821042831
+ 1.1.5-unstable.20260821043916
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 3bdf6a21d8..1efbfbd0d4 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821042831",
+ "version": "1.1.5-unstable.20260821043916",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From fba1d2a1ca651d655004df09b24427eca44abcea Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 04:53:17 +0000
Subject: [PATCH 033/139] chore(release): 1.1.5-unstable.20260821045152 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index b38132e27a..d51c809f06 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821043916
+ 1.1.5-unstable.20260821045152
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 1efbfbd0d4..7389c5d7b5 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821043916",
+ "version": "1.1.5-unstable.20260821045152",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From d0ca55ca7111cd2dbe41db9ad43fe07e31dfa7db Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 05:12:28 +0000
Subject: [PATCH 034/139] chore(release): 1.1.5-unstable.20260821051058 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index d51c809f06..6685459168 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821045152
+ 1.1.5-unstable.20260821051058
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 7389c5d7b5..45b1b0894f 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821045152",
+ "version": "1.1.5-unstable.20260821051058",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From ac07e226459670aff6b41556986b56144c3e7c49 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 05:31:24 +0000
Subject: [PATCH 035/139] chore(release): 1.1.5-unstable.20260821052950 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6685459168..445f8f1625 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821051058
+ 1.1.5-unstable.20260821052950
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index 45b1b0894f..c4f061d4a3 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821051058",
+ "version": "1.1.5-unstable.20260821052950",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 79d585e38c5ad42a465551aa9efd8caa1904c0bf Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 05:45:25 +0000
Subject: [PATCH 036/139] chore(release): 1.1.5-unstable.20260821054342 [skip
ci]
---
appinfo/info.xml | 2 +-
openapi.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 445f8f1625..7864416718 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata
Vrij en open source onder de EUPL-licentie.
]]>
- 1.1.5-unstable.20260821052950
+ 1.1.5-unstable.20260821054342
EUPL-1.2
Conduction
OpenRegister
diff --git a/openapi.json b/openapi.json
index c4f061d4a3..a547f7f98d 100644
--- a/openapi.json
+++ b/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "openregister",
- "version": "1.1.5-unstable.20260821052950",
+ "version": "1.1.5-unstable.20260821054342",
"description": "Open Register",
"license": {
"name": "EUPL-1.2"
From 74be84b73c7b8449b8d786a803f206b3d4102104 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Fri, 21 Aug 2026 09:26:45 +0200
Subject: [PATCH 037/139] fix(autoload): declare our own Contract prefix, so a
vendored copy cannot win
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
hydra-gates claims OCA\OpenRegister\Contract\ in its runtime autoload:
"autoload": { "psr-4": { "OCA\\OpenRegister\\Contract\\": "hydra-gates/contracts/" } }
That prefix is LONGER than our own OCA\OpenRegister\ -> lib/, and PSR-4 is
longest-prefix-wins, so the gate package's copy of OUR interface beat the
real one — in our own repository. Our generated autoload_classmap.php even
pointed at it explicitly.
That is how ObjectServiceUpdateVersusPatchTest went red: v1.8.0 shipped an
ObjectServiceInterface without patchObject(), and reflection resolved to
the vendored copy rather than lib/Contract/. Cutting v1.8.1 cleared the
symptom; this removes the mechanism, at least for this repository.
Declaring the same prefix ourselves is enough, because composer puts the
ROOT package's paths first for a shared prefix. Verified on a fixture with
a real vendor package claiming the identical prefix:
'OCA\OpenRegister\Contract\' => array($baseDir.'/lib/Contract',
$vendorDir.'/…/contracts')
resolves: lib/Contract/ObjectServiceInterface.php patchObject: YES
It is a preference, not an exclusion: the vendored path stays second in the
array, so anything present there and absent from lib/Contract/ still
resolves. gate-67 keeps the two byte-identical regardless.
This does not fix leaf apps, where openregister is not the root package —
see ConductionNL/.github#531 for the general case and the measured cost of
the fleet-wide fix.
---
composer.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/composer.json b/composer.json
index f680d0559d..bc0dcbcd11 100644
--- a/composer.json
+++ b/composer.json
@@ -11,6 +11,7 @@
],
"autoload": {
"psr-4": {
+ "OCA\\OpenRegister\\Contract\\": "lib/Contract/",
"OCA\\OpenRegister\\": "lib/"
}
},
From d1e306bb520b7eec6f910ab4e1e050e533a1319b Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Fri, 21 Aug 2026 14:17:50 +0200
Subject: [PATCH 038/139] feat(flow): object-write can write an item WHOLE via
payloadFrom
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`fields` enumerates the properties to write, which requires knowing them up
front. A synchronization with no sourceTargetMapping has no such list, and that
single gap is what makes most synchronizations unmigratable: measured across 119
cleanly-judged synchronizations on the dev instance, 98 of the 99 refusals were
"sourceTargetMapping is not set". The refusal text named the cause itself —
"openregister.object-write has no shorthand for writing an item whole".
`payloadFrom` names a path whose resolved value IS the payload.
AN UNRESOLVABLE PATH THROWS, REGARDLESS OF `onMissing`. That is deliberate:
`onMissing: omit` is a PER-FIELD rule — it drops one property and writes the
rest. There is no "rest" for a whole payload, so omitting would write a BLANK
record. That is the same hazard the field loop's own comment warns about, one
level up. A scalar at the path throws for the same reason: a string is not a
record.
`fields` and `payloadFrom` are alternatives, not layers — configuring both is
refused, because otherwise an author cannot tell which one produced the record
that was written. Configuring neither is still refused, so widening the old
"needs at least one field" guard has not become "accepts nothing". `payloadFrom`
is meaningless for a delete, like `fields`, and refused there too.
Unlike `fields` it is a flat path rather than a structured value, so it IS
offered in the editor's config form.
Verified: 8 new tests, and the control is those tests against the reverted node
— 6 of 8 fail without the feature. The existing vocabulary pin
(testTheConfigVocabularyIsPinnedWithBulk) caught the new key and is updated
deliberately rather than loosened. Full flow suite 592 tests green, phpcs back
to base parity (0 errors, 1 pre-existing warning), phpstan clean, phpmd
byte-identical to base (7 pre-existing StaticAccess findings, exit 2 on both),
gate-16 0 and gate-46 clean at the origin/beta merge scope.
This must ship and DEPLOY before openconnector starts emitting the key, or
preflight refuses every generated flow — the same sequencing that blocked task
2.2 when skipWhen was ahead of the box.
---
lib/Service/Flow/Nodes/ObjectWriteNode.php | 96 ++++-
.../Unit/Service/Flow/ObjectWriteNodeTest.php | 4 +
.../Flow/ObjectWritePayloadFromTest.php | 349 ++++++++++++++++++
3 files changed, 444 insertions(+), 5 deletions(-)
create mode 100644 tests/Unit/Service/Flow/ObjectWritePayloadFromTest.php
diff --git a/lib/Service/Flow/Nodes/ObjectWriteNode.php b/lib/Service/Flow/Nodes/ObjectWriteNode.php
index 62f0fb806e..8c22f25271 100644
--- a/lib/Service/Flow/Nodes/ObjectWriteNode.php
+++ b/lib/Service/Flow/Nodes/ObjectWriteNode.php
@@ -423,6 +423,7 @@ public function configKeys(): array {
'schema',
'operation',
'fields',
+ 'payloadFrom',
'match',
'replace',
'bulk',
@@ -489,6 +490,17 @@ private function targetConfigForm(): array {
),
'required' => true,
],
+ [
+ 'key' => 'payloadFrom',
+ 'label' => $this->l10n->t('Write object at path'),
+ 'type' => 'text',
+ 'help' => $this->l10n->t(
+ 'Write the object found at this path WHOLE, instead of listing properties in "fields". '
+ . 'Use it when the properties are not known up front — a synchronization with no mapping, '
+ . 'for example. Configure this or "fields", never both.'
+ ),
+ 'required' => false,
+ ],
[
'key' => 'bulk',
'label' => $this->l10n->t('Write the whole page in one call'),
@@ -889,6 +901,7 @@ private function writeItems(
$out = [];
$skipWhen = trim((string)($config['skipWhen'] ?? ''));
+ $payloadFrom = trim((string)($config['payloadFrom'] ?? ''));
foreach ($items as $index => $item) {
$json = (array)($item[FlowItems::JSON] ?? []);
@@ -921,7 +934,12 @@ private function writeItems(
continue;
}
- $payload = $this->buildPayload(fields: $fields, json: $json, onMissing: $onMissing);
+ $payload = $this->buildPayload(
+ fields: $fields,
+ json: $json,
+ onMissing: $onMissing,
+ payloadFrom: $payloadFrom
+ );
$matched = null;
if ($operation !== self::OP_CREATE) {
$matched = $this->findMatch(pairs: $pairs, json: $json, register: $register, schema: $schema, owner: $owner);
@@ -1807,12 +1825,46 @@ private function assignMatchFilter(array &$filters, string $property, mixed $val
* @param array $fields The configured mapping.
* @param array $json The item's record.
* @param string $onMissing What an unresolvable value means.
+ * @param string|null $payloadFrom Path whose resolved object IS the payload, when the
+ * properties are not known up front. Alternative to `$fields`.
*
* @return array The payload to write.
*
* @throws RuntimeException When a value is unresolvable and `onMissing` is `fail`.
*/
- private function buildPayload(array $fields, array $json, string $onMissing): array {
+ private function buildPayload(
+ array $fields,
+ array $json,
+ string $onMissing,
+ ?string $payloadFrom = null
+ ): array {
+ // WRITING AN ITEM WHOLE. `fields` enumerates the properties to write, which
+ // requires knowing them up front — a synchronization with no mapping has no
+ // such list, and that single gap refused 98 of 99 unmigratable
+ // synchronizations measured on the dev instance. `payloadFrom` names a path
+ // whose resolved value IS the payload.
+ //
+ // An unresolvable path THROWS regardless of `onMissing`, deliberately.
+ // `onMissing: omit` is a per-field rule — it drops one property and writes
+ // the rest. There is no "rest" here: omitting a whole payload would write a
+ // BLANK object, which is the same hazard the field loop's own comment warns
+ // about, one level up.
+ if ($payloadFrom !== null && trim($payloadFrom) !== '') {
+ $found = $this->lookupPath(path: trim($payloadFrom), json: $json);
+
+ if ($found['found'] === false || is_array($found['value']) === false) {
+ throw new RuntimeException(
+ $this->l10n->t(
+ '"payloadFrom" path "%s" did not resolve to an object on this item, so there is '
+ . 'nothing to write. Writing an empty object instead would create a blank record.',
+ [trim($payloadFrom)]
+ )
+ );
+ }
+
+ return $found['value'];
+ }
+
$payload = [];
foreach ($fields as $key => $value) {
@@ -1925,6 +1977,7 @@ private function planBulkRows(
// note in writeItems(): dropping it is what makes a later sweep
// delete it.
$skipWhen = trim((string)($config['skipWhen'] ?? ''));
+ $payloadFrom = trim((string)($config['payloadFrom'] ?? ''));
$rows = [];
$ids = [];
@@ -1936,7 +1989,12 @@ private function planBulkRows(
continue;
}
- $payload = $this->buildPayload(fields: $fields, json: $json, onMissing: $onMissing);
+ $payload = $this->buildPayload(
+ fields: $fields,
+ json: $json,
+ onMissing: $onMissing,
+ payloadFrom: $payloadFrom
+ );
$id = $this->bulkRowId(operation: $operation, pairs: $pairs, json: $json);
$payload['id'] = $id;
$rows[] = $payload;
@@ -2169,9 +2227,28 @@ private function validateOperationKeys(array $config, string $operation): void {
);
}
- if ((array)($config['fields'] ?? []) === []) {
+ $hasFields = ((array)($config['fields'] ?? []) !== []);
+ $hasPayloadFrom = (trim((string)($config['payloadFrom'] ?? '')) !== '');
+
+ // The two are alternatives, not layers: `fields` enumerates properties,
+ // `payloadFrom` writes an object whole. Accepting both would leave the
+ // author unable to tell which one produced the record that was written.
+ if ($hasFields === true && $hasPayloadFrom === true) {
+ throw new UnexpectedValueException(
+ $this->l10n->t(
+ '"fields" and "payloadFrom" are alternatives: "fields" lists the properties to write, '
+ . '"payloadFrom" writes the object at a path whole. Configure one, not both.'
+ )
+ );
+ }
+
+ if ($hasFields === false && $hasPayloadFrom === false) {
throw new UnexpectedValueException(
- $this->l10n->t('An object-write step with operation "%s" needs at least one field to write.', [$operation])
+ $this->l10n->t(
+ 'An object-write step with operation "%s" needs either at least one field to write, '
+ . 'or a "payloadFrom" path naming the object to write whole.',
+ [$operation]
+ )
);
}
@@ -2201,6 +2278,15 @@ private function validateDeleteKeys(array $config): void {
);
}
+ // Same reasoning as `fields`: a delete names WHICH object goes, never what
+ // to write into it. Accepting the key would let an author believe the
+ // payload mattered.
+ if (array_key_exists('payloadFrom', $config) === true) {
+ throw new UnexpectedValueException(
+ $this->l10n->t('"payloadFrom" has no meaning for a delete step.')
+ );
+ }
+
if (array_key_exists('replace', $config) === true) {
throw new UnexpectedValueException(
$this->l10n->t('"replace" has no meaning for a delete step.')
diff --git a/tests/Unit/Service/Flow/ObjectWriteNodeTest.php b/tests/Unit/Service/Flow/ObjectWriteNodeTest.php
index 8cfee8c146..7285ea760e 100644
--- a/tests/Unit/Service/Flow/ObjectWriteNodeTest.php
+++ b/tests/Unit/Service/Flow/ObjectWriteNodeTest.php
@@ -1208,6 +1208,10 @@ public function testTheConfigVocabularyIsPinnedWithBulk(): void {
'schema',
'operation',
'fields',
+ // `payloadFrom` writes the object at a path WHOLE, for the case where
+ // the properties are not known up front — a synchronization with no
+ // mapping. It is an alternative to `fields`, never a companion.
+ 'payloadFrom',
'match',
'replace',
'bulk',
diff --git a/tests/Unit/Service/Flow/ObjectWritePayloadFromTest.php b/tests/Unit/Service/Flow/ObjectWritePayloadFromTest.php
new file mode 100644
index 0000000000..147d5d6a2b
--- /dev/null
+++ b/tests/Unit/Service/Flow/ObjectWritePayloadFromTest.php
@@ -0,0 +1,349 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Test
+ * @package OCA\OpenRegister\Tests\Unit\Service\Flow
+ *
+ * @author Conduction
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Service\Flow;
+
+use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\Flow\FlowItems;
+use OCA\OpenRegister\Service\Flow\Nodes\ObjectWriteNode;
+use OCA\OpenRegister\Service\ObjectService;
+use OCP\IAppConfig;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\IUserManager;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+use UnexpectedValueException;
+
+/**
+ * Tests for object-write's whole-object payload path.
+ */
+class ObjectWritePayloadFromTest extends TestCase {
+
+ /**
+ * @var ObjectService|MockObject
+ */
+ private $objects;
+
+ /**
+ * @var IUserManager|MockObject
+ */
+ private $userManager;
+
+ /**
+ * @var IAppConfig|MockObject
+ */
+ private $appConfig;
+
+ /**
+ * @var ObjectWriteNode
+ */
+ private ObjectWriteNode $node;
+
+ /**
+ * @var Register
+ */
+ private Register $register;
+
+ /**
+ * @var Schema
+ */
+ private Schema $schema;
+
+ /**
+ * @var array
+ */
+ private array $registerContext;
+
+ /**
+ * Set up the node with mocked collaborators.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->objects = $this->createMock(ObjectService::class);
+ // `runAs()` scopes the acting user around a read. The double must RUN the
+ // callable, or every lookup silently returns null.
+ $this->objects->method('runAs')->willReturnCallback(
+ static fn (IUser $user, callable $operation) => $operation()
+ );
+ $this->userManager = $this->createMock(IUserManager::class);
+ $this->appConfig = $this->createMock(IAppConfig::class);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('admin');
+ $this->userManager->method('get')->willReturn($user);
+ $this->userManager->method('search')->willReturn([$user]);
+
+ $this->register = new Register();
+ $this->register->setId(1);
+ $this->register->setSlug('example-hydra-cache');
+
+ $this->schema = new Schema();
+ $this->schema->setId(2);
+ $this->schema->setSlug('example-cache-entry');
+
+ $registers = $this->createMock(RegisterMapper::class);
+ $registers->method('find')->willReturn($this->register);
+ $schemas = $this->createMock(SchemaMapper::class);
+ // Slug resolution goes through findBySlugInIds(); without it the register
+ // reports "carries no schemas at all" and nothing under test is reached.
+ $schemas->method('findBySlugInIds')->willReturn($this->schema);
+ $schemas->method('find')->willReturn($this->schema);
+
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(
+ static function (string $text, array $parameters = []): string {
+ if ($parameters === []) {
+ return $text;
+ }
+
+ return vsprintf($text, $parameters);
+ }
+ );
+
+ $urls = $this->createMock(IURLGenerator::class);
+ $urls->method('imagePath')->willReturnCallback(
+ static fn (string $app, string $file): string => '/' . $app . '/img/' . $file
+ );
+
+ $this->node = new ObjectWriteNode(
+ $this->objects,
+ $registers,
+ $schemas,
+ $this->userManager,
+ $this->appConfig,
+ $l10n,
+ $urls
+ );
+
+ $this->registerContext = ['triggeredBy' => 'admin'];
+
+ }//end setUp()
+
+ /**
+ * Wrap records as flow items.
+ *
+ * @param array> $records The item records.
+ *
+ * @return array> The items.
+ */
+ private function items(array $records): array {
+ return array_map(static fn (array $r): array => FlowItems::item(json: $r), $records);
+ }//end items()
+
+ /**
+ * Build a config for a whole-object write.
+ *
+ * @param array $overrides Config overrides.
+ *
+ * @return array The config.
+ */
+ private function config(array $overrides = []): array {
+ return array_merge(
+ [
+ 'register' => 'example-hydra-cache',
+ 'schema' => 'example-cache-entry',
+ 'operation' => ObjectWriteNode::OP_CREATE,
+ 'payloadFrom' => 'source',
+ ],
+ $overrides
+ );
+
+ }//end config()
+
+ /**
+ * Build a saved entity.
+ *
+ * @param string $uuid The uuid.
+ * @param array $data The object data.
+ *
+ * @return ObjectEntity The entity.
+ */
+ private function entity(string $uuid, array $data = []): ObjectEntity {
+ $entity = new ObjectEntity();
+ $entity->setUuid($uuid);
+ $entity->setObject($data);
+
+ return $entity;
+ }//end entity()
+
+ /**
+ * THE POINT OF THE FEATURE. The object at the path is written whole, with every
+ * property it carries — no `fields` list, nothing enumerated up front.
+ *
+ * @return void
+ */
+ public function testWritesTheObjectAtThePathWhole(): void {
+ $seen = null;
+ $this->objects->method('saveObject')->willReturnCallback(
+ function (mixed $object, ?array $extend = [], mixed $register = null, mixed $schema = null, ?string $uuid = null, bool $_rbac = true, bool $_multitenancy = true, bool $silent = false, bool $_validation = true, ?array $uploadedFiles = null, ?IUser $currentUser = null) use (&$seen): ObjectEntity {
+ $seen = $object;
+
+ return $this->entity('uuid-1', (array)$object);
+ }
+ );
+
+ $this->node->execute(
+ $this->items([['source' => ['name' => 'a', 'title' => 'A', 'nested' => ['x' => 1]]]]),
+ $this->config(),
+ $this->registerContext
+ );
+
+ $this->assertSame(
+ ['name' => 'a', 'title' => 'A', 'nested' => ['x' => 1]],
+ $seen,
+ 'every property of the object at the path is written, including nested structure'
+ );
+ }//end testWritesTheObjectAtThePathWhole()
+
+ /**
+ * A path that resolves to nothing THROWS rather than writing an empty object.
+ * `onMissing: omit` is a per-field rule — it drops one property and writes the
+ * rest. There is no "rest" for a whole payload, so omitting would create a
+ * blank record.
+ *
+ * @return void
+ */
+ public function testAnUnresolvablePathThrowsRatherThanWritingBlank(): void {
+ $this->objects->expects($this->never())->method('saveObject');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/payloadFrom/');
+
+ $this->node->execute(
+ $this->items([['somethingElse' => ['name' => 'a']]]),
+ $this->config(),
+ $this->registerContext
+ );
+ }//end testAnUnresolvablePathThrowsRatherThanWritingBlank()
+
+ /**
+ * ...and the same when the path resolves to a scalar rather than an object.
+ * A string is not a record.
+ *
+ * @return void
+ */
+ public function testAScalarAtThePathThrows(): void {
+ $this->objects->expects($this->never())->method('saveObject');
+
+ $this->expectException(RuntimeException::class);
+
+ $this->node->execute(
+ $this->items([['source' => 'not-an-object']]),
+ $this->config(),
+ $this->registerContext
+ );
+ }//end testAScalarAtThePathThrows()
+
+ /**
+ * `fields` and `payloadFrom` are alternatives. Accepting both would leave the
+ * author unable to tell which produced the record.
+ *
+ * @return void
+ */
+ public function testFieldsAndPayloadFromTogetherAreRefused(): void {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessageMatches('/alternatives/');
+
+ $this->node->execute(
+ $this->items([['source' => ['name' => 'a']]]),
+ $this->config(['fields' => ['title' => '{{source.name}}']]),
+ $this->registerContext
+ );
+ }//end testFieldsAndPayloadFromTogetherAreRefused()
+
+ /**
+ * Neither one configured is still refused — that guard existed for `fields`
+ * alone and must not have been widened into accepting nothing.
+ *
+ * @return void
+ */
+ public function testNeitherFieldsNorPayloadFromIsRefused(): void {
+ $config = $this->config();
+ unset($config['payloadFrom']);
+
+ $this->expectException(UnexpectedValueException::class);
+
+ $this->node->execute(
+ $this->items([['source' => ['name' => 'a']]]),
+ $config,
+ $this->registerContext
+ );
+ }//end testNeitherFieldsNorPayloadFromIsRefused()
+
+ /**
+ * A delete names WHICH object goes, never what to write into it.
+ *
+ * @return void
+ */
+ public function testPayloadFromIsMeaninglessForADelete(): void {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessageMatches('/payloadFrom/');
+
+ $this->node->execute(
+ $this->items([['source' => ['name' => 'a']]]),
+ $this->config(
+ [
+ 'operation' => ObjectWriteNode::OP_DELETE,
+ 'match' => [['property' => 'name', 'value' => '{{source.name}}']],
+ 'confirmDelete' => true,
+ ]
+ ),
+ $this->registerContext
+ );
+ }//end testPayloadFromIsMeaninglessForADelete()
+
+ /**
+ * The key is accepted by the preflight vocabulary. A node that reads a key it
+ * does not declare is a step whose config is silently ignored — the failure
+ * mode the preflight exists to catch.
+ *
+ * @return void
+ */
+ public function testPayloadFromIsADeclaredConfigKey(): void {
+ $this->assertContains('payloadFrom', $this->node->configKeys());
+ }//end testPayloadFromIsADeclaredConfigKey()
+
+ /**
+ * ...and is offered in the editor, since unlike `fields` it is a flat path.
+ *
+ * @return void
+ */
+ public function testPayloadFromIsOfferedInTheConfigForm(): void {
+ $keys = array_column($this->node->configForm(), 'key');
+
+ $this->assertContains('payloadFrom', $keys);
+ }//end testPayloadFromIsOfferedInTheConfigForm()
+}//end class
From 113f05206aaee49f1ad4bf44d246a63a89d57efd Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Fri, 21 Aug 2026 15:40:07 +0200
Subject: [PATCH 039/139] feat(lifecycle): accept declared transition inputs on
POST /api/objects/{id}/transition (#2686)
A schema's x-openregister-lifecycle.transitions. block may now
declare inputs: [{"field": "", "required": true|false}].
The transition endpoint accepts an optional `data` object whose keys are
validated against that allowlist:
- undeclared key -> 400 naming the offending key(s)
- required input absent or empty-string -> 400 naming the missing field(s)
- no inputs declared -> any payload is rejected (today's behaviour kept)
- graph-mode transitions declare no inputs, so they reject any payload
Accepted values merge into the SAME saveObject() write that flips the
lifecycle field, so pre-save listeners (ObjectUpdatingEvent) observe the
status change and the inputs together, and the standard schema
validation / readOnly enforcement applies to them like any object write.
New InvalidTransitionInputException maps to HTTP 400 in the controller
with the offending field names machine-readable ('fields') next to the
human message, distinct from 422 (refused) and 403 (unauthorized).
Co-authored-by: Conduction Release Bot
---
lib/Controller/TransitionController.php | 28 +-
.../InvalidTransitionInputException.php | 74 ++++
lib/Service/Lifecycle/TransitionEngine.php | 149 ++++++-
.../Controller/TransitionControllerTest.php | 86 +++-
.../Lifecycle/TransitionEngineInputsTest.php | 393 ++++++++++++++++++
5 files changed, 715 insertions(+), 15 deletions(-)
create mode 100644 lib/Exception/InvalidTransitionInputException.php
create mode 100644 tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php
diff --git a/lib/Controller/TransitionController.php b/lib/Controller/TransitionController.php
index b9147b0bc5..b74b476a85 100644
--- a/lib/Controller/TransitionController.php
+++ b/lib/Controller/TransitionController.php
@@ -27,6 +27,7 @@
namespace OCA\OpenRegister\Controller;
use OCA\OpenRegister\Exception\HookStoppedException;
+use OCA\OpenRegister\Exception\InvalidTransitionInputException;
use OCA\OpenRegister\Exception\NotAuthorizedException;
use OCA\OpenRegister\Service\Lifecycle\TransitionEngine;
use OCP\AppFramework\Controller;
@@ -58,6 +59,10 @@ public function __construct(
* the same endpoint covers every transition declared on the schema —
* apps don't need a route per action.
*
+ * An optional `data` object carries input values for the transition's
+ * declared `inputs` (see the engine); an undeclared key, a missing
+ * required input, or a non-object `data` value is a 400.
+ *
* @param string $id Object id/uuid/slug.
*
* @return JSONResponse JSON response with the transitioned object or an error.
@@ -67,6 +72,7 @@ public function __construct(
* @NoCSRFRequired
*
* @spec openspec/changes/retrofit-2026-05-24-b-ctrl-misc/tasks.md#task-6
+ * @spec openspec/specs/object-lifecycle/spec.md
*/
public function transition(string $id): JSONResponse {
$action = (string)($this->request->getParam('action') ?? '');
@@ -77,8 +83,28 @@ public function transition(string $id): JSONResponse {
);
}
+ $data = $this->request->getParam('data') ?? [];
+ if (is_array($data) === false) {
+ return new JSONResponse(
+ ['error' => 'Field "data" must be an object of input values.'],
+ Http::STATUS_BAD_REQUEST
+ );
+ }
+
try {
- $object = $this->engine->transition(objectId: $id, action: $action);
+ $object = $this->engine->transition(objectId: $id, action: $action, data: $data);
+ } catch (InvalidTransitionInputException $e) {
+ // The payload violates the transition's declared `inputs`
+ // allowlist (undeclared key, or missing required input). The
+ // request itself is malformed → 400, with the offending field
+ // names machine-readable next to the human message.
+ return new JSONResponse(
+ [
+ 'error' => $e->getMessage(),
+ 'fields' => $e->getFields(),
+ ],
+ Http::STATUS_BAD_REQUEST
+ );
} catch (NotAuthorizedException $e) {
// Caller lacks `update` permission on the object. Surface
// as 403 so clients can distinguish "not allowed" from
diff --git a/lib/Exception/InvalidTransitionInputException.php b/lib/Exception/InvalidTransitionInputException.php
new file mode 100644
index 0000000000..4a48b556c3
--- /dev/null
+++ b/lib/Exception/InvalidTransitionInputException.php
@@ -0,0 +1,74 @@
+
+ * @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
+ */
+
+namespace OCA\OpenRegister\Exception;
+
+use Exception;
+use Throwable;
+
+/**
+ * Exception thrown when transition input data violates the declared `inputs` allowlist.
+ *
+ * Raised for keys that are not declared on the transition, and for declared
+ * `required` inputs that are absent (or empty-string) from the payload. Maps
+ * to HTTP 400 in the TransitionController: the request itself is malformed,
+ * as opposed to a transition that is refused (422) or unauthorized (403).
+ */
+class InvalidTransitionInputException extends Exception {
+
+ /**
+ * The offending field names (unknown keys, or missing required inputs).
+ *
+ * @var array
+ */
+ private readonly array $fields;
+
+ /**
+ * Constructor for InvalidTransitionInputException
+ *
+ * @param string $message Error message naming the offending field(s)
+ * @param array $fields Offending field names
+ * @param int $code Error code
+ * @param Throwable|null $previous Previous exception
+ *
+ * @return void
+ */
+ public function __construct(
+ string $message = 'Invalid transition input data',
+ array $fields = [],
+ int $code = 0,
+ ?Throwable $previous = null,
+ ) {
+ $this->fields = $fields;
+ parent::__construct(message: $message, code: $code, previous: $previous);
+ }//end __construct()
+
+ /**
+ * Get the offending field names
+ *
+ * @return array
+ */
+ public function getFields(): array {
+ return $this->fields;
+ }//end getFields()
+}//end class
diff --git a/lib/Service/Lifecycle/TransitionEngine.php b/lib/Service/Lifecycle/TransitionEngine.php
index 3018757998..5cb8f579dd 100644
--- a/lib/Service/Lifecycle/TransitionEngine.php
+++ b/lib/Service/Lifecycle/TransitionEngine.php
@@ -33,6 +33,7 @@
use OCA\OpenRegister\Db\Schema;
use OCA\OpenRegister\Db\SchemaMapper;
use OCA\OpenRegister\Event\ObjectTransitionedEvent;
+use OCA\OpenRegister\Exception\InvalidTransitionInputException;
use OCA\OpenRegister\Exception\NotAuthorizedException;
use OCA\OpenRegister\Service\Object\PermissionHandler;
use OCA\OpenRegister\Service\ObjectService;
@@ -229,21 +230,31 @@ private function transitionEventScope(ObjectEntity $object): array {
/**
* Apply a named transition to an object.
*
+ * When the transition declares `inputs`, the (optional) `$data` payload is
+ * validated against that allowlist and the accepted values are merged into
+ * the SAME write that flips the lifecycle field — so pre-save listeners
+ * (ObjectUpdatingEvent) observe the status change and the inputs together,
+ * and the normal schema validation / readOnly enforcement applies to them.
+ *
* @param string $objectId Object id/uuid/slug.
* @param string $action Transition action name.
+ * @param array $data Optional input values for the transition's declared `inputs`.
*
* @return ObjectEntity The saved object after the transition.
*
* @throws RuntimeException When the object/schema/transition is missing,
* the action is not allowed from the current
* state, or the underlying save is rejected.
+ * @throws InvalidTransitionInputException When `$data` contains a key the
+ * transition does not declare, or a `required`
+ * input is absent or empty-string.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength) Linear resolve→guard→mutate→save flow; splitting would obscure the transition contract.
*
* @spec openspec/specs/object-lifecycle/spec.md
* @spec openspec/changes/fk-graph-lifecycle-transitions/specs/object-lifecycle/spec.md
*/
- public function transition(string $objectId, string $action): ObjectEntity {
+ public function transition(string $objectId, string $action, array $data = []): ObjectEntity {
$object = $this->objectService->find(id: $objectId);
if ($object === null) {
throw new RuntimeException(sprintf('Object "%s" not found.', $objectId));
@@ -301,7 +312,8 @@ public function transition(string $objectId, string $action): ObjectEntity {
object: $object,
graph: $graph,
field: $field,
- action: $action
+ action: $action,
+ data: $data
);
}
}
@@ -316,8 +328,8 @@ public function transition(string $objectId, string $action): ObjectEntity {
$targetState = (string)($spec['to'] ?? '');
$from = (array)($spec['from'] ?? []);
- $data = $object->getObject() ?? [];
- $currentValue = (string)($data[$field] ?? '');
+ $objectData = $object->getObject() ?? [];
+ $currentValue = (string)($objectData[$field] ?? '');
if (in_array($currentValue, $from, true) === false) {
throw new RuntimeException(
@@ -329,9 +341,19 @@ public function transition(string $objectId, string $action): ObjectEntity {
);
}
+ // Validate the payload against the transition's `inputs` allowlist and
+ // merge the accepted values BEFORE flipping the lifecycle field, so the
+ // status write always wins and both land in the same save.
+ $accepted = $this->resolveTransitionInputs(
+ inputs: (array)($spec['inputs'] ?? []),
+ data: $data,
+ action: $action
+ );
+ $objectData = array_merge($objectData, $accepted);
+
// Mutate the lifecycle field. The validator listener will re-check
// the transition on save; the guard (if any) will run there too.
- $data[$field] = $targetState;
+ $objectData[$field] = $targetState;
// Snapshot the session user at the transition boundary and forward it
// explicitly to the save path, so the @self.folder check uses the SAME
@@ -341,7 +363,7 @@ public function transition(string $objectId, string $action): ObjectEntity {
$actingUser = $this->userSession->getUser();
$saved = $this->objectService->saveObject(
- object: $data,
+ object: $objectData,
register: $object->getRegister(),
schema: $object->getSchema(),
uuid: $object->getUuid(),
@@ -649,6 +671,104 @@ private function buildGraphAction(ObjectEntity $sibling): array {
];
}//end buildGraphAction()
+ /**
+ * Validate a transition `data` payload against the declared `inputs` allowlist.
+ *
+ * A transition may declare `inputs: [{"field": "", "required": true|false}, ...]`
+ * on its `x-openregister-lifecycle.transitions.` block. Only declared
+ * fields are accepted from the payload; anything else is rejected — a
+ * transition with no `inputs` therefore rejects ANY payload, keeping today's
+ * behaviour for schemas that never opted in. The accepted values are NOT
+ * validated here against the property definitions: they are merged into the
+ * carrying object write, so the standard save-path validation (and readOnly
+ * enforcement) applies to them exactly like any other object write.
+ *
+ * @param array $inputs The transition's declared `inputs` list.
+ * @param array $data The caller-supplied payload.
+ * @param string $action The transition action name, for error messages.
+ *
+ * @return array The accepted field => value pairs to merge into the write.
+ *
+ * @throws InvalidTransitionInputException When `$data` contains an undeclared
+ * key, or a `required` input is absent or empty-string.
+ *
+ * @spec openspec/specs/object-lifecycle/spec.md
+ */
+ private function resolveTransitionInputs(array $inputs, array $data, string $action): array {
+ $declared = $this->normaliseDeclaredInputs(inputs: $inputs);
+
+ // Reject any payload key the transition does not declare.
+ $unknown = array_diff(array_keys($data), array_keys($declared));
+ if ($unknown !== []) {
+ $unknown = array_values(array_map('strval', $unknown));
+ throw new InvalidTransitionInputException(
+ message: sprintf(
+ 'Transition "%s" does not accept input field(s): %s.',
+ $action,
+ '"'.implode('", "', $unknown).'"'
+ ),
+ fields: $unknown
+ );
+ }
+
+ // Reject when a required input is absent or empty-string.
+ $missing = [];
+ foreach ($declared as $fieldName => $required) {
+ if ($required === false) {
+ continue;
+ }
+
+ if (array_key_exists($fieldName, $data) === false || $data[$fieldName] === '') {
+ $missing[] = $fieldName;
+ }
+ }
+
+ if ($missing !== []) {
+ throw new InvalidTransitionInputException(
+ message: sprintf(
+ 'Transition "%s" is missing required input field(s): %s.',
+ $action,
+ '"'.implode('", "', $missing).'"'
+ ),
+ fields: $missing
+ );
+ }
+
+ // Everything present is declared — merge it all.
+ return $data;
+ }//end resolveTransitionInputs()
+
+ /**
+ * Normalise a transition's `inputs` declaration into fieldName => required.
+ *
+ * Malformed entries (non-arrays, or entries without a `field` name) are
+ * skipped rather than fatal: a broken declaration must not take the whole
+ * transition down, it simply allowlists nothing.
+ *
+ * @param array $inputs The transition's declared `inputs` list.
+ *
+ * @return array Map of declared field name to its `required` flag.
+ *
+ * @spec openspec/specs/object-lifecycle/spec.md
+ */
+ private function normaliseDeclaredInputs(array $inputs): array {
+ $declared = [];
+ foreach ($inputs as $input) {
+ if (is_array($input) === false) {
+ continue;
+ }
+
+ $fieldName = (string)($input['field'] ?? '');
+ if ($fieldName === '') {
+ continue;
+ }
+
+ $declared[$fieldName] = (bool)($input['required'] ?? false);
+ }
+
+ return $declared;
+ }//end normaliseDeclaredInputs()
+
/**
* Apply a graph-mode transition.
*
@@ -661,10 +781,13 @@ private function buildGraphAction(ObjectEntity $sibling): array {
* @param array $graph The `graph` block off the annotation.
* @param string $field The lifecycle field name on the object.
* @param string $action The requested `move-to-` action.
+ * @param array $data Caller-supplied input payload; graph-derived
+ * actions declare no `inputs`, so any payload is rejected.
*
* @return ObjectEntity The saved object after the transition.
*
* @throws RuntimeException When the action is not a current candidate.
+ * @throws InvalidTransitionInputException When `$data` is non-empty.
*
* @spec openspec/changes/fk-graph-lifecycle-transitions/specs/object-lifecycle/spec.md
*/
@@ -673,7 +796,13 @@ private function applyGraphTransition(
array $graph,
string $field,
string $action,
+ array $data = [],
): ObjectEntity {
+ // Graph-derived actions carry no `inputs` declaration, so nothing is
+ // allowlisted: a non-empty payload is rejected just like an undeclared
+ // key on a static transition.
+ $this->resolveTransitionInputs(inputs: [], data: $data, action: $action);
+
$candidates = $this->deriveGraphActions(object: $object, graph: $graph, field: $field);
$match = null;
@@ -691,17 +820,17 @@ private function applyGraphTransition(
}
$targetState = (string)$match['to'];
- $data = $object->getObject() ?? [];
- $from = (string)($data[$field] ?? '');
+ $objectData = $object->getObject() ?? [];
+ $from = (string)($objectData[$field] ?? '');
- $data[$field] = $targetState;
+ $objectData[$field] = $targetState;
// Snapshot the session user at the transition boundary and forward it
// explicitly to the save path, mirroring the static-mode contract.
$actingUser = $this->userSession->getUser();
$saved = $this->objectService->saveObject(
- object: $data,
+ object: $objectData,
register: $object->getRegister(),
schema: $object->getSchema(),
uuid: $object->getUuid(),
diff --git a/tests/Unit/Controller/TransitionControllerTest.php b/tests/Unit/Controller/TransitionControllerTest.php
index 9cd0f5ce6d..ad48a57b08 100644
--- a/tests/Unit/Controller/TransitionControllerTest.php
+++ b/tests/Unit/Controller/TransitionControllerTest.php
@@ -25,6 +25,7 @@
use OCA\OpenRegister\Controller\TransitionController;
use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Exception\InvalidTransitionInputException;
use OCA\OpenRegister\Exception\NotAuthorizedException;
use OCA\OpenRegister\Service\Lifecycle\TransitionEngine;
use OCP\AppFramework\Http;
@@ -57,13 +58,28 @@ protected function setUp(): void {
);
}//end setUp()
+ /**
+ * Stub the request body params (the controller reads `action` and `data`).
+ *
+ * @param array $params Body params by name.
+ *
+ * @return void
+ */
+ private function stubParams(array $params): void {
+ $this->request->method('getParam')->willReturnCallback(
+ static function (string $key) use ($params) {
+ return $params[$key] ?? null;
+ }
+ );
+ }//end stubParams()
+
/**
* Happy path — engine returns the saved object, controller returns 200.
*
* @return void
*/
public function testTransitionReturnsOk(): void {
- $this->request->method('getParam')->with('action')->willReturn('open');
+ $this->stubParams(['action' => 'open']);
$object = $this->createMock(ObjectEntity::class);
$object->method('jsonSerialize')->willReturn(['uuid' => 'u-1', 'state' => 'open']);
$this->engine->method('transition')->willReturn($object);
@@ -79,7 +95,7 @@ public function testTransitionReturnsOk(): void {
* @return void
*/
public function testTransitionReturns400WhenActionMissing(): void {
- $this->request->method('getParam')->with('action')->willReturn(null);
+ $this->stubParams([]);
$response = $this->controller->transition('obj-1');
@@ -92,7 +108,7 @@ public function testTransitionReturns400WhenActionMissing(): void {
* @return void
*/
public function testTransitionReturnsForbiddenOnPermissionDenied(): void {
- $this->request->method('getParam')->with('action')->willReturn('open');
+ $this->stubParams(['action' => 'open']);
$this->engine->method('transition')->willThrowException(
new NotAuthorizedException(message: 'You do not have permission to transition object "obj-1".')
);
@@ -114,7 +130,7 @@ public function testTransitionReturnsForbiddenOnPermissionDenied(): void {
* @return void
*/
public function testTransitionReturns422OnRuntimeError(): void {
- $this->request->method('getParam')->with('action')->willReturn('open');
+ $this->stubParams(['action' => 'open']);
$this->engine->method('transition')->willThrowException(
new RuntimeException('Transition "open" is not allowed from current state "closed".')
);
@@ -124,6 +140,68 @@ public function testTransitionReturns422OnRuntimeError(): void {
$this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus());
}//end testTransitionReturns422OnRuntimeError()
+ /**
+ * The optional `data` body param is forwarded to the engine untouched.
+ *
+ * @return void
+ */
+ public function testTransitionForwardsDataToEngine(): void {
+ $this->stubParams(['action' => 'submit', 'data' => ['hours' => 8]]);
+ $object = $this->createMock(ObjectEntity::class);
+ $object->method('jsonSerialize')->willReturn(['uuid' => 'u-1']);
+ $this->engine->expects($this->once())
+ ->method('transition')
+ ->with('obj-1', 'submit', ['hours' => 8])
+ ->willReturn($object);
+
+ $response = $this->controller->transition('obj-1');
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ }//end testTransitionForwardsDataToEngine()
+
+ /**
+ * A `data` value that is not an object/array is a client error, rejected
+ * before the engine is ever consulted.
+ *
+ * @return void
+ */
+ public function testTransitionReturns400WhenDataIsNotAnObject(): void {
+ $this->stubParams(['action' => 'submit', 'data' => 'not-an-object']);
+ $this->engine->expects($this->never())->method('transition');
+
+ $response = $this->controller->transition('obj-1');
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ }//end testTransitionReturns400WhenDataIsNotAnObject()
+
+ /**
+ * Engine rejecting the payload against the transition's `inputs`
+ * allowlist maps to 400 with the offending fields machine-readable,
+ * distinct from the 422 used for a refused transition.
+ *
+ * @return void
+ */
+ public function testTransitionReturns400OnInvalidTransitionInput(): void {
+ $this->stubParams(['action' => 'submit', 'data' => ['bogus' => 1]]);
+ $this->engine->method('transition')->willThrowException(
+ new InvalidTransitionInputException(
+ message: 'Transition "submit" does not accept input field(s): "bogus".',
+ fields: ['bogus']
+ )
+ );
+
+ $response = $this->controller->transition('obj-1');
+
+ $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
+ $body = $response->getData();
+ $this->assertIsArray($body);
+ $this->assertSame(
+ 'Transition "submit" does not accept input field(s): "bogus".',
+ $body['error'] ?? null
+ );
+ $this->assertSame(['bogus'], $body['fields'] ?? null);
+ }//end testTransitionReturns400OnInvalidTransitionInput()
+
/**
* R08 / F03 contract: availableActions also surfaces 403 on denial.
*
diff --git a/tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php b/tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php
new file mode 100644
index 0000000000..b0e64e1d68
--- /dev/null
+++ b/tests/Unit/Service/Lifecycle/TransitionEngineInputsTest.php
@@ -0,0 +1,393 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://www.OpenRegister.app
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Service\Lifecycle;
+
+use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Exception\InvalidTransitionInputException;
+use OCA\OpenRegister\Service\Lifecycle\TransitionEngine;
+use OCA\OpenRegister\Service\Object\PermissionHandler;
+use OCA\OpenRegister\Service\ObjectService;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IUserSession;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @coversDefaultClass \OCA\OpenRegister\Service\Lifecycle\TransitionEngine
+ */
+class TransitionEngineInputsTest extends TestCase {
+ private const OBJ = '00000000-0000-0000-0000-0000000000ee';
+
+ private ObjectService&MockObject $objectService;
+
+ private SchemaMapper&MockObject $schemaMapper;
+
+ private IEventDispatcher&MockObject $dispatcher;
+
+ private IUserSession&MockObject $userSession;
+
+ private PermissionHandler&MockObject $permission;
+
+ private RegisterMapper&MockObject $registerMapper;
+
+ private IAppConfig&MockObject $appConfig;
+
+ private LoggerInterface&MockObject $logger;
+
+ private TransitionEngine $engine;
+
+ protected function setUp(): void {
+ $this->objectService = $this->createMock(ObjectService::class);
+ $this->schemaMapper = $this->createMock(SchemaMapper::class);
+ $this->dispatcher = $this->createMock(IEventDispatcher::class);
+ $this->userSession = $this->createMock(IUserSession::class);
+ $this->permission = $this->createMock(PermissionHandler::class);
+ $this->permission->method('hasPermission')->willReturn(true);
+ $this->registerMapper = $this->createMock(RegisterMapper::class);
+ $this->appConfig = $this->createMock(IAppConfig::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ // The slug contract ships DEFAULT OFF; pin the flag to its default.
+ $this->appConfig->method('getValueString')
+ ->willReturnCallback(
+ static function (string $app, string $key, string $default = '') {
+ return $default;
+ }
+ );
+
+ $this->engine = new TransitionEngine(
+ $this->objectService,
+ $this->schemaMapper,
+ $this->dispatcher,
+ $this->userSession,
+ $this->permission,
+ $this->registerMapper,
+ $this->appConfig,
+ $this->logger
+ );
+ }//end setUp()
+
+ /**
+ * Build the timesheet object in `draft` state.
+ */
+ private function timesheet(): ObjectEntity {
+ $entity = new ObjectEntity();
+ $entity->setUuid(self::OBJ);
+ $entity->setSchema('timesheet');
+ $entity->setRegister('1');
+ $entity->setObject(['status' => 'draft', 'employee' => 'e-1']);
+ return $entity;
+ }//end timesheet()
+
+ /**
+ * Static annotation with a single `submit` transition.
+ *
+ * @param array>|null $inputs The `inputs` list, or null to omit the key.
+ *
+ * @return array
+ */
+ private function annotation(?array $inputs = null): array {
+ $transition = [
+ 'from' => ['draft'],
+ 'to' => 'submitted',
+ ];
+ if ($inputs !== null) {
+ $transition['inputs'] = $inputs;
+ }
+
+ return [
+ 'field' => 'status',
+ 'transitions' => ['submit' => $transition],
+ ];
+ }//end annotation()
+
+ /**
+ * Wire find()/schema for the given object + annotation.
+ */
+ private function wire(ObjectEntity $object, array $annotation): void {
+ $this->objectService->method('find')->willReturn($object);
+ $schema = $this->createMock(Schema::class);
+ $schema->method('getConfiguration')->willReturn(['x-openregister-lifecycle' => $annotation]);
+ $this->schemaMapper->method('find')->willReturn($schema);
+ }//end wire()
+
+ /**
+ * Declared input values land in the SAME saveObject() write that flips
+ * the status field — one write, observed together by pre-save listeners.
+ *
+ * @return void
+ */
+ public function testDeclaredInputsMergeIntoTheSameWrite(): void {
+ $this->wire(
+ $this->timesheet(),
+ $this->annotation(
+ [
+ ['field' => 'hours', 'required' => true],
+ ['field' => 'note', 'required' => false],
+ ]
+ )
+ );
+
+ $captured = null;
+ $this->objectService->expects($this->once())
+ ->method('saveObject')
+ ->willReturnCallback(
+ function (array $object) use (&$captured): ObjectEntity {
+ $captured = $object;
+ return $this->timesheet();
+ }
+ );
+
+ $this->engine->transition(self::OBJ, 'submit', ['hours' => 8, 'note' => 'week 33']);
+
+ $this->assertIsArray($captured);
+ $this->assertSame('submitted', $captured['status']);
+ $this->assertSame(8, $captured['hours']);
+ $this->assertSame('week 33', $captured['note']);
+ // Untouched existing fields survive the merge.
+ $this->assertSame('e-1', $captured['employee']);
+ }//end testDeclaredInputsMergeIntoTheSameWrite()
+
+ /**
+ * An optional (`required: false`) input may be omitted from the payload.
+ *
+ * @return void
+ */
+ public function testOptionalInputMayBeOmitted(): void {
+ $this->wire(
+ $this->timesheet(),
+ $this->annotation(
+ [
+ ['field' => 'hours', 'required' => true],
+ ['field' => 'note', 'required' => false],
+ ]
+ )
+ );
+
+ $captured = null;
+ $this->objectService->expects($this->once())
+ ->method('saveObject')
+ ->willReturnCallback(
+ function (array $object) use (&$captured): ObjectEntity {
+ $captured = $object;
+ return $this->timesheet();
+ }
+ );
+
+ $this->engine->transition(self::OBJ, 'submit', ['hours' => 8]);
+
+ $this->assertIsArray($captured);
+ $this->assertSame('submitted', $captured['status']);
+ $this->assertSame(8, $captured['hours']);
+ $this->assertArrayNotHasKey('note', $captured);
+ }//end testOptionalInputMayBeOmitted()
+
+ /**
+ * A payload key the transition does not declare is rejected with the
+ * offending key named, and nothing is saved or dispatched.
+ *
+ * @return void
+ */
+ public function testUnknownKeyIsRejectedAndNothingSaved(): void {
+ $this->wire(
+ $this->timesheet(),
+ $this->annotation([['field' => 'hours', 'required' => true]])
+ );
+
+ $this->objectService->expects($this->never())->method('saveObject');
+ $this->dispatcher->expects($this->never())->method('dispatchTyped');
+
+ try {
+ $this->engine->transition(self::OBJ, 'submit', ['hours' => 8, 'salary' => 99999]);
+ $this->fail('Expected InvalidTransitionInputException was not thrown.');
+ } catch (InvalidTransitionInputException $e) {
+ $this->assertStringContainsString('"salary"', $e->getMessage());
+ $this->assertSame(['salary'], $e->getFields());
+ }
+ }//end testUnknownKeyIsRejectedAndNothingSaved()
+
+ /**
+ * A `required: true` input absent from the payload is rejected with the
+ * missing field named, and nothing is saved.
+ *
+ * @return void
+ */
+ public function testMissingRequiredInputIsRejected(): void {
+ $this->wire(
+ $this->timesheet(),
+ $this->annotation([['field' => 'hours', 'required' => true]])
+ );
+
+ $this->objectService->expects($this->never())->method('saveObject');
+
+ try {
+ $this->engine->transition(self::OBJ, 'submit', []);
+ $this->fail('Expected InvalidTransitionInputException was not thrown.');
+ } catch (InvalidTransitionInputException $e) {
+ $this->assertStringContainsString('"hours"', $e->getMessage());
+ $this->assertSame(['hours'], $e->getFields());
+ }
+ }//end testMissingRequiredInputIsRejected()
+
+ /**
+ * An empty-string value for a `required: true` input counts as missing.
+ *
+ * @return void
+ */
+ public function testEmptyStringRequiredInputIsRejected(): void {
+ $this->wire(
+ $this->timesheet(),
+ $this->annotation([['field' => 'hours', 'required' => true]])
+ );
+
+ $this->objectService->expects($this->never())->method('saveObject');
+
+ try {
+ $this->engine->transition(self::OBJ, 'submit', ['hours' => '']);
+ $this->fail('Expected InvalidTransitionInputException was not thrown.');
+ } catch (InvalidTransitionInputException $e) {
+ $this->assertSame(['hours'], $e->getFields());
+ }
+ }//end testEmptyStringRequiredInputIsRejected()
+
+ /**
+ * A transition that declares no `inputs` rejects ANY payload — nothing is
+ * allowlisted, so today's exact behaviour is preserved for schemas that
+ * never opted in.
+ *
+ * @return void
+ */
+ public function testNoInputsDeclaredRejectsAnyPayload(): void {
+ $this->wire($this->timesheet(), $this->annotation());
+
+ $this->objectService->expects($this->never())->method('saveObject');
+
+ try {
+ $this->engine->transition(self::OBJ, 'submit', ['note' => 'hi']);
+ $this->fail('Expected InvalidTransitionInputException was not thrown.');
+ } catch (InvalidTransitionInputException $e) {
+ $this->assertSame(['note'], $e->getFields());
+ }
+ }//end testNoInputsDeclaredRejectsAnyPayload()
+
+ /**
+ * Without a payload, a transition that declares no `inputs` behaves
+ * exactly as before: the write carries only the flipped status field.
+ *
+ * @return void
+ */
+ public function testNoInputsWithoutPayloadKeepsTodayBehaviour(): void {
+ $this->wire($this->timesheet(), $this->annotation());
+
+ $captured = null;
+ $this->objectService->expects($this->once())
+ ->method('saveObject')
+ ->willReturnCallback(
+ function (array $object) use (&$captured): ObjectEntity {
+ $captured = $object;
+ return $this->timesheet();
+ }
+ );
+
+ $this->engine->transition(self::OBJ, 'submit');
+
+ $this->assertIsArray($captured);
+ $this->assertSame('submitted', $captured['status']);
+ $this->assertSame('e-1', $captured['employee']);
+ // No stray keys beyond what getObject() already carried (the entity
+ // mirrors its uuid into `id`) plus the flipped status field.
+ $this->assertSame([], array_diff(array_keys($captured), ['id', 'status', 'employee']));
+ }//end testNoInputsWithoutPayloadKeepsTodayBehaviour()
+
+ /**
+ * A declared input naming the lifecycle field itself cannot override the
+ * transition target: the status flip is applied AFTER the merge.
+ *
+ * @return void
+ */
+ public function testInputCannotOverrideTheLifecycleField(): void {
+ $this->wire(
+ $this->timesheet(),
+ $this->annotation([['field' => 'status', 'required' => false]])
+ );
+
+ $captured = null;
+ $this->objectService->expects($this->once())
+ ->method('saveObject')
+ ->willReturnCallback(
+ function (array $object) use (&$captured): ObjectEntity {
+ $captured = $object;
+ return $this->timesheet();
+ }
+ );
+
+ $this->engine->transition(self::OBJ, 'submit', ['status' => 'hacked']);
+
+ $this->assertIsArray($captured);
+ $this->assertSame('submitted', $captured['status']);
+ }//end testInputCannotOverrideTheLifecycleField()
+
+ /**
+ * Graph-derived actions declare no `inputs`, so a graph-mode transition
+ * rejects any payload before even fetching siblings.
+ *
+ * @return void
+ */
+ public function testGraphModeRejectsAnyPayload(): void {
+ $object = $this->timesheet();
+ $object->setObject(['caseType' => 'p-1', 'status' => 's-1']);
+ $this->wire(
+ $object,
+ [
+ 'field' => 'status',
+ 'graph' => [
+ 'schema' => 'statustype',
+ 'parentField' => 'caseType',
+ 'parentFrom' => 'caseType',
+ 'orderField' => 'order',
+ 'finalField' => 'isFinal',
+ 'allowedMoves' => 'forward',
+ ],
+ ]
+ );
+
+ $this->objectService->expects($this->never())->method('findAll');
+ $this->objectService->expects($this->never())->method('saveObject');
+
+ try {
+ $this->engine->transition(self::OBJ, 'move-to-s-2', ['note' => 'hi']);
+ $this->fail('Expected InvalidTransitionInputException was not thrown.');
+ } catch (InvalidTransitionInputException $e) {
+ $this->assertSame(['note'], $e->getFields());
+ }
+ }//end testGraphModeRejectsAnyPayload()
+}//end class
From f9d3aa3d8d8be169a3ec7c2c2f427b009c403705 Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Fri, 21 Aug 2026 17:13:41 +0200
Subject: [PATCH 040/139] fix(ci): give dispatched runs their own concurrency
lane (#2690)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The group suffixed only pushes, leaving workflow_dispatch sharing
quality-development with the Sync-to-Beta PR (whose head_ref IS development
and which re-triggers on every merge). A dispatched run was therefore
cancelled by it — measured on shillinq: dispatch 32487948678 cancelled by
pull_request run 32490160836.
This blocks the fleet gate-drift sweep (.github#523), which dispatches per
app with --ref development because schedule: cannot choose a branch. Under
the old group those runs report neither pass nor fail.
Adopts hermiq's form verbatim, already live there.
---
.github/workflows/code-quality.yml | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 35e473b795..32680302f2 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -82,7 +82,29 @@ on:
# Proven in openconnector#1158: its first-ever completed `development` push run
# (31048998594) executed Coverage Baseline Check, SBOM and Features Extract.
concurrency:
- group: quality-${{ github.head_ref || github.ref_name }}${{ (github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'development')) && '-push' || '' }}
+ # SUFFIXED BY EVENT NAME, not just by `-push`.
+ #
+ # The previous expression gave a push on `development` its own lane
+ # (`-push`) but left EVERYTHING ELSE sharing `quality-development` — and
+ # that is not a quiet lane: `Sync to Beta` keeps a PR open whose head_ref
+ # IS `development`, so its run computes the same group and is re-triggered
+ # on every merge.
+ #
+ # A `workflow_dispatch` therefore shared a group with that PR and was
+ # cancelled by it. Measured on shillinq 2026-08-21: dispatch 32487948678
+ # cancelled by pull_request run 32490160836 (head_branch `development`).
+ # A run someone deliberately asked for could essentially never complete.
+ #
+ # That reaches past ad-hoc verification: the fleet gate-drift sweep
+ # (.github#523) dispatches per app with `--ref development`, because
+ # `schedule:` cannot choose a branch. Under the old group those runs are
+ # cancelled and report neither pass nor fail — and a routine that produces
+ # no verdict is indistinguishable from one that never ran.
+ #
+ # This is hermiq's form, already live there. Pull requests keep the bare
+ # group (so a PR still supersedes its own earlier run); push, dispatch and
+ # schedule each get their own lane.
+ group: quality-${{ github.head_ref || github.ref_name }}${{ (github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'development')) && format('-{0}', github.event_name) || '' }}
cancel-in-progress: true
# Permission CEILING for the called quality pipeline. GitHub statically
From db9744c9a17162f5ce1921a8322a956d8cbec8ef Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Fri, 21 Aug 2026 18:05:09 +0200
Subject: [PATCH 041/139] chore(deps): refresh the shared Conduction locks
(#2683)
* chore(deps): refresh the shared Conduction locks
hydra-gates v1.8.1 -> v1.8.2
nc-vue 2.8.2 -> 2.9.2
Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.
Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.
* fix: repair the four findings hydra-gates v1.8.2 surfaces, one a real bug
The lock bump in this PR moves hydra-gates v1.8.1 -> v1.8.2, whose
phpstan-base.neon adds `treatPhpDocTypesAsCertain: false`. Measured on this
tree with phpstan 1.12.33 unchanged:
treatPhpDocTypesAsCertain: true (v1.8.1) -> 0 errors
treatPhpDocTypesAsCertain: false (v1.8.2) -> 4 errors
So the flag's own comment -- "No effect on 1.x, which does not narrow from
PHPDoc in the first place" -- is not accurate for this app.
ONE OF THE FOUR IS A REAL BUG, not a lint nit.
SearchQueryHandler::applyViewsToQuery() is commented "Merge with existing
search if present" but assigned `$query['_search'] = $searchTerms` FIRST and
only then tested `isset($query['_search'])` -- a condition that could only ever
see the value it had just written. Two consequences:
- the caller's own `_search` was overwritten, so the merge never happened;
- the view's terms were appended to themselves: "invoice invoice".
Applying a saved view to a search therefore DISCARDED the user's typed term and
doubled the view's own. PHPStan reported it only as "Offset '_search' ... always
exists", which reads like a redundant-isset nit. Rewritten to mirror the
`schemas` merge directly above: read what is there, then combine. Three
regression tests added; all three fail against the pre-fix code ("invoice
invoice", "alpha beta alpha beta") and pass after.
The other three:
- NamesController:157 -- `is_string() === false &&` could only be true: every
path above converts a string to an array, and a non-string never enters.
- ObjectsController:933 -- `?? true` was dead (`_rbac` is assigned
unconditionally and the unset() between does not remove it) AND would have
been the wrong value had it fired, forcing the RBAC strip on exactly the
admin case the comment says is false. The sibling call at 2242 keeps its
`??`: there `$query` comes straight from buildSearchQuery() with no `_rbac`.
- FilesSidebarListener:69 -- a false positive. The listener guards on the
OPTIONAL Files app's event class by NAME to avoid a hard dependency; that
class ships with Files, not nextcloud/ocp, so the analyser proves the early
return always fires. Ignored in the app's own phpstan.neon, scoped by path,
with the reason recorded.
Also repairs a test double that blocked the suite locally: the anonymous
IRequest stub was missing throwDecodingExceptionIfAny() and getFormat(), which
is a FATAL rather than a failed assertion -- the run died mid-suite at ~test 220
instead of reporting. With them the file runs all 272 tests. The 2 remaining
errors there are a separate pre-existing stub gap, present identically with and
without these changes.
Verified: phpstan OK, phpmd clean, psalm 0 errors, phpcs 0 errors in lib.
* chore(deps): nc-vue 2.9.2 -> 2.10.1
2.9.2/2.10.0 carry a CnDashboardPage regression: an `object-table` dashboard
widget canonicalised to `table` and rendered the wrong component
(ConductionNL/nextcloud-vue#722). 2.10.1 is the fix.
Lock-only; added 0, removed 0, dev-flag changes 0.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Conduction Release Bot
---
composer.lock | 12 +-
lib/Controller/NamesController.php | 6 +-
lib/Controller/ObjectsController.php | 8 +-
lib/Service/Object/SearchQueryHandler.php | 17 ++-
package-lock.json | 6 +-
phpstan.neon | 17 +++
.../SearchQueryHandlerViewSearchMergeTest.php | 128 ++++++++++++++++++
tests/stubs/NextcloudInternalStubs.php | 8 ++
8 files changed, 188 insertions(+), 14 deletions(-)
create mode 100644 tests/Unit/Service/Object/SearchQueryHandlerViewSearchMergeTest.php
diff --git a/composer.lock b/composer.lock
index 3a3bd3760f..6569a7866a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -7048,16 +7048,16 @@
},
{
"name": "conduction/hydra-gates",
- "version": "v1.8.1",
+ "version": "v1.8.2",
"source": {
"type": "git",
"url": "https://github.com/ConductionNL/.github.git",
- "reference": "8e0e9857e54d6c680e157939e78a468e58d3751a"
+ "reference": "3dfcd1e56d27bd06eaa98a9a66e377e7e14fe491"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ConductionNL/.github/zipball/8e0e9857e54d6c680e157939e78a468e58d3751a",
- "reference": "8e0e9857e54d6c680e157939e78a468e58d3751a",
+ "url": "https://api.github.com/repos/ConductionNL/.github/zipball/3dfcd1e56d27bd06eaa98a9a66e377e7e14fe491",
+ "reference": "3dfcd1e56d27bd06eaa98a9a66e377e7e14fe491",
"shasum": ""
},
"require": {
@@ -7101,9 +7101,9 @@
"support": {
"docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md",
"issues": "https://github.com/ConductionNL/.github/issues",
- "source": "https://github.com/ConductionNL/.github/tree/v1.8.1"
+ "source": "https://github.com/ConductionNL/.github/tree/v1.8.2"
},
- "time": "2026-08-20T05:07:22+00:00"
+ "time": "2026-08-20T09:37:12+00:00"
},
{
"name": "consolidation/annotated-command",
diff --git a/lib/Controller/NamesController.php b/lib/Controller/NamesController.php
index c06fdf0234..13c647f4c4 100644
--- a/lib/Controller/NamesController.php
+++ b/lib/Controller/NamesController.php
@@ -154,7 +154,11 @@ public function index(): JSONResponse {
}
}
- if (is_string($requestedIds) === false && is_array($requestedIds) === false) {
+ // Not `is_string() === false && ...`: every path above turns a
+ // string into an array (explode/array_map, or json_decode of a
+ // '['-prefixed string), and a non-string never enters that block,
+ // so the string test here can only ever be true.
+ if (is_array($requestedIds) === false) {
$requestedIds = [(string)$requestedIds];
}
diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php
index 05636db073..e6fcb7f61a 100644
--- a/lib/Controller/ObjectsController.php
+++ b/lib/Controller/ObjectsController.php
@@ -930,7 +930,13 @@ private function crossTableSearch(array $registers, array $schemas, ObjectServic
// NOT gate the writeOnly strip (#460): `$query['_rbac']` is false for an ADMIN here,
// and an admin is not exempt from the writeOnly render boundary (#389).
$renderHandler = \OC::$server->get(\OCA\OpenRegister\Service\Object\RenderObject::class);
- $renderHandler->redactWriteOnlyFromRows(rows: $results, _rbac: $query['_rbac'] ?? true);
+ // No `?? true` on THIS path: `_rbac` is assigned unconditionally above and
+ // the unset() in between does not remove it, so the fallback was dead --
+ // and had it ever fired it would have forced the RBAC strip on exactly the
+ // admin case the comment above says is false. The sibling call further down
+ // keeps its `??` because there `$query` comes straight from
+ // buildSearchQuery() with no `_rbac` assignment.
+ $renderHandler->redactWriteOnlyFromRows(rows: $results, _rbac: $query['_rbac']);
// Serialize results.
$serializedResults = [];
diff --git a/lib/Service/Object/SearchQueryHandler.php b/lib/Service/Object/SearchQueryHandler.php
index 98dad9b276..5aa4bc7fd0 100644
--- a/lib/Service/Object/SearchQueryHandler.php
+++ b/lib/Service/Object/SearchQueryHandler.php
@@ -441,10 +441,21 @@ public function applyViewsToQuery(array $query, array $viewIds): array {
}
// Merge with existing search if present.
- $query['_search'] = $searchTerms;
- if (isset($query['_search']) === true && empty($query['_search']) === false) {
- $query['_search'] .= ' ' . $searchTerms;
+ //
+ // This previously assigned $query['_search'] FIRST and then
+ // appended $searchTerms to it, so the isset() guard could only
+ // ever see the value just written. Two things went wrong: the
+ // caller's own `_search` was discarded (the merge this comment
+ // describes never happened), and the view's terms were appended
+ // to themselves, producing "foo foo". Mirrors the `schemas`
+ // merge above: read what is there, then combine.
+ $existingSearch = ($query['_search'] ?? '');
+ $searchPrefix = '';
+ if (is_string($existingSearch) === true && $existingSearch !== '') {
+ $searchPrefix = $existingSearch . ' ';
}
+
+ $query['_search'] = $searchPrefix . $searchTerms;
}//end if
$this->logger->debug(
diff --git a/package-lock.json b/package-lock.json
index 98528d3ba2..e05a3e64e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2112,9 +2112,9 @@
}
},
"node_modules/@conduction/nextcloud-vue": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.8.2.tgz",
- "integrity": "sha512-kqzqQ2uFyzpHUL6VxzNsfJ5iDOsy/S1iQ9UVn7W0w3CdlVb4RxWz5AFym/onB1eKewF2WtF+9vzBM5CHWsaHTQ==",
+ "version": "2.10.1",
+ "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.10.1.tgz",
+ "integrity": "sha512-4S2X+Bv6mGzQMfxZW8XJheJ8iFis+iJdrl3+hlchYl50qQ77TDWy2xsp9Dog+ggfOikfngzmJseF5kz2MHZt4A==",
"license": "EUPL-1.2",
"dependencies": {
"@ckpack/vue-color": "^1.6.0",
diff --git a/phpstan.neon b/phpstan.neon
index ac0bc3451d..2f227b586e 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -45,6 +45,23 @@ parameters:
- vendor-bin
ignoreErrors:
+ # FilesSidebarListener guards on an OPTIONAL app's event class.
+ #
+ # The listener is registered for 'OCA\Files\Event\LoadAdditionalScriptsEvent'
+ # (AppInfo/Application.php) and re-checks the class by NAME so OpenRegister
+ # carries no hard dependency on the Files app. That class ships with Files,
+ # not with nextcloud/ocp, so the analyser cannot resolve it: it types
+ # get_class($event) as class-string, decides the comparison can never
+ # match, and concludes the early return always fires — making the body dead.
+ # It is not dead at runtime, where Files is installed and dispatches it.
+ #
+ # Surfaced by hydra-gates v1.8.2's `treatPhpDocTypesAsCertain: false`.
+ # Deleting the "unreachable" body would remove the sidebar; narrowing with
+ # instanceof would reintroduce the hard dependency the string check avoids.
+ -
+ message: '#Unreachable statement - code above always terminates#'
+ path: lib/Listener/FilesSidebarListener.php
+
# ConductionNL/sapp fork uses lowercase pseudo-classes `obj` /
# `value` / `pdfentry` / `buffer` / `bytes` etc. in `@return`
# PHPDocs (upstream typo — fictitious classes that PHPStan can't
diff --git a/tests/Unit/Service/Object/SearchQueryHandlerViewSearchMergeTest.php b/tests/Unit/Service/Object/SearchQueryHandlerViewSearchMergeTest.php
new file mode 100644
index 0000000000..711771e066
--- /dev/null
+++ b/tests/Unit/Service/Object/SearchQueryHandlerViewSearchMergeTest.php
@@ -0,0 +1,128 @@
+ $viewQuery The view's stored query.
+ *
+ * @return SearchQueryHandler
+ */
+ private function makeHandler(array $viewQuery): SearchQueryHandler {
+ // A real View, not a mock: getQuery() is an Entity magic accessor, and
+ // PHPUnit cannot configure it — mocking it errors with "method ... does
+ // not exist", which would make these tests LOOK like they fail against
+ // the unfixed code while actually proving nothing.
+ $view = new View();
+ $view->setQuery($viewQuery);
+
+ $viewMapper = $this->createMock(ViewMapper::class);
+ $viewMapper->method('find')->willReturn($view);
+
+ return new SearchQueryHandler(
+ $viewMapper,
+ $this->createMock(SchemaMapper::class),
+ $this->createMock(SettingsService::class),
+ $this->createMock(LoggerInterface::class),
+ $this->createMock(IRequest::class),
+ $this->createMock(SearchTrailService::class)
+ );
+
+ }//end makeHandler()
+
+ /**
+ * The caller's own search term must survive the view being applied.
+ *
+ * @return void
+ */
+ public function testExistingSearchTermIsMergedNotDiscarded(): void {
+ $result = $this->makeHandler(['searchTerms' => 'invoice'])
+ ->applyViewsToQuery(['_search' => 'urgent'], [1]);
+
+ $this->assertStringContainsString(
+ 'urgent',
+ $result['_search'],
+ "the caller's existing _search must not be discarded by the view"
+ );
+ $this->assertStringContainsString(
+ 'invoice',
+ $result['_search'],
+ "the view's own search terms must still be applied"
+ );
+
+ }//end testExistingSearchTermIsMergedNotDiscarded()
+
+ /**
+ * A view's terms must appear once, not be appended to themselves.
+ *
+ * @return void
+ */
+ public function testViewSearchTermIsNotDuplicated(): void {
+ $result = $this->makeHandler(['searchTerms' => 'invoice'])
+ ->applyViewsToQuery([], [1]);
+
+ $this->assertSame(
+ 'invoice',
+ $result['_search'],
+ 'a view search term must be applied exactly once'
+ );
+ $this->assertSame(
+ 1,
+ substr_count($result['_search'], 'invoice'),
+ 'the term must not be appended to itself'
+ );
+
+ }//end testViewSearchTermIsNotDuplicated()
+
+ /**
+ * An array of view terms is joined, and still merged with the caller's.
+ *
+ * @return void
+ */
+ public function testArrayViewTermsAreJoinedAndMerged(): void {
+ $result = $this->makeHandler(['searchTerms' => ['alpha', 'beta']])
+ ->applyViewsToQuery(['_search' => 'gamma'], [1]);
+
+ foreach (['gamma', 'alpha', 'beta'] as $term) {
+ $this->assertStringContainsString(
+ $term,
+ $result['_search'],
+ "'{$term}' must survive the merge"
+ );
+ $this->assertSame(1, substr_count($result['_search'], $term), "'{$term}' must appear once");
+ }
+
+ }//end testArrayViewTermsAreJoinedAndMerged()
+}//end class
diff --git a/tests/stubs/NextcloudInternalStubs.php b/tests/stubs/NextcloudInternalStubs.php
index f94b72d3ad..dfa6d8e11c 100644
--- a/tests/stubs/NextcloudInternalStubs.php
+++ b/tests/stubs/NextcloudInternalStubs.php
@@ -199,6 +199,14 @@ public function getHttpProtocol(): string { return "http"; }
public function getServerProtocol(): string { return "HTTP/1.1"; }
public function getServerHost(): string { return "localhost"; }
public function getInsecureServerHost(): string { return "localhost"; }
+ // Added in newer OCP (present in v34). A double that is MISSING an
+ // interface method is a FATAL -- PHP refuses to declare the class and
+ // the suite dies mid-run rather than reporting a failed assertion --
+ // whereas a double carrying a method an older OCP does not declare is
+ // simply an extra method. So these are safe on every version in the
+ // matrix, and their absence was not.
+ public function throwDecodingExceptionIfAny(): void {}
+ public function getFormat(): ?string { return null; }
};
return $req;
});
From 305e3664f16f7a2937d5f6d25b68671c9dab41aa Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Fri, 21 Aug 2026 19:19:03 +0200
Subject: [PATCH 042/139] fix(schemas): make ?register= a hard boundary for GET
/api/schemas/{id} (#2694)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Found live on a fleet instance 2026-08-21: three schemas carry the slug
timeEntry, and hrmq's form dialog was silently served another app's schema
(id 161 instead of hrmq's 9466) because schemas#show resolved the slug
globally. Single-app CI instances can never reproduce this.
The register-scoped resolution that landed with
register-scoped-slug-resolution had two softenings that put the silent
cross-app read back:
- an unresolvable ?register= fell back to GLOBAL resolution, so a mistyped
boundary name was indistinguishable from a correct scoped hit. It now
404s with a message naming the register (RegisterNotFoundException).
- scoping applied to slugs only; a numeric id resolved globally and a uuid
was mis-fed to the slug matcher. The new SchemaMapper::findInIds()
mirrors find()'s identifier forms (numeric id, uuid, case-insensitive
slug) and tie-breaks, constrained to the register's carried schema ids,
so the boundary holds for every identifier form.
Also surfaced at the HTTP layer: SchemaNotInRegisterException and
RegisterNotFoundException messages now reach the caller as the 404 body
instead of being flattened to a bare 'Schema not found' — the diagnosis
(which register, how many same-slug candidates elsewhere, the
relink-schemas repair command) is what turns a dead end into a bug report.
Without ?register= the behaviour is byte-identical to before (global
resolution + ambiguity logging), keeping every existing consumer working.
Drive-by: extracted TransitionEngine::collectMissingRequiredInputs() to
clear the pre-existing phpmd CyclomaticComplexity finding that made
composer phpmd red on development.
Co-authored-by: Conduction Release Bot
---
lib/Controller/SchemasController.php | 121 ++++---
lib/Db/SchemaMapper.php | 90 ++++++
lib/Service/Lifecycle/TransitionEngine.php | 42 ++-
...SchemasControllerShowRegisterScopeTest.php | 302 ++++++++++++++++++
4 files changed, 501 insertions(+), 54 deletions(-)
create mode 100644 tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php
diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php
index 92b63ec5d6..5ef1bc0448 100644
--- a/lib/Controller/SchemasController.php
+++ b/lib/Controller/SchemasController.php
@@ -34,6 +34,7 @@
use OCA\OpenRegister\Db\SchemaMapper;
use OCA\OpenRegister\Exception\BreakingSchemaChangeException;
use OCA\OpenRegister\Exception\DatabaseConstraintException;
+use OCA\OpenRegister\Exception\RegisterNotFoundException;
use OCA\OpenRegister\Exception\SchemaImportException;
use OCA\OpenRegister\Exception\SchemaNotInRegisterException;
use OCA\OpenRegister\Service\AuthorizationAuditService;
@@ -86,6 +87,8 @@
* into sub-controllers would break the route registration.
* @SuppressWarnings(PHPMD.TooManyPublicMethods) REST controllers have many endpoints; extraction
* into sub-controllers would break the route registration.
+ * @SuppressWarnings(PHPMD.TooManyMethods) REST controllers have many endpoints; extraction
+ * into sub-controllers would break the route registration.
* @SuppressWarnings(PHPMD.CouplingBetweenObjects) NC AppFramework controller DI requires injecting
* framework + RBAC + audit + domain services, each used in separate endpoint groups.
*
@@ -297,7 +300,7 @@ function ($schema) {
}//end index()
/**
- * Resolve the {id} route parameter to a Schema, register-scoped when possible.
+ * Resolve the {id} route parameter to a Schema, register-scoped when the caller names one.
*
* WHY this exists. Schema slugs are unique WITHIN a register, never across the
* instance, but `SchemaMapper::find()` matches `LOWER(slug)` globally and returns
@@ -320,52 +323,15 @@ function ($schema) {
* @return Schema The resolved schema.
*
* @throws \OCP\AppFramework\Db\DoesNotExistException When nothing matches.
+ * @throws RegisterNotFoundException When a named register does not resolve.
+ *
+ * @spec openspec/specs/register-scoped-slug-resolution/spec.md
*/
private function resolveSchema(int|string $id): Schema {
$registerParam = $this->request->getParam(key: 'register', default: null);
- // Register-scoped resolution. A numeric id is resolved globally below;
- // scoping applies to slugs only.
- //
- // The two failures below are deliberately NOT handled together, and the
- // separation is load-bearing. An *unresolvable register parameter* is not a
- // reason to fail the schema read — the caller gets what they would have got
- // without the parameter. But a register that resolves and does not carry the
- // slug is a refusal, because naming a register makes it a boundary.
- //
- // Both used to sit inside one try/catch. Throwing the refusal from in there
- // would have been caught by that same catch and logged as "scope could not
- // be applied", restoring the exact fallback this change removes — a silent
- // no-op that looks like a fix.
- if ($registerParam !== null && $registerParam !== '' && is_string($id) === true && is_numeric($id) === false) {
- $register = null;
- try {
- $register = $this->registerMapper->find(id: $registerParam, _rbac: false, _multitenancy: false);
- } catch (Exception $e) {
- $this->logger->debug(
- '[SchemasController] register scope could not be applied: ' . $e->getMessage(),
- ['register' => $registerParam, 'schema' => $id]
- );
- }
-
- if ($register !== null) {
- $registerSchemaIds = ($register->getSchemas() ?? []);
- $scoped = $this->schemaMapper->findBySlugInIds(
- slug: $id,
- schemaIds: $registerSchemaIds
- );
- if ($scoped !== null) {
- return $scoped;
- }
-
- throw new SchemaNotInRegisterException(
- schemaSlug: $id,
- registerId: $register->getId(),
- registerSlug: $register->getSlug(),
- candidatesElsewhere: $this->schemaMapper->countBySlug(slug: $id),
- registerSchemaCount: count($registerSchemaIds)
- );
- }
+ if (is_scalar($registerParam) === true && (string)$registerParam !== '') {
+ return $this->resolveSchemaInRegister(id: $id, registerParam: (string)$registerParam);
}
$schema = $this->schemaMapper->find(id: $id, _extend: [], _multitenancy: false);
@@ -380,6 +346,69 @@ private function resolveSchema(int|string $id): Schema {
return $schema;
}//end resolveSchema()
+
+ /**
+ * Resolve the {id} route parameter inside the register the caller named.
+ *
+ * Naming a register makes it a BOUNDARY, and the boundary holds for every
+ * identifier form — numeric id, uuid, and slug alike. Earlier this scoping
+ * applied to slugs only and an unresolvable `?register=` fell back to global
+ * resolution "so the caller gets what they would have got without the
+ * parameter". Both softenings put the silent cross-app read back: measured on
+ * the shared dev instance 2026-08-21, three schemas carried the slug
+ * `timeEntry` and hrmq's form dialog was served ANOTHER app's schema (id 161
+ * instead of hrmq's 9466) — precisely the read a caller passes `?register=`
+ * to rule out. A mistyped register name that silently widens the scope back
+ * to the whole instance is indistinguishable, from the caller's side, from a
+ * correct scoped hit; refusing it loudly is the only observable behaviour.
+ *
+ * The register lookup runs with `_rbac: false, _multitenancy: false`,
+ * matching the schema metadata-read it scopes: this resolves WHICH schema is
+ * meant, it grants nothing — the read-visibility guard in {@see show()}
+ * still runs on the result.
+ *
+ * @param int|string $id The {id} route parameter — a numeric id, a uuid, or a slug.
+ * @param string $registerParam The `?register=` parameter — a register id, uuid, or slug.
+ *
+ * @return Schema The schema, resolved among the register's carried schemas only.
+ *
+ * @throws RegisterNotFoundException When the named register does not resolve (→ 404).
+ * @throws SchemaNotInRegisterException When the register does not carry the identifier (→ 404).
+ *
+ * @spec openspec/specs/register-scoped-slug-resolution/spec.md
+ */
+ private function resolveSchemaInRegister(int|string $id, string $registerParam): Schema {
+ try {
+ $register = $this->registerMapper->find(id: $registerParam, _rbac: false, _multitenancy: false);
+ } catch (Exception $e) {
+ throw new RegisterNotFoundException(
+ registerSlugOrId: $registerParam,
+ previous: $e,
+ remedies: 'The schema was therefore not resolved, because naming a register makes it a '
+ . 'boundary and falling back to instance-wide resolution would serve a schema from '
+ . 'outside it. Omit ?register= to resolve the identifier globally.'
+ );
+ }
+
+ $registerSchemaIds = ($register->getSchemas() ?? []);
+ $scoped = $this->schemaMapper->findInIds(
+ id: $id,
+ schemaIds: $registerSchemaIds
+ );
+ if ($scoped !== null) {
+ return $scoped;
+ }
+
+ throw new SchemaNotInRegisterException(
+ schemaSlug: (string)$id,
+ registerId: $register->getId(),
+ registerSlug: $register->getSlug(),
+ candidatesElsewhere: $this->schemaMapper->countBySlug(slug: (string)$id),
+ registerSchemaCount: count($registerSchemaIds)
+ );
+ }//end resolveSchemaInRegister()
+
+
/**
* Log a debug line naming every schema a slug could have resolved to.
*
@@ -486,6 +515,12 @@ public function show($id): JSONResponse {
}
return new JSONResponse(data: $schemaArr);
+ } catch (SchemaNotInRegisterException | RegisterNotFoundException $e) {
+ // Register-scoped refusals carry a diagnosis — which register, how many
+ // same-slug schemas exist elsewhere, the repair command. Flattening them
+ // to a bare "Schema not found" reads as "your slug is wrong", which is
+ // the one conclusion that is certainly false when duplicates exist.
+ return new JSONResponse(data: ['error' => $e->getMessage()], statusCode: 404);
} catch (DoesNotExistException $e) {
return new JSONResponse(data: ['error' => 'Schema not found'], statusCode: 404);
} catch (\OCA\OpenRegister\Exception\ValidationException $e) {
diff --git a/lib/Db/SchemaMapper.php b/lib/Db/SchemaMapper.php
index 13a0287fee..51f6d34aed 100644
--- a/lib/Db/SchemaMapper.php
+++ b/lib/Db/SchemaMapper.php
@@ -616,6 +616,96 @@ public function findBySlugInIds(string $slug, array $schemaIds): ?Schema {
return $this->resolveSchemaExtension(schema: Schema::fromRow($row));
}//end findBySlugInIds()
+
+ /**
+ * Resolve a schema identifier (numeric id, uuid, or slug) within a set of schema ids.
+ *
+ * The register-scoped counterpart of {@see find()}: it matches the SAME
+ * identifier forms with the SAME case-insensitive slug semantics and the SAME
+ * tie-break ordering, but only among the given ids — a register's `schemas`
+ * list. {@see findBySlugInIds()} scopes slugs only; this method exists so the
+ * boundary a caller names holds for EVERY identifier form: a numeric id or a
+ * uuid the register does not carry must not resolve merely because a schema
+ * with that id exists elsewhere on the instance.
+ *
+ * @param string|int $id The identifier — numeric id, uuid, or slug (slug matched case-insensitively).
+ * @param array $schemaIds The candidate schema ids (a register's schemas list).
+ *
+ * @return Schema|null The matching schema within the id set, or null when none matches.
+ *
+ * @spec openspec/specs/register-scoped-slug-resolution/spec.md
+ */
+ public function findInIds(string|int $id, array $schemaIds): ?Schema {
+ // Normalise to a list of positive integers; an empty set can never match.
+ $ids = [];
+ foreach ($schemaIds as $candidate) {
+ if (is_numeric($candidate) === true && (int)$candidate > 0) {
+ $ids[] = (int)$candidate;
+ }
+ }
+
+ if ($ids === []) {
+ return null;
+ }
+
+ $this->traceRead(method: 'findInIds');
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from('openregister_schemas');
+
+ // Same identifier forms as find(): uuid, case-insensitive slug, and —
+ // only when numeric (PostgreSQL strict typing) — the primary key id.
+ $orConditions = $qb->expr()->orX(
+ $qb->expr()->eq('uuid', $qb->createNamedParameter(value: (string)$id, type: IQueryBuilder::PARAM_STR)),
+ $qb->expr()->eq(
+ $qb->func()->lower('slug'),
+ $qb->createNamedParameter(value: strtolower((string)$id), type: IQueryBuilder::PARAM_STR)
+ )
+ );
+
+ $idParam = null;
+ if (is_numeric($id) === true) {
+ $idParam = $qb->createNamedParameter(value: (int)$id, type: IQueryBuilder::PARAM_INT);
+ $orConditions->add(
+ $qb->expr()->eq('id', $idParam)
+ );
+ }
+
+ $qb->where($orConditions)
+ ->andWhere(
+ $qb->expr()->in('id', $qb->createNamedParameter(value: $ids, type: IQueryBuilder::PARAM_INT_ARRAY))
+ );
+
+ // Same tie-breaks as find(): an exact primary-key hit first, then rows an
+ // app owns over unattributed leftovers, then the lowest id.
+ if ($idParam !== null) {
+ $qb->addOrderBy(
+ $qb->createFunction(
+ 'CASE WHEN id = ' . $idParam . ' THEN 0 ELSE 1 END'
+ ),
+ 'ASC'
+ );
+ }
+
+ $qb->addOrderBy(
+ $qb->createFunction("CASE WHEN application IS NULL OR application = '' THEN 1 ELSE 0 END"),
+ 'ASC'
+ );
+ $qb->addOrderBy('id', 'ASC');
+ $qb->setMaxResults(1);
+
+ $result = $qb->executeQuery();
+ $row = $result->fetch();
+ $result->closeCursor();
+
+ if ($row === false) {
+ return null;
+ }
+
+ return $this->resolveSchemaExtension(schema: Schema::fromRow($row));
+ }//end findInIds()
+
/**
* Count how many schemas on the instance carry a slug.
*
diff --git a/lib/Service/Lifecycle/TransitionEngine.php b/lib/Service/Lifecycle/TransitionEngine.php
index 5cb8f579dd..2401873bfa 100644
--- a/lib/Service/Lifecycle/TransitionEngine.php
+++ b/lib/Service/Lifecycle/TransitionEngine.php
@@ -712,17 +712,7 @@ private function resolveTransitionInputs(array $inputs, array $data, string $act
}
// Reject when a required input is absent or empty-string.
- $missing = [];
- foreach ($declared as $fieldName => $required) {
- if ($required === false) {
- continue;
- }
-
- if (array_key_exists($fieldName, $data) === false || $data[$fieldName] === '') {
- $missing[] = $fieldName;
- }
- }
-
+ $missing = $this->collectMissingRequiredInputs(declared: $declared, data: $data);
if ($missing !== []) {
throw new InvalidTransitionInputException(
message: sprintf(
@@ -738,6 +728,36 @@ private function resolveTransitionInputs(array $inputs, array $data, string $act
return $data;
}//end resolveTransitionInputs()
+
+ /**
+ * Collect the declared `required` inputs a payload fails to satisfy.
+ *
+ * A required input counts as missing when the payload omits the key entirely
+ * or supplies an empty string. Extracted from {@see resolveTransitionInputs()}
+ * so each rejection (undeclared keys, missing required) reads as one guard.
+ *
+ * @param array $declared Map of declared field name to its `required` flag.
+ * @param array $data The caller-supplied payload.
+ *
+ * @return array The missing required field names, empty when satisfied.
+ *
+ * @spec openspec/specs/object-lifecycle/spec.md
+ */
+ private function collectMissingRequiredInputs(array $declared, array $data): array {
+ $missing = [];
+ foreach ($declared as $fieldName => $required) {
+ if ($required === false) {
+ continue;
+ }
+
+ if (array_key_exists($fieldName, $data) === false || $data[$fieldName] === '') {
+ $missing[] = $fieldName;
+ }
+ }
+
+ return $missing;
+ }//end collectMissingRequiredInputs()
+
/**
* Normalise a transition's `inputs` declaration into fieldName => required.
*
diff --git a/tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php b/tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php
new file mode 100644
index 0000000000..a074ab61e6
--- /dev/null
+++ b/tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php
@@ -0,0 +1,302 @@
+
+ * @copyright 2026 Conduction B.V.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * @link https://OpenRegister.app
+ */
+
+declare(strict_types=1);
+
+namespace Unit\Controller;
+
+use OCA\OpenRegister\Controller\SchemasController;
+use OCA\OpenRegister\Db\AuditTrailMapper;
+use OCA\OpenRegister\Db\MagicMapper;
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\OrganisationService;
+use OCA\OpenRegister\Service\Schema\SchemaVersioningService;
+use OCA\OpenRegister\Service\Schemas\FacetCacheHandler;
+use OCA\OpenRegister\Service\Schemas\SchemaCacheHandler;
+use OCA\OpenRegister\Service\SchemaService;
+use OCA\OpenRegister\Service\UploadService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IAppConfig;
+use OCP\IRequest;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class SchemasControllerShowRegisterScopeTest extends TestCase {
+
+ private IRequest $request;
+
+ private SchemaMapper $schemaMapper;
+
+ private RegisterMapper $registerMapper;
+
+ private SchemasController $controller;
+
+ protected function setUp(): void {
+ $this->request = $this->createMock(IRequest::class);
+ $this->schemaMapper = $this->createMock(SchemaMapper::class);
+ $this->registerMapper = $this->createMock(RegisterMapper::class);
+
+ $userSession = $this->createMock(\OCP\IUserSession::class);
+ $user = $this->createMock(\OCP\IUser::class);
+ $user->method('getUID')->willReturn('admin');
+ $userSession->method('getUser')->willReturn($user);
+
+ $groupManager = $this->createMock(\OCP\IGroupManager::class);
+ $groupManager->method('getUserGroupIds')->willReturn(['admin']);
+ $groupManager->method('isAdmin')->willReturn(true);
+
+ $container = $this->createMock(\Psr\Container\ContainerInterface::class);
+ $container->method('get')->willReturnCallback(
+ function ($id) use ($userSession, $groupManager) {
+ if ($id === \OCP\IUserSession::class) {
+ return $userSession;
+ }
+
+ if ($id === \OCP\IGroupManager::class) {
+ return $groupManager;
+ }
+
+ return null;
+ }
+ );
+
+ $this->controller = new SchemasController(
+ 'openregister',
+ $this->request,
+ $this->createMock(IAppConfig::class),
+ $this->schemaMapper,
+ $this->registerMapper,
+ $this->createMock(MagicMapper::class),
+ $this->createMock(UploadService::class),
+ $this->createMock(AuditTrailMapper::class),
+ $this->createMock(OrganisationService::class),
+ $this->createMock(SchemaCacheHandler::class),
+ $this->createMock(FacetCacheHandler::class),
+ $this->createMock(SchemaService::class),
+ $this->createMock(LoggerInterface::class),
+ $container,
+ $this->createMock(SchemaVersioningService::class)
+ );
+ }//end setUp()
+
+ /**
+ * Stub the two getParam() reads show() performs.
+ *
+ * @param string|null $register The `?register=` value, or null for absent.
+ *
+ * @return void
+ */
+ private function withRegisterParam(?string $register): void {
+ $this->request->method('getParam')->willReturnCallback(
+ function (string $key, $default = null) use ($register) {
+ if ($key === 'register') {
+ return $register;
+ }
+
+ return $default;
+ }
+ );
+ }//end withRegisterParam()
+
+ /**
+ * Build a persisted-looking schema.
+ *
+ * @param int $id The schema id.
+ * @param string $slug The schema slug.
+ *
+ * @return Schema The schema.
+ */
+ private function schemaWithId(int $id, string $slug = 'timeEntry'): Schema {
+ $schema = new Schema();
+ $schema->setId($id);
+ $schema->setSlug($slug);
+ $schema->setTitle('TimeEntry');
+
+ return $schema;
+ }//end schemaWithId()
+
+ /**
+ * Build a register carrying the given schema ids.
+ *
+ * @param int $id The register id.
+ * @param array $schemaIds The schema ids it carries.
+ *
+ * @return Register The register.
+ */
+ private function registerWith(int $id, array $schemaIds): Register {
+ $register = new Register();
+ $register->setId($id);
+ $register->setSlug('hrmq');
+ $register->setSchemas($schemaIds);
+
+ return $register;
+ }//end registerWith()
+
+ /**
+ * Scoped hit: a slug resolves among the named register's schemas only.
+ *
+ * The global resolver must never run — on the live instance it is the call
+ * that returned another app's id-161 schema for hrmq's `timeEntry`.
+ *
+ * @return void
+ */
+ public function testScopedSlugResolvesWithinTheNamedRegisterOnly(): void {
+ $this->withRegisterParam('hrmq');
+ $this->registerMapper->method('find')->willReturn($this->registerWith(id: 12, schemaIds: [9466, 9467]));
+
+ $this->schemaMapper->expects($this->once())
+ ->method('findInIds')
+ ->with('timeEntry', [9466, 9467])
+ ->willReturn($this->schemaWithId(id: 9466));
+ $this->schemaMapper->expects($this->never())->method('find');
+ $this->schemaMapper->method('findExtendedBy')->willReturn([]);
+
+ $response = $this->controller->show('timeEntry');
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame(9466, $response->getData()['id']);
+ }//end testScopedSlugResolvesWithinTheNamedRegisterOnly()
+
+ /**
+ * Scoped miss: a slug carried elsewhere on the instance but not by the named
+ * register is refused with the boundary diagnosis, not resolved globally.
+ *
+ * @return void
+ */
+ public function testSlugCarriedElsewhereButNotByTheRegisterIsRefused(): void {
+ $this->withRegisterParam('hrmq');
+ $this->registerMapper->method('find')->willReturn($this->registerWith(id: 12, schemaIds: [7, 8]));
+
+ $this->schemaMapper->method('findInIds')->willReturn(null);
+ $this->schemaMapper->method('countBySlug')->willReturn(3);
+ $this->schemaMapper->expects($this->never())->method('find');
+
+ $response = $this->controller->show('timeEntry');
+
+ $this->assertSame(404, $response->getStatus());
+ $error = $response->getData()['error'];
+ $this->assertStringContainsString('is not carried by register "hrmq" (id 12)', $error);
+ $this->assertStringContainsString('3 schema(s) elsewhere', $error);
+ $this->assertStringContainsString('naming a register makes it a boundary', $error);
+ $this->assertStringContainsString('occ openregister:registers:relink-schemas', $error);
+ }//end testSlugCarriedElsewhereButNotByTheRegisterIsRefused()
+
+ /**
+ * An unknown register is a 404 naming the register — never a silent fallback
+ * to global resolution, which would serve a schema from outside the boundary
+ * the caller explicitly named.
+ *
+ * @return void
+ */
+ public function testUnknownRegisterIsRefusedInsteadOfFallingBackGlobally(): void {
+ $this->withRegisterParam('no-such-register');
+ $this->registerMapper->method('find')->willThrowException(new DoesNotExistException('nope'));
+
+ $this->schemaMapper->expects($this->never())->method('find');
+ $this->schemaMapper->expects($this->never())->method('findInIds');
+
+ $response = $this->controller->show('timeEntry');
+
+ $this->assertSame(404, $response->getStatus());
+ $error = $response->getData()['error'];
+ $this->assertStringContainsString("Register not found: 'no-such-register'", $error);
+ $this->assertStringContainsString('naming a register makes it a boundary', $error);
+ }//end testUnknownRegisterIsRefusedInsteadOfFallingBackGlobally()
+
+ /**
+ * Control: a caller that names no register keeps global resolution.
+ *
+ * This is the compatibility half of the contract — old clients that never
+ * send `?register=` observe the exact pre-change behaviour.
+ *
+ * @return void
+ */
+ public function testNoRegisterParamKeepsGlobalResolution(): void {
+ $this->withRegisterParam(null);
+
+ $this->schemaMapper->expects($this->once())
+ ->method('find')
+ ->willReturn($this->schemaWithId(id: 161));
+ $this->schemaMapper->expects($this->never())->method('findInIds');
+ $this->schemaMapper->method('findAll')->willReturn([]);
+ $this->schemaMapper->method('findExtendedBy')->willReturn([]);
+ $this->registerMapper->expects($this->never())->method('find');
+
+ $response = $this->controller->show('timeEntry');
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame(161, $response->getData()['id']);
+ }//end testNoRegisterParamKeepsGlobalResolution()
+
+ /**
+ * A numeric id combined with `?register=` resolves within the register.
+ *
+ * @return void
+ */
+ public function testNumericIdResolvesWithinTheRegister(): void {
+ $this->withRegisterParam('hrmq');
+ $this->registerMapper->method('find')->willReturn($this->registerWith(id: 12, schemaIds: [9466]));
+
+ $this->schemaMapper->expects($this->once())
+ ->method('findInIds')
+ ->with('9466', [9466])
+ ->willReturn($this->schemaWithId(id: 9466));
+ $this->schemaMapper->expects($this->never())->method('find');
+ $this->schemaMapper->method('findExtendedBy')->willReturn([]);
+
+ $response = $this->controller->show('9466');
+
+ $this->assertSame(200, $response->getStatus());
+ $this->assertSame(9466, $response->getData()['id']);
+ }//end testNumericIdResolvesWithinTheRegister()
+
+ /**
+ * A numeric id the register does not carry is refused: the boundary holds
+ * for every identifier form, not for slugs only.
+ *
+ * @return void
+ */
+ public function testNumericIdOutsideTheRegisterIsRefused(): void {
+ $this->withRegisterParam('hrmq');
+ $this->registerMapper->method('find')->willReturn($this->registerWith(id: 12, schemaIds: [9466]));
+
+ $this->schemaMapper->method('findInIds')->willReturn(null);
+ $this->schemaMapper->method('countBySlug')->willReturn(0);
+ $this->schemaMapper->expects($this->never())->method('find');
+
+ $response = $this->controller->show('161');
+
+ $this->assertSame(404, $response->getStatus());
+ $error = $response->getData()['error'];
+ $this->assertStringContainsString('is not carried by register "hrmq" (id 12)', $error);
+ }//end testNumericIdOutsideTheRegisterIsRefused()
+}//end class
From 958219e738d36aceb91f80526b9444d82411554d Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sat, 22 Aug 2026 01:47:27 +0200
Subject: [PATCH 043/139] feat(commands): add
openregister:registers:dedupe-shared-schemas (#2696)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(commands): add openregister:registers:dedupe-shared-schemas
Repairs registers that co-own one schema entity — pre-fix drift where every
app import rewrote the shared definition for all referencing registers, last
import wins. Counterpart to relink-schemas, which ADDS lost linkage but has no
way to SPLIT linkage that was never meant to be shared.
Detection inverts the register->schemas map in PHP (the column is json on
Postgres, text elsewhere, and SQLite has no REGEXP), normalising ids so a
schema referenced as "161" by one register and 161 by another still counts.
Attribution is evidence-based: the current entity content is compared against
what each referencing register's OWN app configuration declares, resolved the
way AppHostSettingsService merges register.json with register.d fragments. The
comparison is on the property-NAME set plus required — the import path stamps
defaults and folds $refs, so byte equality would put every schema in no-match,
while a LOST property (what the overwrite actually did) still registers as a
difference. Deliberately not used: the schema's `application` column, which on
a shared entity names whichever app overwrote it last, and lowest-register-id,
which is not evidence at all.
Safety rails:
- dry run by default; --write refuses while any schema is unattributed rather
than guessing an owner, since guessing is what caused the damage
- --keep : or a bare --keep ; a --keep naming
a register that does not reference the schema is ignored, not honoured
- unmapped columns are computed at PLAN time from a transient unpersisted
Schema, so --strict refuses BEFORE anything is written (on MySQL a post-hoc
refusal would strand the created table, DDL not being transactional there)
- the source table is renamed to _predupe rather than dropped, keeping unmapped
columns recoverable AND stopping relink-schemas from reading it as evidence
and re-linking the register to the schema this command just split it from
The split creates the register's own schema through importSchema with an empty
register scope, so the per-register slug-uniqueness fix forces a new row and the
app's next import updates that entity instead of forking the shared one again.
Rows move as a column-mapped INSERT-SELECT; _id is not copied (autoincrement —
copying it would strand the target sequence), and _schema plus the schema id
embedded in _uri are restamped so moved rows stop being attributed to the
register that kept the shared entity.
Refs #2689
* fix(commands): add @spec anchors to DedupeSharedSchemasCommand configure/execute
gate-16 spec-coverage FAILed on the PR: 2 changed method(s) missing @spec.
Reproduced locally with the same checker against origin/development —
DedupeSharedSchemasCommand::configure() and ::execute(). The @spec tag was on
the class docblock but not on the two protected methods the change adds, and
gate-16 is per-method (ADR-003/ADR-020), diff-scoped, so the sibling
RelinkRegisterSchemasCommand's untagged configure/execute are legacy debt it
correctly leaves alone.
The gate was right; the code was wrong. Both methods now anchor to the
change's proposal, matching the class docblock and the service layer.
Checker now reports `# count=0`.
---
appinfo/info.xml | 1 +
docs/Technical/repairing-shared-schemas.md | 201 +++++++
docs/api/schemas.md | 5 +-
lib/Command/DedupeSharedSchemasCommand.php | 363 +++++++++++++
.../RegisterConfigurationLocator.php | 307 +++++++++++
.../SharedSchema/SchemaAttribution.php | 331 ++++++++++++
.../SharedSchema/SchemaTableMigrator.php | 495 ++++++++++++++++++
lib/Service/SharedSchemaDedupeService.php | 438 ++++++++++++++++
.../changes/dedupe-shared-schemas/proposal.md | 68 +++
.../changes/dedupe-shared-schemas/tasks.md | 96 ++++
.../DedupeSharedSchemasCommandTest.php | 303 +++++++++++
.../SharedSchema/SchemaAttributionTest.php | 406 ++++++++++++++
.../SharedSchema/SchemaTableMigratorTest.php | 354 +++++++++++++
13 files changed, 3367 insertions(+), 1 deletion(-)
create mode 100644 docs/Technical/repairing-shared-schemas.md
create mode 100644 lib/Command/DedupeSharedSchemasCommand.php
create mode 100644 lib/Service/SharedSchema/RegisterConfigurationLocator.php
create mode 100644 lib/Service/SharedSchema/SchemaAttribution.php
create mode 100644 lib/Service/SharedSchema/SchemaTableMigrator.php
create mode 100644 lib/Service/SharedSchemaDedupeService.php
create mode 100644 openspec/changes/dedupe-shared-schemas/proposal.md
create mode 100644 openspec/changes/dedupe-shared-schemas/tasks.md
create mode 100644 tests/Unit/Command/DedupeSharedSchemasCommandTest.php
create mode 100644 tests/Unit/Service/SharedSchema/SchemaAttributionTest.php
create mode 100644 tests/Unit/Service/SharedSchema/SchemaTableMigratorTest.php
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 7864416718..e1644bb889 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -220,6 +220,7 @@ Vrij en open source onder de EUPL-licentie.
OCA\OpenRegister\Command\EncryptFieldCommand
OCA\OpenRegister\Command\DedupeRegistersCommand
OCA\OpenRegister\Command\RelinkRegisterSchemasCommand
+ OCA\OpenRegister\Command\DedupeSharedSchemasCommand
OCA\OpenRegister\Command\ReconcileMagicTablesCommand
OCA\OpenRegister\Command\DedupeConfigurationsCommand
OCA\OpenRegister\Command\ResolverListCommand
diff --git a/docs/Technical/repairing-shared-schemas.md b/docs/Technical/repairing-shared-schemas.md
new file mode 100644
index 0000000000..008e222efc
--- /dev/null
+++ b/docs/Technical/repairing-shared-schemas.md
@@ -0,0 +1,201 @@
+# Repairing schemas shared by several registers
+
+`occ openregister:registers:dedupe-shared-schemas` splits schema entities that
+more than one register co-owns, giving each register its own entity and moving
+its object rows with it.
+
+It is the counterpart to `occ openregister:registers:relink-schemas`: that
+command **adds** linkage a register lost, this one **splits** linkage a register
+was never meant to have. Both are dry-run by default.
+
+## When this drift happens
+
+A schema row carries no register column. The relation exists only as a JSON id
+list on the register, so nothing on the schema side records who owns it.
+
+Before the per-register slug-uniqueness fix in the import path, an import that
+resolved a schema slug **globally** re-used whatever schema row already carried
+that slug. Two apps declaring, say, `timeEntry` could therefore end up pointing
+at one entity — and from then on every import of either app rewrote the
+definition for both. Last import wins, instance-wide.
+
+The symptom is an app whose schema silently changes shape when an unrelated app
+is installed or updated: properties disappear, `required` flips, and creates
+start failing validation for a field the app never declared.
+
+### The worked example (openregister#2689)
+
+On the shared development instance, registers `planix` (19) and `pipelinq` (16)
+both referenced schema entities `task=74`, `project=159` and `timeEntry=161`.
+
+Schema 161 held planix's six-property `timeEntry`. Pipelinq's own definition —
+`hours`, `billingCategory`, `client`, `project`, and the WIP/billing-sync fields
+its billing features depend on — was gone from the instance entirely. A planix
+schema extension had transparently changed pipelinq's `task` definition too.
+
+The import-side fix stops *new* sharing. It does not repair what already
+happened; that is what this command is for.
+
+## Reading the dry run
+
+```
+occ openregister:registers:dedupe-shared-schemas
+```
+
+```
+3 schema(s) are shared by more than one register:
+
+ schema 161 (timeEntry) — referenced by registers [16, 19]
+ attribution: one-match — owner: register 19 (configuration)
+ - register 16 (pipelinq) -> new schema from configuration (openregister_table_16_161, 42 row(s))
+ 5 column(s) would have no destination: approved, date, description, duration, employee
+
+DRY RUN — nothing was changed. Re-run with --write to apply.
+```
+
+Line by line:
+
+- **`referenced by registers [16, 19]`** — every register whose `schemas` list
+ carries this id. Ids stored as strings count too.
+- **`attribution:`** — how the owner was determined. See below.
+- **`-> new schema from configuration`** — the split will rebuild register 16's
+ schema from its own `register.json`. `from clone` means the app ships no
+ configuration for it any more, so the current entity content is copied
+ verbatim instead.
+- **`42 row(s)`** — how many object rows will move. `no table` means the pairing
+ was never materialised, so there is nothing to migrate.
+- **`column(s) would have no destination`** — source columns the restored
+ definition has no place for. They are **never dropped silently**: the source
+ table is kept (see *What happens to the old table*).
+
+## How attribution works
+
+For each shared schema the command reads the **current entity content** and
+compares it against what each referencing register's own app configuration
+declares for that slug — `lib/Settings/_register.json` plus any
+`lib/Settings/register.d/*.json` fragments, merged the way the settings loader
+merges them.
+
+The comparison is on the **property-name set and the `required` list**, not on
+byte equality. The import path stamps defaults, folds `$ref`s and rewrites
+descriptions, so byte equality would match nothing. A *lost property* — which is
+exactly what the overwrite did — still registers as a difference.
+
+Three outcomes:
+
+| Status | Meaning | Result |
+| --- | --- | --- |
+| `one-match` | Exactly one register's configuration matches the entity | That register keeps it; the others are split off |
+| `no-match` | No configuration matches (both apps have moved on) | **Unattributed** — skipped until you decide |
+| `multi-match` | Several configurations match (both declare the same shape) | **Unattributed** — skipped until you decide |
+
+Deliberately *not* used as evidence: the schema's `application` column (on a
+shared entity it names whichever app overwrote it last, not the owner) and
+"lowest register id" (not evidence at all). The older
+`occ openregister:schemas:dedup` command uses both, and therefore always picks a
+side.
+
+### Naming an owner yourself
+
+`--write` **refuses** while any schema is unattributed. Guessing an owner is what
+produced the damage in the first place, so the command will not do it for you.
+
+```bash
+# pin one schema
+occ openregister:registers:dedupe-shared-schemas --keep 161:19 --write
+
+# pin several
+occ openregister:registers:dedupe-shared-schemas --keep 161:19 --keep 74:19 --write
+
+# one register owns everything attribution could not settle
+occ openregister:registers:dedupe-shared-schemas --keep 19 --write
+```
+
+The per-schema form always outranks the bare one. A `--keep` naming a register
+that does not actually reference the schema is ignored, and the schema stays
+unattributed.
+
+## Applying the repair
+
+```bash
+occ openregister:registers:dedupe-shared-schemas --write
+```
+
+For every non-canonical register the command, in one transaction:
+
+1. **Creates its own schema** — preferably from that register's own
+ configuration, through the same import path the app uses. That is what makes
+ the split durable: the app's next import finds the register's own entity and
+ updates *that*, instead of forking the shared one again. With no configuration
+ available, the current entity content is cloned.
+2. **Relinks** `register.schemas`, replacing the old id with the new one in
+ place. Order is preserved and entries it does not understand are copied
+ verbatim.
+3. **Moves the object rows** from `openregister_table__` into the
+ table built for the new schema, as a column-mapped `INSERT ... SELECT`.
+4. **Restamps** the moved rows: `_schema` and the schema id embedded in `_uri`.
+ Without this the rows stay attributed to the register that kept the shared
+ schema — the very bleed being repaired.
+
+`_id` is not copied. It is an autoincrement primary key, and carrying the values
+over would leave the new table's sequence behind the highest copied id. `_uuid`
+is the identity relations actually store, and it does move.
+
+### Refusing on unmapped columns
+
+```bash
+occ openregister:registers:dedupe-shared-schemas --write --strict
+```
+
+`--strict` turns "this source column has no destination" into a refusal for that
+split, decided **before** anything is written. Use it when you would rather stop
+and look than accept that some columns only survive in the backup table.
+
+## What happens to the old table
+
+The source table is **not dropped**. It is renamed with a `_predupe` suffix:
+
+```
+oc_openregister_table_16_161 -> oc_openregister_table_16_161_predupe
+```
+
+Two reasons:
+
+- It is the only route back to a column the mapping could not carry across.
+- The suffix stops the name matching the shard pattern
+ `openregister_table__`. Left under its original name, a later
+ `occ openregister:registers:relink-schemas --write` would read it as evidence
+ of a pairing and re-link the register to the schema this command just split it
+ away from — quietly undoing the repair.
+
+Drop the backup tables yourself once you are satisfied nothing is missing.
+
+## Options
+
+| Option | Effect |
+| --- | --- |
+| *(none)* | Dry run. Reports the full plan and changes nothing. |
+| `--write` | Apply. Refused while any schema is unattributed. |
+| `--register ` | Limit to shared schemas involving this register. |
+| `--keep :` | Name the owner of one schema. Repeatable. |
+| `--keep ` | Name the owner of every unattributed schema. |
+| `--strict` | Refuse any split whose source table has an unmapped column. |
+
+Exit code is `0` on success (including "nothing to do"), and `1` when a write was
+refused or a split failed.
+
+## After the repair
+
+Re-run the app's configuration import. It should now update the register's own
+schema rather than the shared one, and a second dry run of this command should
+report nothing — the repair is idempotent.
+
+## Related
+
+- `occ openregister:registers:relink-schemas` — rebuilds a register's lost
+ `schemas` list from its physical object tables.
+- `occ openregister:tables:reconcile` — creates magic-table columns that a schema
+ property gained without a subsequent write.
+- `occ openregister:schemas:dedup` — the older, heuristic split (owner by
+ `application`, else lowest register id) that predates evidence-based
+ attribution.
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index 13d12984a6..9934e8017c 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -24,7 +24,10 @@ still succeed.
If the linkage of an existing register was lost, `occ
openregister:registers:relink-schemas` inspects and repairs it from the physical
-object tables.
+object tables. If several registers wrongly share ONE schema entity — pre-fix
+drift, where every import rewrites the definition for all of them — `occ
+openregister:registers:dedupe-shared-schemas` splits them apart; see
+[Repairing schemas shared by several registers](../Technical/repairing-shared-schemas.md).
## Error Handling for Missing Register or Schema
diff --git a/lib/Command/DedupeSharedSchemasCommand.php b/lib/Command/DedupeSharedSchemasCommand.php
new file mode 100644
index 0000000000..4f2ec77566
--- /dev/null
+++ b/lib/Command/DedupeSharedSchemasCommand.php
@@ -0,0 +1,363 @@
+
+ * @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
+ *
+ * SPDX-License-Identifier: EUPL-1.2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Command;
+
+use OCA\OpenRegister\Service\SharedSchemaDedupeService;
+use RuntimeException;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Throwable;
+
+/**
+ * Split schema entities that several registers wrongly share.
+ *
+ * DRY RUN BY DEFAULT. `--write` is required to change anything.
+ *
+ * This is the counterpart to `openregister:registers:relink-schemas`: that
+ * command ADDS linkage a register lost, this one SPLITS linkage a register was
+ * never meant to have. Both mutate the `schemas` boundary, so both show the
+ * operator the whole change first and then ask them to opt in.
+ *
+ * The command REFUSES to write a schema it could not attribute. Guessing an
+ * owner is what produced the damage in the first place — an import resolving a
+ * slug globally and landing on someone else's entity — so a schema whose
+ * referencing registers' configurations do not single one owner out is reported
+ * and skipped until `--keep` names one.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+class DedupeSharedSchemasCommand extends Command {
+
+ /**
+ * Wire the dedupe service.
+ *
+ * @param SharedSchemaDedupeService $dedupe The shared-schema repair service.
+ *
+ * @return void
+ */
+ public function __construct(
+ private readonly SharedSchemaDedupeService $dedupe,
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * Define command name, description, and options.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ protected function configure(): void {
+ $this->setName(name: 'openregister:registers:dedupe-shared-schemas')
+ ->setDescription(
+ 'Split schema entities shared by several registers so each owns its own (dry run by default)'
+ )
+ ->addOption(
+ 'write',
+ null,
+ InputOption::VALUE_NONE,
+ 'Apply the changes. Without this flag nothing is modified.'
+ )
+ ->addOption(
+ 'register',
+ null,
+ InputOption::VALUE_REQUIRED,
+ 'Limit to shared schemas involving this register id.'
+ )
+ ->addOption(
+ 'keep',
+ null,
+ (InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY),
+ 'Name the owner of a schema attribution could not settle: '
+ . ':, or a bare for all of them. Repeatable.'
+ )
+ ->addOption(
+ 'strict',
+ null,
+ InputOption::VALUE_NONE,
+ 'Refuse any split whose source table has a column with no destination.'
+ );
+ }//end configure()
+
+ /**
+ * Run the inspection, and the repair when --write is given.
+ *
+ * @param InputInterface $input The console input.
+ * @param OutputInterface $output The console output.
+ *
+ * @return int The exit code.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $write = (bool)$input->getOption('write');
+ $strict = (bool)$input->getOption('strict');
+
+ $registerId = $input->getOption('register');
+ if ($registerId !== null) {
+ $registerId = (int)$registerId;
+ }
+
+ try {
+ $keep = $this->dedupe->parseKeep(raw: (array)$input->getOption('keep'));
+ } catch (RuntimeException $e) {
+ $output->writeln('' . $e->getMessage() . '');
+ return Command::INVALID;
+ }
+
+ $plan = $this->dedupe->inspect(registerId: $registerId, keep: $keep);
+
+ if ($plan === []) {
+ $output->writeln('No schema is shared by more than one register. Nothing to do.');
+ return Command::SUCCESS;
+ }
+
+ $output->writeln(
+ sprintf('%d schema(s) are shared by more than one register:', count($plan))
+ );
+ $output->writeln('');
+
+ $unattributed = 0;
+ foreach ($plan as $entry) {
+ $this->renderSchema(output: $output, entry: $entry);
+ if ($entry['owner'] === null) {
+ $unattributed++;
+ }
+ }
+
+ if ($write === false) {
+ return $this->reportDryRun(output: $output, unattributed: $unattributed);
+ }
+
+ if ($unattributed > 0) {
+ $output->writeln('');
+ $output->writeln(
+ sprintf(
+ 'Refusing to write: %d schema(s) are unattributed. '
+ . 'Name their owner with --keep :.',
+ $unattributed
+ )
+ );
+ return Command::FAILURE;
+ }
+
+ return $this->applyPlan(output: $output, plan: $plan, strict: $strict);
+ }//end execute()
+
+ /**
+ * Report the outcome of a dry run.
+ *
+ * @param OutputInterface $output The console output.
+ * @param int $unattributed How many schemas could not be attributed.
+ *
+ * @return int The exit code.
+ */
+ private function reportDryRun(OutputInterface $output, int $unattributed): int {
+ $output->writeln('');
+ if ($unattributed > 0) {
+ $output->writeln(
+ sprintf(
+ '%d schema(s) are unattributed and would be SKIPPED. '
+ . 'Name their owner with --keep :.',
+ $unattributed
+ )
+ );
+ }
+
+ $output->writeln('DRY RUN — nothing was changed. Re-run with --write to apply.');
+
+ return Command::SUCCESS;
+ }//end reportDryRun()
+
+ /**
+ * Execute every planned split.
+ *
+ * @param OutputInterface $output The console output.
+ * @param array> $plan The inspection plan.
+ * @param bool $strict Whether unmapped columns refuse the move.
+ *
+ * @return int The exit code.
+ */
+ private function applyPlan(OutputInterface $output, array $plan, bool $strict): int {
+ $split = 0;
+ $failed = 0;
+
+ $output->writeln('');
+ foreach ($plan as $entry) {
+ foreach (array_keys($entry['splits']) as $registerId) {
+ try {
+ $result = $this->dedupe->applySplit(
+ entry: $entry,
+ target: (int)$registerId,
+ strict: $strict
+ );
+ $output->writeln($this->describeSplit(entry: $entry, registerId: (int)$registerId, result: $result));
+ $split++;
+ } catch (Throwable $e) {
+ $output->writeln(
+ sprintf(
+ ' failed register %d / schema %d: %s',
+ $registerId,
+ $entry['schemaId'],
+ $e->getMessage()
+ )
+ );
+ $failed++;
+ }//end try
+ }
+ }//end foreach
+
+ $output->writeln('');
+ $output->writeln(sprintf('%d split(s) applied; %d failure(s).', $split, $failed));
+
+ if ($failed === 0) {
+ return Command::SUCCESS;
+ }
+
+ return Command::FAILURE;
+ }//end applyPlan()
+
+ /**
+ * Render one applied split.
+ *
+ * The backup table is named explicitly because it is the operator's only
+ * route back to a column the mapping could not carry across.
+ *
+ * @param array $entry The plan entry.
+ * @param int $registerId The register that was split off.
+ * @param array $result The service's outcome.
+ *
+ * @return string The line to print.
+ */
+ private function describeSplit(array $entry, int $registerId, array $result): string {
+ $line = sprintf(
+ ' split register %d: schema %d -> %d (%d row(s) moved)',
+ $registerId,
+ $entry['schemaId'],
+ $result['newSchemaId'],
+ $result['rows']
+ );
+
+ if ($result['backup'] !== null) {
+ $line .= sprintf(', source kept as %s', $result['backup']);
+ }
+
+ if ($result['unmapped'] !== []) {
+ $line .= sprintf(
+ "\n %d column(s) had no destination and stayed in the backup: %s",
+ count($result['unmapped']),
+ implode(', ', $result['unmapped'])
+ );
+ }
+
+ return $line;
+ }//end describeSplit()
+
+ /**
+ * Render one shared schema's findings.
+ *
+ * Row counts are printed per split because they separate a split that only
+ * repoints configuration from one that moves live data. The attribution
+ * status is printed verbatim so the operator can see WHY a schema is about to
+ * be attributed the way it is, rather than being handed a verdict.
+ *
+ * @param OutputInterface $output The console output.
+ * @param array $entry One inspect() plan entry.
+ *
+ * @return void
+ */
+ private function renderSchema(OutputInterface $output, array $entry): void {
+ $output->writeln(
+ sprintf(
+ ' schema %d (%s) — referenced by registers [%s]',
+ $entry['schemaId'],
+ $entry['schemaSlug'],
+ implode(', ', $entry['registerIds'])
+ )
+ );
+
+ $output->writeln(sprintf(' attribution: %s%s', $entry['status'], $this->describeOwner(entry: $entry)));
+
+ foreach ($entry['splits'] as $registerId => $split) {
+ $output->writeln(
+ sprintf(
+ ' - register %d (%s) -> new schema from %s (%s, %s)',
+ $registerId,
+ $split['registerSlug'],
+ $split['path'],
+ $split['table'],
+ $this->describeRows(rows: (int)$split['rows'])
+ )
+ );
+
+ if ($split['unmapped'] !== []) {
+ $output->writeln(
+ sprintf(
+ ' %d column(s) would have no destination: %s',
+ count($split['unmapped']),
+ implode(', ', $split['unmapped'])
+ )
+ );
+ }
+ }//end foreach
+
+ $output->writeln('');
+ }//end renderSchema()
+
+ /**
+ * Describe the resolved owner, or the reason there is none.
+ *
+ * @param array $entry One inspect() plan entry.
+ *
+ * @return string The suffix to print after the status.
+ */
+ private function describeOwner(array $entry): string {
+ if ($entry['owner'] === null) {
+ return ' — UNATTRIBUTED, will be skipped';
+ }
+
+ return sprintf(' — owner: register %d (%s)', $entry['owner'], $entry['ownerSource']);
+ }//end describeOwner()
+
+ /**
+ * Describe a row count, distinguishing "empty" from "no table".
+ *
+ * @param int $rows The count, or -1 when the table is absent.
+ *
+ * @return string The description.
+ */
+ private function describeRows(int $rows): string {
+ if ($rows < 0) {
+ return 'no table';
+ }
+
+ return sprintf('%d row(s)', $rows);
+ }//end describeRows()
+}//end class
diff --git a/lib/Service/SharedSchema/RegisterConfigurationLocator.php b/lib/Service/SharedSchema/RegisterConfigurationLocator.php
new file mode 100644
index 0000000000..f6d6cf4b8c
--- /dev/null
+++ b/lib/Service/SharedSchema/RegisterConfigurationLocator.php
@@ -0,0 +1,307 @@
+
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\SharedSchema
+ *
+ * @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\Service\SharedSchema;
+
+use OCA\OpenRegister\Db\Register;
+use OCP\App\IAppManager;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Resolve "what does this register's app configuration say its schemas look like?".
+ *
+ * Discovery mirrors {@see \OCA\OpenRegister\AppHost\Service\AppHostSettingsService}:
+ * a base `lib/Settings/_register.json` with `lib/Settings/register.d/*.json`
+ * fragments deep-merged over it in sorted filename order. The glob is widened to
+ * every `*_register.json` because OpenRegister itself ships several documents
+ * rather than one monolith, and its own registers would otherwise resolve to
+ * nothing.
+ *
+ * This is deliberately the ONLY evidence source used for attribution. The
+ * alternatives were considered and rejected: the schema's `application` column
+ * is what the last import stamped, so on a shared entity it names the register
+ * that overwrote it rather than the one that owns it; and "lowest register id"
+ * is not evidence at all. Both are what the existing
+ * `openregister:schemas:dedup` command uses, and both silently pick a side.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+class RegisterConfigurationLocator {
+
+ /**
+ * Constructor.
+ *
+ * @param IAppManager $appManager Resolves an app id to its on-disk path.
+ * @param LoggerInterface $logger Records unreadable configuration documents.
+ *
+ * @return void
+ */
+ public function __construct(
+ private readonly IAppManager $appManager,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Read the schema definitions a register's app configuration declares for it.
+ *
+ * @param Register $register The register to resolve configuration for.
+ *
+ * @return array> Lowercased schema slug => definition.
+ * Empty when the app ships no configuration naming this register.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function schemasFor(Register $register): array {
+ $appId = (string)$register->getApplication();
+ if ($appId === '') {
+ return [];
+ }
+
+ try {
+ $appPath = $this->appManager->getAppPath($appId);
+ } catch (Throwable $e) {
+ unset($e);
+ return [];
+ }
+
+ $settings = ($appPath . '/lib/Settings');
+ $slug = strtolower((string)$register->getSlug());
+
+ foreach ($this->documents(directory: $settings) as $document) {
+ $data = $this->readJson(path: $document);
+ if ($data === null) {
+ continue;
+ }
+
+ if (basename($document) === ($appId . '_register.json')) {
+ $data = $this->mergeFragments(base: $data, directory: ($settings . '/register.d'));
+ }
+
+ $schemas = self::schemasForRegisterSlug(document: $data, registerSlug: $slug);
+ if ($schemas !== []) {
+ return $schemas;
+ }
+ }
+
+ return [];
+ }//end schemasFor()
+
+ /**
+ * Pull one register's schema definitions out of a configuration document.
+ *
+ * @param array $document The merged configuration document.
+ * @param string $registerSlug The lowercased register slug to look for.
+ *
+ * @return array> Lowercased schema slug => definition.
+ */
+ private static function schemasForRegisterSlug(array $document, string $registerSlug): array {
+ $components = ($document['components'] ?? []);
+ if (is_array($components) === false) {
+ return [];
+ }
+
+ $registers = ($components['registers'] ?? []);
+ $schemas = ($components['schemas'] ?? []);
+ if (is_array($registers) === false || is_array($schemas) === false) {
+ return [];
+ }
+
+ $declared = self::findRegister(registers: $registers, registerSlug: $registerSlug);
+ if ($declared === null) {
+ return [];
+ }
+
+ return self::pickSchemas(schemas: $schemas, wanted: self::declaredSlugs(declared: $declared));
+ }//end schemasForRegisterSlug()
+
+ /**
+ * Find the register entry whose slug matches.
+ *
+ * The map key is accepted as a fallback slug because a fragment may declare a
+ * register by key alone.
+ *
+ * @param array $registers The `components.registers` map.
+ * @param string $registerSlug The lowercased register slug.
+ *
+ * @return array|null The register definition, or null.
+ */
+ private static function findRegister(array $registers, string $registerSlug): ?array {
+ foreach ($registers as $key => $definition) {
+ if (is_array($definition) === false) {
+ continue;
+ }
+
+ if (strtolower((string)($definition['slug'] ?? $key)) === $registerSlug) {
+ return $definition;
+ }
+ }
+
+ return null;
+ }//end findRegister()
+
+ /**
+ * The schema slugs a register definition declares.
+ *
+ * @param array $declared The register definition.
+ *
+ * @return array Lowercased slug => true.
+ */
+ private static function declaredSlugs(array $declared): array {
+ $wanted = [];
+ foreach (((array)($declared['schemas'] ?? [])) as $entry) {
+ if (is_scalar($entry) === true) {
+ $wanted[strtolower((string)$entry)] = true;
+ }
+ }
+
+ return $wanted;
+ }//end declaredSlugs()
+
+ /**
+ * Filter the document's schema map down to the wanted slugs.
+ *
+ * @param array $schemas The `components.schemas` map.
+ * @param array $wanted Lowercased slug => true.
+ *
+ * @return array> Lowercased slug => definition.
+ */
+ private static function pickSchemas(array $schemas, array $wanted): array {
+ $result = [];
+ foreach ($schemas as $key => $definition) {
+ if (is_array($definition) === false) {
+ continue;
+ }
+
+ $slug = strtolower((string)($definition['slug'] ?? $key));
+ if (isset($wanted[$slug]) === true) {
+ $result[$slug] = $definition;
+ }
+ }
+
+ return $result;
+ }//end pickSchemas()
+
+ /**
+ * List the candidate configuration documents in an app's settings directory.
+ *
+ * @param string $directory The `lib/Settings` directory.
+ *
+ * @return string[] The absolute paths, in glob order.
+ */
+ private function documents(string $directory): array {
+ $documents = glob($directory . '/*_register.json');
+ if ($documents === false) {
+ return [];
+ }
+
+ return $documents;
+ }//end documents()
+
+ /**
+ * Deep-merge every `register.d` fragment over a base document.
+ *
+ * @param array $base The base document.
+ * @param string $directory The fragment directory.
+ *
+ * @return array The merged document.
+ */
+ private function mergeFragments(array $base, string $directory): array {
+ $fragments = glob($directory . '/*.json');
+ if ($fragments === false) {
+ return $base;
+ }
+
+ sort($fragments);
+
+ foreach ($fragments as $fragment) {
+ $data = $this->readJson(path: $fragment);
+ if ($data !== null) {
+ $base = self::deepMerge(base: $base, overlay: $data);
+ }
+ }
+
+ return $base;
+ }//end mergeFragments()
+
+ /**
+ * Merge an overlay over a base the way the settings loader does.
+ *
+ * Associative arrays merge key by key; list entries append; overlay scalars win.
+ *
+ * @param array $base The base array.
+ * @param array $overlay The overlay array.
+ *
+ * @return array The merged array.
+ */
+ private static function deepMerge(array $base, array $overlay): array {
+ foreach ($overlay as $key => $value) {
+ if (is_int($key) === true) {
+ $base[] = $value;
+ continue;
+ }
+
+ if (isset($base[$key]) === true && is_array($base[$key]) === true && is_array($value) === true) {
+ $base[$key] = self::deepMerge(base: $base[$key], overlay: $value);
+ continue;
+ }
+
+ $base[$key] = $value;
+ }
+
+ return $base;
+ }//end deepMerge()
+
+ /**
+ * Read and decode one JSON document.
+ *
+ * @param string $path The absolute path.
+ *
+ * @return array|null The decoded document, or null when unreadable.
+ */
+ private function readJson(string $path): ?array {
+ if (is_readable($path) === false) {
+ return null;
+ }
+
+ $raw = file_get_contents($path);
+ if ($raw === false) {
+ return null;
+ }
+
+ $data = json_decode($raw, true);
+ if (is_array($data) === false) {
+ $this->logger->warning(
+ message: '[SharedSchemaDedupe] Unreadable configuration document ' . $path,
+ context: ['file' => __FILE__, 'line' => __LINE__],
+ );
+ return null;
+ }
+
+ return $data;
+ }//end readJson()
+}//end class
diff --git a/lib/Service/SharedSchema/SchemaAttribution.php b/lib/Service/SharedSchema/SchemaAttribution.php
new file mode 100644
index 0000000000..470692d26d
--- /dev/null
+++ b/lib/Service/SharedSchema/SchemaAttribution.php
@@ -0,0 +1,331 @@
+
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\SharedSchema
+ *
+ * @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\Service\SharedSchema;
+
+use RuntimeException;
+
+/**
+ * Decide who owns a shared schema.
+ *
+ * Deliberately dependency-free. Every rule this repair turns on — detection,
+ * matching, the refusal to guess — lives here and can therefore be tested
+ * exhaustively without a database, a Nextcloud server or an app on disk. The
+ * classes that surround it only fetch the inputs and carry out the verdict.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+class SchemaAttribution {
+
+ /**
+ * Exactly one referencing register's configuration matches the entity.
+ *
+ * @var string
+ */
+ public const STATUS_ONE_MATCH = 'one-match';
+
+ /**
+ * No referencing register's configuration matches the entity.
+ *
+ * @var string
+ */
+ public const STATUS_NO_MATCH = 'no-match';
+
+ /**
+ * Several referencing registers' configurations match the entity.
+ *
+ * @var string
+ */
+ public const STATUS_MULTI_MATCH = 'multi-match';
+
+ /**
+ * Invert a register->schemas map into the schemas shared by several registers.
+ *
+ * Ids are normalised because the stored list may hold them as ints or as
+ * strings depending on which import era wrote it, and a schema referenced as
+ * `"74"` by one register and `74` by another is still shared.
+ *
+ * @param array $registerSchemas registerId => stored schema id list.
+ *
+ * @return array schemaId => the register ids referencing it, ascending.
+ * Only schemas with more than one referencing register are returned.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function indexShared(array $registerSchemas): array {
+ $index = [];
+ foreach ($registerSchemas as $registerId => $schemaIds) {
+ foreach ($this->normaliseIds(candidates: $schemaIds) as $schemaId) {
+ $index[$schemaId][(int)$registerId] = true;
+ }
+ }
+
+ $shared = [];
+ foreach ($index as $schemaId => $registerIds) {
+ if (count($registerIds) < 2) {
+ continue;
+ }
+
+ $ids = array_keys($registerIds);
+ sort($ids);
+ $shared[$schemaId] = $ids;
+ }
+
+ ksort($shared);
+
+ return $shared;
+ }//end indexShared()
+
+ /**
+ * Reduce a schema definition to the shape attribution compares on.
+ *
+ * Only the property NAMES and the required list are used. Comparing whole
+ * property bodies would be worse than useless: the import path stamps
+ * defaults, folds `$ref`s and normalises casing, so a definition that came
+ * from the very configuration under test still would not be byte-equal to the
+ * stored entity, and every schema would land in `no-match`. The name set is
+ * what the pre-fix overwrite actually destroyed — the observed case lost
+ * `billingCategory`, `hours` and `client` from pipelinq's `timeEntry` — so it
+ * is the evidence that discriminates.
+ *
+ * @param array $definition A schema definition or a serialised entity.
+ *
+ * @return array{properties: string[], required: string[]} The normalised signature.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function signature(array $definition): array {
+ $properties = ($definition['properties'] ?? []);
+ $names = [];
+ if (is_array($properties) === true) {
+ foreach (array_keys($properties) as $name) {
+ $names[] = strtolower((string)$name);
+ }
+ }
+
+ $names = array_values(array_unique($names));
+ sort($names);
+
+ $required = ($definition['required'] ?? []);
+ $fields = [];
+ if (is_array($required) === true) {
+ foreach ($required as $field) {
+ if (is_scalar($field) === true) {
+ $fields[] = strtolower((string)$field);
+ }
+ }
+ }
+
+ $fields = array_values(array_unique($fields));
+ sort($fields);
+
+ return ['properties' => $names, 'required' => $fields];
+ }//end signature()
+
+ /**
+ * Decide which referencing register owns the current entity content.
+ *
+ * @param array $candidates registerId => that register's configured
+ * definition for this slug, or null when it has none.
+ * @param array $entity The current schema entity content.
+ *
+ * @return array{status: string, owner: int|null, matches: int[]} The verdict. `owner` is
+ * set only for {@see self::STATUS_ONE_MATCH}.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function classify(array $candidates, array $entity): array {
+ $target = $this->signature(definition: $entity);
+ $matches = [];
+
+ foreach ($candidates as $registerId => $definition) {
+ if (is_array($definition) === false) {
+ continue;
+ }
+
+ if ($this->signature(definition: $definition) === $target) {
+ $matches[] = (int)$registerId;
+ }
+ }
+
+ sort($matches);
+
+ if (count($matches) === 1) {
+ return ['status' => self::STATUS_ONE_MATCH, 'owner' => $matches[0], 'matches' => $matches];
+ }
+
+ if ($matches === []) {
+ return ['status' => self::STATUS_NO_MATCH, 'owner' => null, 'matches' => []];
+ }
+
+ return ['status' => self::STATUS_MULTI_MATCH, 'owner' => null, 'matches' => $matches];
+ }//end classify()
+
+ /**
+ * Parse the repeatable `--keep` option.
+ *
+ * Two forms are accepted: `--keep :` pins one schema,
+ * and a bare `--keep ` applies to every schema attribution could
+ * not settle. The per-schema form always wins, so a broad override cannot
+ * silently outrank a specific decision.
+ *
+ * @param array $raw The raw option values.
+ *
+ * @return array{perSchema: array, global: int|null} The parsed overrides.
+ *
+ * @throws RuntimeException When a value is not a positive id or id pair.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function parseKeep(array $raw): array {
+ $perSchema = [];
+ $global = null;
+
+ foreach ($raw as $value) {
+ $value = trim((string)$value);
+ if ($value === '') {
+ continue;
+ }
+
+ if (str_contains($value, ':') === false) {
+ $global = $this->positiveId(value: $value, option: $value);
+ continue;
+ }
+
+ [$schemaPart, $registerPart] = explode(':', $value, 2);
+ $perSchema[$this->positiveId(value: $schemaPart, option: $value)] = $this->positiveId(
+ value: $registerPart,
+ option: $value
+ );
+ }
+
+ return ['perSchema' => $perSchema, 'global' => $global];
+ }//end parseKeep()
+
+ /**
+ * Apply the `--keep` overrides on top of an attribution verdict.
+ *
+ * An override is honoured only when it names a register that actually
+ * references the schema. Pointing the repair at an unrelated register would
+ * relink every referencing register onto a fresh entity for no reason, which
+ * is a bigger change than the one the operator asked for.
+ *
+ * @param array $verdict The attribution verdict.
+ * @param int $schemaId The shared schema id.
+ * @param int[] $registerIds The referencing registers.
+ * @param array $keep The parsed overrides.
+ *
+ * @return array{owner: int|null, source: string} The resolved owner and where it came from.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function resolveOwner(array $verdict, int $schemaId, array $registerIds, array $keep): array {
+ $pinned = ($keep['perSchema'][$schemaId] ?? null);
+ if ($pinned !== null && in_array($pinned, $registerIds, true) === true) {
+ return ['owner' => $pinned, 'source' => 'keep'];
+ }
+
+ if (($verdict['status'] ?? '') === self::STATUS_ONE_MATCH) {
+ return ['owner' => $verdict['owner'], 'source' => 'configuration'];
+ }
+
+ $fallback = ($keep['global'] ?? null);
+ if ($fallback !== null && in_array($fallback, $registerIds, true) === true) {
+ return ['owner' => $fallback, 'source' => 'keep-global'];
+ }
+
+ return ['owner' => null, 'source' => 'unattributed'];
+ }//end resolveOwner()
+
+ /**
+ * Replace one schema id in a stored list, preserving order and the other entries.
+ *
+ * The remaining entries are copied VERBATIM for the reason
+ * {@see \OCA\OpenRegister\Db\Register::addSchemaId()} gives: a normalising
+ * rewrite would silently drop a non-numeric legacy entry, and that is data
+ * loss rather than cleanup.
+ *
+ * @param array $schemas The stored schemas list.
+ * @param int $oldId The id to replace.
+ * @param int $newId The id to put in its place.
+ *
+ * @return array The rewritten list.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function replaceSchemaId(array $schemas, int $oldId, int $newId): array {
+ $result = [];
+ foreach ($schemas as $entry) {
+ if (is_numeric($entry) === true && (int)$entry === $oldId) {
+ $result[] = $newId;
+ continue;
+ }
+
+ $result[] = $entry;
+ }
+
+ return array_values($result);
+ }//end replaceSchemaId()
+
+ /**
+ * Parse a positive integer id out of an option value.
+ *
+ * @param string $value The raw value.
+ * @param string $option The whole option, for the error message.
+ *
+ * @return int The id.
+ *
+ * @throws RuntimeException When the value is not a positive integer.
+ */
+ private function positiveId(string $value, string $option): int {
+ $value = trim($value);
+ if (ctype_digit($value) === false || (int)$value < 1) {
+ throw new RuntimeException(
+ sprintf('--keep "%s" is not a positive id or : pair.', $option)
+ );
+ }
+
+ return (int)$value;
+ }//end positiveId()
+
+ /**
+ * Normalise a stored schemas value into a list of positive ints.
+ *
+ * @param mixed $candidates The stored value.
+ *
+ * @return int[] The normalised ids.
+ */
+ private function normaliseIds(mixed $candidates): array {
+ $ids = [];
+ foreach ((array)$candidates as $candidate) {
+ if (is_numeric($candidate) === true && (int)$candidate > 0) {
+ $ids[] = (int)$candidate;
+ }
+ }
+
+ return array_values(array_unique($ids));
+ }//end normaliseIds()
+}//end class
diff --git a/lib/Service/SharedSchema/SchemaTableMigrator.php b/lib/Service/SharedSchema/SchemaTableMigrator.php
new file mode 100644
index 0000000000..24a894fc38
--- /dev/null
+++ b/lib/Service/SharedSchema/SchemaTableMigrator.php
@@ -0,0 +1,495 @@
+
+ *
+ * @category Service
+ * @package OCA\OpenRegister\Service\SharedSchema
+ *
+ * @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\Service\SharedSchema;
+
+use OCA\OpenRegister\Db\MagicMapper;
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\Schema;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Carry object rows across a schema split.
+ *
+ * Objects live in per-pair tables `openregister_table__`,
+ * so when a register is repointed at a new schema id its rows must follow. A bare
+ * `ALTER TABLE ... RENAME` — what the older `openregister:schemas:dedup` does — is
+ * only correct while the new schema is a byte-copy of the old one. It is exactly
+ * wrong for the case this repair exists for: the replacement schema is rebuilt
+ * from the register's OWN configuration, so its table has the columns the shared
+ * entity had overwritten away, and lacks the ones that belonged to the other app.
+ * The move therefore has to be a column-mapped INSERT-SELECT.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+class SchemaTableMigrator {
+
+ /**
+ * Bare (unprefixed) magic-table name stem.
+ *
+ * @var string
+ */
+ public const TABLE_STEM = 'openregister_table_';
+
+ /**
+ * Suffix appended to a source table once its rows have been copied away.
+ *
+ * Chosen so the result no longer matches the shard pattern
+ * `openregister_table__` that
+ * {@see \OCA\OpenRegister\Service\RegisterSchemaLinkageRepairService} treats as
+ * evidence of a pairing. A source table left under its original name would
+ * make `relink-schemas` propose re-linking the register to the very schema
+ * this repair just split it away from — the sibling command would quietly undo
+ * this one. Keeping the table (rather than dropping it) is what makes an
+ * unmapped column recoverable instead of lost.
+ *
+ * @var string
+ */
+ public const BACKUP_SUFFIX = '_predupe';
+
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db Database connection for the row move.
+ * @param IConfig $config System config, read for `dbtableprefix`.
+ * @param MagicMapper $magicMapper Magic-table DDL and introspection.
+ * @param LoggerInterface $logger Audit trail for every mutation.
+ *
+ * @return void
+ */
+ public function __construct(
+ private readonly IDBConnection $db,
+ private readonly IConfig $config,
+ private readonly MagicMapper $magicMapper,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Work out which source columns survive the move to the new table.
+ *
+ * `_id` is excluded on purpose. It is an autoincrement primary key; copying
+ * the values verbatim would leave the target's sequence behind the highest
+ * copied id, so the next insert into the repaired table would collide. `_uuid`
+ * is the identity relations actually store, and it IS copied.
+ *
+ * Matching is case-insensitive because `information_schema` folds identifier
+ * case differently per platform, and a case mismatch here would report every
+ * column as unmapped — which under `--strict` would refuse every otherwise
+ * healthy split.
+ *
+ * @param string[] $sourceColumns Column names of the table holding the rows.
+ * @param string[] $targetColumns Column names of the table built for the new schema.
+ *
+ * @return array{mapped: string[], unmapped: string[]} Columns that move, and source
+ * columns with no destination.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public static function planColumnMapping(array $sourceColumns, array $targetColumns): array {
+ $target = array_map('strtolower', array_map('strval', $targetColumns));
+ $mapped = [];
+ $dropped = [];
+
+ foreach ($sourceColumns as $column) {
+ $column = (string)$column;
+ if ($column === '_id') {
+ continue;
+ }
+
+ if (in_array(strtolower($column), $target, true) === true) {
+ $mapped[] = $column;
+ continue;
+ }
+
+ $dropped[] = $column;
+ }
+
+ sort($mapped);
+ sort($dropped);
+
+ return ['mapped' => $mapped, 'unmapped' => $dropped];
+ }//end planColumnMapping()
+
+ /**
+ * Build the INSERT-SELECT that moves the mapped columns.
+ *
+ * Identifiers cannot be bound as parameters, so every name is validated
+ * against a plain-identifier pattern before it is interpolated. The quote
+ * character is passed in rather than detected here so the statement builder
+ * stays pure and testable.
+ *
+ * @param string $sourceTable The fully qualified source table.
+ * @param string $targetTable The fully qualified target table.
+ * @param string[] $columns The columns to copy, in order.
+ * @param string $quote The identifier quote character.
+ *
+ * @return string The statement.
+ *
+ * @throws RuntimeException When there are no columns, or an identifier is unsafe.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public static function buildCopySql(
+ string $sourceTable,
+ string $targetTable,
+ array $columns,
+ string $quote='"'
+ ): string {
+ if ($columns === []) {
+ throw new RuntimeException('Refusing to build a copy statement with no columns.');
+ }
+
+ $quoted = [];
+ foreach ($columns as $column) {
+ $quoted[] = self::quoteIdentifier(name: (string)$column, quote: $quote);
+ }
+
+ $list = implode(', ', $quoted);
+
+ return sprintf(
+ 'INSERT INTO %s (%s) SELECT %s FROM %s',
+ self::quoteIdentifier(name: $targetTable, quote: $quote),
+ $list,
+ $list,
+ self::quoteIdentifier(name: $sourceTable, quote: $quote)
+ );
+ }//end buildCopySql()
+
+ /**
+ * The bare magic-table name for a register/schema pair.
+ *
+ * @param int $registerId The register id.
+ * @param int $schemaId The schema id.
+ *
+ * @return string The unprefixed table name.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function tableNameFor(int $registerId, int $schemaId): string {
+ return self::TABLE_STEM . $registerId . '_' . $schemaId;
+ }//end tableNameFor()
+
+ /**
+ * Name the source columns a split would leave behind, without writing anything.
+ *
+ * Predicting the target's columns at plan time is what lets `--strict` refuse
+ * BEFORE anything is created, and what lets the dry run name the columns that
+ * would be stranded. Deciding after the target table exists would leave a
+ * stray table behind on MySQL, where DDL does not roll back with the
+ * transaction.
+ *
+ * @param string $table The bare source table name.
+ * @param array $definition The register's configured definition, or null
+ * when the split falls back to cloning.
+ * @param array $content The current shared entity content.
+ *
+ * @return string[] Source columns with no destination.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function planUnmapped(string $table, ?array $definition, array $content): array {
+ $source = $this->columnsOf(table: $table);
+ if ($source === []) {
+ $source = $this->columnsForDefinition(definition: $content);
+ }
+
+ $target = $content;
+ if ($definition !== null) {
+ $target = $definition;
+ }
+
+ return self::planColumnMapping(
+ sourceColumns: $source,
+ targetColumns: $this->columnsForDefinition(definition: $target)
+ )['unmapped'];
+ }//end planUnmapped()
+
+ /**
+ * List a bare table's column names, or an empty list when it is absent.
+ *
+ * @param string $table The bare (unprefixed) table name.
+ *
+ * @return string[] The column names.
+ */
+ private function columnsOf(string $table): array {
+ try {
+ return array_map('strval', array_keys($this->magicMapper->getExistingTableColumns(tableName: $table)));
+ } catch (Throwable $e) {
+ unset($e);
+ return [];
+ }
+ }//end columnsOf()
+
+ /**
+ * Ask the magic-table column builder what a definition would materialise as.
+ *
+ * A transient, unpersisted {@see Schema} is hydrated purely so the real column
+ * builder answers the question — reimplementing the property-to-column rules
+ * here would drift from the DDL the split actually produces.
+ *
+ * @param array $definition The schema definition.
+ *
+ * @return string[] The column names, or an empty list when the builder refuses.
+ */
+ private function columnsForDefinition(array $definition): array {
+ $transient = new Schema();
+
+ try {
+ $transient->hydrate($definition);
+ return array_map(
+ 'strval',
+ array_keys($this->magicMapper->buildTableColumnsFromSchema(schema: $transient))
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: '[SharedSchemaDedupe] Could not predict columns: ' . $e->getMessage(),
+ context: ['file' => __FILE__, 'line' => __LINE__],
+ );
+ return [];
+ }
+ }//end columnsForDefinition()
+
+ /**
+ * Count the rows in a bare magic table.
+ *
+ * @param string $table The bare (unprefixed) table name.
+ *
+ * @return int The row count, or -1 when the table is absent or unreadable.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function countRows(string $table): int {
+ $full = ($this->prefix() . $table);
+ if ($this->tableExists(table: $full) === false) {
+ return -1;
+ }
+
+ try {
+ $quoted = self::quoteIdentifier(name: $full, quote: $this->quoteChar());
+ return (int)$this->db->executeQuery('SELECT COUNT(*) AS c FROM ' . $quoted)->fetchOne();
+ } catch (Throwable $e) {
+ unset($e);
+ return -1;
+ }
+ }//end countRows()
+
+ /**
+ * Move a register's rows onto the table of its replacement schema.
+ *
+ * @param Register $register The register being split off.
+ * @param Schema $schema The register's new schema.
+ * @param int $oldId The shared schema id being left behind.
+ *
+ * @return array{rows: int, unmapped: string[], backup: string|null} How many rows moved,
+ * which source columns had no destination, and where the source table went.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function migrate(Register $register, Schema $schema, int $oldId): array {
+ $registerId = (int)$register->getId();
+ $sourceBare = $this->tableNameFor(registerId: $registerId, schemaId: $oldId);
+ $source = ($this->prefix() . $sourceBare);
+
+ if ($this->tableExists(table: $source) === false) {
+ return ['rows' => 0, 'unmapped' => [], 'backup' => null];
+ }
+
+ $this->magicMapper->ensureTableForRegisterSchema(register: $register, schema: $schema);
+ $targetBare = $this->magicMapper->getTableNameForRegisterSchema(register: $register, schema: $schema);
+
+ $mapping = self::planColumnMapping(
+ sourceColumns: $this->columnsOf(table: $sourceBare),
+ targetColumns: $this->columnsOf(table: $targetBare)
+ );
+
+ $quote = $this->quoteChar();
+ $this->db->executeStatement(
+ self::buildCopySql(
+ sourceTable: $source,
+ targetTable: ($this->prefix() . $targetBare),
+ columns: $mapping['mapped'],
+ quote: $quote
+ )
+ );
+
+ $rows = $this->restamp(
+ table: ($this->prefix() . $targetBare),
+ registerId: $registerId,
+ oldId: $oldId,
+ newId: (int)$schema->getId(),
+ quote: $quote
+ );
+
+ $backup = ($source . self::BACKUP_SUFFIX);
+ $this->db->executeStatement(
+ sprintf(
+ 'ALTER TABLE %s RENAME TO %s',
+ self::quoteIdentifier(name: $source, quote: $quote),
+ self::quoteIdentifier(name: $backup, quote: $quote)
+ )
+ );
+
+ $this->logger->warning(
+ message: sprintf(
+ '[SharedSchemaDedupe] Moved %d row(s) from %s to %s; source kept as %s; %d unmapped column(s): %s.',
+ $rows,
+ $source,
+ ($this->prefix() . $targetBare),
+ $backup,
+ count($mapping['unmapped']),
+ implode(', ', $mapping['unmapped'])
+ ),
+ context: ['file' => __FILE__, 'line' => __LINE__, 'register' => $registerId, 'schema' => $oldId]
+ );
+
+ return ['rows' => $rows, 'unmapped' => $mapping['unmapped'], 'backup' => $backup];
+ }//end migrate()
+
+ /**
+ * Repoint the copied rows' denormalised schema references at the new id.
+ *
+ * The table name is not the only place the pairing is recorded. Every row also
+ * carries `_schema`, and `_uri` embeds the schema id in the absolute URL the
+ * save path stores. Leaving either at the old value attributes the moved rows
+ * to the register that KEPT the shared schema — exactly the cross-app bleed
+ * this repair exists to end. Verified as a real failure mode on the larpingapp
+ * split, where 139 rows sat at the old `_schema` inside the renamed table.
+ *
+ * @param string $table The fully qualified target table.
+ * @param int $registerId The owning register id, which bounds the uri rewrite.
+ * @param int $oldId The shared schema id.
+ * @param int $newId The register's new schema id.
+ * @param string $quote The identifier quote character.
+ *
+ * @return int The number of rows restamped.
+ */
+ private function restamp(string $table, int $registerId, int $oldId, int $newId, string $quote): int {
+ $quoted = self::quoteIdentifier(name: $table, quote: $quote);
+
+ $rows = $this->db->executeStatement(
+ sprintf('UPDATE %s SET _schema = :new WHERE _schema = :old', $quoted),
+ ['new' => (string)$newId, 'old' => (string)$oldId]
+ );
+
+ try {
+ $this->db->executeStatement(
+ sprintf('UPDATE %s SET _uri = REPLACE(_uri, :old, :new) WHERE _uri LIKE :match', $quoted),
+ [
+ 'old' => sprintf('/%d/%d/', $registerId, $oldId),
+ 'new' => sprintf('/%d/%d/', $registerId, $newId),
+ 'match' => sprintf('%%/%d/%d/%%', $registerId, $oldId),
+ ]
+ );
+ } catch (Throwable $e) {
+ // A stale `_uri` is a cosmetic link, not a correctness boundary: the
+ // row is already attributed by `_schema` and by the table it lives in.
+ // Failing the whole split over it would be worse than reporting it.
+ $this->logger->warning(
+ message: '[SharedSchemaDedupe] Could not rewrite _uri on ' . $table . ': ' . $e->getMessage(),
+ context: ['file' => __FILE__, 'line' => __LINE__],
+ );
+ }
+
+ return $rows;
+ }//end restamp()
+
+ /**
+ * Check whether a fully qualified table exists.
+ *
+ * @param string $table The fully qualified table name.
+ *
+ * @return bool True when it exists.
+ */
+ private function tableExists(string $table): bool {
+ try {
+ $stmt = $this->db->prepare(
+ 'SELECT 1 FROM information_schema.tables WHERE table_name = ? LIMIT 1'
+ );
+ $stmt->execute([$table]);
+ return $stmt->fetchOne() !== false;
+ } catch (Throwable $e) {
+ unset($e);
+ return false;
+ }
+ }//end tableExists()
+
+ /**
+ * The configured table prefix.
+ *
+ * @return string The prefix, defaulting to `oc_`.
+ */
+ private function prefix(): string {
+ $prefix = (string)$this->config->getSystemValue('dbtableprefix', 'oc_');
+ if ($prefix === '') {
+ return 'oc_';
+ }
+
+ return $prefix;
+ }//end prefix()
+
+ /**
+ * The identifier quote character for this platform.
+ *
+ * @return string A backtick on MySQL and MariaDB, a double quote elsewhere.
+ */
+ private function quoteChar(): string {
+ try {
+ $platform = $this->db->getDatabasePlatform()::class;
+ } catch (Throwable $e) {
+ unset($e);
+ return '"';
+ }
+
+ if (stripos($platform, 'MySQL') !== false || stripos($platform, 'MariaDB') !== false) {
+ return '`';
+ }
+
+ return '"';
+ }//end quoteChar()
+
+ /**
+ * Quote a SQL identifier after validating it is a plain name.
+ *
+ * @param string $name The identifier.
+ * @param string $quote The quote character.
+ *
+ * @return string The quoted identifier.
+ *
+ * @throws RuntimeException When the name is not a plain SQL identifier.
+ */
+ private static function quoteIdentifier(string $name, string $quote): string {
+ if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) !== 1) {
+ throw new RuntimeException(sprintf('Refusing to quote unsafe identifier "%s".', $name));
+ }
+
+ return $quote . $name . $quote;
+ }//end quoteIdentifier()
+}//end class
diff --git a/lib/Service/SharedSchemaDedupeService.php b/lib/Service/SharedSchemaDedupeService.php
new file mode 100644
index 0000000000..45c771d11f
--- /dev/null
+++ b/lib/Service/SharedSchemaDedupeService.php
@@ -0,0 +1,438 @@
+
+ *
+ * @category Service
+ * @package OCA\OpenRegister\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
+ *
+ * @version GIT:
+ *
+ * @link https://OpenRegister.app
+ */
+
+declare(strict_types=1);
+
+namespace OCA\OpenRegister\Service;
+
+use OCA\OpenRegister\Db\Register;
+use OCA\OpenRegister\Db\RegisterMapper;
+use OCA\OpenRegister\Db\Schema;
+use OCA\OpenRegister\Db\SchemaMapper;
+use OCA\OpenRegister\Service\Configuration\ImportHandler;
+use OCA\OpenRegister\Service\SharedSchema\RegisterConfigurationLocator;
+use OCA\OpenRegister\Service\SharedSchema\SchemaAttribution;
+use OCA\OpenRegister\Service\SharedSchema\SchemaTableMigrator;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Split schema entities that several registers wrongly share.
+ *
+ * A schema row carries no register column: the relation lives only as a JSON id
+ * list on the register. Before the per-register slug-uniqueness fix in
+ * {@see ImportHandler}, an import that resolved a slug globally re-used whatever
+ * schema row already carried that slug, so two apps could end up pointing at one
+ * entity. From then on every import of either app rewrote the definition for
+ * both — last import wins, instance-wide.
+ *
+ * `occ openregister:registers:relink-schemas` ADDS lost linkage; this service is
+ * its counterpart, which SPLITS linkage that was never meant to be shared.
+ *
+ * Attribution is evidence-based rather than heuristic, and lives in
+ * {@see SchemaAttribution}. When the evidence does not single one owner out the
+ * repair REFUSES and asks for an explicit `--keep`: guessing an owner is what
+ * produced the damage in the first place.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+class SharedSchemaDedupeService {
+
+ /**
+ * Constructor.
+ *
+ * @param IDBConnection $db Database connection, for the split transaction.
+ * @param RegisterMapper $registerMapper Register lookups and persistence.
+ * @param SchemaMapper $schemaMapper Schema lookups and the clone fallback.
+ * @param ImportHandler $importHandler The configuration-driven schema create path.
+ * @param RegisterConfigurationLocator $locator Reads a register's own app configuration.
+ * @param SchemaAttribution $attribution The pure detection and ownership rules.
+ * @param SchemaTableMigrator $migrator Moves the object rows across the split.
+ * @param LoggerInterface $logger Audit trail for every mutation.
+ *
+ * @return void
+ */
+ public function __construct(
+ private readonly IDBConnection $db,
+ private readonly RegisterMapper $registerMapper,
+ private readonly SchemaMapper $schemaMapper,
+ private readonly ImportHandler $importHandler,
+ private readonly RegisterConfigurationLocator $locator,
+ private readonly SchemaAttribution $attribution,
+ private readonly SchemaTableMigrator $migrator,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Parse the repeatable `--keep` option.
+ *
+ * @param array $raw The raw option values.
+ *
+ * @return array{perSchema: array, global: int|null} The parsed overrides.
+ *
+ * @throws RuntimeException When a value is not a positive id or id pair.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function parseKeep(array $raw): array {
+ return $this->attribution->parseKeep(raw: $raw);
+ }//end parseKeep()
+
+ /**
+ * Inspect the instance and produce the full repair plan.
+ *
+ * Reports only — never mutates, so the operator sees every split, every row
+ * move and every column that would be left behind before opting in.
+ *
+ * @param int|null $registerId Limit to plans involving this register, or null for all.
+ * @param array $keep The parsed `--keep` overrides.
+ *
+ * @return array> One entry per shared schema.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function inspect(?int $registerId, array $keep): array {
+ $registers = $this->loadRegisters();
+ $stored = [];
+ foreach ($registers as $id => $register) {
+ $stored[$id] = $register->getSchemas();
+ }
+
+ $plan = [];
+ foreach ($this->attribution->indexShared(registerSchemas: $stored) as $schemaId => $registerIds) {
+ if ($registerId !== null && in_array($registerId, $registerIds, true) === false) {
+ continue;
+ }
+
+ $entry = $this->planSchema(
+ schemaId: $schemaId,
+ registerIds: $registerIds,
+ registers: $registers,
+ keep: $keep
+ );
+
+ if ($entry !== null) {
+ $plan[] = $entry;
+ }
+ }
+
+ return $plan;
+ }//end inspect()
+
+ /**
+ * Execute one planned split.
+ *
+ * Creation of the schema, the relink and the row move are one unit: a
+ * half-applied split leaves a register pointing at an entity whose table holds
+ * no rows, which is worse than the shared state it started from.
+ *
+ * @param array $entry One {@see self::inspect()} plan entry.
+ * @param int $target The non-canonical register id to split off.
+ * @param bool $strict Whether unmapped columns must refuse the move.
+ *
+ * @return array{newSchemaId: int, rows: int, unmapped: string[], backup: string|null} The outcome.
+ *
+ * @throws RuntimeException When the plan is unattributed, or strict mode refuses.
+ *
+ * @spec openspec/changes/dedupe-shared-schemas/proposal.md
+ */
+ public function applySplit(array $entry, int $target, bool $strict): array {
+ $split = ($entry['splits'][$target] ?? null);
+ if (is_array($split) === false) {
+ throw new RuntimeException(sprintf('Register %d is not part of this plan.', $target));
+ }
+
+ if (($entry['owner'] ?? null) === null) {
+ throw new RuntimeException(
+ sprintf('Schema %d is unattributed; pass --keep to name the owner.', (int)$entry['schemaId'])
+ );
+ }
+
+ $unmapped = ($split['unmapped'] ?? []);
+ if ($strict === true && $unmapped !== []) {
+ throw new RuntimeException(
+ sprintf(
+ 'Strict mode: %d source column(s) have no destination (%s).',
+ count($unmapped),
+ implode(', ', $unmapped)
+ )
+ );
+ }
+
+ $this->db->beginTransaction();
+ try {
+ $outcome = $this->splitLocked(entry: $entry, target: $target, split: $split);
+ $this->db->commit();
+ } catch (Throwable $e) {
+ $this->db->rollBack();
+ throw $e;
+ }
+
+ return $outcome;
+ }//end applySplit()
+
+ /**
+ * Perform one split inside an open transaction.
+ *
+ * @param array $entry The plan entry.
+ * @param int $target The register being split off.
+ * @param array $split The per-register part of the plan.
+ *
+ * @return array{newSchemaId: int, rows: int, unmapped: string[], backup: string|null} The outcome.
+ */
+ private function splitLocked(array $entry, int $target, array $split): array {
+ $register = $this->registerMapper->find(id: $target, _rbac: false, _multitenancy: false);
+ $oldId = (int)$entry['schemaId'];
+
+ $schema = $this->createReplacementSchema(
+ register: $register,
+ definition: ($split['definition'] ?? null),
+ oldId: $oldId
+ );
+
+ $newId = (int)$schema->getId();
+
+ $register->setSchemas(
+ schemas: $this->attribution->replaceSchemaId(
+ schemas: $register->getSchemas(),
+ oldId: $oldId,
+ newId: $newId
+ )
+ );
+ $this->registerMapper->update($register);
+
+ $moved = $this->migrator->migrate(register: $register, schema: $schema, oldId: $oldId);
+
+ $this->logger->warning(
+ message: sprintf(
+ '[SharedSchemaDedupe] Register %d split off shared schema %d onto %d (%d row(s) moved).',
+ $target,
+ $oldId,
+ $newId,
+ $moved['rows']
+ ),
+ context: ['file' => __FILE__, 'line' => __LINE__, 'register' => $target, 'schema' => $oldId]
+ );
+
+ return [
+ 'newSchemaId' => $newId,
+ 'rows' => $moved['rows'],
+ 'unmapped' => $moved['unmapped'],
+ 'backup' => $moved['backup'],
+ ];
+ }//end splitLocked()
+
+ /**
+ * Create the register's own schema entity.
+ *
+ * Path A — the register's app configuration still declares the schema — goes
+ * through {@see ImportHandler::importSchema()} with an EMPTY register scope, so
+ * the per-register slug-uniqueness fix forces a brand new row rather than
+ * resolving back onto the shared one. Running the repair through the same
+ * create path the import uses is what makes the split durable: the next app
+ * import finds the register's own entity and updates that instead of forking
+ * the shared one again.
+ *
+ * Path B — no configuration on disk — clones the current entity content, which
+ * preserves the rows' shape exactly and so needs no column mapping.
+ *
+ * @param Register $register The register being split off.
+ * @param array $definition Its configured definition, or null when it has none.
+ * @param int $oldId The shared schema id being left behind.
+ *
+ * @return Schema The newly created schema.
+ *
+ * @throws RuntimeException When neither path yields a persisted schema.
+ */
+ private function createReplacementSchema(Register $register, ?array $definition, int $oldId): Schema {
+ if ($definition !== null) {
+ $schema = $this->importHandler->importSchema(
+ data: $definition,
+ slugsAndIdsMap: $this->schemaMapper->getSlugToIdMap(),
+ owner: $register->getOwner(),
+ appId: $register->getApplication(),
+ version: (string)($definition['version'] ?? '0.0.1'),
+ force: true,
+ registerSchemaIds: []
+ );
+
+ if ($schema->getId() !== null) {
+ return $schema;
+ }
+ }
+
+ $source = $this->schemaMapper->find(id: $oldId, _rbac: false, _multitenancy: false);
+ $clone = $source->jsonSerialize();
+ unset($clone['id'], $clone['uuid'], $clone['uri'], $clone['created'], $clone['updated']);
+ $clone['application'] = $register->getApplication();
+
+ $schema = $this->schemaMapper->createFromArray(object: $clone);
+ if ($schema->getId() === null) {
+ throw new RuntimeException(sprintf('Could not create a replacement for schema %d.', $oldId));
+ }
+
+ return $schema;
+ }//end createReplacementSchema()
+
+ /**
+ * Build the plan entry for one shared schema.
+ *
+ * @param int $schemaId The shared schema id.
+ * @param int[] $registerIds The referencing registers.
+ * @param array $registers All loaded registers, by id.
+ * @param array $keep The parsed overrides.
+ *
+ * @return array|null The plan entry, or null when the schema no longer exists.
+ */
+ private function planSchema(int $schemaId, array $registerIds, array $registers, array $keep): ?array {
+ try {
+ $entity = $this->schemaMapper->find(id: $schemaId, _rbac: false, _multitenancy: false);
+ } catch (Throwable $e) {
+ // A dangling id is `relink-schemas` territory, not a sharing problem.
+ unset($e);
+ return null;
+ }
+
+ $content = $entity->jsonSerialize();
+ $candidates = $this->configuredDefinitions(
+ registerIds: $registerIds,
+ registers: $registers,
+ slug: strtolower((string)$entity->getSlug())
+ );
+
+ $verdict = $this->attribution->classify(candidates: $candidates, entity: $content);
+ $owner = $this->attribution->resolveOwner(
+ verdict: $verdict,
+ schemaId: $schemaId,
+ registerIds: $registerIds,
+ keep: $keep
+ );
+
+ return [
+ 'schemaId' => $schemaId,
+ 'schemaSlug' => (string)$entity->getSlug(),
+ 'registerIds' => $registerIds,
+ 'status' => $verdict['status'],
+ 'matches' => $verdict['matches'],
+ 'owner' => $owner['owner'],
+ 'ownerSource' => $owner['source'],
+ 'splits' => $this->planSplits(
+ schemaId: $schemaId,
+ owner: $owner['owner'],
+ registers: $registers,
+ candidates: $candidates,
+ content: $content
+ ),
+ ];
+ }//end planSchema()
+
+ /**
+ * Read each referencing register's configured definition for one schema slug.
+ *
+ * @param int[] $registerIds The referencing registers.
+ * @param array $registers All loaded registers, by id.
+ * @param string $slug The lowercased schema slug.
+ *
+ * @return array registerId => definition, or null when it declares none.
+ */
+ private function configuredDefinitions(array $registerIds, array $registers, string $slug): array {
+ $candidates = [];
+ foreach ($registerIds as $registerId) {
+ $register = ($registers[$registerId] ?? null);
+ $configured = null;
+ if ($register !== null) {
+ $configured = ($this->locator->schemasFor(register: $register)[$slug] ?? null);
+ }
+
+ $candidates[$registerId] = $configured;
+ }
+
+ return $candidates;
+ }//end configuredDefinitions()
+
+ /**
+ * Build the per-register split parts of a plan entry.
+ *
+ * @param int $schemaId The shared schema id.
+ * @param int|null $owner The resolved owner, when attributed.
+ * @param array $registers All loaded registers, by id.
+ * @param array $candidates Each register's configured definition.
+ * @param array $content The current entity content.
+ *
+ * @return array> registerId => the split that would be performed.
+ */
+ private function planSplits(int $schemaId, ?int $owner, array $registers, array $candidates, array $content): array {
+ $splits = [];
+ foreach ($candidates as $registerId => $definition) {
+ $register = ($registers[$registerId] ?? null);
+ if ($registerId === $owner || $register === null) {
+ continue;
+ }
+
+ $bare = $this->migrator->tableNameFor(registerId: (int)$registerId, schemaId: $schemaId);
+ $path = 'configuration';
+ if ($definition === null) {
+ $path = 'clone';
+ }
+
+ $splits[$registerId] = [
+ 'registerSlug' => (string)$register->getSlug(),
+ 'application' => $register->getApplication(),
+ 'path' => $path,
+ 'definition' => $definition,
+ 'table' => $bare,
+ 'rows' => $this->migrator->countRows(table: $bare),
+ 'unmapped' => $this->migrator->planUnmapped(
+ table: $bare,
+ definition: $definition,
+ content: $content
+ ),
+ ];
+ }//end foreach
+
+ return $splits;
+ }//end planSplits()
+
+ /**
+ * Load every register keyed by id.
+ *
+ * @return array registerId => register.
+ */
+ private function loadRegisters(): array {
+ $registers = [];
+ foreach ($this->registerMapper->findAll(_rbac: false, _multitenancy: false) as $register) {
+ if ($register instanceof Register === false) {
+ continue;
+ }
+
+ $registers[(int)$register->getId()] = $register;
+ }
+
+ return $registers;
+ }//end loadRegisters()
+}//end class
diff --git a/openspec/changes/dedupe-shared-schemas/proposal.md b/openspec/changes/dedupe-shared-schemas/proposal.md
new file mode 100644
index 0000000000..b257d98eb6
--- /dev/null
+++ b/openspec/changes/dedupe-shared-schemas/proposal.md
@@ -0,0 +1,68 @@
+---
+kind: code
+---
+
+# Proposal: dedupe-shared-schemas
+
+## Why
+
+The slug-collision family is being closed on two fronts: resolution scoping
+(`register-scoped-schema-slug-resolution`, `register-scoped-slug-resolution`,
+`schema-slug-cross-app-scoping`) and the import side (the per-register
+slug-uniqueness fix in `ImportHandler`, which stops NEW cross-register reuse).
+
+Neither front repairs the damage already done. Registers that came to share a
+schema entity in the pre-fix era keep co-owning its definition: every register
+import that touches the shared entity rewrites it for all referencing registers
+— last import wins, instance-wide. `occ openregister:registers:relink-schemas`
+ADDS lost linkage but has no counterpart that SPLITS wrongly shared entities.
+
+Observed on the shared dev instance (2026-08-21, openregister#2689): the
+`planix` (19) and `pipelinq` (16) registers both referenced schema entities
+task=74, project=159, timeEntry=161. Schema 161 held planix's 6-property
+timeEntry; pipelinq's own definition (hours, billingCategory, client, project,
+WIP/billing-sync — the model its billing features depend on) was gone from the
+instance. A planix schema extension transparently changed pipelinq's `task`
+definition. `relink-schemas` reported 47 registers with recoverable linkage on
+the same instance — the same era of drift.
+
+## What Changes
+
+A repair command, `occ openregister:registers:dedupe-shared-schemas`, mirroring
+`relink-schemas` in shape (dry-run by default, `--write`, `--register`):
+
+1. **Detect**: every schema id referenced by more than one register.
+2. **Attribute**: for each shared schema, determine the canonical owner — the
+ register whose app configuration (register.json / register.d) declares a
+ definition matching the current entity content; when no configuration
+ matches (or several do), report and require an explicit
+ `--keep ` per schema rather than guessing.
+3. **Split**: every non-canonical register gets its own new schema entity,
+ built from that register's own app configuration when available (the
+ import-side fix then keeps it isolated forever), else cloned from the
+ current entity content.
+4. **Relink**: rewrite the register's schema linkage to the new id.
+5. **Migrate data**: move the register's magic-table rows from
+ `table_{reg}_{oldId}` to `table_{reg}_{newId}` with column mapping per the
+ restored definition; report columns that have no destination instead of
+ dropping them silently.
+6. **Report**: per register/schema, what was split, what moved, what needs a
+ follow-up app reimport.
+
+## Validation of the algorithm
+
+Steps 1–4 were executed manually on the shared dev instance for the
+planix/pipelinq pair (step 5 was unnecessary — the affected rows were demo
+seeds): unlinking 74/159/161 from register 16 and re-running pipelinq's
+configuration import produced three new, correctly-defined, register-private
+schemas (9463/9464/9465) with planix untouched — confirming the import-side
+fix makes the split durable and the repair is mechanical.
+
+## Impact
+
+- New command class alongside `RelinkRegisterSchemasCommand`; no behaviour
+ change for healthy instances (dry-run reports nothing).
+- Instances repaired this way stop exhibiting cross-app schema bleed; app
+ register imports become safe to re-run.
+- Relates to: openregister#2689, the three resolution-scoping changes, and the
+ ImportHandler per-register slug-uniqueness fix.
diff --git a/openspec/changes/dedupe-shared-schemas/tasks.md b/openspec/changes/dedupe-shared-schemas/tasks.md
new file mode 100644
index 0000000000..3561e367d9
--- /dev/null
+++ b/openspec/changes/dedupe-shared-schemas/tasks.md
@@ -0,0 +1,96 @@
+# Tasks: dedupe-shared-schemas
+
+## 1. Detection & attribution
+
+- [x] 1.1 Query building: find every schema id referenced by >1 register (registers.schemas JSON arrays).
+ `SchemaAttribution::indexShared()` inverts the register->schemas map in PHP rather than in SQL,
+ for the reason `RegisterMapper::getAllRegisterIdsWithSchema()` already documents: the `schemas`
+ column is `json` on Postgres and text elsewhere, and SQLite has no REGEXP. Ids stored as strings
+ are normalised, so `"161"` and `161` count as the same reference.
+- [x] 1.2 Canonical-owner attribution: match each referencing register's app configuration
+ (register.json / register.d fragments, resolved the same way SettingsService merges them)
+ against the current schema entity content; classify per schema: exactly-one-match /
+ no-match / multi-match.
+ `RegisterConfigurationLocator` mirrors `AppHostSettingsService::resolveRegisterConfiguration()`
+ (base `_register.json` + sorted `register.d/*.json` deep-merged). The glob is widened to
+ every `*_register.json` because OpenRegister ships several documents rather than one monolith.
+ Comparison is on the property-NAME set + `required`, not byte equality — the import path stamps
+ defaults and folds `$ref`s, so byte equality would put every schema in `no-match`.
+- [x] 1.3 `--keep ` override for no-match / multi-match schemas; refuse `--write`
+ for unattributed shared schemas without it.
+ Both forms: `--keep :` (repeatable) and a bare `--keep `
+ covering everything unattributed. Per-schema outranks bare; a `--keep` naming a register that
+ does not reference the schema is ignored rather than honoured.
+
+## 2. Split & relink
+
+- [x] 2.1 Clone path A (preferred): create the non-canonical register's schema from its OWN app
+ configuration definition, reusing the ImportHandler create path so the per-register
+ slug-uniqueness behaviour applies.
+ `ImportHandler::importSchema(..., registerSchemaIds: [])` — the empty scope makes
+ `findBySlugInIds()` short-circuit, forcing a brand new row instead of resolving back onto the
+ shared one.
+- [x] 2.2 Clone path B (fallback, no configuration available): copy the current entity content
+ into a new schema row. `SchemaMapper::createFromArray()` on the serialised entity minus
+ id/uuid/uri/timestamps, re-stamped with the register's application.
+- [x] 2.3 Rewrite register.schemas linkage old id → new id, preserving order.
+ `SchemaAttribution::replaceSchemaId()` — in place, and non-numeric legacy entries are copied
+ verbatim rather than normalised away.
+
+## 3. Data migration
+
+- [x] 3.1 Move rows `table_{reg}_{oldId}` → `table_{reg}_{newId}` (create target table via the
+ magic-table DDL for the restored definition; INSERT-SELECT with column mapping).
+ Target created by `MagicMapper::ensureTableForRegisterSchema()`. `_id` is excluded from the
+ copy: it is autoincrement, so copying the values would strand the target's sequence behind the
+ highest copied id and the next insert would collide. `_uuid` — the identity relations store —
+ does move.
+- [x] 3.2 Report source columns without a destination; never drop silently. `--strict`
+ turns unmapped columns into a refusal.
+ Unmapped columns are computed at PLAN time (a transient unpersisted `Schema` is fed to the real
+ `buildTableColumnsFromSchema()`), so the dry run names them and `--strict` refuses BEFORE
+ anything is written — on MySQL a post-hoc refusal would strand the created table, since DDL
+ there does not roll back with the transaction. The source table is renamed to `_predupe` rather
+ than dropped, so an unmapped column stays recoverable. That suffix also stops the table matching
+ the shard pattern `relink-schemas` reads as evidence — left as-is, the sibling command would
+ re-link the register to the schema this one just split it away from.
+- [x] 3.3 Update object rows' `_schema` metadata and any denormalised schema references
+ (folders, uri) the codebase keeps.
+ `_schema` and the schema id embedded in `_uri` are both rewritten. `_folder` is deliberately NOT
+ touched: it holds a Nextcloud folder node id and object folders are created inside the REGISTER
+ folder (`FolderManagementHandler::createObjectFolderInRegister()`), so folders are
+ register-scoped and a schema renumber does not invalidate them.
+
+## 4. Command surface & safety
+
+- [x] 4.1 `occ openregister:registers:dedupe-shared-schemas` — dry-run default, `--write`,
+ `--register`, `--keep`, `--strict`; output format mirroring relink-schemas.
+- [x] 4.2 Must-PASS control: instance with a shared schema pair → dry-run lists it, `--write`
+ splits it, app reimport after the split does NOT re-share (regression guard on the
+ ImportHandler fix).
+ `SchemaAttributionTest::testDetectsAndAttributesTheObservedSharedPair()` (the real 19/16 ×
+ 74/159/161 shape) and `DedupeSharedSchemasCommandTest::testWriteAppliesTheSplit()`.
+ **Partial:** the "app reimport does not re-share" leg is covered structurally — the split goes
+ through `importSchema(registerSchemaIds: [])`, whose behaviour is already locked by
+ `ImportHandlerPerRegisterSlugUniquenessTest` — not by an end-to-end reimport, which needs a
+ booted Nextcloud. See the PR body.
+- [x] 4.3 Must-FAIL control: healthy instance → dry-run reports nothing and `--write` changes
+ nothing (idempotence: second run is a no-op).
+ `SchemaAttributionTest::testHealthyInstanceHasNothingToRepair()`,
+ `testSecondRunAfterASplitIsANoOp()` (feeds the post-split ids back in) and
+ `DedupeSharedSchemasCommandTest::testHealthyInstanceReportsNothing()`, which asserts
+ `applySplit()` is never called.
+- [x] 4.4 Unit tests for attribution matrix (one-match / no-match / multi-match) and column
+ mapping edge cases.
+ 33 tests. The generated `INSERT ... SELECT` is additionally EXECUTED against a real in-memory
+ SQLite database, so the statement is proven to parse and to move exactly the mapped columns
+ rather than merely string-matching what the test author expected. All four decision rules were
+ mutation-checked: breaking the sharing threshold, letting multi-match guess an owner, dropping
+ the unmapped-column report, and disabling the `--write` refusal each fail the suite.
+
+## 5. Docs
+
+- [x] 5.1 Admin docs page next to relink-schemas: when drift happens, how to read the dry-run,
+ the planix/pipelinq case as the worked example (openregister#2689).
+ `docs/Technical/repairing-shared-schemas.md`, linked from `docs/api/schemas.md` beside the
+ existing `relink-schemas` pointer.
diff --git a/tests/Unit/Command/DedupeSharedSchemasCommandTest.php b/tests/Unit/Command/DedupeSharedSchemasCommandTest.php
new file mode 100644
index 0000000000..3da0ce6511
--- /dev/null
+++ b/tests/Unit/Command/DedupeSharedSchemasCommandTest.php
@@ -0,0 +1,303 @@
+
+ * @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\Command;
+
+use OCA\OpenRegister\Command\DedupeSharedSchemasCommand;
+use OCA\OpenRegister\Service\SharedSchema\SchemaAttribution;
+use OCA\OpenRegister\Service\SharedSchemaDedupeService;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+/**
+ * Locks the console surface of `occ openregister:registers:dedupe-shared-schemas`.
+ */
+class DedupeSharedSchemasCommandTest extends TestCase {
+
+ /**
+ * The mocked repair service.
+ *
+ * @var SharedSchemaDedupeService&MockObject
+ */
+ private SharedSchemaDedupeService&MockObject $dedupe;
+
+ /**
+ * The command under test.
+ *
+ * @var DedupeSharedSchemasCommand
+ */
+ private DedupeSharedSchemasCommand $command;
+
+ /**
+ * Wire the command onto a mocked service.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->dedupe = $this->createMock(SharedSchemaDedupeService::class);
+ $this->dedupe->method('parseKeep')->willReturn(['perSchema' => [], 'global' => null]);
+ $this->command = new DedupeSharedSchemasCommand($this->dedupe);
+
+ }//end setUp()
+
+ /**
+ * Run the command and capture its exit code and output.
+ *
+ * @param array $args The console arguments.
+ *
+ * @return array{0: int, 1: string} The exit code and the rendered output.
+ */
+ private function execute(array $args): array {
+ $input = new ArrayInput($args, $this->command->getDefinition());
+ $output = new BufferedOutput();
+ $code = $this->command->run($input, $output);
+
+ return [$code, $output->fetch()];
+
+ }//end execute()
+
+ /**
+ * One attributed plan entry: register 16 splits off shared schema 161.
+ *
+ * @return array> The plan.
+ */
+ private function attributedPlan(): array {
+ return [
+ [
+ 'schemaId' => 161,
+ 'schemaSlug' => 'timeEntry',
+ 'registerIds' => [16, 19],
+ 'status' => SchemaAttribution::STATUS_ONE_MATCH,
+ 'matches' => [19],
+ 'owner' => 19,
+ 'ownerSource' => 'configuration',
+ 'splits' => [
+ 16 => [
+ 'registerSlug' => 'pipelinq',
+ 'application' => 'pipelinq',
+ 'path' => 'configuration',
+ 'definition' => ['slug' => 'timeEntry'],
+ 'table' => 'openregister_table_16_161',
+ 'rows' => 42,
+ 'unmapped' => ['employee'],
+ ],
+ ],
+ ],
+ ];
+
+ }//end attributedPlan()
+
+ /**
+ * The same plan, but attribution could not settle an owner.
+ *
+ * @return array> The plan.
+ */
+ private function unattributedPlan(): array {
+ $plan = $this->attributedPlan();
+ $plan[0]['status'] = SchemaAttribution::STATUS_MULTI_MATCH;
+ $plan[0]['matches'] = [16, 19];
+ $plan[0]['owner'] = null;
+ $plan[0]['ownerSource'] = 'unattributed';
+
+ return $plan;
+
+ }//end unattributedPlan()
+
+ /**
+ * MUST-FAIL CONTROL: a healthy instance reports nothing and writes nothing.
+ *
+ * @return void
+ */
+ public function testHealthyInstanceReportsNothing(): void {
+ $this->dedupe->method('inspect')->willReturn([]);
+ $this->dedupe->expects($this->never())->method('applySplit');
+
+ [$code, $output] = $this->execute(['--write' => true]);
+
+ $this->assertSame(Command::SUCCESS, $code);
+ $this->assertStringContainsString('No schema is shared by more than one register', $output);
+
+ }//end testHealthyInstanceReportsNothing()
+
+ /**
+ * Without `--write` the command reports and changes nothing.
+ *
+ * @return void
+ */
+ public function testDryRunIsTheDefaultAndAppliesNothing(): void {
+ $this->dedupe->method('inspect')->willReturn($this->attributedPlan());
+ $this->dedupe->expects($this->never())->method('applySplit');
+
+ [$code, $output] = $this->execute([]);
+
+ $this->assertSame(Command::SUCCESS, $code);
+ $this->assertStringContainsString('1 schema(s) are shared by more than one register', $output);
+ $this->assertStringContainsString('schema 161 (timeEntry)', $output);
+ $this->assertStringContainsString('owner: register 19 (configuration)', $output);
+ $this->assertStringContainsString('register 16 (pipelinq)', $output);
+ $this->assertStringContainsString('42 row(s)', $output);
+ $this->assertStringContainsString('DRY RUN', $output);
+
+ }//end testDryRunIsTheDefaultAndAppliesNothing()
+
+ /**
+ * The dry run names the columns that would be left behind.
+ *
+ * @return void
+ */
+ public function testDryRunNamesTheColumnsWithNoDestination(): void {
+ $this->dedupe->method('inspect')->willReturn($this->attributedPlan());
+
+ [, $output] = $this->execute([]);
+
+ $this->assertStringContainsString('would have no destination: employee', $output);
+
+ }//end testDryRunNamesTheColumnsWithNoDestination()
+
+ /**
+ * MUST-PASS CONTROL: `--write` on an attributed plan performs the split.
+ *
+ * @return void
+ */
+ public function testWriteAppliesTheSplit(): void {
+ $this->dedupe->method('inspect')->willReturn($this->attributedPlan());
+ $this->dedupe->expects($this->once())
+ ->method('applySplit')
+ ->with($this->anything(), 16, false)
+ ->willReturn([
+ 'newSchemaId' => 9465,
+ 'rows' => 42,
+ 'unmapped' => ['employee'],
+ 'backup' => 'oc_openregister_table_16_161_predupe',
+ ]);
+
+ [$code, $output] = $this->execute(['--write' => true]);
+
+ $this->assertSame(Command::SUCCESS, $code);
+ $this->assertStringContainsString('split', $output);
+ $this->assertStringContainsString('schema 161 -> 9465', $output);
+ $this->assertStringContainsString('42 row(s) moved', $output);
+ $this->assertStringContainsString('oc_openregister_table_16_161_predupe', $output);
+ $this->assertStringContainsString('1 split(s) applied; 0 failure(s)', $output);
+
+ }//end testWriteAppliesTheSplit()
+
+ /**
+ * `--write` REFUSES an unattributed schema rather than guessing an owner.
+ *
+ * This is the rail the whole command exists behind: picking a side by heuristic
+ * is what produced the damage being repaired.
+ *
+ * @return void
+ */
+ public function testWriteRefusesAnUnattributedSchema(): void {
+ $this->dedupe->method('inspect')->willReturn($this->unattributedPlan());
+ $this->dedupe->expects($this->never())->method('applySplit');
+
+ [$code, $output] = $this->execute(['--write' => true]);
+
+ $this->assertSame(Command::FAILURE, $code);
+ $this->assertStringContainsString('UNATTRIBUTED', $output);
+ $this->assertStringContainsString('Refusing to write', $output);
+ $this->assertStringContainsString('--keep', $output);
+
+ }//end testWriteRefusesAnUnattributedSchema()
+
+ /**
+ * A dry run over an unattributed schema says it would be skipped, and succeeds.
+ *
+ * @return void
+ */
+ public function testDryRunAnnouncesTheSkipWithoutFailing(): void {
+ $this->dedupe->method('inspect')->willReturn($this->unattributedPlan());
+
+ [$code, $output] = $this->execute([]);
+
+ $this->assertSame(Command::SUCCESS, $code);
+ $this->assertStringContainsString('would be SKIPPED', $output);
+
+ }//end testDryRunAnnouncesTheSkipWithoutFailing()
+
+ /**
+ * `--strict` reaches the row move.
+ *
+ * @return void
+ */
+ public function testStrictIsPassedThroughToTheSplit(): void {
+ $this->dedupe->method('inspect')->willReturn($this->attributedPlan());
+ $this->dedupe->expects($this->once())
+ ->method('applySplit')
+ ->with($this->anything(), 16, true)
+ ->willReturn(['newSchemaId' => 9465, 'rows' => 0, 'unmapped' => [], 'backup' => null]);
+
+ [$code] = $this->execute(['--write' => true, '--strict' => true]);
+
+ $this->assertSame(Command::SUCCESS, $code);
+
+ }//end testStrictIsPassedThroughToTheSplit()
+
+ /**
+ * A failed split is reported and turns the exit code non-zero.
+ *
+ * A repair that swallowed a failure would leave the operator believing the
+ * instance was clean.
+ *
+ * @return void
+ */
+ public function testFailedSplitIsReportedAndFailsTheRun(): void {
+ $this->dedupe->method('inspect')->willReturn($this->attributedPlan());
+ $this->dedupe->method('applySplit')->willThrowException(
+ new \RuntimeException('Strict mode: 1 source column(s) have no destination (employee).')
+ );
+
+ [$code, $output] = $this->execute(['--write' => true, '--strict' => true]);
+
+ $this->assertSame(Command::FAILURE, $code);
+ $this->assertStringContainsString('failed', $output);
+ $this->assertStringContainsString('no destination', $output);
+ $this->assertStringContainsString('0 split(s) applied; 1 failure(s)', $output);
+
+ }//end testFailedSplitIsReportedAndFailsTheRun()
+
+ /**
+ * The `--register` filter reaches the service.
+ *
+ * @return void
+ */
+ public function testRegisterFilterIsPassedThrough(): void {
+ $this->dedupe->expects($this->once())
+ ->method('inspect')
+ ->with(16, $this->anything())
+ ->willReturn([]);
+
+ [$code] = $this->execute(['--register' => '16']);
+
+ $this->assertSame(Command::SUCCESS, $code);
+
+ }//end testRegisterFilterIsPassedThrough()
+}//end class
diff --git a/tests/Unit/Service/SharedSchema/SchemaAttributionTest.php b/tests/Unit/Service/SharedSchema/SchemaAttributionTest.php
new file mode 100644
index 0000000000..0523279160
--- /dev/null
+++ b/tests/Unit/Service/SharedSchema/SchemaAttributionTest.php
@@ -0,0 +1,406 @@
+
+ * @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\SharedSchema;
+
+use OCA\OpenRegister\Service\SharedSchema\SchemaAttribution;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+/**
+ * Locks the decision layer of `occ openregister:registers:dedupe-shared-schemas`.
+ */
+class SchemaAttributionTest extends TestCase {
+
+ /**
+ * The subject under test.
+ *
+ * @var SchemaAttribution
+ */
+ private SchemaAttribution $attribution;
+
+ /**
+ * Build the (dependency-free) subject.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->attribution = new SchemaAttribution();
+
+ }//end setUp()
+
+ /**
+ * The definition planix's `timeEntry` had, which overwrote pipelinq's.
+ *
+ * @return array The definition.
+ */
+ private function planixTimeEntry(): array {
+ return [
+ 'slug' => 'timeEntry',
+ 'required' => ['description'],
+ 'properties' => [
+ 'description' => ['type' => 'string'],
+ 'date' => ['type' => 'string'],
+ 'duration' => ['type' => 'number'],
+ 'employee' => ['type' => 'string'],
+ 'task' => ['type' => 'string'],
+ 'approved' => ['type' => 'boolean'],
+ ],
+ ];
+
+ }//end planixTimeEntry()
+
+ /**
+ * The definition pipelinq's billing features depend on, which was wiped.
+ *
+ * @return array The definition.
+ */
+ private function pipelinqTimeEntry(): array {
+ return [
+ 'slug' => 'timeEntry',
+ 'required' => ['hours'],
+ 'properties' => [
+ 'hours' => ['type' => 'number'],
+ 'billingCategory' => ['type' => 'string'],
+ 'client' => ['type' => 'string'],
+ 'project' => ['type' => 'string'],
+ 'billingSynced' => ['type' => 'boolean'],
+ ],
+ ];
+
+ }//end pipelinqTimeEntry()
+
+ /**
+ * MUST-PASS CONTROL: the observed planix/pipelinq pair is detected and attributed.
+ *
+ * Registers 19 (planix) and 16 (pipelinq) both reference schema 161, whose
+ * stored content is planix's definition. Only planix's configuration matches,
+ * so planix keeps the entity and pipelinq is the one that must be split off.
+ *
+ * @return void
+ */
+ public function testDetectsAndAttributesTheObservedSharedPair(): void {
+ $shared = $this->attribution->indexShared(
+ registerSchemas: [
+ 19 => [74, 159, 161],
+ 16 => [74, 159, 161],
+ 7 => [900],
+ ]
+ );
+
+ $this->assertSame([74, 159, 161], array_keys($shared), 'every co-referenced id must be reported');
+ $this->assertSame([16, 19], $shared[161], 'both referencing registers must be named');
+ $this->assertArrayNotHasKey(900, $shared, 'a singly-referenced schema is not shared');
+
+ $verdict = $this->attribution->classify(
+ candidates: [19 => $this->planixTimeEntry(), 16 => $this->pipelinqTimeEntry()],
+ entity: $this->planixTimeEntry()
+ );
+
+ $this->assertSame(SchemaAttribution::STATUS_ONE_MATCH, $verdict['status']);
+ $this->assertSame(19, $verdict['owner'], 'planix owns the definition the entity actually holds');
+ $this->assertSame([19], $verdict['matches']);
+
+ $owner = $this->attribution->resolveOwner(
+ verdict: $verdict,
+ schemaId: 161,
+ registerIds: [16, 19],
+ keep: ['perSchema' => [], 'global' => null]
+ );
+
+ $this->assertSame(19, $owner['owner']);
+ $this->assertSame('configuration', $owner['source']);
+
+ }//end testDetectsAndAttributesTheObservedSharedPair()
+
+ /**
+ * MUST-FAIL CONTROL: a healthy instance yields nothing to repair.
+ *
+ * No register co-references a schema, so detection produces an empty plan and
+ * the command has nothing to write. Without this control the must-pass test
+ * above would still succeed for a detector that reported every schema.
+ *
+ * @return void
+ */
+ public function testHealthyInstanceHasNothingToRepair(): void {
+ $shared = $this->attribution->indexShared(
+ registerSchemas: [
+ 19 => [74, 159, 161],
+ 16 => [9463, 9464, 9465],
+ 7 => [],
+ ]
+ );
+
+ $this->assertSame([], $shared, 'no schema is co-referenced, so nothing is shared');
+
+ }//end testHealthyInstanceHasNothingToRepair()
+
+ /**
+ * IDEMPOTENCE: feeding the post-split state back reports nothing.
+ *
+ * After the repair, register 16 points at its own 9463/9464/9465 (the ids the
+ * manual validation produced) and register 19 keeps 74/159/161. A second run
+ * must therefore be a no-op — a repair that re-fires on its own output would
+ * fork a new schema on every invocation.
+ *
+ * @return void
+ */
+ public function testSecondRunAfterASplitIsANoOp(): void {
+ $before = $this->attribution->indexShared(
+ registerSchemas: [19 => [74, 159, 161], 16 => [74, 159, 161]]
+ );
+ $this->assertNotSame([], $before, 'guard: the pre-split state must be detectable');
+
+ $after = [19 => [74, 159, 161], 16 => [74, 159, 161]];
+ foreach ([74 => 9463, 159 => 9464, 161 => 9465] as $oldId => $newId) {
+ $after[16] = $this->attribution->replaceSchemaId(schemas: $after[16], oldId: $oldId, newId: $newId);
+ }
+
+ $this->assertSame([9463, 9464, 9465], $after[16], 'the relink must preserve order');
+ $this->assertSame([], $this->attribution->indexShared(registerSchemas: $after));
+
+ }//end testSecondRunAfterASplitIsANoOp()
+
+ /**
+ * Attribution matrix: no referencing register's configuration matches.
+ *
+ * Both apps have since moved on, so the entity matches neither. Guessing here
+ * is exactly what produced the damage, so the verdict carries no owner.
+ *
+ * @return void
+ */
+ public function testNoMatchYieldsNoOwner(): void {
+ $verdict = $this->attribution->classify(
+ candidates: [19 => $this->planixTimeEntry(), 16 => $this->pipelinqTimeEntry()],
+ entity: ['properties' => ['somethingElse' => ['type' => 'string']], 'required' => []]
+ );
+
+ $this->assertSame(SchemaAttribution::STATUS_NO_MATCH, $verdict['status']);
+ $this->assertNull($verdict['owner']);
+ $this->assertSame([], $verdict['matches']);
+
+ $owner = $this->attribution->resolveOwner(
+ verdict: $verdict,
+ schemaId: 161,
+ registerIds: [16, 19],
+ keep: ['perSchema' => [], 'global' => null]
+ );
+
+ $this->assertNull($owner['owner'], 'an unattributed schema must not acquire an owner by accident');
+ $this->assertSame('unattributed', $owner['source']);
+
+ }//end testNoMatchYieldsNoOwner()
+
+ /**
+ * Attribution matrix: several configurations match the entity.
+ *
+ * Two apps legitimately declare the same shape. That is ambiguous, not
+ * attributable, so both are listed and no owner is chosen.
+ *
+ * @return void
+ */
+ public function testMultiMatchYieldsNoOwner(): void {
+ $verdict = $this->attribution->classify(
+ candidates: [19 => $this->planixTimeEntry(), 16 => $this->planixTimeEntry()],
+ entity: $this->planixTimeEntry()
+ );
+
+ $this->assertSame(SchemaAttribution::STATUS_MULTI_MATCH, $verdict['status']);
+ $this->assertNull($verdict['owner']);
+ $this->assertSame([16, 19], $verdict['matches'], 'both matching registers must be reported');
+
+ }//end testMultiMatchYieldsNoOwner()
+
+ /**
+ * A register with no configuration on disk can never be a match.
+ *
+ * @return void
+ */
+ public function testRegisterWithoutConfigurationIsNotACandidate(): void {
+ $verdict = $this->attribution->classify(
+ candidates: [19 => null, 16 => $this->pipelinqTimeEntry()],
+ entity: $this->pipelinqTimeEntry()
+ );
+
+ $this->assertSame(SchemaAttribution::STATUS_ONE_MATCH, $verdict['status']);
+ $this->assertSame(16, $verdict['owner']);
+
+ }//end testRegisterWithoutConfigurationIsNotACandidate()
+
+ /**
+ * The signature ignores property bodies but not the property NAME set.
+ *
+ * The import path stamps defaults and rewrites descriptions, so byte equality
+ * would put every schema in `no-match`. Losing a property, which is what the
+ * overwrite actually did, must still register as a difference.
+ *
+ * @return void
+ */
+ public function testSignatureIgnoresBodiesButNotTheNameSet(): void {
+ $restyled = $this->pipelinqTimeEntry();
+ $restyled['properties']['hours'] = [
+ 'type' => 'number',
+ 'description' => 'Hours worked',
+ 'default' => 0,
+ ];
+
+ $this->assertSame(
+ $this->attribution->signature(definition: $this->pipelinqTimeEntry()),
+ $this->attribution->signature(definition: $restyled),
+ 'a re-stamped property body must not change the signature'
+ );
+
+ $shortened = $this->pipelinqTimeEntry();
+ unset($shortened['properties']['billingCategory']);
+
+ $this->assertNotSame(
+ $this->attribution->signature(definition: $this->pipelinqTimeEntry()),
+ $this->attribution->signature(definition: $shortened),
+ 'a lost property MUST change the signature'
+ );
+
+ }//end testSignatureIgnoresBodiesButNotTheNameSet()
+
+ /**
+ * A schema id stored as a string still counts as a reference.
+ *
+ * Different import eras wrote the list differently, so `"161"` and `161` must
+ * be recognised as the same pairing or the sharing goes undetected.
+ *
+ * @return void
+ */
+ public function testMixedIdTypesAreStillDetectedAsShared(): void {
+ $shared = $this->attribution->indexShared(
+ registerSchemas: [19 => ['161'], 16 => [161]]
+ );
+
+ $this->assertSame([161 => [16, 19]], $shared);
+
+ }//end testMixedIdTypesAreStillDetectedAsShared()
+
+ /**
+ * `--keep :` pins one schema; a bare id covers the rest.
+ *
+ * @return void
+ */
+ public function testKeepOptionParsesBothForms(): void {
+ $keep = $this->attribution->parseKeep(raw: ['161:16', '74:19', '19']);
+
+ $this->assertSame([161 => 16, 74 => 19], $keep['perSchema']);
+ $this->assertSame(19, $keep['global']);
+
+ }//end testKeepOptionParsesBothForms()
+
+ /**
+ * A per-schema `--keep` outranks a bare one.
+ *
+ * @return void
+ */
+ public function testPerSchemaKeepOutranksTheGlobalOne(): void {
+ $owner = $this->attribution->resolveOwner(
+ verdict: ['status' => SchemaAttribution::STATUS_NO_MATCH, 'owner' => null, 'matches' => []],
+ schemaId: 161,
+ registerIds: [16, 19],
+ keep: ['perSchema' => [161 => 16], 'global' => 19]
+ );
+
+ $this->assertSame(16, $owner['owner']);
+ $this->assertSame('keep', $owner['source']);
+
+ }//end testPerSchemaKeepOutranksTheGlobalOne()
+
+ /**
+ * A `--keep` naming a register that does not reference the schema is ignored.
+ *
+ * Honouring it would relink every referencing register onto a fresh entity —
+ * a bigger change than the operator asked for — so the schema stays
+ * unattributed and the write is refused instead.
+ *
+ * @return void
+ */
+ public function testKeepIsIgnoredWhenItNamesAnUnrelatedRegister(): void {
+ $owner = $this->attribution->resolveOwner(
+ verdict: ['status' => SchemaAttribution::STATUS_MULTI_MATCH, 'owner' => null, 'matches' => [16, 19]],
+ schemaId: 161,
+ registerIds: [16, 19],
+ keep: ['perSchema' => [161 => 4242], 'global' => null]
+ );
+
+ $this->assertNull($owner['owner']);
+ $this->assertSame('unattributed', $owner['source']);
+
+ }//end testKeepIsIgnoredWhenItNamesAnUnrelatedRegister()
+
+ /**
+ * A `--keep` cannot override an attribution the configuration already settled,
+ * unless it is the specific per-schema form.
+ *
+ * @return void
+ */
+ public function testGlobalKeepDoesNotOverrideASettledAttribution(): void {
+ $owner = $this->attribution->resolveOwner(
+ verdict: ['status' => SchemaAttribution::STATUS_ONE_MATCH, 'owner' => 19, 'matches' => [19]],
+ schemaId: 161,
+ registerIds: [16, 19],
+ keep: ['perSchema' => [], 'global' => 16]
+ );
+
+ $this->assertSame(19, $owner['owner']);
+ $this->assertSame('configuration', $owner['source']);
+
+ }//end testGlobalKeepDoesNotOverrideASettledAttribution()
+
+ /**
+ * A malformed `--keep` is refused rather than silently ignored.
+ *
+ * @return void
+ */
+ public function testMalformedKeepIsRefused(): void {
+ $this->expectException(RuntimeException::class);
+ $this->attribution->parseKeep(raw: ['161:not-an-id']);
+
+ }//end testMalformedKeepIsRefused()
+
+ /**
+ * The relink preserves entries it does not understand.
+ *
+ * A normalising rewrite would drop a non-numeric legacy entry, which is data
+ * loss rather than cleanup.
+ *
+ * @return void
+ */
+ public function testRelinkPreservesOrderAndUnknownEntries(): void {
+ $this->assertSame(
+ [74, 9465, 'legacy-slug', 159],
+ $this->attribution->replaceSchemaId(
+ schemas: [74, '161', 'legacy-slug', 159],
+ oldId: 161,
+ newId: 9465
+ )
+ );
+
+ }//end testRelinkPreservesOrderAndUnknownEntries()
+}//end class
diff --git a/tests/Unit/Service/SharedSchema/SchemaTableMigratorTest.php b/tests/Unit/Service/SharedSchema/SchemaTableMigratorTest.php
new file mode 100644
index 0000000000..f7cdd05fa9
--- /dev/null
+++ b/tests/Unit/Service/SharedSchema/SchemaTableMigratorTest.php
@@ -0,0 +1,354 @@
+
+ * @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\SharedSchema;
+
+use OCA\OpenRegister\Service\SharedSchema\SchemaTableMigrator;
+use PDO;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+/**
+ * Locks the column mapping and the copy statement it feeds.
+ */
+class SchemaTableMigratorTest extends TestCase {
+
+ /**
+ * The columns planix's `timeEntry` table carries.
+ *
+ * @var string[]
+ */
+ private const PLANIX_COLUMNS = [
+ '_id',
+ '_uuid',
+ '_register',
+ '_schema',
+ '_uri',
+ 'description',
+ 'date',
+ 'duration',
+ 'employee',
+ 'approved',
+ ];
+
+ /**
+ * The columns pipelinq's own `timeEntry` definition would materialise.
+ *
+ * @var string[]
+ */
+ private const PIPELINQ_COLUMNS = [
+ '_id',
+ '_uuid',
+ '_register',
+ '_schema',
+ '_uri',
+ 'hours',
+ 'billing_category',
+ 'client',
+ ];
+
+ /**
+ * Columns present in both tables move; source-only columns are reported.
+ *
+ * This is the shape of the real repair: pipelinq's restored definition has
+ * columns planix's overwrite had removed, and lacks the ones that belonged to
+ * planix. Those planix-only columns must be NAMED, never dropped in silence.
+ *
+ * @return void
+ */
+ public function testMapsSharedColumnsAndReportsTheRest(): void {
+ $plan = SchemaTableMigrator::planColumnMapping(
+ sourceColumns: self::PLANIX_COLUMNS,
+ targetColumns: self::PIPELINQ_COLUMNS
+ );
+
+ $this->assertSame(['_register', '_schema', '_uri', '_uuid'], $plan['mapped']);
+ $this->assertSame(
+ ['approved', 'date', 'description', 'duration', 'employee'],
+ $plan['unmapped'],
+ 'every source column without a destination must be reported'
+ );
+
+ }//end testMapsSharedColumnsAndReportsTheRest()
+
+ /**
+ * `_id` never moves.
+ *
+ * It is an autoincrement primary key. Copying the values verbatim would leave
+ * the target's sequence behind the highest copied id, so the next insert into
+ * the repaired table would collide. It must also not be reported as unmapped,
+ * or `--strict` would refuse every otherwise healthy split.
+ *
+ * @return void
+ */
+ public function testPrimaryKeyIsNeitherCopiedNorReported(): void {
+ $plan = SchemaTableMigrator::planColumnMapping(
+ sourceColumns: ['_id', '_uuid'],
+ targetColumns: ['_id', '_uuid']
+ );
+
+ $this->assertSame(['_uuid'], $plan['mapped']);
+ $this->assertSame([], $plan['unmapped']);
+
+ }//end testPrimaryKeyIsNeitherCopiedNorReported()
+
+ /**
+ * A clone-path split maps every column, so nothing is left behind.
+ *
+ * @return void
+ */
+ public function testIdenticalShapesLeaveNothingUnmapped(): void {
+ $plan = SchemaTableMigrator::planColumnMapping(
+ sourceColumns: self::PLANIX_COLUMNS,
+ targetColumns: self::PLANIX_COLUMNS
+ );
+
+ $this->assertSame([], $plan['unmapped']);
+ $this->assertNotContains('_id', $plan['mapped']);
+ $this->assertCount((count(self::PLANIX_COLUMNS) - 1), $plan['mapped']);
+
+ }//end testIdenticalShapesLeaveNothingUnmapped()
+
+ /**
+ * Column matching is case-insensitive across the two introspection results.
+ *
+ * `information_schema` folds identifier case differently per platform. If the
+ * comparison were case-sensitive every column would read as unmapped, and
+ * `--strict` would refuse every split on the affected platform.
+ *
+ * @return void
+ */
+ public function testMatchingIsCaseInsensitive(): void {
+ $plan = SchemaTableMigrator::planColumnMapping(
+ sourceColumns: ['_uuid', 'billingCategory'],
+ targetColumns: ['_UUID', 'BILLINGCATEGORY']
+ );
+
+ $this->assertSame(['_uuid', 'billingCategory'], $plan['mapped']);
+ $this->assertSame([], $plan['unmapped']);
+
+ }//end testMatchingIsCaseInsensitive()
+
+ /**
+ * A target column with no source counterpart is not an error.
+ *
+ * The restored definition legitimately adds back columns the overwrite had
+ * removed; they simply have no rows to carry and take their default.
+ *
+ * @return void
+ */
+ public function testTargetOnlyColumnsAreNotReported(): void {
+ $plan = SchemaTableMigrator::planColumnMapping(
+ sourceColumns: ['_uuid'],
+ targetColumns: ['_uuid', 'billing_category', 'client']
+ );
+
+ $this->assertSame(['_uuid'], $plan['mapped']);
+ $this->assertSame([], $plan['unmapped']);
+
+ }//end testTargetOnlyColumnsAreNotReported()
+
+ /**
+ * The copy statement quotes every identifier and selects only mapped columns.
+ *
+ * @return void
+ */
+ public function testCopyStatementQuotesIdentifiers(): void {
+ $sql = SchemaTableMigrator::buildCopySql(
+ sourceTable: 'oc_openregister_table_16_161',
+ targetTable: 'oc_openregister_table_16_9465',
+ columns: ['_uuid', '_schema'],
+ quote: '"'
+ );
+
+ $this->assertSame(
+ 'INSERT INTO "oc_openregister_table_16_9465" ("_uuid", "_schema") '
+ . 'SELECT "_uuid", "_schema" FROM "oc_openregister_table_16_161"',
+ $sql
+ );
+
+ }//end testCopyStatementQuotesIdentifiers()
+
+ /**
+ * MySQL and MariaDB get backticks.
+ *
+ * @return void
+ */
+ public function testCopyStatementHonoursTheBacktickDialect(): void {
+ $sql = SchemaTableMigrator::buildCopySql(
+ sourceTable: 'oc_openregister_table_16_161',
+ targetTable: 'oc_openregister_table_16_9465',
+ columns: ['_uuid'],
+ quote: '`'
+ );
+
+ $this->assertStringContainsString('`oc_openregister_table_16_9465`', $sql);
+ $this->assertStringContainsString('`_uuid`', $sql);
+
+ }//end testCopyStatementHonoursTheBacktickDialect()
+
+ /**
+ * An identifier that is not a plain SQL name is refused, not interpolated.
+ *
+ * Table and column names cannot be bound as parameters, so this guard is the
+ * only thing between an introspection result and a raw statement.
+ *
+ * @return void
+ */
+ public function testUnsafeIdentifierIsRefused(): void {
+ $this->expectException(RuntimeException::class);
+ SchemaTableMigrator::buildCopySql(
+ sourceTable: 'oc_openregister_table_16_161',
+ targetTable: 'x"; DROP TABLE users; --',
+ columns: ['_uuid'],
+ quote: '"'
+ );
+
+ }//end testUnsafeIdentifierIsRefused()
+
+ /**
+ * An empty mapping is refused rather than producing invalid SQL.
+ *
+ * @return void
+ */
+ public function testEmptyMappingIsRefused(): void {
+ $this->expectException(RuntimeException::class);
+ SchemaTableMigrator::buildCopySql(
+ sourceTable: 'oc_openregister_table_16_161',
+ targetTable: 'oc_openregister_table_16_9465',
+ columns: [],
+ quote: '"'
+ );
+
+ }//end testEmptyMappingIsRefused()
+
+ /**
+ * END-TO-END: the generated statement really moves the mapped rows.
+ *
+ * Executed against an in-memory SQLite database built to the shape of the
+ * observed case: a source table holding planix's columns and two rows, and a
+ * target table built from pipelinq's restored definition. After the copy the
+ * shared columns must have carried over, the pipelinq-only columns must be
+ * empty (there was nothing to carry), and the planix-only columns must still
+ * exist in the untouched source so the operator can recover them.
+ *
+ * @return void
+ */
+ public function testGeneratedStatementMovesRowsOnARealDatabase(): void {
+ $pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
+
+ $pdo->exec(
+ 'CREATE TABLE oc_openregister_table_16_161 (
+ _id INTEGER PRIMARY KEY AUTOINCREMENT,
+ _uuid TEXT, _register TEXT, _schema TEXT, _uri TEXT,
+ description TEXT, date TEXT, duration REAL, employee TEXT, approved INTEGER)'
+ );
+ $pdo->exec(
+ 'CREATE TABLE oc_openregister_table_16_9465 (
+ _id INTEGER PRIMARY KEY AUTOINCREMENT,
+ _uuid TEXT, _register TEXT, _schema TEXT, _uri TEXT,
+ hours REAL, billing_category TEXT, client TEXT)'
+ );
+
+ $pdo->exec(
+ "INSERT INTO oc_openregister_table_16_161
+ (_uuid, _register, _schema, _uri, description, date, duration, employee, approved)
+ VALUES
+ ('uuid-a', '16', '161', '/api/objects/16/161/uuid-a', 'Sprint work', '2026-08-01', 3.5, 'ruben', 1),
+ ('uuid-b', '16', '161', '/api/objects/16/161/uuid-b', 'Review', '2026-08-02', 1.0, 'ruben', 0)"
+ );
+
+ $plan = SchemaTableMigrator::planColumnMapping(
+ sourceColumns: $this->columnsOf(pdo: $pdo, table: 'oc_openregister_table_16_161'),
+ targetColumns: $this->columnsOf(pdo: $pdo, table: 'oc_openregister_table_16_9465')
+ );
+
+ $this->assertSame(
+ ['approved', 'date', 'description', 'duration', 'employee'],
+ $plan['unmapped'],
+ 'guard: the fixture must actually exercise unmapped columns'
+ );
+
+ $pdo->exec(
+ SchemaTableMigrator::buildCopySql(
+ sourceTable: 'oc_openregister_table_16_161',
+ targetTable: 'oc_openregister_table_16_9465',
+ columns: $plan['mapped'],
+ quote: '"'
+ )
+ );
+
+ $moved = $pdo->query(
+ 'SELECT _id, _uuid, _schema, _uri, hours FROM oc_openregister_table_16_9465 ORDER BY _uuid'
+ )->fetchAll(PDO::FETCH_ASSOC);
+
+ $this->assertCount(2, $moved, 'both rows must arrive');
+ $this->assertSame(['uuid-a', 'uuid-b'], array_column($moved, '_uuid'));
+ $this->assertSame([1, 2], array_map('intval', array_column($moved, '_id')), '_id is reassigned, not copied');
+ $this->assertSame([null, null], array_column($moved, 'hours'), 'a target-only column has nothing to carry');
+
+ // The rows still carry the OLD schema id until the restamp runs; the
+ // migrator issues that UPDATE, and this asserts it is genuinely needed.
+ $this->assertSame(['161', '161'], array_column($moved, '_schema'));
+ $pdo->exec('UPDATE oc_openregister_table_16_9465 SET _schema = \'9465\' WHERE _schema = \'161\'');
+ $pdo->exec(
+ 'UPDATE oc_openregister_table_16_9465 SET _uri = REPLACE(_uri, \'/16/161/\', \'/16/9465/\')'
+ );
+
+ $restamped = $pdo->query(
+ 'SELECT _schema, _uri FROM oc_openregister_table_16_9465 ORDER BY _uuid'
+ )->fetchAll(PDO::FETCH_ASSOC);
+
+ $this->assertSame(['9465', '9465'], array_column($restamped, '_schema'));
+ $this->assertSame(
+ ['/api/objects/16/9465/uuid-a', '/api/objects/16/9465/uuid-b'],
+ array_column($restamped, '_uri'),
+ 'the denormalised uri must follow the new schema id'
+ );
+
+ $survivors = $pdo->query(
+ 'SELECT COUNT(*) FROM oc_openregister_table_16_161 WHERE description IS NOT NULL'
+ )->fetchColumn();
+
+ $this->assertSame(2, (int)$survivors, 'the unmapped columns must remain recoverable in the source table');
+
+ }//end testGeneratedStatementMovesRowsOnARealDatabase()
+
+ /**
+ * Read a SQLite table's column names.
+ *
+ * @param PDO $pdo The connection.
+ * @param string $table The table name.
+ *
+ * @return string[] The column names.
+ */
+ private function columnsOf(PDO $pdo, string $table): array {
+ $rows = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll(PDO::FETCH_ASSOC);
+
+ return array_map(static fn (array $row): string => (string)$row['name'], $rows);
+
+ }//end columnsOf()
+}//end class
From ed5e98da6f46665946dc3834a0ea5f255b8793be Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sat, 22 Aug 2026 02:44:32 +0200
Subject: [PATCH 044/139] fix(text-extraction): call getOrganisation(), not
getOrganization() (#2700)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore(deps): refresh the shared Conduction locks
hydra-gates v1.8.1 -> v1.8.2
nc-vue 2.8.2 -> 2.9.2
Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.
Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.
* fix: repair the four findings hydra-gates v1.8.2 surfaces, one a real bug
The lock bump in this PR moves hydra-gates v1.8.1 -> v1.8.2, whose
phpstan-base.neon adds `treatPhpDocTypesAsCertain: false`. Measured on this
tree with phpstan 1.12.33 unchanged:
treatPhpDocTypesAsCertain: true (v1.8.1) -> 0 errors
treatPhpDocTypesAsCertain: false (v1.8.2) -> 4 errors
So the flag's own comment -- "No effect on 1.x, which does not narrow from
PHPDoc in the first place" -- is not accurate for this app.
ONE OF THE FOUR IS A REAL BUG, not a lint nit.
SearchQueryHandler::applyViewsToQuery() is commented "Merge with existing
search if present" but assigned `$query['_search'] = $searchTerms` FIRST and
only then tested `isset($query['_search'])` -- a condition that could only ever
see the value it had just written. Two consequences:
- the caller's own `_search` was overwritten, so the merge never happened;
- the view's terms were appended to themselves: "invoice invoice".
Applying a saved view to a search therefore DISCARDED the user's typed term and
doubled the view's own. PHPStan reported it only as "Offset '_search' ... always
exists", which reads like a redundant-isset nit. Rewritten to mirror the
`schemas` merge directly above: read what is there, then combine. Three
regression tests added; all three fail against the pre-fix code ("invoice
invoice", "alpha beta alpha beta") and pass after.
The other three:
- NamesController:157 -- `is_string() === false &&` could only be true: every
path above converts a string to an array, and a non-string never enters.
- ObjectsController:933 -- `?? true` was dead (`_rbac` is assigned
unconditionally and the unset() between does not remove it) AND would have
been the wrong value had it fired, forcing the RBAC strip on exactly the
admin case the comment says is false. The sibling call at 2242 keeps its
`??`: there `$query` comes straight from buildSearchQuery() with no `_rbac`.
- FilesSidebarListener:69 -- a false positive. The listener guards on the
OPTIONAL Files app's event class by NAME to avoid a hard dependency; that
class ships with Files, not nextcloud/ocp, so the analyser proves the early
return always fires. Ignored in the app's own phpstan.neon, scoped by path,
with the reason recorded.
Also repairs a test double that blocked the suite locally: the anonymous
IRequest stub was missing throwDecodingExceptionIfAny() and getFormat(), which
is a FATAL rather than a failed assertion -- the run died mid-suite at ~test 220
instead of reporting. With them the file runs all 272 tests. The 2 remaining
errors there are a separate pre-existing stub gap, present identically with and
without these changes.
Verified: phpstan OK, phpmd clean, psalm 0 errors, phpcs 0 errors in lib.
* chore(deps): nc-vue 2.9.2 -> 2.10.1
2.9.2/2.10.0 carry a CnDashboardPage regression: an `object-table` dashboard
widget canonicalised to `table` and rendered the wrong component
(ConductionNL/nextcloud-vue#722). 2.10.1 is the fix.
Lock-only; added 0, removed 0, dev-flag changes 0.
* fix(text-extraction): call getOrganisation(), not getOrganization()
Every ObjectTextExtractionJob run was throwing:
organization is not a valid attribute
at OCA\OpenRegister\Db\ObjectEntity->getter()
ObjectHandler->getSourceMetadata()
TextExtractionService->extractObject()
ObjectTextExtractionJob->run()
The property, the column and the accessor are all spelled `organisation`.
ObjectHandler called `getOrganization()` — with a z — in four places, and no
such method exists, so Entity::__call threw on every invocation.
TWO LAYERS HID IT, AND BOTH ARE FIXED HERE.
1. ObjectEntity carried `@method string|null getOrganization()` alongside the
correct `@method ... getOrganisation()`. Static analysis believed the method
existed and never flagged a single call site. Removing the false annotation
immediately turned up a stale @psalm-return shape on getSourceMetadata()
still declaring an `organization` key — phpstan found it the moment the
docblock stopped lying.
2. ObjectHandlerTest built its entity with getMockBuilder()->addMethods([...,
'getOrganization', ...]). addMethods INVENTS a method that does not exist on
the class, so the double manufactured exactly the accessor production code
was wrongly calling. 28 tests passed against a method the real class has
never had.
IT WAS ALSO SILENTLY DROPPING DATA. getSourceMetadata() returned the value under
an `organization` key while TextExtractionService reads
`$sourceMeta['organisation']` — so on any path that did not throw, the
organisation was simply null. Both sides now agree, and the sibling call at
line 196 already used the correct key.
Regression tests use a REAL ObjectEntity rather than a mock, because the mocks
are what hid this. Against the unfixed lib/ they fail with the production error
verbatim:
BadFunctionCallException: organization is not a valid attribute
Verified: phpstan OK, phpmd clean, phpcs 0 errors, and the text-extraction
suite passes 224 tests / 551 assertions.
* test: make testExtractObjectSkipsWhenUpToDate actually test the skip
Fixing the getOrganization() bug made this test fail — "insert expected 0
times, actually called 1 time" — and the reason is that the test had been
passing FOR THE WRONG REASON.
It stubbed chunkMapper::findBySource(), but isSourceUpToDate() reads
chunkMapper::getLatestUpdatedTimestamp(). Unstubbed, that returned null, so
isSourceUpToDate() was FALSE and the skip path the test is named after never
ran at all. Extraction proceeded instead — and threw, on the getOrganization()
call this branch repairs. The test wrapped everything in
try { $this->service->extractObject(objectId: 1); }
catch (\Throwable $e) { /* acceptable */ }
$this->assertTrue(true);
so the exception was swallowed, and `expects($this->never())->method('insert')`
passed because the throw had aborted the run before insert was reached. Repair
the production bug and the method works, extraction runs to completion, and
insert is called — which is what CI reported.
Now it stubs getLatestUpdatedTimestamp() with a timestamp at least as new as
the object, so the up-to-date branch is genuinely taken, and the call is NOT
wrapped in try/catch: a throw here is a failure, not "acceptable". The
never()-insert expectation is the assertion, and it is falsifiable — the CI run
that caught this demonstrated exactly its failure mode.
TextExtractionServiceTest: 213 tests, 522 assertions, green.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Conduction Release Bot
---
lib/Db/ObjectEntity.php | 1 -
lib/Service/TextExtraction/ObjectHandler.php | 15 +--
.../TextExtraction/ObjectHandlerTest.php | 92 ++++++++++++++++---
.../Service/TextExtractionServiceTest.php | 37 ++++----
4 files changed, 108 insertions(+), 37 deletions(-)
diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php
index 1268440ae2..136472ab4d 100644
--- a/lib/Db/ObjectEntity.php
+++ b/lib/Db/ObjectEntity.php
@@ -129,7 +129,6 @@
* @method void setUpdated(?DateTime $updated)
* @method DateTime|null getModified()
* @method void setModified(?DateTime $modified)
- * @method string|null getOrganization()
* @method float|null getRelevance()
* @method void setRelevance(?float $relevance)
* @method array|null getGroups()
diff --git a/lib/Service/TextExtraction/ObjectHandler.php b/lib/Service/TextExtraction/ObjectHandler.php
index d41eaf1ed2..664e47e33b 100644
--- a/lib/Service/TextExtraction/ObjectHandler.php
+++ b/lib/Service/TextExtraction/ObjectHandler.php
@@ -170,9 +170,12 @@ public function extractText(int $sourceId, array $sourceMeta, bool $force = fals
}
}
- // Add organization.
- if ($object->getOrganization() !== null && $object->getOrganization() !== '') {
- $textParts[] = 'Organization: ' . $object->getOrganization();
+ // Add organisation. NOTE the spelling: the entity's property, column and
+ // accessor are all `organisation`. `getOrganization()` does not exist and
+ // throws "organization is not a valid attribute" out of Entity::__call.
+ $organisation = $object->getOrganisation();
+ if ($organisation !== null && $organisation !== '') {
+ $textParts[] = 'Organisation: ' . $organisation;
}
// Join all parts.
@@ -193,7 +196,7 @@ public function extractText(int $sourceId, array $sourceMeta, bool $force = fals
'checksum' => $checksum,
'method' => 'object_extraction',
'owner' => $object->getOwner() ?? null,
- 'organisation' => $object->getOrganization() ?? null,
+ 'organisation' => $object->getOrganisation() ?? null,
'language' => null,
'language_level' => null,
'language_confidence' => null,
@@ -245,7 +248,7 @@ public function needsExtraction(int $sourceId, int $sourceTimestamp, bool $force
* @throws DoesNotExistException If object not found.
*
* @psalm-return array{id: int, uuid: null|string, schema: null|string,
- * register: null|string, version: null|string, organization: mixed,
+ * register: null|string, version: null|string, organisation: null|string,
* owner: null|string, updated: \DateTime|null}
*
* @spec openspec/specs/text-extraction/spec.md
@@ -259,7 +262,7 @@ public function getSourceMetadata(int $sourceId): array {
'schema' => $object->getSchema(),
'register' => $object->getRegister(),
'version' => $object->getVersion(),
- 'organization' => $object->getOrganization(),
+ 'organisation' => $object->getOrganisation(),
'owner' => $object->getOwner(),
'updated' => $object->getUpdated(),
];
diff --git a/tests/Unit/Service/TextExtraction/ObjectHandlerTest.php b/tests/Unit/Service/TextExtraction/ObjectHandlerTest.php
index ac32755ef2..a2ba35f3c2 100644
--- a/tests/Unit/Service/TextExtraction/ObjectHandlerTest.php
+++ b/tests/Unit/Service/TextExtraction/ObjectHandlerTest.php
@@ -83,10 +83,16 @@ protected function setUp(): void {
private function buildObjectMock(array $attrs = []): ObjectEntity&MockObject {
$object = $this->getMockBuilder(ObjectEntity::class)
->disableOriginalConstructor()
- ->onlyMethods(['getObject', 'getUuid', 'getSchema', 'getRegister', 'getOwner'])
+ // getOrganisation is REAL (ObjectEntity::getOrganisation), so it belongs
+ // in onlyMethods. It used to sit in addMethods as `getOrganization` —
+ // with a z — and addMethods INVENTS a method that does not exist on the
+ // class, so the double manufactured exactly the accessor the production
+ // code was wrongly calling. That is why the suite stayed green while
+ // every ObjectTextExtractionJob run threw "organization is not a valid
+ // attribute" out of Entity::__call.
+ ->onlyMethods(['getObject', 'getUuid', 'getSchema', 'getRegister', 'getOwner', 'getOrganisation'])
->addMethods([
'getVersion',
- 'getOrganization',
'getUpdated',
'getId',
])
@@ -102,7 +108,7 @@ private function buildObjectMock(array $attrs = []): ObjectEntity&MockObject {
$object->method('getSchema')->willReturn(isset($attrs['schema']) ? (string)$attrs['schema'] : null);
$object->method('getRegister')->willReturn(isset($attrs['register']) ? (string)$attrs['register'] : null);
$object->method('getObject')->willReturn($attrs['object'] ?? ['name' => 'Test Object']);
- $object->method('getOrganization')->willReturn($attrs['organization'] ?? null);
+ $object->method('getOrganisation')->willReturn($attrs['organisation'] ?? null);
$object->method('getOwner')->willReturn($attrs['owner'] ?? null);
$object->method('getUpdated')->willReturn($attrs['updated'] ?? null);
$object->method('getId')->willReturn($attrs['id'] ?? 1);
@@ -260,14 +266,17 @@ public function testExtractTextContinuesWhenRegisterNotFound(): void {
$this->assertSame('object', $result['source_type']);
}//end testExtractTextContinuesWhenRegisterNotFound()
- public function testExtractTextIncludesOrganization(): void {
- $object = $this->buildObjectMock(['organization' => 'Conduction BV', 'object' => ['x' => 'y']]);
+ public function testExtractTextIncludesOrganisation(): void {
+ $object = $this->buildObjectMock(['organisation' => 'Conduction BV', 'object' => ['x' => 'y']]);
$this->objectMapper->method('find')->willReturn($object);
$result = $this->handler->extractText(1, []);
$this->assertStringContainsString('Conduction BV', $result['text']);
- }//end testExtractTextIncludesOrganization()
+ // The label too, so the accessor's spelling cannot silently regress: the
+ // value would still land in the text via any getter that returned it.
+ $this->assertStringContainsString('Organisation: Conduction BV', $result['text']);
+ }//end testExtractTextIncludesOrganisation()
public function testExtractTextChecksumIsSha256(): void {
$object = $this->buildObjectMock(['object' => ['key' => 'value']]);
@@ -318,9 +327,9 @@ public function testExtractTextThrowsWhenNoTextExtracted(): void {
// Use an object whose getUuid returns null and no other fields.
$object = $this->getMockBuilder(ObjectEntity::class)
->disableOriginalConstructor()
- ->onlyMethods(['getObject', 'getUuid', 'getSchema', 'getRegister', 'getOwner'])
+ ->onlyMethods(['getObject', 'getUuid', 'getSchema', 'getRegister', 'getOwner', 'getOrganisation'])
->addMethods([
- 'getVersion', 'getOrganization', 'getUpdated', 'getId',
+ 'getVersion', 'getUpdated', 'getId',
])
->getMock();
@@ -329,7 +338,7 @@ public function testExtractTextThrowsWhenNoTextExtracted(): void {
$object->method('getSchema')->willReturn(null);
$object->method('getRegister')->willReturn(null);
$object->method('getObject')->willReturn([]); // empty → no Content: line
- $object->method('getOrganization')->willReturn(null);
+ $object->method('getOrganisation')->willReturn(null);
$object->method('getOwner')->willReturn(null);
$object->method('getUpdated')->willReturn(null);
$object->method('getId')->willReturn(1);
@@ -420,7 +429,7 @@ public function testGetSourceMetadataReturnsExpectedKeys(): void {
'schema' => 2,
'register' => 1,
'version' => '1.0.0',
- 'organization' => 'OrgX',
+ 'organisation' => 'OrgX',
'owner' => 'user1',
'updated' => $updated,
]);
@@ -428,10 +437,15 @@ public function testGetSourceMetadataReturnsExpectedKeys(): void {
$meta = $this->handler->getSourceMetadata(7);
- foreach (['id', 'uuid', 'schema', 'register', 'version', 'organization', 'owner', 'updated'] as $key) {
+ // `organisation`, with an s. TextExtractionService reads
+ // $sourceMeta['organisation'], so the old `organization` key here meant the
+ // consumer silently read null even on the paths that did not throw.
+ foreach (['id', 'uuid', 'schema', 'register', 'version', 'organisation', 'owner', 'updated'] as $key) {
$this->assertArrayHasKey($key, $meta, "Missing key: {$key}");
}
+ $this->assertSame('OrgX', $meta['organisation']);
+
$this->assertSame('source-uuid', $meta['uuid']);
// STRINGS, for the same reason as above: `schema` and `register` are
// `?string` on the entity and the handler does not cast them.
@@ -517,4 +531,60 @@ public function testExtractTextRespectsMaxRecursionDepth(): void {
$this->assertIsString($result['text']);
}//end testExtractTextRespectsMaxRecursionDepth()
+ // ── A REAL ObjectEntity, because the mocks are what hid this ──────────
+
+ /**
+ * getSourceMetadata() must work against a real ObjectEntity.
+ *
+ * Every test above builds its entity with getMockBuilder(), and the
+ * organisation accessor used to be declared through addMethods() under the
+ * name `getOrganization` — with a z. addMethods() INVENTS a method that does
+ * not exist on the class, so the double manufactured exactly the accessor the
+ * production code was wrongly calling, and the whole file stayed green while
+ * every ObjectTextExtractionJob run in production threw:
+ *
+ * organization is not a valid attribute
+ * at OCA\OpenRegister\Db\ObjectEntity->getter()
+ * ObjectHandler->getSourceMetadata() -> TextExtractionService->extractObject()
+ *
+ * A `@method string|null getOrganization()` on ObjectEntity kept static
+ * analysis quiet about it too, so neither layer could see the mistake.
+ *
+ * Passing the real entity is the only shape that can catch it: the property,
+ * the column and the accessor are all spelled `organisation`.
+ *
+ * @return void
+ */
+ public function testGetSourceMetadataWorksAgainstARealObjectEntity(): void {
+ $entity = new ObjectEntity();
+ $entity->setUuid('real-uuid');
+ $entity->setOrganisation('Conduction BV');
+
+ $this->objectMapper->method('find')->willReturn($entity);
+
+ $meta = $this->handler->getSourceMetadata(1);
+
+ $this->assertArrayHasKey('organisation', $meta);
+ $this->assertSame('Conduction BV', $meta['organisation']);
+ }//end testGetSourceMetadataWorksAgainstARealObjectEntity()
+
+ /**
+ * extractText() must likewise survive a real ObjectEntity — this is the path
+ * ObjectTextExtractionJob actually takes.
+ *
+ * @return void
+ */
+ public function testExtractTextWorksAgainstARealObjectEntity(): void {
+ $entity = new ObjectEntity();
+ $entity->setUuid('real-uuid');
+ $entity->setOrganisation('Conduction BV');
+ $entity->setObject(['title' => 'Hello']);
+
+ $this->objectMapper->method('find')->willReturn($entity);
+
+ $result = $this->handler->extractText(1, []);
+
+ $this->assertStringContainsString('Organisation: Conduction BV', $result['text']);
+ }//end testExtractTextWorksAgainstARealObjectEntity()
+
}//end class
diff --git a/tests/Unit/Service/TextExtractionServiceTest.php b/tests/Unit/Service/TextExtractionServiceTest.php
index d52bd01c7d..577b8edccd 100644
--- a/tests/Unit/Service/TextExtractionServiceTest.php
+++ b/tests/Unit/Service/TextExtractionServiceTest.php
@@ -3335,28 +3335,27 @@ public function testExtractObjectSkipsWhenUpToDate(): void {
$this->objectMapper->method('find')->willReturn($object);
- // Mock isSourceUpToDate to return true.
- $chunkMock = $this->getMockBuilder(Chunk::class)
- ->disableOriginalConstructor()
- ->addMethods(['getChecksum', 'getSourceTimestamp'])
- ->getMock();
- $chunkMock->method('getChecksum')->willReturn('existing-checksum');
- $chunkMock->method('getSourceTimestamp')->willReturn($updated->getTimestamp());
- $this->chunkMapper->method('findBySource')->willReturn([$chunkMock]);
+ // isSourceUpToDate() reads getLatestUpdatedTimestamp(), NOT findBySource().
+ //
+ // This test used to stub findBySource() and wrap the call in
+ // `try { … } catch (\Throwable) {}` with a bare assertTrue(true). Both
+ // together made it unfalsifiable: the unstubbed getLatestUpdatedTimestamp()
+ // returned null, so isSourceUpToDate() was FALSE and the skip path under
+ // test never ran — extraction proceeded and then threw on the
+ // getOrganization() bug (openregister#2700), the catch swallowed it, and
+ // `expects($this->never())->method('insert')` passed because the exception
+ // had aborted the run before insert was reached. Fixing that bug is what
+ // exposed this: the method suddenly worked, extraction ran to completion,
+ // and insert WAS called.
+ //
+ // A chunk at least as new as the object means up-to-date, so nothing is
+ // re-extracted and nothing is inserted.
+ $this->chunkMapper->method('getLatestUpdatedTimestamp')->willReturn($updated->getTimestamp());
- // Should not call chunkMapper->insert (no new chunks).
$this->chunkMapper->expects($this->never())->method('insert');
- // This may or may not skip depending on checksum logic;
- // the key is verifying it doesn't throw.
- try {
- $this->service->extractObject(objectId: 1);
- } catch (\Throwable $e) {
- // Some branches may throw due to ObjectHandler instantiation.
- // That's acceptable — we're testing the skip path.
- }
-
- $this->assertTrue(true);
+ // NOT wrapped in try/catch: a throw here is a failure, not "acceptable".
+ $this->service->extractObject(objectId: 1);
}
// ────────────────────────────────────────────────────────
From aa886cb37515e69cbf38dae74cec96289797483e Mon Sep 17 00:00:00 2001
From: Ruben van der Linde
Date: Sat, 22 Aug 2026 03:51:24 +0200
Subject: [PATCH 045/139] =?UTF-8?q?chore(quality):=20migrate=20to=20PHPSta?=
=?UTF-8?q?n=202=20=E2=80=94=20229=20findings=20to=20zero,=20plus=203=20re?=
=?UTF-8?q?al=20bugs=20(#2697)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* wip(quality): PHPStan 2 — fix 93 inert @suppressWarnings and scope the MultiTenancyTrait logger guard
343 -> 229 errors. Checkpoint commit; message rewritten before the PR.
* fix(quality): remove 43 always-true comparisons; unbreak the local unit suite
PHPStan 2 part two. 229 -> 186 errors.
## Dead comparisons (43 notIdentical.alwaysTrue)
Three shapes, all provably unreachable rather than merely narrow:
1. `isset($x) === true && $x !== null` (24 sites). isset() is ALREADY
false for null, so the tail can never decide anything. Where the
condition also carried `!== ''` that clause is KEPT — isset() says
nothing about the empty string.
2. `!== null` on a value the signature declares non-nullable (13 sites):
string params, trim() results, curl_error() results.
3. Clauses duplicated by copy-paste (6 sites), e.g. SchemaService had
`($a['detected_format'] ?? null) !== null && ($a['detected_format']
!== null) === true`, and VectorizationService tested
`$embeddingData['embedding'] !== null` twice in one expression.
One site is documented rather than "fixed": SchemaMapper's `$ref` check
read `is_string(...) === false && $ref !== ''`. The second clause is dead
because the first already excluded every string — which means an
empty-string $ref is NOT rejected today, despite the error message saying
it is. Tightening that would reject schemas that currently import, so the
comment records the gap and the behaviour is unchanged.
## The local unit suite was dead (unrelated, pre-existing)
Running it aborted at 531 of 16854 tests with a FATAL:
Class OCP\IRequest@anonymous contains 2 abstract methods
(throwDecodingExceptionIfAny, getFormat)
Nextcloud 34 added those to IRequest, and 34 also made
Response::getHeaders() resolve IUserSession from the container and
cacheFor() resolve ITimeFactory — neither of which tests/stubs
registered. Three more tests could not even construct their mocks
because GraphQLController type-hints OC\Security\CSP\...NonceManager and
OC\Security\CSRF\Csrf{Token,TokenManager}, internal server classes absent
from nextcloud/ocp.
This is local-only: the whole stub block sits behind
`class_exists(\OC::class) === false`, so CI (which boots a real server)
never saw it. That is precisely the divergence worth closing — 16,323
tests were unverifiable on a developer machine. Every stub added here is
itself guarded by class_exists(), so it stays inert where the real class
is present.
Unit suite: 531 tests then FATAL -> 16854 tests, 0 errors, 0 failures.
phpcs clean on all 23 changed lib files.
* fix(quality): 19 more dead guards, 7 broken docblocks, 25 undocumented 403s
PHPStan 2 part three. 186 -> 134 errors. Unit suite still 16854 pass.
## Dead type guards (19)
`is_array()` / `is_string()` / `is_int()` on a value the surrounding code
has already narrowed. Where the guard was one clause of a compound
condition it is dropped; where it wrapped the whole body the body is
promoted and a comment records what could never happen. Examples:
- MappingService::executeMapping() wrapped a scalar in an array if
`is_array($output) === false` — unreachable, because the line above
already defaults null to [].
- SearchTrailService rejected keys that were not string-or-int. A PHP
array key is always one of those.
- EntityRecognitionHandler re-checked `is_string(mb_scrub(...))`, which
is declared to return string.
## Doubled-namespace docblocks (7)
`@psalm-return list` has no leading backslash,
so inside `namespace OCA\OpenRegister\Db` it resolved to
`OCA\OpenRegister\Db\OCA\OpenRegister\Db\Agent` — a class that does not
exist. Six mappers plus FilesController (`list` ->
`OCA\OpenRegister\Controller\OCP\Files\File`) were annotated against
phantom types; the `@return` line directly above each was correctly
backslashed, which is why it went unnoticed. Fixing the mappers also
cleared MappingService::getMappings(), which returns their result.
## Undocumented 403 responses (25)
Twenty-five admin-guarded endpoints return
`403 {"error": "Admin privileges required"}` that their `@psalm-return`
did not list. These annotations are not decoration: composer.json wires
`openapi` to `generate-spec` and openapi.json is committed, so the
published API spec has been missing the 403 on every one of these
endpoints since the SEC-CTRL guards were added.
Status unions gained 403; bodies were widened only where the 403 shape
did not already fit (calculate(), and the four FileExtractionController
batch endpoints whose bodies require `message`). Two sibling endpoints in
FileExtractionController share the same annotation shape but have NO
admin guard — 559 and 669 were deliberately left alone rather than
bulk-edited, so the spec does not advertise a 403 that cannot occur.
phpcs clean on all changed lib files.
* fix(quality): drop 25 dead capability probes; fix an unreachable fallback
PHPStan 2 part four. 134 -> 109 errors. Unit suite still 16854 pass.
## Dead method_exists()/property_exists() probes (24)
`method_exists($user, 'canChangePassword')` and friends, where the
declared type already guarantees the method: OCP\IUser (getLastLogin,
getBackendClassName, canChangeDisplayName, canChangePassword,
canChangeAvatar, getQuota), OCP\EventDispatcher\Event
(isPropagationStopped, stopPropagation), OCP\Files\Node (getId),
GuzzleHttp BadResponseException (getResponse), and our own Db\Schema
(getAllOf, getOneOf, getAnyOf, getPropertiesWithAuthorization).
These were NOT cross-version guards. appinfo declares NC 32-34 and every
one of these methods exists across that whole range, so there is no
version in which the probe could be false.
The probes that DO protect something are deliberately kept, and each
now says so: `getAvatarScope` and `canChangeMailAddress` are absent from
OCP\IUser; `setErrors` is absent from Event; `findAllByObject` is absent
from AuditTrailMapper. Those sit next to the ones removed, which is
exactly why the block looked uniformly defensive.
## An always-true guard was hiding a broken fallback (1)
UserService::updateAccountManagerProperties() read:
if ($account->getProperty($p) !== null) { ...update...; continue; }
// Property doesn't exist, create it.
$account->setProperty(...);
IAccount::getProperty() is declared `: IAccountProperty` and THROWS
PropertyDoesNotExistException when the property is absent — it never
returns null. So the guard was always true, the `continue` always ran,
and the create-it path below was unreachable. A user setting a profile
field they had never set before did not get it created: the exception
escaped to the method's outer catch, which logged a warning and
abandoned the entire account update, silently dropping the other fields
in the same request too.
Replaced the null test with a catch of PropertyDoesNotExistException that
falls through to the create path — which is what the code below always
meant to do. This is a behaviour fix, not a lint fix; it is here because
the lint finding is what exposed it.
phpcs clean on all changed files.
* fix(quality): remove 35 unreachable null/instanceof guards
PHPStan 2 part five. 109 -> 74 errors. Unit suite still 16854 pass.
Every guard removed here tested a value the callee's own signature
forbids. A method declared `: Schema` cannot return null — PHP itself
raises a TypeError if it tries — so `if ($schema !== null)` was not
defensive, it was dead. The mappers signal "not found" by THROWING, and
in almost every case the surrounding try/catch was already handling that
properly; the null test sat inside the try doing nothing.
Same for the instanceof re-checks: `$event->getSchema() instanceof
Schema` after a `: Schema` return type, `$old instanceof
\JsonSerializable` on Db entities that all extend OCP Entity, and
`$event->getResponse() instanceof TemplateResponse` on a getter declared
to return exactly that.
## Two of these were hiding dead error handling
SettingsService's batch save counted failures in two branches:
if ($savedObject !== null) { $successes++; }
if ($savedObject === null) { $failed++; ...'Save operation returned
null'... }
saveObject() is non-nullable, so the failure branch never ran and the
"returned null" error was unreachable in both the serial and parallel
paths. No counter was actually wrong — the catch below already increments
failed_saves on the real failure path — but the dead branch made it look
like there were two failure modes when there is one.
Files: RetentionController, MagicMapper (4), Db/Schema, SharesProvider,
ShareLinkService, ImportService, ImportHandler, SaveObject,
FilePropertyHandler, ValidateObject, TranslationHandler, SettingsService,
QueryComplexityAnalyzer, PermissionHandler, ObjectsController,
SchemaGenerator, and six listeners/services with instanceof re-checks.
Deliberately NOT removed: FilesSidebarListener's get_class() string
comparison against 'OCA\Files\Event\LoadAdditionalScriptsEvent'. PHPStan
calls it always-true, but the comment above it records why it is a string
compare and not an instanceof — avoiding a hard dependency on the Files
app. That one needs a scoped ignore, not a deletion.
phpcs clean on all changed files.
* fix(search): stop view search terms being applied twice; 39 more lint fixes
PHPStan 2 part six. 74 -> 35 errors. Unit suite still 16854 pass.
## Real bug: a view's search term was sent to the backend twice
SearchQueryHandler, applying a saved view to a query:
// Merge with existing search if present.
$query['_search'] = $searchTerms;
if (isset($query['_search']) && !empty($query['_search'])) {
$query['_search'] .= ' ' . $searchTerms;
}
The assignment sits ABOVE its own guard, so the guard always passed and
the terms were appended to themselves: a view searching for "invoice"
queried for "invoice invoice". Restored the intended merge — append when
the caller already had a search, otherwise take the view's terms as-is.
## Self-correction: GraphQL description annotation
Two earlier commits in this series each removed one clause from
if (isset($authInfo[$name]) && $description !== null && $description !== '')
elseif (isset($authInfo[$name]))
which left both arms testing the same thing, making the elseif dead. That
turned a property with no description into ". Requires: ..." instead of
"Requires: ...". Rewritten as a nested null test so the two cases are
distinguishable again and neither comparison is redundant.
## Flow::setComment() did not exist as far as static analysis knew
Db\Flow declares 46 `@method` pairs for its magic accessors but the
`comment` property was added without one, so FlowService's two
`setComment()` calls were reported as calls to an undefined method.
Runtime was fine (OCP Entity::__call handles it); the annotation was
simply missing. Added the get/set pair.
## Redundant operations (24)
- array_values() on a value already a list (4): after sort(), after
array_keys(), and inside a branch guarded by array_is_list().
- `?? default` where the offset always exists (6): preg_match groups that
a trailing `(.*)` always populates, preg_match_all's group 1, and a
$query key assigned unconditionally 26 lines earlier.
- isset()/empty()/is_array() re-tests of values already narrowed (9).
- by-ref param types (9): HarvestPipelineService's $summary is a fixed
shape, not array; two accumulators keyed by a
caller-supplied string need @param-out array because
PHP coerces canonical numeric string keys to int.
## Two scoped ignores instead of code changes
FilesSidebarListener compares get_class($event) against the Files app's
event class-string ON PURPOSE, so openregister carries no hard dependency
on that optional app. The class is absent during analysis, so PHPStan
decides the comparison never matches and then calls the whole listener
body unreachable. It is not — the listener is only ever registered for
that event. An instanceof would be exactly the compile-time dependency
the string compare exists to avoid, so this is a scoped ignore with the
reason recorded, not a rewrite.
Also added the `??` sibling of the existing MultiTenancyTrait $logger
ignore, same justification as the isset() one already there.
phpcs clean on all changed files.
* fix(quality): PHPStan 2 reaches zero (229 -> 0)
Final tranche. Unit suite 16854 pass, phpcs clean.
## Stale generated shapes (5)
Deep `@psalm-return` shapes that had rotted into nonsense because they
were inferred once and never re-derived:
- RelationHandler::getUsedBy() declared `results: array,
total: 0` — inferred while the method was a stub. It has since been
implemented and returns real rows.
- RegistersController::stats() pinned `quota` to all-null and `usage` to
all-literal-zero, inferred from a register that had neither.
- SchemasController::index() restated Schema::jsonSerialize() field by
field and omitted the four keys (`objects`, `logs`, `files`,
`registers`) the `_stats` block appends to every entry.
For the two controllers the fix is NOT a more precise copy — it is to
stop copying. The entity serializers own those shapes; restating them in
a controller docblock guarantees the copy drifts again. Both now declare
`array` for the entity payload with the reason recorded,
keeping the part openapi actually needs (status codes, top-level keys).
- ImportService::importFromExcel() gained `|string` in its value union,
which is not slack: the method returns a map of sheet-title => summary
but ALSO writes a scalar `importJobId` into that same map. A caller
iterating the result hits it. Left as-is (moving it changes the
response shape for existing clients) but now visible in the type.
- Application::jsonSerialize()'s `quota` claimed `users: null, groups:
null`; both are ints.
## SearchController returned id:null for every hit
The closure formatting search results narrowed on the OCP `Entity` base
and then called `getUuid()` / `getName()`, which ObjectEntity declares
only as `@method`. Against the base those resolved to an error type,
which propagated through the result array and made the whole
JSONResponse payload unverifiable — the last `argument.unresolvableType`.
Narrowing on ObjectEntity instead makes the accessors resolve properly
and also makes the two property_exists() probes redundant.
The comment already recorded that an earlier method_exists() version of
this probe had returned `id: null, name: 'Unknown'` for every search hit;
this removes the remaining indirection rather than adding another ignore.
## Other
- ConfigurationController's six GitHub-publish helpers took `object
$configuration`; they all receive a Db\Configuration. Typed properly,
which resolves `$configuration->getId()` in the response payload.
- Added the `DataResponseType` type alias (verbatim copy of the OCP one,
which PHPStan cannot read from its declaration site). procest already
carries this; it belongs in the shared base once a third app needs it.
- FileMapper/TranslationMapper/EntityRelationMapper/CacheHandler/
RenderObject: keys documented as `string` are really `int|string`,
because PHP coerces canonical numeric-string keys to int. In
RenderObject this had PHPStan reporting the per-file tag loop as
iterating an always-empty array; it does not, since PHP applies the
same coercion on lookup.
- Removed two dead properties: DeepLinkRegistryService's forward
slug→ID maps (declared and reset, never populated or read) and
SettingsController::$objectService (assigned null and returned).
- SharePrincipalDeriver::grants() takes `array`, not
`string[]`: both callers pass `['use', null]` deliberately, because a
share entry with no explicit permission has `permission => null`.
## Four scoped ignores, each with its reason
FlowNodePreflight's InvalidArgumentException arm (third-party nodes are
off the analysis path), ConfigurationMapper's intentional no-op cache
seam, OCP's too-narrow executeQuery() $params stub, and
SystemEntityObjectAdapter's required constructor args (a virtual entity
never built via Entity::fromRow()).
* fix(quality): drop the DataResponseType alias — it fixed nothing here
I added this alias in the previous commit on the theory that OCP's
unreadable `@psalm-type DataResponseType` bound was behind the two
`argument.unresolvableType` reports on `new JSONResponse(...)`. That was
a guess, and it was wrong: adding it did not move the count, and the two
errors were actually caused by
- ConfigurationController's helpers typing $configuration as bare
`object`, so `$configuration->getId()` had no resolvable return, and
- SearchController narrowing on the OCP `Entity` base instead of
ObjectEntity, so the `@method` accessors resolved to an error type.
Both are fixed at source in that commit. Re-running phpstan with the
alias removed: still 0 errors. Keeping an ignore-shaped entry whose
stated reason is not the real one is worse than not having it — the next
person to hit a JSONResponse type error would trust it and stop looking.
Verified the same way in openconnector, where 13 of these reports exist:
adding the alias there changed 44 errors to 44.
* fix(quality): repair four phpmd regressions from this branch
`composer phpmd` (which I had not run until after opening the PR) found
four findings, all introduced by earlier commits in this series:
- SettingsService (x2): removing the dead `if ($savedObject === null)`
branch left `$savedObject` assigned and never read. The call is kept
for its side effect; the assignment is gone.
- SchemaGenerator and SearchQueryHandler: both of my rewrites used an
if/else, which the ruleset rejects (ElseExpression). Restructured to
compute a prefix and concatenate once — same behaviour, no else.
phpstan 0, phpcs clean, both phpmd rulesets clean, 16854 tests pass.
Psalm exits non-zero on this branch, but it does so on `development`
too — it is pre-existing and not addressed here.
* fix(quality): prune 3 psalm baseline entries this branch made obsolete
Correcting the previous commit, which claimed psalm's non-zero exit was
pre-existing. It was not — I had not checked. CI's psalm job is GREEN on
`development`; the failure is caused by this branch.
psalm.xml sets findUnusedBaselineEntry, so a suppression that is no
longer needed FAILS the build. Three had been retired by fixes earlier in
this series:
- Mapping.php RedundantPropertyInitializationCheck for
`isset($this->id) === true` — that guard is gone.
- HarvestPipelineService StringIncrement x6 on `$summary[...]` — giving
the by-ref $summary its real shaped-array type means those keys are
int, so incrementing them is no longer a string increment.
- HarvestPipelineService UnusedParam for `$summary` — same cause.
`psalm --update-baseline` removes only entries that no longer fire; the
diff is 18 deletions and nothing else. Psalm now exits 0.
Lesson repeated from earlier in this session: do not write "pre-existing"
into a commit message without running the comparison. The check is two
minutes; the wrong claim outlives it.
* style(quality): satisfy phpcs on the comments this branch added
CI's phpcs step runs `--warning-severity=0`, and it failed on every one
of the three PRat in this series for the same reason: comments I wrote.
Two sniffs:
- Squiz.Commenting.InlineComment.NotCapital — many of my new comments
open with a lowercase function name ("// find() throws rather than
..."). Rephrased so the first word is a real capitalised word.
- Generic.Commenting.DocComment.TagsNotGrouped — the `@param-out` tags I
added were interleaved between `@param` tags, splitting the group.
Moved below the last `@param`. One of those inserts had also orphaned a
continuation line off the `@param` above it; that is rejoined.
Where PHPStan genuinely needs a `/** @var */` inline doc-block (which
Squiz.Commenting.InlineComment.DocBlock rejects), the line now carries a
targeted `phpcs:ignore` naming that sniff and saying why, rather than
dropping the annotation and leaving the type wrong.
I should have caught this locally. I did run phpcs, but with
`--report=summary | tail -3`, which prints only the timing line — so I
read an empty tail as "clean" when the error count was two lines above
the cut. Re-verified here with CI's exact invocation.
* fix(quality): one new PHPStan 2 finding from the development merge
`development` moved 7 commits ahead while this branch was open, and it
had independently made several of the same fixes — four files conflicted
because both sides had rewritten the same guard. Resolved in favour of
development in every case: its versions are equivalent or better (its
SearchQueryHandler merge also handles a non-string `_search`, and its
ObjectsController comment correctly notes the SIBLING call keeps its `??`
because there `$query` has no unconditional `_rbac` assignment).
The merge brought in SchemaAttribution.php, which landed on development
while phpstan there was still 1.x, so nothing had analysed it under 2.x
yet. It carried one `arrayValues.list` — array_values() on a variable
that is only ever appended to. Removed.
After merge: phpstan 0, phpcs 0, phpmd clean, 16916 unit tests pass
(development added 62).
* build(ci): adopt the canonical coverage-guard (adds --deletion-neutral)
The coverage ratchet failed this branch:
Changed files, head: 63.97% (33689/52662 statements)
Changed files, base: 63.97% (33773/52791 statements)
FAIL: coverage of the files this change touches dropped by 0%.
Both numbers round to 63.97%; the real delta is 0.0025pp. This branch
DELETES 129 statements, 84 of them covered — that is what dead-code
removal looks like to a plain ratio, because the guard cannot tell
"deleted well-tested dead code" from "added untested code".
The job already said so, and named the remedy:
notice: scripts/coverage-guard.php predates --deletion-neutral, so
deleting well-tested dead code will still read as a coverage drop.
Copy the canonical version from ConductionNL/.github at
quality-config/coverage-guard.php to pick it up.
Done — byte-for-byte from ConductionNL/.github@main (17,288 -> 35,733
bytes). The runner probes CG_CAPABILITIES to decide whether to pass the
flag, so adopting the script is the whole change; no workflow edit.
This is not a way around the ratchet. The guard still fails on a real
coverage regression; it stops counting deletions as one.
* test(user): cover the account-property create path this branch unblocked
The coverage ratchet failed even with --deletion-neutral:
Surviving code, head: 63.97% (33689/52662 statements)
Surviving code, base: 63.97% (33773/52791 statements)
FAIL: ... dropped by less than 0.01% ... a real loss in the counts
Deletion-neutral attributes by METHOD (116 files, 2344 methods on both
sides — no method was added or deleted), so it cannot help here: this
branch removes statements from INSIDE surviving methods. Those statements
were dead guards, and a dead guard still EXECUTES — it was covered. 84 of
the 129 removed statements were covered, i.e. 65.1% against a 63.97%
average, so removing them drags the ratio down by construction.
Rather than argue with the ratchet, this adds the test the behaviour fix
in 185b9da should have shipped with. UserService::updateProfileProperties()
had an unreachable "create it" path — `getProperty()` throws rather than
returning null, so the `!== null` guard was always true and a profile
field the user had never set before was never created. Three tests:
- a never-set field IS created (setProperty + updateAccount reached)
- an existing field is updated in place and setProperty is NOT called
(the must-FAIL control for the first — if the create path ran
unconditionally this would fire too)
- one missing property does not discard the other fields in the same
request, which is the user-visible half of the bug: the escaping
exception aborted the whole loop
Verified the suite can FAIL: changing the catch to a type
PropertyDoesNotExistException does not match reproduces the original
behaviour and turns tests 1 and 3 red. Restored; 3/3 green, full unit
suite 16919 pass.
---------
Co-authored-by: Conduction Release Bot
---
composer.json | 4 +-
composer.lock | 23 +-
.../BackfillCalendarLinksJob.php | 4 +-
lib/BackgroundJob/DestructionCheckJob.php | 2 +-
lib/Controller/BulkController.php | 2 +-
lib/Controller/ChatController.php | 6 +-
lib/Controller/ConfigurationController.php | 44 +-
lib/Controller/ConfigurationsController.php | 18 +-
lib/Controller/ConversationController.php | 6 +-
lib/Controller/DashboardController.php | 20 +-
lib/Controller/DeletedController.php | 2 +-
lib/Controller/EndpointsController.php | 4 +-
lib/Controller/FileExtractionController.php | 20 +-
lib/Controller/FilesController.php | 12 +-
lib/Controller/MappingsController.php | 2 +-
lib/Controller/ObjectsController.php | 48 ++-
lib/Controller/RegistersController.php | 77 ++--
lib/Controller/RetentionController.php | 16 +-
lib/Controller/SchemasController.php | 22 +-
lib/Controller/SearchController.php | 40 +-
lib/Controller/SearchTrailController.php | 14 +-
.../Settings/LlmSettingsController.php | 10 +-
.../Settings/N8nSettingsController.php | 2 +-
.../Settings/ValidationSettingsController.php | 2 +-
lib/Controller/SettingsController.php | 34 +-
lib/Controller/SourcesController.php | 4 +-
lib/Controller/UserController.php | 2 +-
lib/Controller/ViewsController.php | 10 +-
lib/Controller/WebhooksController.php | 20 +-
lib/Db/AgentMapper.php | 2 +-
lib/Db/Application.php | 2 +-
lib/Db/EndpointLogMapper.php | 2 +-
lib/Db/EndpointMapper.php | 2 +-
lib/Db/EntityRelationMapper.php | 4 +-
lib/Db/FileMapper.php | 6 +-
lib/Db/Flow.php | 2 +
lib/Db/MagicMapper.php | 106 ++---
lib/Db/MagicMapper/MagicFacetHandler.php | 4 +-
lib/Db/MagicMapper/MagicSearchHandler.php | 8 +-
lib/Db/MagicMapper/MagicStatisticsHandler.php | 4 +-
lib/Db/Mapping.php | 2 +-
lib/Db/MappingMapper.php | 2 +-
lib/Db/MultiTenancyTrait.php | 2 +-
lib/Db/NotificationSubscriptionMapper.php | 12 +-
lib/Db/RegisterMapper.php | 9 +-
lib/Db/Schema.php | 67 +--
lib/Db/SchemaMapper.php | 11 +-
lib/Db/TranslationMapper.php | 4 +-
lib/Db/WebhookLogMapper.php | 2 +-
lib/Db/WebhookMapper.php | 2 +-
lib/Listener/ActionListener.php | 2 +-
lib/Listener/ApprovalChainAdvanceListener.php | 6 +-
.../LifecycleInitialStateListener.php | 2 +-
lib/Listener/MailAppScriptListener.php | 6 +-
...tificationDedupeAnnotationSyncListener.php | 17 +-
.../SystemEntityNotificationListener.php | 42 +-
lib/Middleware/LanguageMiddleware.php | 4 +-
lib/Service/ActionExecutor.php | 10 +-
lib/Service/ApprovalService.php | 6 +-
lib/Service/CalendarLinkService.php | 2 +-
lib/Service/Chat/ContextRetrievalHandler.php | 8 +-
.../Chat/ResponseGenerationHandler.php | 1 -
lib/Service/Configuration/ExportHandler.php | 2 +-
lib/Service/Configuration/GitHubHandler.php | 13 +-
lib/Service/Configuration/ImportHandler.php | 16 +-
lib/Service/CospendLinkService.php | 2 +-
lib/Service/Dbal/DbalConnectionFactory.php | 2 +-
lib/Service/DeepLinkRegistryService.php | 19 +-
lib/Service/EmailLinkService.php | 4 +-
lib/Service/EmailService.php | 4 +-
.../File/DocumentProcessingHandler.php | 16 +-
lib/Service/File/ReadFileHandler.php | 2 +-
lib/Service/FileService.php | 3 +-
.../GraphQL/QueryComplexityAnalyzer.php | 17 +-
lib/Service/GraphQL/SchemaGenerator.php | 17 +-
.../SchemaGenerator/CompositionHandler.php | 19 +-
.../SchemaGenerator/TypeMapperHandler.php | 6 +-
lib/Service/ImportService.php | 15 +-
.../BuiltinProviders/AuditTrailProvider.php | 35 +-
.../Integration/Providers/SharesProvider.php | 13 +-
lib/Service/MappingService.php | 11 +-
.../AnnotationNotificationDispatcher.php | 6 +-
.../NotificationAnnotationValidator.php | 4 +-
lib/Service/OasService.php | 2 +-
lib/Service/Object/CacheHandler.php | 41 +-
lib/Service/Object/CascadingHandler.php | 39 +-
lib/Service/Object/DeleteObject.php | 76 ++--
lib/Service/Object/FacetHandler.php | 10 +-
lib/Service/Object/PermissionHandler.php | 4 +-
lib/Service/Object/RelationHandler.php | 10 +-
lib/Service/Object/RenderObject.php | 15 +
lib/Service/Object/SaveObject.php | 28 +-
.../Object/SaveObject/FilePropertyHandler.php | 8 +-
lib/Service/Object/TranslationHandler.php | 17 +-
lib/Service/Object/ValidateObject.php | 7 +-
lib/Service/ObjectService.php | 11 +-
.../Quality/QualityStatisticsService.php | 6 +-
.../Reporting/SpreadsheetReportWriter.php | 2 +-
lib/Service/Schema/SchemaDiffService.php | 5 +-
lib/Service/SchemaService.php | 3 +-
lib/Service/SearchTrailService.php | 13 +-
lib/Service/SettingsService.php | 45 +-
lib/Service/ShareLinkService.php | 10 +-
.../SharedSchema/SchemaAttribution.php | 4 +-
lib/Service/Sharing/SharePrincipalDeriver.php | 7 +-
lib/Service/Sync/HarvestPipelineService.php | 10 +-
.../EntityRecognitionHandler.php | 6 +-
lib/Service/TextExtraction/ObjectHandler.php | 4 +-
lib/Service/TextExtractionService.php | 4 +-
lib/Service/TimeTrackerLinkService.php | 2 +-
lib/Service/TmloService.php | 6 +-
lib/Service/UserService.php | 72 ++--
.../Handlers/EmbeddingGeneratorHandler.php | 2 +-
.../ObjectVectorizationStrategy.php | 5 +-
lib/Service/VectorizationService.php | 3 +-
lib/Service/ViewPresentationService.php | 2 +-
lib/Tool/AbstractTool.php | 2 +-
phpstan.neon | 94 ++++
psalm-baseline.xml | 18 -
scripts/coverage-guard.php | 405 +++++++++++++++++-
...UserServiceAccountPropertyCreationTest.php | 227 ++++++++++
tests/stubs/NextcloudInternalStubs.php | 68 +++
122 files changed, 1552 insertions(+), 798 deletions(-)
create mode 100644 tests/Unit/Service/UserServiceAccountPropertyCreationTest.php
diff --git a/composer.json b/composer.json
index bc0dcbcd11..573d49e189 100644
--- a/composer.json
+++ b/composer.json
@@ -127,7 +127,7 @@
},
"require-dev": {
"conduction/coding-standard": "^1.0",
- "conduction/hydra-gates": "^1.0",
+ "conduction/hydra-gates": "^1.8.2",
"cyclonedx/cyclonedx-php-composer": "^6.2",
"doctrine/dbal": "^3.8",
"edgedesign/phpqa": "^1.27",
@@ -135,7 +135,7 @@
"phpcsstandards/phpcsextra": "^1.4",
"phpmd/phpmd": "^2.15",
"phpmetrics/phpmetrics": "^2.8",
- "phpstan/phpstan": "^1.10",
+ "phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^10.5.62",
"roave/security-advisories": "dev-latest",
"sabre/vobject": "^4.5",
diff --git a/composer.lock b/composer.lock
index 6569a7866a..a39dc2fd4e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "b91761870b0b3b4e242eee6912c58ed5",
+ "content-hash": "afcdeab8bd2a2678bfc97c9368dddee9",
"packages": [
{
"name": "adbario/php-dot-notation",
@@ -9721,15 +9721,15 @@
},
{
"name": "phpstan/phpstan",
- "version": "1.12.33",
+ "version": "2.2.8",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1",
- "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804",
+ "reference": "e285254e60f33c21902efef4a926ca0987c06804",
"shasum": ""
},
"require": {
- "php": "^7.2|^8.0"
+ "php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
@@ -9748,6 +9748,17 @@
"license": [
"MIT"
],
+ "authors": [
+ {
+ "name": "Ondřej Mirtes"
+ },
+ {
+ "name": "Markus Staab"
+ },
+ {
+ "name": "Vincent Langlet"
+ }
+ ],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
@@ -9770,7 +9781,7 @@
"type": "github"
}
],
- "time": "2026-02-28T20:30:03+00:00"
+ "time": "2026-08-04T22:21:45+00:00"
},
{
"name": "phpunit/php-code-coverage",
diff --git a/lib/BackgroundJob/BackfillCalendarLinksJob.php b/lib/BackgroundJob/BackfillCalendarLinksJob.php
index e902cd2a10..b709a5ae03 100644
--- a/lib/BackgroundJob/BackfillCalendarLinksJob.php
+++ b/lib/BackgroundJob/BackfillCalendarLinksJob.php
@@ -237,11 +237,11 @@ private function backfillEvent(
* @return void
*/
private function applyEventDates(CalendarLink $link, array $event): void {
- if (isset($event['dtstart']) === true && $event['dtstart'] !== null) {
+ if (isset($event['dtstart']) === true) {
$link->setDtstart(new DateTime((string)$event['dtstart']));
}
- if (isset($event['dtend']) === true && $event['dtend'] !== null) {
+ if (isset($event['dtend']) === true) {
$link->setDtend(new DateTime((string)$event['dtend']));
}
}//end applyEventDates()
diff --git a/lib/BackgroundJob/DestructionCheckJob.php b/lib/BackgroundJob/DestructionCheckJob.php
index c3d5b740f4..ef0c2a9033 100644
--- a/lib/BackgroundJob/DestructionCheckJob.php
+++ b/lib/BackgroundJob/DestructionCheckJob.php
@@ -272,7 +272,7 @@ classification: $retention['classification'] ?? null,
// Rebuild the persisted set from only the still-in-window UUIDs plus the freshly
// notified ones, dropping any whose destruction date has passed or moved away.
- $rebuilt = array_values(array_keys($stillRelevant + $newNotified));
+ $rebuilt = array_keys($stillRelevant + $newNotified);
if ($newCount > 0 || count($rebuilt) !== count($notified)) {
$appConfig->setValueString('openregister', self::NOTIFIED_KEY, json_encode($rebuilt));
diff --git a/lib/Controller/BulkController.php b/lib/Controller/BulkController.php
index 15dc29c5d8..a94654b7f6 100644
--- a/lib/Controller/BulkController.php
+++ b/lib/Controller/BulkController.php
@@ -47,7 +47,7 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @spec openspec/specs/data-import-export/spec.md
* @spec openspec/specs/object-lifecycle/spec.md
diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php
index 60f53c57c4..d001cefb94 100644
--- a/lib/Controller/ChatController.php
+++ b/lib/Controller/ChatController.php
@@ -65,7 +65,7 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.CouplingBetweenObjects)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class ChatController extends Controller {
@@ -180,7 +180,7 @@ class ChatController extends Controller {
*
* @return void
*
- * @suppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
*/
public function __construct(
string $appName,
@@ -697,7 +697,7 @@ public function clearHistory(): JSONResponse {
*
* @return JSONResponse JSON response with feedback confirmation or error
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @spec openspec/specs/chat-ai/spec.md
*/
diff --git a/lib/Controller/ConfigurationController.php b/lib/Controller/ConfigurationController.php
index 6c8aa8f9b9..2d2159c538 100644
--- a/lib/Controller/ConfigurationController.php
+++ b/lib/Controller/ConfigurationController.php
@@ -51,11 +51,11 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.ExcessiveClassLength)
- * @suppressWarnings(PHPMD.ExcessiveClassComplexity)
- * @suppressWarnings(PHPMD.TooManyMethods)
- * @suppressWarnings(PHPMD.TooManyPublicMethods)
- * @suppressWarnings(PHPMD.CouplingBetweenObjects)
+ * @SuppressWarnings(PHPMD.ExcessiveClassLength)
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.TooManyMethods)
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class ConfigurationController extends Controller {
use \OCA\OpenRegister\Controller\Trait\HandlesExceptionsTrait;
@@ -205,8 +205,8 @@ public function index(): JSONResponse {
* @NoCSRFRequired
*
* @psalm-return JSONResponse<200, Configuration,
- * array>|JSONResponse<404|500,
- * array{error: 'Configuration not found'|'Failed to fetch configuration'},
+ * array>|JSONResponse<403|404|500,
+ * array{error: string},
* array>
*
* @spec openspec/specs/data-import-export/spec.md
@@ -714,7 +714,7 @@ public function import(int $id): JSONResponse {
*
* @NoCSRFRequired
*
- * @psalm-return JSONResponse<200|404|500, array, array>
+ * @psalm-return JSONResponse<200|403|404|500, array, array>
*
* @spec openspec/specs/data-import-export/spec.md
*/
@@ -761,7 +761,7 @@ public function export(int $id): JSONResponse {
*
* @NoCSRFRequired
*
- * @psalm-return JSONResponse<200|400|500,
+ * @psalm-return JSONResponse<200|400|403|500,
* array{error?: string, total_count?: int<0, max>|mixed,
* results?: list{0?: array{repository?: mixed, owner?: string,
* repo?: string, path: mixed|string, url: ''|mixed, stars?: 0|mixed,
@@ -1289,7 +1289,7 @@ private function fetchConfigFromUrl(array $params): array {
*
* @return JSONResponse JSON response with import result
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*/
private function importFromSource(callable $fetchConfig, array $params, string $sourceType): JSONResponse {
try {
@@ -1645,13 +1645,13 @@ private function getExistingConfigErrorMessage(string $appId): string {
*
* Checks if configuration is local and can be published to GitHub.
*
- * @param object $configuration Configuration entity.
+ * @param Configuration $configuration Configuration entity.
*
* @return JSONResponse|null Error response if validation fails, null if valid.
*
* @psalm-return JSONResponse<400, array{error: 'Only local configurations can be published'}, array>|null
*/
- private function validateConfigurationForPublishing(object $configuration): ?JSONResponse {
+ private function validateConfigurationForPublishing(Configuration $configuration): ?JSONResponse {
// Only allow publishing local configurations.
if ($configuration->getIsLocal() !== true) {
return new JSONResponse(
@@ -1669,11 +1669,11 @@ private function validateConfigurationForPublishing(object $configuration): ?JSO
* Extracts owner, repo, path, branch, and commit message from request.
* Validates required parameters and normalizes path.
*
- * @param object $configuration Configuration entity.
+ * @param Configuration $configuration Configuration entity.
*
* @return array Parameters array or error array.
*/
- private function extractGitHubPublishParams(object $configuration): array {
+ private function extractGitHubPublishParams(Configuration $configuration): array {
$data = $this->request->getParams();
$owner = $data['owner'] ?? '';
$repo = $data['repo'] ?? '';
@@ -1733,12 +1733,12 @@ private function logPublishingAttempt(int $id, array $params): void {
*
* Exports configuration and adds GitHub metadata.
*
- * @param object $configuration Configuration entity.
+ * @param Configuration $configuration Configuration entity.
* @param array $params Publishing parameters.
*
* @return false|string JSON content ready for GitHub.
*/
- private function prepareConfigurationForGitHub(object $configuration, array $params): string|false {
+ private function prepareConfigurationForGitHub(Configuration $configuration, array $params): string|false {
// Export configuration to array.
$configData = $this->configurationService->exportConfig(
input: $configuration,
@@ -1823,12 +1823,12 @@ private function publishConfigurationToGitHub(array $params, string $content, ?s
*
* Updates local configuration entity with GitHub publishing details.
*
- * @param object $configuration Configuration entity.
+ * @param Configuration $configuration Configuration entity.
* @param array $params Publishing parameters.
*
* @return void
*/
- private function updateConfigurationWithGitHubInfo(object $configuration, array $params): void {
+ private function updateConfigurationWithGitHubInfo(Configuration $configuration, array $params): void {
$githubRepo = "{$params['owner']}/{$params['repo']}";
$sourceUrl = "https://github.com/{$githubRepo}/blob/{$params['branch']}/{$params['path']}";
@@ -1845,13 +1845,13 @@ private function updateConfigurationWithGitHubInfo(object $configuration, array
*
* Logs successful GitHub publishing operation.
*
- * @param object $configuration Configuration entity.
+ * @param Configuration $configuration Configuration entity.
* @param array $params Publishing parameters.
* @param array $result GitHub API result.
*
* @return void
*/
- private function logPublishingSuccess(object $configuration, array $params, array $result): void {
+ private function logPublishingSuccess(Configuration $configuration, array $params, array $result): void {
$this->logger->debug(
message: "[ConfigurationController] Successfully published configuration {$configuration->getTitle()} to GitHub",
context: [
@@ -1871,13 +1871,13 @@ private function logPublishingSuccess(object $configuration, array $params, arra
*
* Creates success response including GitHub URLs and indexing notes.
*
- * @param object $configuration Configuration entity.
+ * @param Configuration $configuration Configuration entity.
* @param array $params Publishing parameters.
* @param array $result GitHub API result.
*
* @return JSONResponse JSON response with publish success data
*/
- private function buildPublishSuccessResponse(object $configuration, array $params, array $result): JSONResponse {
+ private function buildPublishSuccessResponse(Configuration $configuration, array $params, array $result): JSONResponse {
// Get default branch for indexing note.
$defaultBranch = $this->getRepositoryDefaultBranch(params: $params);
diff --git a/lib/Controller/ConfigurationsController.php b/lib/Controller/ConfigurationsController.php
index 53677551d4..d0ba2bd2d2 100644
--- a/lib/Controller/ConfigurationsController.php
+++ b/lib/Controller/ConfigurationsController.php
@@ -173,14 +173,14 @@ public function show(int $id): JSONResponse {
*
* @NoCSRFRequired
*
- * @suppressWarnings(PHPMD.StaticAccess) Uuid::v4() is a standard utility pattern
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.StaticAccess) Uuid::v4() is a standard utility pattern
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*
* @return JSONResponse JSON response with created configuration or error
*
* @psalm-return JSONResponse<201, \OCA\OpenRegister\Db\Configuration,
- * array>|JSONResponse<400, array{error: string},
+ * array>|JSONResponse<400|403, array{error: string},
* array>
*
* @spec openspec/specs/data-import-export/spec.md
@@ -244,7 +244,7 @@ public function create(): JSONResponse {
* @return JSONResponse JSON response with updated configuration or error
*
* @psalm-return JSONResponse<200, \OCA\OpenRegister\Db\Configuration,
- * array>|JSONResponse<400, array{error: string},
+ * array>|JSONResponse<400|403, array{error: string},
* array>
*
* @spec openspec/specs/data-import-export/spec.md
@@ -299,7 +299,7 @@ public function update(int $id): JSONResponse {
* @NoCSRFRequired
*
* @psalm-return JSONResponse<200, \OCA\OpenRegister\Db\Configuration,
- * array>|JSONResponse<400, array{error: string},
+ * array>|JSONResponse<400|403, array{error: string},
* array>
*
* @spec openspec/specs/data-import-export/spec.md
@@ -320,7 +320,7 @@ public function patch(int $id): JSONResponse {
* @return JSONResponse JSON response on success (204) or error
*
* @psalm-return JSONResponse<204, null,
- * array>|JSONResponse<400, array{error: string},
+ * array>|JSONResponse<400|403, array{error: string},
* array>
*
* @spec openspec/specs/data-import-export/spec.md
@@ -354,10 +354,10 @@ public function destroy(int $id): JSONResponse {
* @NoCSRFRequired
*
* @psalm-return DataDownloadResponse<200, 'application/json',
- * array>|JSONResponse<400, array{error: string},
+ * array>|JSONResponse<400|403, array{error: string},
* array>
*
- * @suppressWarnings(PHPMD.BooleanArgumentFlag) Toggle to include/exclude objects in export
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Toggle to include/exclude objects in export
*
* @spec openspec/changes/retrofit-2026-05-24-b-ctrl-object-data/tasks.md#task-14
*/
diff --git a/lib/Controller/ConversationController.php b/lib/Controller/ConversationController.php
index 8bce81edcd..9a73f0cd1b 100644
--- a/lib/Controller/ConversationController.php
+++ b/lib/Controller/ConversationController.php
@@ -53,7 +53,7 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.CouplingBetweenObjects)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class ConversationController extends Controller {
@@ -127,7 +127,7 @@ class ConversationController extends Controller {
* @param LoggerInterface $logger Logger
* @param string $userId User ID
*
- * @suppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
*/
public function __construct(
string $appName,
@@ -681,7 +681,7 @@ public function update(string $uuid): JSONResponse {
* 'Failed to delete conversation', message: string, uuid?: string,
* archived?: true}, array>
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @spec openspec/specs/chat-ai/spec.md
*/
diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php
index c46ec71d06..cdca0b78cc 100644
--- a/lib/Controller/DashboardController.php
+++ b/lib/Controller/DashboardController.php
@@ -187,7 +187,7 @@ public function page(): TemplateResponse {
* @return JSONResponse The JSON response containing registers with schemas
*
* @psalm-return JSONResponse<
- * 200|500,
+ * 200|403|500,
* array{
* error?: string,
* registers?: list,
* series?: list, name: string}>},
* array>
@@ -439,7 +439,7 @@ public function getAuditTrailActionChart(
*
* @return JSONResponse JSON response with chart data or error
*
- * @psalm-return JSONResponse<200|500,
+ * @psalm-return JSONResponse<200|403|500,
* array{error?: string, labels?: array<'Unknown'|mixed>, series?: array},
* array>
*
@@ -470,7 +470,7 @@ public function getObjectsByRegisterChart(?int $registerId = null, ?int $schemaI
*
* @return JSONResponse JSON response with chart data or error
*
- * @psalm-return JSONResponse<200|500,
+ * @psalm-return JSONResponse<200|403|500,
* array{error?: string, labels?: array<'Unknown'|mixed>, series?: array},
* array>
*
@@ -501,7 +501,7 @@ public function getObjectsBySchemaChart(?int $registerId = null, ?int $schemaId
*
* @return JSONResponse JSON response with chart data or error
*
- * @psalm-return JSONResponse<200|500,
+ * @psalm-return JSONResponse<200|403|500,
* array{error?: string,
* labels?: list<'0-1 KB'|'1-10 KB'|'10-100 KB'|'100 KB-1 MB'|'> 1 MB'>,
* series?: list},
@@ -535,7 +535,7 @@ public function getObjectsBySizeChart(?int $registerId = null, ?int $schemaId =
*
* @return JSONResponse JSON response with statistics or error
*
- * @psalm-return JSONResponse<200|500,
+ * @psalm-return JSONResponse<200|403|500,
* array{error?: string, total?: int, creates?: int,
* updates?: int, deletes?: int, reads?: int},
* array>
@@ -572,7 +572,7 @@ public function getAuditTrailStatistics(?int $registerId = null, ?int $schemaId
*
* @return JSONResponse JSON response with action distribution or error
*
- * @psalm-return JSONResponse<200|500,
+ * @psalm-return JSONResponse<200|403|500,
* array{error?: string, actions?: list},
* array>
*
@@ -609,7 +609,7 @@ public function getAuditTrailActionDistribution(?int $registerId = null, ?int $s
*
* @return JSONResponse JSON response with most active objects or error
*
- * @psalm-return JSONResponse<200|500,
+ * @psalm-return JSONResponse<200|403|500,
* array{error?: string, objects?: list},
* array>
*
diff --git a/lib/Controller/DeletedController.php b/lib/Controller/DeletedController.php
index 02e2739509..d31043e1ec 100644
--- a/lib/Controller/DeletedController.php
+++ b/lib/Controller/DeletedController.php
@@ -149,7 +149,7 @@ private function userMayActOnDeletedObject(ObjectEntity $object, string $action)
*
* @return array Request parameters including pagination and filters
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
private function extractRequestParameters(): array {
$params = $this->request->getParams();
diff --git a/lib/Controller/EndpointsController.php b/lib/Controller/EndpointsController.php
index 25c81dae13..458a7de631 100644
--- a/lib/Controller/EndpointsController.php
+++ b/lib/Controller/EndpointsController.php
@@ -299,7 +299,7 @@ public function show(int $id): JSONResponse {
* @return JSONResponse JSON response with created endpoint or error
*
* @psalm-return JSONResponse<201, \OCA\OpenRegister\Db\Endpoint,
- * array>|JSONResponse<400|500, array{error: string},
+ * array>|JSONResponse<400|403|500, array{error: string},
* array>
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-7
@@ -381,7 +381,7 @@ public function create(): JSONResponse {
* @return JSONResponse JSON response with updated endpoint or error
*
* @psalm-return JSONResponse<200, \OCA\OpenRegister\Db\Endpoint,
- * array>|JSONResponse<404|500, array{error: string},
+ * array>|JSONResponse<403|404|500, array{error: string},
* array>
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-7
diff --git a/lib/Controller/FileExtractionController.php b/lib/Controller/FileExtractionController.php
index 4de6c1d080..0b31da9f53 100644
--- a/lib/Controller/FileExtractionController.php
+++ b/lib/Controller/FileExtractionController.php
@@ -51,7 +51,7 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.TooManyPublicMethods)
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
@@ -331,7 +331,7 @@ public function show(int $id): JSONResponse {
* array
* >
*
- * @suppressWarnings(PHPMD.BooleanArgumentFlag) Force flag allows re-extraction bypass
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Force flag allows re-extraction bypass
*
* @spec openspec/specs/object-lifecycle/spec.md
*/
@@ -395,8 +395,8 @@ public function extract(int $id, bool $forceReExtract = false): JSONResponse {
* @NoCSRFRequired
*
* @psalm-return JSONResponse<
- * 200|500,
- * array{
+ * 200|403|500,
+ * array{success: false, error: string}|array{
* success: bool,
* error?: 'File discovery failed',
* message: string,
@@ -454,8 +454,8 @@ public function discover(int $limit = 100): JSONResponse {
* @NoCSRFRequired
*
* @psalm-return JSONResponse<
- * 200|500,
- * array{
+ * 200|403|500,
+ * array{success: false, error: string}|array{
* success: bool,
* error?: 'Batch extraction failed',
* message: string,
@@ -505,8 +505,8 @@ public function extractAll(int $limit = 100): JSONResponse {
* @NoCSRFRequired
*
* @psalm-return JSONResponse<
- * 200|500,
- * array{
+ * 200|403|500,
+ * array{success: false, error: string}|array{
* success: bool,
* error?: 'Retry failed',
* message: string,
@@ -610,8 +610,8 @@ public function stats(): JSONResponse {
* @NoCSRFRequired
*
* @psalm-return JSONResponse<
- * 200|500,
- * array{
+ * 200|403|500,
+ * array{success: false, error: string}|array{
* success: bool,
* error?: 'Cleanup failed',
* message: string,
diff --git a/lib/Controller/FilesController.php b/lib/Controller/FilesController.php
index c7485d738b..116020e940 100644
--- a/lib/Controller/FilesController.php
+++ b/lib/Controller/FilesController.php
@@ -526,7 +526,7 @@ private function fileBelongsToObject(File $file, ObjectEntity $object): bool {
*
* @NoCSRFRequired
*
- * @psalm-return JSONResponse<200|400|404, array{error?: mixed|string, labels?: list,...}, array>
+ * @psalm-return JSONResponse<200|400|403|404, array{error?: mixed|string, labels?: list,...}, array>
*
* @spec openspec/specs/object-interactions/spec.md
*
@@ -612,11 +612,11 @@ public function create(
*
* @NoCSRFRequired
*
- * @psalm-return JSONResponse<200|400|404,
+ * @psalm-return JSONResponse<200|400|403|404,
* array{error?: mixed|string, labels?: list,...},
* array>
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-12
*
@@ -722,7 +722,7 @@ public function save(
*
* @NoCSRFRequired
*
- * @psalm-return JSONResponse<200|400|404, array{error?: string, 0?: array,...}, array>
+ * @psalm-return JSONResponse<200|400|403|404, array{error?: string, 0?: array,...}, array>
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-12
*
@@ -859,7 +859,7 @@ private function normalizeMultipartFiles(array $files, array $data): array {
}
// Multiple file upload.
- if ($fileName !== null && is_array($fileName) === true) {
+ if ($fileName !== null) {
$uploadedFiles = $this->normalizeMultipleFiles(files: $files, data: $data, fileNames: $fileName);
}
@@ -974,7 +974,7 @@ private function normalizeMultipleFiles(array $files, array $data, array $fileNa
*
* @throws Exception If file validation or processing fails
*
- * @psalm-return list
+ * @psalm-return list<\OCP\Files\File>
*/
private function processUploadedFiles(ObjectEntity $object, array $uploadedFiles): array {
$results = [];
diff --git a/lib/Controller/MappingsController.php b/lib/Controller/MappingsController.php
index d3fffd2243..a86ccb43ee 100644
--- a/lib/Controller/MappingsController.php
+++ b/lib/Controller/MappingsController.php
@@ -334,7 +334,7 @@ public function destroy(int $id): JSONResponse {
*
* @return JSONResponse JSON response with test results
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/specs/openapi-generation/spec.md#requirement-schema-authoring-sub-resources-and-meta-entity-operational-endpoints
*/
diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php
index e6fcb7f61a..790acbffc9 100644
--- a/lib/Controller/ObjectsController.php
+++ b/lib/Controller/ObjectsController.php
@@ -76,13 +76,13 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.ExcessiveClassLength)
- * @suppressWarnings(PHPMD.ExcessiveClassComplexity)
- * @suppressWarnings(PHPMD.TooManyMethods)
- * @suppressWarnings(PHPMD.TooManyPublicMethods)
- * @suppressWarnings(PHPMD.CouplingBetweenObjects)
- * @suppressWarnings(PHPMD.ElseExpression) File upload extraction requires conditional branching
- * @suppressWarnings(PHPMD.ExcessiveMethodLength) Complex file upload handling with multiple formats
+ * @SuppressWarnings(PHPMD.ExcessiveClassLength)
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.TooManyMethods)
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ * @SuppressWarnings(PHPMD.ElseExpression) File upload extraction requires conditional branching
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength) Complex file upload handling with multiple formats
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
*
@@ -134,7 +134,7 @@ class ObjectsController extends Controller {
*
* @return void
*
- * @suppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
*/
public function __construct(
string $appName,
@@ -637,7 +637,7 @@ private function paginate(array $results, ?int $total = 0, ?int $limit = 20, ?in
* ids: array|null
* }
*
- * @suppressWarnings(PHPMD.UnusedFormalParameter)
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
private function getConfig(?string $_register = null, ?string $_schema = null, ?array $ids = null): array {
$params = $this->request->getParams();
@@ -826,7 +826,7 @@ private function parseMultiValue($param, string $defaultValue): array {
*
* @psalm-suppress UnusedParam Params are used in foreach loops and method calls.
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*/
private function crossTableSearch(array $registers, array $schemas, ObjectService $objectService): JSONResponse {
$magicMapper = \OC::$server->get(\OCA\OpenRegister\Db\MagicMapper::class);
@@ -929,6 +929,8 @@ private function crossTableSearch(array $registers, array $schemas, ObjectServic
// `_rbac` is forwarded only to gate the property `authorization.read` strip. It does
// NOT gate the writeOnly strip (#460): `$query['_rbac']` is false for an ADMIN here,
// and an admin is not exempt from the writeOnly render boundary (#389).
+ // No `?? true` fallback: this method sets $query['_rbac'] unconditionally
+ // a few lines above, so the key is always present here.
$renderHandler = \OC::$server->get(\OCA\OpenRegister\Service\Object\RenderObject::class);
// No `?? true` on THIS path: `_rbac` is assigned unconditionally above and
// the unset() in between does not remove it, so the fallback was dead --
@@ -1087,8 +1089,8 @@ private function resolveRegisterSchemaIds(string $register, string $schema, Obje
*
* @psalm-return JSONResponse<200|404, array, array>
*
- * @suppressWarnings(PHPMD.NPathComplexity) Complex request parameter handling for flexible API
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.NPathComplexity) Complex request parameter handling for flexible API
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Multi-schema search + pagination + filtering requires branching
*
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
@@ -1421,7 +1423,7 @@ function (string $item): bool {
// Content negotiation: JSON-LD @graph for magic-mapped results
// (json-ld-output).
- if ($this->wantsJsonLd() === true && $registerEntity !== null && $schemaEntity !== null) {
+ if ($this->wantsJsonLd() === true) {
return $this->jsonLdCollectionResponse(
result: $responseData,
register: $registerEntity,
@@ -2144,10 +2146,10 @@ private function flattenGeoParams(array $params): array {
*
* @PublicPage
*
- * @psalm-return JSONResponse<200, array, array>
+ * @psalm-return JSONResponse<200|404, array, array>
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Cross-table search + multi-schema routing requires branching
*
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
@@ -2368,7 +2370,7 @@ public function objects(ObjectService $objectService): JSONResponse {
*
* @return JSONResponse JSON response with the object or error
*
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Object retrieval with slug resolution + access checks requires branching
*
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
@@ -2575,7 +2577,7 @@ public function show(
* @psalm-suppress TypeDoesNotContainType
* @psalm-suppress NoValue
*
- * @suppressWarnings(PHPMD.NPathComplexity) Object creation requires many validation and processing steps
+ * @SuppressWarnings(PHPMD.NPathComplexity) Object creation requires many validation and processing steps
*
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
*
@@ -2775,8 +2777,8 @@ public function create(
* @psalm-suppress TypeDoesNotContainType
* @psalm-suppress NoValue
*
- * @suppressWarnings(PHPMD.NPathComplexity) Object update requires many validation and processing steps
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.NPathComplexity) Object update requires many validation and processing steps
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Object update requires many validation and processing steps
*
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
@@ -2999,8 +3001,8 @@ public function update(
*
* @PublicPage
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
*/
@@ -3810,7 +3812,7 @@ public function used(string $id, string $register, string $schema, ObjectService
* message?: 'Object does not belong to specified register/schema'|'Object not found'},
* array>
*
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Audit log retrieval with pagination + access checks requires branching
*
* @spec openspec/archive/retrofit-annotate-openregister-2026-04-23/tasks.md
diff --git a/lib/Controller/RegistersController.php b/lib/Controller/RegistersController.php
index d54119a7cc..18af7bfa9a 100644
--- a/lib/Controller/RegistersController.php
+++ b/lib/Controller/RegistersController.php
@@ -81,14 +81,14 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.ExcessiveClassLength) NC REST controller must expose all CRUD + subresource
+ * @SuppressWarnings(PHPMD.ExcessiveClassLength) NC REST controller must expose all CRUD + subresource
* endpoints (registers, schemas, objects, statistics) in one class per NC AppFramework routing;
* splitting into multiple controllers would require additional routing registration.
- * @suppressWarnings(PHPMD.ExcessiveClassComplexity) Aggregate complexity from N independent REST actions;
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Aggregate complexity from N independent REST actions;
* each action is individually simple — the class total is a routing artifact, not design debt.
- * @suppressWarnings(PHPMD.TooManyPublicMethods) Each public method maps to one REST endpoint; NC AppFramework
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods) Each public method maps to one REST endpoint; NC AppFramework
* requires public methods for route dispatch — they cannot be made protected/private.
- * @suppressWarnings(PHPMD.CouplingBetweenObjects) NC Controller DI injects AppFramework, RBAC, audit, domain
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) NC Controller DI injects AppFramework, RBAC, audit, domain
* services, and mappers — each dep is used and cannot be combined without violating SRP.
* @SuppressWarnings(PHPMD.ExcessiveMethodLength) The create/update actions include multi-step validation
* that is a single atomic write; extracting sub-steps would create misleading partial-update helpers.
@@ -190,7 +190,7 @@ class RegistersController extends Controller {
*
* @return void
*
- * @suppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
+ * @SuppressWarnings(PHPMD.ExcessiveParameterList) Nextcloud DI requires constructor injection
*/
public function __construct(
string $appName,
@@ -256,8 +256,8 @@ public function __construct(
*
* @return JSONResponse The JSON response containing the list of registers
*
- * @suppressWarnings(PHPMD.NPathComplexity) Complex request parameter handling for flexible API
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity) Complex request parameter handling for flexible API
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/changes/register-schema-read-accessibility/tasks.md#task-1
*/
@@ -541,7 +541,7 @@ public function show($id): JSONResponse {
*
* @NoCSRFRequired
*
- * @suppressWarnings(PHPMD.StaticAccess) DatabaseConstraintException factory method is standard pattern
+ * @SuppressWarnings(PHPMD.StaticAccess) DatabaseConstraintException factory method is standard pattern
*
* @return JSONResponse JSON response with created register or error
*
@@ -1179,9 +1179,9 @@ public function importTemplate(int|string $id, int|string $schema): JSONResponse
*
* @return JSONResponse JSON response with publish result or error
*
- * @suppressWarnings(PHPMD.NPathComplexity) GitHub publishing requires many conditional checks
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity) GitHub publishing requires many conditional checks
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-5
*/
@@ -1357,10 +1357,10 @@ public function publishToGitHub(int $id): JSONResponse {
*
* @NoCSRFRequired
*
- * @suppressWarnings(PHPMD.BooleanArgumentFlag) Force flag to override version checks
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Force flag to override version checks
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-10
*/
@@ -1808,48 +1808,19 @@ public function rollbackImport(): JSONResponse {
* 200|404|500,
* array{
* error?: string,
- * register?: array{
- * id: int,
- * uuid: null|string,
- * slug: null|string,
- * title: null|string,
- * version: null|string,
- * description: null|string,
- * schemas: array,
- * source: null|string,
- * tablePrefix: null|string,
- * folder: null|string,
- * updated: null|string,
- * created: null|string,
- * owner: null|string,
- * application: null|string,
- * organisation: null|string,
- * authorization: array|null,
- * groups: array>,
- * configuration: array|null,
- * quota: array{
- * storage: null,
- * bandwidth: null,
- * requests: null,
- * users: null,
- * groups: null
- * },
- * usage: array{
- * storage: 0,
- * bandwidth: 0,
- * requests: 0,
- * users: 0,
- * groups: int<0, max>
- * },
- * deleted: null|string,
- * published: null|string,
- * depublished: null|string
- * },
+ * register?: array,
* message?: 'Stats calculation not yet implemented'
* },
* array
* >
*
+ * `register` is deliberately `array` and not a spelled-out
+ * shape. The previous annotation duplicated Register::jsonSerialize() field
+ * by field and had rotted into nonsense: it pinned `quota` to all-null and
+ * `usage` to all-literal-zero, because it was inferred from a register that
+ * had neither. The entity's serializer owns that contract; restating it here
+ * only guarantees the copy drifts again.
+ *
* @spec openspec/specs/production-observability/spec.md#requirement-per-entity-statistics-and-endpoint-delivery-log-api
*/
#[NoAdminRequired]
@@ -1887,7 +1858,7 @@ public function stats(int $id): JSONResponse {
*
* @return bool The parsed boolean value
*
- * @suppressWarnings(PHPMD.BooleanArgumentFlag) Default value is needed for parameter parsing
+ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) Default value is needed for parameter parsing
*/
private function parseBooleanParam(string $paramName, bool $default = false): bool {
$value = $this->request->getParam(key: $paramName, default: $default);
diff --git a/lib/Controller/RetentionController.php b/lib/Controller/RetentionController.php
index 3be03aedbd..3f5b0058a3 100644
--- a/lib/Controller/RetentionController.php
+++ b/lib/Controller/RetentionController.php
@@ -160,10 +160,10 @@ public function approveDestructionList(string $id): JSONResponse {
false,
false
);
- if ($exclObject !== null) {
- $this->retentionService->extendArchiveActionDate($exclObject);
- $this->objectMapper->update($exclObject);
- }
+ // The find() call throws rather than returning null; the catch below
+ // is what handles a missing object.
+ $this->retentionService->extendArchiveActionDate($exclObject);
+ $this->objectMapper->update($exclObject);
} catch (Exception $e) {
$this->logger->warning(
'[RetentionController] Failed to extend excluded object: ' . $e->getMessage()
@@ -311,10 +311,10 @@ public function rejectDestructionList(string $id): JSONResponse {
try {
$object = $this->objectMapper->find($uuid, null, null, false, false, false);
- if ($object !== null) {
- $this->retentionService->extendArchiveActionDate($object);
- $this->objectMapper->update($object);
- }
+ // The find() call throws rather than returning null; the catch below is
+ // what handles a missing object.
+ $this->retentionService->extendArchiveActionDate($object);
+ $this->objectMapper->update($object);
} catch (Exception $e) {
$this->logger->warning(
'[RetentionController] Failed to extend rejected object: ' . $e->getMessage()
diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php
index 5ef1bc0448..1185d30998 100644
--- a/lib/Controller/SchemasController.php
+++ b/lib/Controller/SchemasController.php
@@ -165,20 +165,14 @@ public function __construct(
* @return JSONResponse JSON response with array of schemas
*
* @psalm-return JSONResponse<200,
- * array{results: array>|null,
- * authorization: array|null, deleted: null|string,
- * published: null|string, depublished: null|string,
- * configuration: array|null|string, allOf: array|null,
- * oneOf: array|null, anyOf: array|null}>}, array>
+ * array{results: list>}, array>
+ *
+ * Each result is deliberately `array` and not a spelled-out
+ * shape. The previous annotation restated Schema::jsonSerialize() field by
+ * field and was already wrong: it omitted the four keys (`objects`, `logs`,
+ * `files`, `registers`) that the `_stats` block appends to every entry. The
+ * entity's serializer owns that contract; copying it here only guarantees
+ * the copy drifts.
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Multiple optional extend/pagination/filter parameters each add one branch.
* @SuppressWarnings(PHPMD.NPathComplexity) Multiple optional extend/pagination/filter parameters each add one branch.
diff --git a/lib/Controller/SearchController.php b/lib/Controller/SearchController.php
index 8586b3fb15..6f951ef102 100644
--- a/lib/Controller/SearchController.php
+++ b/lib/Controller/SearchController.php
@@ -24,9 +24,9 @@
namespace OCA\OpenRegister\Controller;
+use OCA\OpenRegister\Db\ObjectEntity;
use OCA\OpenRegister\Service\ObjectService;
use OCP\AppFramework\Controller;
-use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
@@ -135,30 +135,24 @@ public function search(): JSONResponse {
*/
function ($object): array {
- // Probe the PROPERTY, not the method. searchObjectsPaginated()
- // returns ObjectEntity rows, and ObjectEntity declares getUuid() and
- // getName() only as `@method` — Nextcloud's Entity serves them through
- // __call(). method_exists() is FALSE for both, so every row fell past
- // this branch, and the array branch below cannot read an object either:
- // $objectArr stayed [] and EVERY search hit was returned as
- // `id: null, name: 'Unknown'`.
+ // The searchObjectsPaginated() call returns ObjectEntity rows, and
+ // ObjectEntity declares getUuid() / getName() only as `@method`
+ // — Nextcloud's Entity serves them through __call(). An earlier
+ // version of this closure probed with method_exists(), which is
+ // FALSE for both, so every row fell past this branch and came
+ // back as `id: null, name: 'Unknown'`.
//
- // There is no fallback to recover it here: unlike the getUuid probes
- // elsewhere in this app, nothing on this path routes through
- // getObject(), which is the concrete method that injects the uuid
- // under 'id'. property_exists() is the same test Entity::getter() runs
- // before returning the value, so it cannot throw.
- if ($object instanceof Entity && property_exists($object, 'uuid') === true) {
- $name = null;
- if (property_exists($object, 'name') === true) {
- // @phpstan-ignore-next-line Entity::getName() is dispatched via __call.
- $name = $object->getName();
- }
-
+ // Narrowing on ObjectEntity rather than the OCP Entity base is
+ // what makes the accessors resolvable: the `@method` tags live on
+ // ObjectEntity. Against the base they produced an error type that
+ // propagated out through this array and left the whole
+ // JSONResponse payload unverifiable. It also makes the
+ // property_exists('uuid') / ('name') probes redundant — both are
+ // declared properties of ObjectEntity — so they are gone.
+ if ($object instanceof ObjectEntity) {
return [
- // @phpstan-ignore-next-line Entity::getUuid() is dispatched via __call.
'id' => $object->getUuid(),
- 'name' => $name ?? 'Unknown',
+ 'name' => $object->getName() ?? 'Unknown',
'type' => 'object',
'url' => null,
'source' => 'openregister',
@@ -204,7 +198,7 @@ function ($object): array {
*
* @return string The processed search query ready for the SOLR search service
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/specs/zoeken-filteren/spec.md#requirement-dutch-language-search-support-i18n
*/
diff --git a/lib/Controller/SearchTrailController.php b/lib/Controller/SearchTrailController.php
index 91276a4b77..acf31f954d 100644
--- a/lib/Controller/SearchTrailController.php
+++ b/lib/Controller/SearchTrailController.php
@@ -39,8 +39,8 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.TooManyPublicMethods)
- * @suppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*/
class SearchTrailController extends Controller {
/**
@@ -100,9 +100,9 @@ private function requireAdmin(): ?JSONResponse {
*
* @return array Request parameters including pagination and filters
*
- * @suppressWarnings(PHPMD.NPathComplexity) Request parameter extraction requires many conditional checks
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity) Request parameter extraction requires many conditional checks
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec exclude Private helper: parses pagination/filter/date params; the search-trail analytics API is owned by
* retrofit-2026-05-25-bw2-ctrl-1/tasks.md#task-3.
@@ -248,8 +248,8 @@ function ($key) {
* prev?: null|string
* }
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*
* @spec exclude Private helper: shared pagination-envelope builder; the search-trail analytics API is owned by
* retrofit-2026-05-25-bw2-ctrl-1/tasks.md#task-3.
diff --git a/lib/Controller/Settings/LlmSettingsController.php b/lib/Controller/Settings/LlmSettingsController.php
index 81aa8ca9d2..1fea05ec56 100644
--- a/lib/Controller/Settings/LlmSettingsController.php
+++ b/lib/Controller/Settings/LlmSettingsController.php
@@ -92,8 +92,8 @@ public function getLLMSettings(): JSONResponse {
*
* @return JSONResponse JSON response with updated LLM settings
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*
* @spec openspec/specs/chat-ai/spec.md
*/
@@ -332,8 +332,8 @@ public function testChat(): JSONResponse {
* modified: mixed|null, name: 'unknown'|mixed, size: 0|mixed}>,
* count?: int<0, max>}, array>
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/specs/chat-ai/spec.md
*/
@@ -410,7 +410,7 @@ function (array $model): array {
$description = $family;
if ($size !== '') {
// Add size separator if description exists.
- if ($description !== null && $description !== '') {
+ if ($description !== '') {
$description .= ' • ';
}
diff --git a/lib/Controller/Settings/N8nSettingsController.php b/lib/Controller/Settings/N8nSettingsController.php
index ce0b6a2abf..d6f9a54e05 100644
--- a/lib/Controller/Settings/N8nSettingsController.php
+++ b/lib/Controller/Settings/N8nSettingsController.php
@@ -270,7 +270,7 @@ public function testN8nConnection(): JSONResponse {
*
* @return JSONResponse JSON response with initialization result
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @spec openspec/specs/production-observability/spec.md
*/
diff --git a/lib/Controller/Settings/ValidationSettingsController.php b/lib/Controller/Settings/ValidationSettingsController.php
index 55102821bf..dc60b16773 100644
--- a/lib/Controller/Settings/ValidationSettingsController.php
+++ b/lib/Controller/Settings/ValidationSettingsController.php
@@ -98,7 +98,7 @@ public function validateAllObjects(): JSONResponse {
*
* @return JSONResponse JSON response with mass validation results
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/specs/production-observability/spec.md
*/
diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php
index c5547f074c..0443c1a2fc 100644
--- a/lib/Controller/SettingsController.php
+++ b/lib/Controller/SettingsController.php
@@ -127,14 +127,12 @@
*/
class SettingsController extends Controller {
- /**
- * The OpenRegister object service
- *
- * Lazily loaded from container when needed.
- *
- * @var \OCA\OpenRegister\Service\ObjectService|null OpenRegister object service or null
+ /*
+ * There is no $objectService property: getObjectService() below assigned it
+ * null and returned that (the "CIRCULAR FIX"), so it was never anything but
+ * null. Code that needs the service resolves it from the container at the
+ * point of use instead — see the $objectService locals further down.
*/
- private ?\OCA\OpenRegister\Service\ObjectService $objectService = null;
/**
* SettingsController constructor.
@@ -178,9 +176,9 @@ public function __construct(
*/
public function getObjectService() {
if (in_array(needle: 'openregister', haystack: $this->appManager->getInstalledApps()) === true) {
- $this->objectService = null;
- // CIRCULAR FIX.
- return $this->objectService;
+ // CIRCULAR FIX: returning the service here would close a container
+ // cycle, so callers resolve it themselves.
+ return null;
}
throw new RuntimeException('OpenRegister service is not available.');
@@ -391,9 +389,9 @@ public function updateSearchBackend(): JSONResponse {
*
* @return JSONResponse JSON response with database info
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*
* @spec openspec/specs/production-observability/spec.md
*/
@@ -750,7 +748,7 @@ public function getVersionInfo(): JSONResponse {
* type: 'NO TYPE'|mixed, object_json: mixed}>}},
* array>
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @spec exclude Debug/test scaffolding endpoint ("Debug endpoint for type filtering issue"): dumps
* organisation/object data; not a product contract (see proposal Notes — routed debug surface,
@@ -1012,11 +1010,9 @@ public function hybridSearch(
weights: $weights,
provider: $provider
);
- // Ensure result is an array for the spread operator.
- $resultArray = [];
- if (is_array($result) === true) {
- $resultArray = $result;
- }
+ // The service already returns an array, so the old is_array() guard
+ // (and the [] fallback it protected) could never fire.
+ $resultArray = $result;
return new JSONResponse(
data: [
diff --git a/lib/Controller/SourcesController.php b/lib/Controller/SourcesController.php
index 1b4304bad1..aeabcc58c9 100644
--- a/lib/Controller/SourcesController.php
+++ b/lib/Controller/SourcesController.php
@@ -252,7 +252,7 @@ public function create(): JSONResponse {
$data = $this->sanitizeDatabaseSourceData(data: $data);
// Encrypt databaseUrl at rest before persisting (legacy harvest path).
- if (isset($data['databaseUrl']) === true && $data['databaseUrl'] !== null && $data['databaseUrl'] !== '') {
+ if (isset($data['databaseUrl']) === true && $data['databaseUrl'] !== '') {
$data['databaseUrl'] = $this->crypto->encrypt((string)$data['databaseUrl']);
}
@@ -303,7 +303,7 @@ public function update(int $id): JSONResponse {
$data = $this->sanitizeDatabaseSourceData(data: $data);
// Encrypt databaseUrl at rest before persisting (legacy harvest path).
- if (isset($data['databaseUrl']) === true && $data['databaseUrl'] !== null && $data['databaseUrl'] !== '') {
+ if (isset($data['databaseUrl']) === true && $data['databaseUrl'] !== '') {
$data['databaseUrl'] = $this->crypto->encrypt((string)$data['databaseUrl']);
}
diff --git a/lib/Controller/UserController.php b/lib/Controller/UserController.php
index 17f073662f..5df39d60f3 100644
--- a/lib/Controller/UserController.php
+++ b/lib/Controller/UserController.php
@@ -105,7 +105,7 @@ public function __construct(
*
* @return JSONResponse JSON response with user profile data
*
- * @suppressWarnings(PHPMD.ShortMethodName) Standard REST API endpoint name for current user
+ * @SuppressWarnings(PHPMD.ShortMethodName) Standard REST API endpoint name for current user
*
* @spec openspec/specs/auth-system/spec.md
*/
diff --git a/lib/Controller/ViewsController.php b/lib/Controller/ViewsController.php
index 65719d802c..3142ba400d 100644
--- a/lib/Controller/ViewsController.php
+++ b/lib/Controller/ViewsController.php
@@ -42,7 +42,7 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @spec openspec/specs/faceting-configuration/spec.md
@@ -115,7 +115,7 @@ public function __construct(
*
* @return JSONResponse JSON response with views or error
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-8
*/
@@ -270,7 +270,7 @@ public function show(string $id): JSONResponse {
*
* @return JSONResponse JSON response with created view or error
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-8
*/
@@ -398,7 +398,7 @@ public function create(): JSONResponse {
*
* @return JSONResponse JSON response with updated view or error
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-8
*/
@@ -537,7 +537,7 @@ public function update(string $id): JSONResponse {
*
* @return JSONResponse JSON response with patched view or error
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-8
*/
diff --git a/lib/Controller/WebhooksController.php b/lib/Controller/WebhooksController.php
index 4b04afd763..38888b206a 100644
--- a/lib/Controller/WebhooksController.php
+++ b/lib/Controller/WebhooksController.php
@@ -55,9 +55,9 @@
*
* @psalm-suppress UnusedClass
*
- * @suppressWarnings(PHPMD.ExcessiveClassLength)
- * @suppressWarnings(PHPMD.ExcessiveClassComplexity)
- * @suppressWarnings(PHPMD.TooManyPublicMethods)
+ * @SuppressWarnings(PHPMD.ExcessiveClassLength)
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @spec openspec/specs/webhook-payload-mapping/spec.md
@@ -199,8 +199,8 @@ private function forbiddenResponse(): JSONResponse {
* array
* >
*
- * @suppressWarnings(PHPMD.NPathComplexity) Complex request parameter handling for flexible API
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity) Complex request parameter handling for flexible API
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/specs/webhook-payload-mapping/spec.md
*/
@@ -719,7 +719,7 @@ public function test(int $id): JSONResponse {
* @no-admin-idor-exempt No per-object resource: returns the static catalogue of available webhook event-type definitions
* (identical for every install); no tenant data.
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @spec openspec/specs/event-driven-architecture/spec.md
*/
@@ -1193,7 +1193,7 @@ public function logStats(int $id): JSONResponse {
*
* @NoCSRFRequired
*
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @spec openspec/specs/webhook-payload-mapping/spec.md
*/
@@ -1313,9 +1313,9 @@ function ($log) use ($successBool) {
*
* @NoCSRFRequired
*
- * @suppressWarnings(PHPMD.ExcessiveMethodLength)
- * @suppressWarnings(PHPMD.CyclomaticComplexity)
- * @suppressWarnings(PHPMD.NPathComplexity)
+ * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
+ * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * @SuppressWarnings(PHPMD.NPathComplexity)
*
* @spec openspec/specs/webhook-payload-mapping/spec.md
*/
diff --git a/lib/Db/AgentMapper.php b/lib/Db/AgentMapper.php
index 72afdeb9fe..45f07c5320 100644
--- a/lib/Db/AgentMapper.php
+++ b/lib/Db/AgentMapper.php
@@ -317,7 +317,7 @@ public function canUserModifyAgent(Agent $agent, string $userId): bool {
*
* @throws \Exception If user doesn't have read permission
*
- * @psalm-return list
+ * @psalm-return list<\OCA\OpenRegister\Db\Agent>
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
diff --git a/lib/Db/Application.php b/lib/Db/Application.php
index fc1678ffd1..a4ac324a05 100644
--- a/lib/Db/Application.php
+++ b/lib/Db/Application.php
@@ -605,7 +605,7 @@ public function setQuota(array|string|null $quota): static {
* registers: array|null, schemas: array|null, owner: null|string,
* active: bool|null, groups: array|null,
* quota: array{storage: int|null, bandwidth: int|null,
- * requests: int|null, users: null, groups: null},
+ * requests: int|null, users: int|null, groups: int|null},
* usage: array{storage: 0, bandwidth: 0, requests: 0, users: 0,
* groups: int<0, max>}, authorization: array,
* created: null|string, updated: null|string,
diff --git a/lib/Db/EndpointLogMapper.php b/lib/Db/EndpointLogMapper.php
index 2a679c5512..5bbe532c42 100644
--- a/lib/Db/EndpointLogMapper.php
+++ b/lib/Db/EndpointLogMapper.php
@@ -87,7 +87,7 @@ public function __construct(IDBConnection $db) {
*
* @return EndpointLog[]
*
- * @psalm-return list
+ * @psalm-return list<\OCA\OpenRegister\Db\EndpointLog>
*/
public function findAll(?int $limit = null, ?int $offset = null): array {
// Step 1: Get query builder instance.
diff --git a/lib/Db/EndpointMapper.php b/lib/Db/EndpointMapper.php
index b5912e7cd3..db5878905f 100644
--- a/lib/Db/EndpointMapper.php
+++ b/lib/Db/EndpointMapper.php
@@ -124,7 +124,7 @@ public function __construct(
*
* @return Endpoint[]
*
- * @psalm-return list
+ * @psalm-return list<\OCA\OpenRegister\Db\Endpoint>
*/
public function findAll(?int $limit = null, ?int $offset = null): array {
// Step 1: Get query builder instance.
diff --git a/lib/Db/EntityRelationMapper.php b/lib/Db/EntityRelationMapper.php
index 389fabc365..33f64281ad 100644
--- a/lib/Db/EntityRelationMapper.php
+++ b/lib/Db/EntityRelationMapper.php
@@ -593,7 +593,9 @@ private function buildRelationFromRow(array $row): EntityRelation {
* honoured (skipped rows untouched).
*
* @param int $fileId The file ID.
- * @param array $placeholderByEntityId Map of (stringified) entity id → the
+ * @param array $placeholderByEntityId Keys are int|string,
+ * not string: a stringified entity id is a canonical numeric string,
+ * which PHP coerces to an int array key. Map of entity id → the
* exact placeholder emitted for it
* (e.g. "7" => "[PERSOON: 1]").
*
diff --git a/lib/Db/FileMapper.php b/lib/Db/FileMapper.php
index 5ba034e249..847ebe4d18 100644
--- a/lib/Db/FileMapper.php
+++ b/lib/Db/FileMapper.php
@@ -483,10 +483,12 @@ public function getFile(int $fileId): ?array {
*
* @param array $fileIds List of file ids (int|string) to load
*
- * @return array Map of (string) fileid => file record
+ * @return array Map of fileid => file record. The key type
+ * is int|string, not string: a stringified fileid is a canonical
+ * numeric string, which PHP coerces to an int array key.
*
* @phpstan-param array $fileIds
- * @phpstan-return array
+ * @phpstan-return array
*/
public function getFilesByIds(array $fileIds): array {
// Normalise to unique positive integers; ignore non-numeric entries.
diff --git a/lib/Db/Flow.php b/lib/Db/Flow.php
index 5d2d6320e0..66ed7a6703 100644
--- a/lib/Db/Flow.php
+++ b/lib/Db/Flow.php
@@ -86,6 +86,8 @@
* @method void setOrganisation(?string $organisation)
* @method string|null getNotes()
* @method void setNotes(?string $notes)
+ * @method string|null getComment()
+ * @method void setComment(?string $comment)
* @method DateTime|null getCreated()
* @method void setCreated(?DateTime $created)
* @method DateTime|null getUpdated()
diff --git a/lib/Db/MagicMapper.php b/lib/Db/MagicMapper.php
index dfba24e391..b5b21d28cf 100644
--- a/lib/Db/MagicMapper.php
+++ b/lib/Db/MagicMapper.php
@@ -777,7 +777,7 @@ public function saveObjectsToRegisterSchemaTable(array $objects, Register $regis
schema: $schema,
tableName: $tableName
);
- if ($uuid !== null && $uuid !== '') {
+ if ($uuid !== '') {
$savedUuids[] = $uuid;
}
}
@@ -2169,34 +2169,35 @@ public function buildTableColumnsFromSchema(Schema $schema): array {
// Note: Schema properties do NOT conflict with metadata columns.
// Metadata columns have '_' prefix, schema properties don't.
// Both '_name' (metadata) and 'name' (schema property) can coexist.
+ // mapSchemaPropertyToColumn() returns a non-nullable array, so the
+ // emptiness guard that used to wrap this block was always true.
$column = $this->mapSchemaPropertyToColumn(propertyName: $propertyName, propertyConfig: $propertyConfig);
- if ($column !== null && $column !== '') {
- // BUG-DB-8: disambiguate column-name collisions deterministically.
- if (isset($usedColumnNames[$column['name']]) === true) {
- $base = $column['name'];
- $suffix = 1;
- $candidate = $base . '_' . $suffix;
- while (isset($usedColumnNames[$candidate]) === true) {
- $suffix++;
- $candidate = $base . '_' . $suffix;
- }
- $this->logger->warning(
- message: '[MagicMapper] Column name collision after sanitisation; disambiguating',
- context: [
- 'file' => __FILE__,
- 'line' => __LINE__,
- 'propertyName' => $propertyName,
- 'collidingColumn' => $base,
- 'resolvedColumn' => $candidate,
- ]
- );
- $column['name'] = $candidate;
- }//end if
+ // BUG-DB-8: disambiguate column-name collisions deterministically.
+ if (isset($usedColumnNames[$column['name']]) === true) {
+ $base = $column['name'];
+ $suffix = 1;
+ $candidate = $base . '_' . $suffix;
+ while (isset($usedColumnNames[$candidate]) === true) {
+ $suffix++;
+ $candidate = $base . '_' . $suffix;
+ }
- $usedColumnNames[$column['name']] = true;
- $columns[$propertyName] = $column;
+ $this->logger->warning(
+ message: '[MagicMapper] Column name collision after sanitisation; disambiguating',
+ context: [
+ 'file' => __FILE__,
+ 'line' => __LINE__,
+ 'propertyName' => $propertyName,
+ 'collidingColumn' => $base,
+ 'resolvedColumn' => $candidate,
+ ]
+ );
+ $column['name'] = $candidate;
}//end if
+
+ $usedColumnNames[$column['name']] = true;
+ $columns[$propertyName] = $column;
}//end foreach
}//end if
@@ -2522,7 +2523,7 @@ private function mapSchemaPropertyToColumn(string $propertyName, array $property
case 'boolean':
// Determine default value.
$defaultValue = null;
- if (is_array($propertyConfig) === true && array_key_exists('default', $propertyConfig) === true) {
+ if (array_key_exists('default', $propertyConfig) === true) {
$defaultValue = $propertyConfig['default'];
}
@@ -2788,7 +2789,7 @@ private function mapIntegerProperty(string $columnName, array $propertyConfig):
// Determine default value.
$defaultValue = null;
- if (is_array($propertyConfig) === true && array_key_exists('default', $propertyConfig) === true) {
+ if (array_key_exists('default', $propertyConfig) === true) {
$defaultValue = $propertyConfig['default'];
}
@@ -2827,7 +2828,7 @@ private function mapNumberProperty(string $columnName, array $propertyConfig): a
// Determine default value.
$defaultValue = null;
- if (is_array($propertyConfig) === true && array_key_exists('default', $propertyConfig) === true) {
+ if (array_key_exists('default', $propertyConfig) === true) {
$defaultValue = $propertyConfig['default'];
}
@@ -3657,7 +3658,7 @@ private function prepareObjectDataForTable(array $objectData, Register $register
// LINKED_TYPE_COLUMN_MAP values are column names with _ prefix (e.g., '_mail'),
// but the metadata loop adds its own prefix, so we use the linkedType key directly.
foreach ($schema->getLinkedTypes() as $linkedType) {
- if (isset(self::LINKED_TYPE_COLUMN_MAP[$linkedType]) === true && $linkedType !== 'files') {
+ if (isset(self::LINKED_TYPE_COLUMN_MAP[$linkedType]) === true) {
$metadataFields[] = $linkedType;
}
}
@@ -3698,7 +3699,7 @@ private function prepareObjectDataForTable(array $objectData, Register $register
];
// Add active linked type fields as JSON fields.
foreach ($schema->getLinkedTypes() as $linkedType) {
- if (isset(self::LINKED_TYPE_COLUMN_MAP[$linkedType]) === true && $linkedType !== 'files') {
+ if (isset(self::LINKED_TYPE_COLUMN_MAP[$linkedType]) === true) {
$jsonFields[] = $linkedType;
}
}
@@ -8383,21 +8384,22 @@ private function rowToObjectEntity(array $row): ?ObjectEntity {
$columnToPropertyMap = $this->rowColumnToPropertyCache[$schemaIdForMap];
} else {
try {
+ // The find() call throws when the schema is missing; the catch below is
+ // the "not found" path, so no null test is needed here.
$schema = $this->schemaMapper->find($schemaIdForMap);
- if ($schema !== null) {
- // BUG-DB-8: use the same disambiguated column names the write
- // path produces (buildTableColumnsFromSchema), keyed by
- // property name, so collision-resolved columns round-trip
- // back to their original property instead of being lost.
- foreach ($this->buildTableColumnsFromSchema(schema: $schema) as $propertyName => $columnDef) {
- // Skip metadata columns (handled separately above).
- if (str_starts_with($propertyName, '_') === true) {
- continue;
- }
- $physicalColumn = $columnDef['name'] ?? $this->sanitizeColumnName(name: $propertyName);
- $columnToPropertyMap[$physicalColumn] = $propertyName;
+ // BUG-DB-8: use the same disambiguated column names the write
+ // path produces (buildTableColumnsFromSchema), keyed by
+ // property name, so collision-resolved columns round-trip
+ // back to their original property instead of being lost.
+ foreach ($this->buildTableColumnsFromSchema(schema: $schema) as $propertyName => $columnDef) {
+ // Skip metadata columns (handled separately above).
+ if (str_starts_with($propertyName, '_') === true) {
+ continue;
}
+
+ $physicalColumn = $columnDef['name'] ?? $this->sanitizeColumnName(name: $propertyName);
+ $columnToPropertyMap[$physicalColumn] = $propertyName;
}
$this->rowColumnToPropertyCache[$schemaIdForMap] = $columnToPropertyMap;
@@ -9258,7 +9260,7 @@ public function ultraFastBulkSave(
$groupRegister = $register;
$groupSchema = null;
- if ($groupRegister === null && $groupRegisterId !== null) {
+ if ($groupRegister === null) {
try {
$groupRegister = $this->registerMapper->find(id: (int)$groupRegisterId, _multitenancy: false);
} catch (\Exception $e) {
@@ -9269,15 +9271,15 @@ public function ultraFastBulkSave(
}
}
- if ($groupSchemaId !== null) {
- try {
- $groupSchema = $this->schemaMapper->find(id: (int)$groupSchemaId, _multitenancy: false);
- } catch (\Exception $e) {
- $this->logger->warning(
- message: '[MagicMapper] Failed to resolve schema for group',
- context: ['file' => __FILE__, 'line' => __LINE__, 'id' => $groupSchemaId]
- );
- }
+ // $groupSchemaId comes from explode() on the group key, so it is
+ // always a string — the null guard here could never skip.
+ try {
+ $groupSchema = $this->schemaMapper->find(id: (int)$groupSchemaId, _multitenancy: false);
+ } catch (\Exception $e) {
+ $this->logger->warning(
+ message: '[MagicMapper] Failed to resolve schema for group',
+ context: ['file' => __FILE__, 'line' => __LINE__, 'id' => $groupSchemaId]
+ );
}
$groupResults = $this->ultraFastBulkSaveSingleSchema(
diff --git a/lib/Db/MagicMapper/MagicFacetHandler.php b/lib/Db/MagicMapper/MagicFacetHandler.php
index 434bab7b2f..28b96dcde8 100644
--- a/lib/Db/MagicMapper/MagicFacetHandler.php
+++ b/lib/Db/MagicMapper/MagicFacetHandler.php
@@ -388,7 +388,7 @@ function ($key) {
}
// Add schema property title if available.
- if (isset($config['title']) === true && $config['title'] !== null) {
+ if (isset($config['title']) === true) {
$facets[$field]['title'] = $config['title'];
}
@@ -512,7 +512,7 @@ public function getSimpleFacetsUnion(array $tableConfigs, array $query): array {
}//end if
// Add schema property title if available.
- if (isset($config['title']) === true && $config['title'] !== null) {
+ if (isset($config['title']) === true) {
$facets[$field]['title'] = $config['title'];
}
diff --git a/lib/Db/MagicMapper/MagicSearchHandler.php b/lib/Db/MagicMapper/MagicSearchHandler.php
index a83fd31fca..fa56684eef 100644
--- a/lib/Db/MagicMapper/MagicSearchHandler.php
+++ b/lib/Db/MagicMapper/MagicSearchHandler.php
@@ -727,11 +727,9 @@ private function buildSearchConditionSql(
$searchConditions[] = "similarity(_name::text, {$quotedTerm}) > 0.1";
}
- if (empty($searchConditions) === false) {
- return '(' . implode(' OR ', $searchConditions) . ')';
- }
-
- return null;
+ // Both branches above push three conditions, so $searchConditions is
+ // never empty here and the old null return was unreachable.
+ return '(' . implode(' OR ', $searchConditions) . ')';
}//end buildSearchConditionSql()
/**
diff --git a/lib/Db/MagicMapper/MagicStatisticsHandler.php b/lib/Db/MagicMapper/MagicStatisticsHandler.php
index d1f67c8abd..e6f5aec38c 100644
--- a/lib/Db/MagicMapper/MagicStatisticsHandler.php
+++ b/lib/Db/MagicMapper/MagicStatisticsHandler.php
@@ -714,14 +714,14 @@ public function convertRowToObjectEntity(array $row, Register $_register, Schema
// CRITICAL FIX: Explicitly set ID and UUID to ensure they are never null.
// These are essential for audit trails, rendering, and API responses.
- if (isset($metadata['id']) === true && $metadata['id'] !== null) {
+ if (isset($metadata['id']) === true) {
$idValue = $metadata['id'];
if (is_numeric($idValue) === true) {
$objectEntity->setId((int)$idValue);
}
}
- if (isset($metadata['uuid']) === true && $metadata['uuid'] !== null) {
+ if (isset($metadata['uuid']) === true) {
$objectEntity->setUuid($metadata['uuid']);
}
diff --git a/lib/Db/Mapping.php b/lib/Db/Mapping.php
index 9570f889eb..9317eed333 100644
--- a/lib/Db/Mapping.php
+++ b/lib/Db/Mapping.php
@@ -303,7 +303,7 @@ public function getSlug(): string {
// Safe fallback if empty.
$prefix = 'mapping';
- if (isset($this->id) === true && (string)$this->id !== '') {
+ if ((string)$this->id !== '') {
return $prefix . '-' . (string)$this->id;
}
diff --git a/lib/Db/MappingMapper.php b/lib/Db/MappingMapper.php
index 0b4d2bdaea..7cdc91e4c7 100644
--- a/lib/Db/MappingMapper.php
+++ b/lib/Db/MappingMapper.php
@@ -152,7 +152,7 @@ public function __construct(
*
* @return Mapping[]
*
- * @psalm-return list
+ * @psalm-return list<\OCA\OpenRegister\Db\Mapping>
*/
public function findAll(?int $limit = null, ?int $offset = null): array {
// Step 1: Get query builder instance.
diff --git a/lib/Db/MultiTenancyTrait.php b/lib/Db/MultiTenancyTrait.php
index e29282d10e..8cace24898 100644
--- a/lib/Db/MultiTenancyTrait.php
+++ b/lib/Db/MultiTenancyTrait.php
@@ -359,7 +359,7 @@ private function getUserFromSession(): mixed {
* @return string Qualified column name
*/
private function buildQualifiedColumnName(string $columnName, string $tableAlias): string {
- if ($tableAlias !== null && $tableAlias !== '') {
+ if ($tableAlias !== '') {
return $tableAlias . '.' . $columnName;
}
diff --git a/lib/Db/NotificationSubscriptionMapper.php b/lib/Db/NotificationSubscriptionMapper.php
index f974bde227..6146d01a11 100644
--- a/lib/Db/NotificationSubscriptionMapper.php
+++ b/lib/Db/NotificationSubscriptionMapper.php
@@ -147,10 +147,14 @@ public function findByUser(string $userId): array {
)
->orderBy('created', 'DESC');
- /*
- * @var NotificationSubscription[] $rows
- */
-
+ // The findEntities() helper is typed Entity[] on the parent; this mapper's
+ // generic binding narrows it to NotificationSubscription at runtime. The
+ // annotation has to be a `/** */` docblock for PHPStan to honour it — the
+ // `/* */` one that used to sit here was inert — which is why the inline
+ // doc-block sniff is silenced for this single line rather than the type
+ // being left wrong.
+ // phpcs:ignore Squiz.Commenting.InlineComment.DocBlock -- PHPStan only reads @var from a /** */ block.
+ /** @var NotificationSubscription[] $rows */
$rows = $this->findEntities(query: $qb);
return $rows;
}//end findByUser()
diff --git a/lib/Db/RegisterMapper.php b/lib/Db/RegisterMapper.php
index 8aa91c82df..a30c4d985e 100644
--- a/lib/Db/RegisterMapper.php
+++ b/lib/Db/RegisterMapper.php
@@ -719,9 +719,12 @@ private function bumpPatchVersion(string $version): string {
// Capture: major.minor.patch followed by an optional -prerelease/+build suffix.
if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$/', trim($version), $matches) === 1) {
$major = (int)$matches[1];
- $minor = (int)($matches[2] ?? 0);
- $patch = (int)($matches[3] ?? 0);
- $suffix = $matches[4] ?? '';
+ // Groups 2-4 are always present: the trailing `(.*)` always matches,
+ // so PHP fills the earlier optional groups with '' rather than
+ // omitting them. (int)'' is 0, so the old `?? 0` was a no-op.
+ $minor = (int)$matches[2];
+ $patch = (int)$matches[3];
+ $suffix = $matches[4];
return $major . '.' . $minor . '.' . ($patch + 1) . $suffix;
}
diff --git a/lib/Db/Schema.php b/lib/Db/Schema.php
index 5ba7d96ab4..3b4aa1adb9 100644
--- a/lib/Db/Schema.php
+++ b/lib/Db/Schema.php
@@ -1738,24 +1738,24 @@ public function getSchemaObject(IURLGenerator $urlGenerator): stdClass {
$nestedProperty->title = $property['title'];
$nestedProperty->required = [];
- if (($property['properties'] ?? null) !== null) {
- foreach ($property['properties'] as $subName => $subProperty) {
- $isRequired = (($subProperty['required'] ?? null) !== null);
- if ($isRequired === true && ($subProperty['required'] === true) === true) {
- $nestedProperty->required[] = $subName;
- }
-
- $nestedProp = new stdClass();
- foreach ($subProperty as $key => $value) {
- if ($key === 'oneOf' && empty($value) === true) {
- continue;
- }
+ // No null guard on $property['properties']: this arm is only
+ // entered for a nested object, which always carries one.
+ foreach ($property['properties'] as $subName => $subProperty) {
+ $isRequired = (($subProperty['required'] ?? null) !== null);
+ if ($isRequired === true && ($subProperty['required'] === true) === true) {
+ $nestedProperty->required[] = $subName;
+ }
- $nestedProp->{$key} = $value;
+ $nestedProp = new stdClass();
+ foreach ($subProperty as $key => $value) {
+ if ($key === 'oneOf' && empty($value) === true) {
+ continue;
}
- $nestedProperties->{$subName} = $nestedProp;
+ $nestedProp->{$key} = $value;
}
+
+ $nestedProperties->{$subName} = $nestedProp;
}
$nestedProperty->properties = $nestedProperties;
@@ -2176,6 +2176,9 @@ private function validateWriteOnlyPathsValue(mixed $value): array {
* @param array $boolFields Bool-typed config fields.
* @param array $passThrough Keys stored without validation.
* @param array $validatedConfig Accumulator, passed by reference.
+ * @param-out array $validatedConfig A config key that is a canonical
+ * numeric string ("12") gets an INT key from PHP's array-key coercion, so the
+ * accumulator that comes back out is not key-narrowable to string.
*
* @throws \InvalidArgumentException If the value is invalid for the key.
*
@@ -2842,24 +2845,24 @@ public function regenerateFacetsFromProperties(): void {
// Determine appropriate facet type based on property configuration.
$facetType = $this->determineFacetType(property: $property);
- if ($facetType !== null) {
- $facetConfig['object_fields'][$propertyKey] = [
- 'type' => $facetType,
- 'title' => $property['title'] ?? $propertyKey,
- 'description' => $property['description'] ?? null,
- 'data_type' => $property['type'] ?? 'string',
- 'queryParameter' => $propertyKey,
- ];
-
- // Add type-specific configuration.
- if ($facetType === 'date_histogram') {
- $facetConfig['object_fields'][$propertyKey]['default_interval'] = 'month';
- $facetConfig['object_fields'][$propertyKey]['supported_intervals'] = ['day', 'week', 'month', 'year'];
- } elseif ($facetType === 'range') {
- $facetConfig['object_fields'][$propertyKey]['supports_custom_ranges'] = true;
- } elseif ($facetType === 'terms' && (($property['enum'] ?? null) !== null)) {
- $facetConfig['object_fields'][$propertyKey]['predefined_values'] = $property['enum'];
- }
+ // The determineFacetType() helper is declared `: string`, so there is no null
+ // case to guard against.
+ $facetConfig['object_fields'][$propertyKey] = [
+ 'type' => $facetType,
+ 'title' => $property['title'] ?? $propertyKey,
+ 'description' => $property['description'] ?? null,
+ 'data_type' => $property['type'] ?? 'string',
+ 'queryParameter' => $propertyKey,
+ ];
+
+ // Add type-specific configuration.
+ if ($facetType === 'date_histogram') {
+ $facetConfig['object_fields'][$propertyKey]['default_interval'] = 'month';
+ $facetConfig['object_fields'][$propertyKey]['supported_intervals'] = ['day', 'week', 'month', 'year'];
+ } elseif ($facetType === 'range') {
+ $facetConfig['object_fields'][$propertyKey]['supports_custom_ranges'] = true;
+ } elseif ($facetType === 'terms' && (($property['enum'] ?? null) !== null)) {
+ $facetConfig['object_fields'][$propertyKey]['predefined_values'] = $property['enum'];
}
}//end foreach
diff --git a/lib/Db/SchemaMapper.php b/lib/Db/SchemaMapper.php
index 51f6d34aed..10e3484f8d 100644
--- a/lib/Db/SchemaMapper.php
+++ b/lib/Db/SchemaMapper.php
@@ -1673,7 +1673,8 @@ private function validateConfigField(string $fieldValue, array $propertyKeys, st
if (strpos($fieldValue, '{{') !== false && strpos($fieldValue, '}}') !== false) {
// Extract property names from template: {{ propName }}.
preg_match_all('/\{\{\s*([a-zA-Z0-9_-]+)\s*\}\}/', $fieldValue, $matches);
- $templateProps = $matches[1] ?? [];
+ // Group 1 is always populated by preg_match_all, so no `?? []` fallback.
+ $templateProps = $matches[1];
if (empty($templateProps) === true) {
// Template syntax but no valid property references found.
@@ -1851,7 +1852,13 @@ private function enforceRefIsStringRecursive(array &$properties): void {
} elseif (is_object($property['$ref']) === true && (($property['$ref']->id ?? null) !== null)) {
$property['$ref'] = $property['$ref']->id;
} elseif (is_int($property['$ref']) === true) {
- } elseif (is_string($property['$ref']) === false && $property['$ref'] !== '') {
+ // The `!== ''` clause that used to sit here was dead: `is_string()
+ // === false` has already excluded every string, so an empty-string
+ // $ref never reaches this branch and is NOT rejected today, despite
+ // what the message below says. Left as-is deliberately — tightening
+ // it would reject schemas that currently import, so it belongs in a
+ // behaviour change, not a lint migration.
+ } elseif (is_string($property['$ref']) === false) {
$refValue = json_encode($property['$ref']);
$msg = "Schema property '$key' has a \$ref that is not a string or empty: " . $refValue;
throw new Exception($msg);
diff --git a/lib/Db/TranslationMapper.php b/lib/Db/TranslationMapper.php
index 57608ed5b1..da1e51ec22 100644
--- a/lib/Db/TranslationMapper.php
+++ b/lib/Db/TranslationMapper.php
@@ -306,7 +306,9 @@ public function markDerivedOutdated(string $objectUuid, string $property, string
* register. Used by the
* `openregister:translations:backfill-source-language` console command.
*
- * @param array $registerDefaults Map of `register_id => default_language`.
+ * @param array $registerDefaults Map of `register_id =>
+ * default_language`. Keys are int|string, not string: register ids are
+ * canonical numeric strings, which PHP coerces to int array keys.
* @param int $batchSize Maximum rows updated per pass.
*
* @return int Number of rows updated across all batches.
diff --git a/lib/Db/WebhookLogMapper.php b/lib/Db/WebhookLogMapper.php
index eb0b117973..521cf4e4a0 100644
--- a/lib/Db/WebhookLogMapper.php
+++ b/lib/Db/WebhookLogMapper.php
@@ -114,7 +114,7 @@ public function findByWebhook(int $webhookId, ?int $limit = null, ?int $offset =
*
* @return WebhookLog[]
*
- * @psalm-return list
+ * @psalm-return list<\OCA\OpenRegister\Db\WebhookLog>
*/
public function findAll(?int $limit = null, ?int $offset = null): array {
$qb = $this->db->getQueryBuilder();
diff --git a/lib/Db/WebhookMapper.php b/lib/Db/WebhookMapper.php
index 985c684b7e..fe7cdd837b 100644
--- a/lib/Db/WebhookMapper.php
+++ b/lib/Db/WebhookMapper.php
@@ -164,7 +164,7 @@ public function __construct(
*
* @return Webhook[]
*
- * @psalm-return list
+ * @psalm-return list<\OCA\OpenRegister\Db\Webhook>
*/
public function findAll(?int $limit = null, ?int $offset = null, ?array $filters = []): array {
// Check if table exists before querying (migrations might not have run yet).
diff --git a/lib/Listener/ActionListener.php b/lib/Listener/ActionListener.php
index cc1f57f67d..7d9ac05e18 100644
--- a/lib/Listener/ActionListener.php
+++ b/lib/Listener/ActionListener.php
@@ -70,7 +70,7 @@ public function __construct(
*/
public function handle(Event $event): void {
// Respect propagation stop from inline hooks or previous listeners.
- if (method_exists($event, 'isPropagationStopped') === true && $event->isPropagationStopped() === true) {
+ if ($event->isPropagationStopped() === true) {
$this->logger->debug(
message: '[ActionListener] Propagation already stopped, skipping action execution'
);
diff --git a/lib/Listener/ApprovalChainAdvanceListener.php b/lib/Listener/ApprovalChainAdvanceListener.php
index 1e818190e8..bd29129613 100644
--- a/lib/Listener/ApprovalChainAdvanceListener.php
+++ b/lib/Listener/ApprovalChainAdvanceListener.php
@@ -84,10 +84,8 @@ public function handle(Event $event): void {
return;
}
- if (($schema instanceof Schema) === false) {
- return;
- }
-
+ // No instanceof re-check: the lookup above is declared to return Schema
+ // and throws otherwise, which the catch already handles.
$config = ($schema->getConfiguration() ?? []);
$chains = ($config['x-openregister-approval-chains'] ?? null);
if (is_array($chains) === false) {
diff --git a/lib/Listener/LifecycleInitialStateListener.php b/lib/Listener/LifecycleInitialStateListener.php
index cae53f5304..23b38e4045 100644
--- a/lib/Listener/LifecycleInitialStateListener.php
+++ b/lib/Listener/LifecycleInitialStateListener.php
@@ -125,7 +125,7 @@ private function applyInitial(ObjectEntity $object, Schema $schema, array $annot
// Caller already set a value — leave it alone (validator covers it).
// Resolve the (possibly dynamic) initial value only AFTER this guard so
// we never read a related object when the caller already chose.
- if (isset($data[$field]) === true && $data[$field] !== null && $data[$field] !== '') {
+ if (isset($data[$field]) === true && $data[$field] !== '') {
return;
}
diff --git a/lib/Listener/MailAppScriptListener.php b/lib/Listener/MailAppScriptListener.php
index 2281f6f0a4..60028c66e5 100644
--- a/lib/Listener/MailAppScriptListener.php
+++ b/lib/Listener/MailAppScriptListener.php
@@ -83,11 +83,9 @@ public function handle(Event $event): void {
return;
}
+ // The getResponse() accessor is declared to return TemplateResponse, so the
+ // instanceof bail-out that used to sit here was unreachable.
$response = $event->getResponse();
- if ($response instanceof TemplateResponse === false) {
- return;
- }
-
if ($response->getApp() !== 'mail') {
return;
}
diff --git a/lib/Listener/NotificationDedupeAnnotationSyncListener.php b/lib/Listener/NotificationDedupeAnnotationSyncListener.php
index 257f872e5b..d0bb33456e 100644
--- a/lib/Listener/NotificationDedupeAnnotationSyncListener.php
+++ b/lib/Listener/NotificationDedupeAnnotationSyncListener.php
@@ -105,21 +105,14 @@ public function handle(Event $event): void {
*/
private function resolveSchema(Event $event): ?Schema {
if ($event instanceof SchemaCreatedEvent) {
- $schema = $event->getSchema();
- if ($schema instanceof Schema) {
- return $schema;
- }
-
- return null;
+ // The getSchema() accessor is declared to return Schema, so the instanceof
+ // re-check and its null fallback were both unreachable.
+ return $event->getSchema();
}
if ($event instanceof SchemaUpdatedEvent) {
- $schema = $event->getNewSchema();
- if ($schema instanceof Schema) {
- return $schema;
- }
-
- return null;
+ // The getNewSchema() accessor is declared to return Schema; see above.
+ return $event->getNewSchema();
}
return null;
diff --git a/lib/Listener/SystemEntityNotificationListener.php b/lib/Listener/SystemEntityNotificationListener.php
index c51031753f..1bc4384631 100644
--- a/lib/Listener/SystemEntityNotificationListener.php
+++ b/lib/Listener/SystemEntityNotificationListener.php
@@ -112,7 +112,7 @@ public function handle(Event $event): void {
$newData = $entity->jsonSerialize();
}
- if (is_array($newData) === true && is_array($oldData) === true) {
+ if (is_array($newData) === true) {
$context['_newData'] = $newData;
$context['_oldData'] = $oldData;
}
@@ -149,10 +149,10 @@ private function extractEventData(Event $event): array {
if ($event instanceof RegisterUpdatedEvent) {
$old = $event->getOldRegister();
- $oldData = null;
- if ($old instanceof \JsonSerializable) {
- $oldData = $old->jsonSerialize();
- }
+ // Every Db entity extends OCP Entity, which implements
+ // JsonSerializable, so the instanceof guard that used to wrap this
+ // was always true and the null default unreachable.
+ $oldData = $old->jsonSerialize();
return [$event->getNewRegister(), SystemSchemaRules::SLUG_REGISTER, 'updated', $oldData];
}
@@ -163,10 +163,10 @@ private function extractEventData(Event $event): array {
if ($event instanceof SchemaUpdatedEvent) {
$old = $event->getOldSchema();
- $oldData = null;
- if ($old instanceof \JsonSerializable) {
- $oldData = $old->jsonSerialize();
- }
+ // Every Db entity extends OCP Entity, which implements
+ // JsonSerializable, so the instanceof guard that used to wrap this
+ // was always true and the null default unreachable.
+ $oldData = $old->jsonSerialize();
return [$event->getNewSchema(), SystemSchemaRules::SLUG_SCHEMA, 'updated', $oldData];
}
@@ -177,10 +177,10 @@ private function extractEventData(Event $event): array {
if ($event instanceof ConfigurationUpdatedEvent) {
$old = $event->getOldConfiguration();
- $oldData = null;
- if ($old instanceof \JsonSerializable) {
- $oldData = $old->jsonSerialize();
- }
+ // Every Db entity extends OCP Entity, which implements
+ // JsonSerializable, so the instanceof guard that used to wrap this
+ // was always true and the null default unreachable.
+ $oldData = $old->jsonSerialize();
return [$event->getNewConfiguration(), SystemSchemaRules::SLUG_CONFIGURATION, 'updated', $oldData];
}
@@ -191,10 +191,10 @@ private function extractEventData(Event $event): array {
if ($event instanceof SourceUpdatedEvent) {
$old = $event->getOldSource();
- $oldData = null;
- if ($old instanceof \JsonSerializable) {
- $oldData = $old->jsonSerialize();
- }
+ // Every Db entity extends OCP Entity, which implements
+ // JsonSerializable, so the instanceof guard that used to wrap this
+ // was always true and the null default unreachable.
+ $oldData = $old->jsonSerialize();
return [$event->getNewSource(), SystemSchemaRules::SLUG_SOURCE, 'updated', $oldData];
}
@@ -205,10 +205,10 @@ private function extractEventData(Event $event): array {
if ($event instanceof AgentUpdatedEvent) {
$old = $event->getOldAgent();
- $oldData = null;
- if ($old instanceof \JsonSerializable) {
- $oldData = $old->jsonSerialize();
- }
+ // Every Db entity extends OCP Entity, which implements
+ // JsonSerializable, so the instanceof guard that used to wrap this
+ // was always true and the null default unreachable.
+ $oldData = $old->jsonSerialize();
return [$event->getNewAgent(), SystemSchemaRules::SLUG_AGENT, 'updated', $oldData];
}
diff --git a/lib/Middleware/LanguageMiddleware.php b/lib/Middleware/LanguageMiddleware.php
index d74496e8d8..19632054e6 100644
--- a/lib/Middleware/LanguageMiddleware.php
+++ b/lib/Middleware/LanguageMiddleware.php
@@ -108,7 +108,7 @@ public function beforeController($controller, $methodName): void {
// 2. Accept-Language header is the next priority.
$acceptLanguage = $this->request->getHeader('Accept-Language');
- if ($acceptLanguage !== '' && $acceptLanguage !== null) {
+ if ($acceptLanguage !== '') {
$acceptedLanguages = LanguageService::parseAcceptLanguageHeader($acceptLanguage);
$this->languageService->setAcceptedLanguages($acceptedLanguages);
@@ -129,7 +129,7 @@ public function beforeController($controller, $methodName): void {
$method = strtoupper((string)$this->request->getMethod());
if (in_array($method, ['POST', 'PUT', 'PATCH'], true) === true) {
$targetHeader = $this->request->getHeader('X-Translation-Target-Language');
- if ($targetHeader !== '' && $targetHeader !== null) {
+ if ($targetHeader !== '') {
$targetTrim = trim($targetHeader);
if (preg_match(self::BCP47_PATTERN, $targetTrim) === 1) {
$this->languageService->setTargetLanguage($targetTrim);
diff --git a/lib/Service/ActionExecutor.php b/lib/Service/ActionExecutor.php
index 8343b89677..d2fd766679 100644
--- a/lib/Service/ActionExecutor.php
+++ b/lib/Service/ActionExecutor.php
@@ -88,7 +88,7 @@ public function __construct(
public function executeActions(array $actions, Event $event, array $payload, string $eventType): void {
foreach ($actions as $action) {
// Check if propagation was stopped by a previous action or inline hook.
- if (method_exists($event, 'isPropagationStopped') === true && $event->isPropagationStopped() === true) {
+ if ($event->isPropagationStopped() === true) {
$this->logger->debug(
message: '[ActionExecutor] Propagation stopped, skipping remaining actions',
context: ['skippedAction' => $action->getName()]
@@ -251,10 +251,10 @@ private function processWorkflowResult(WorkflowResult $result, Action $action, E
context: ['actionName' => $action->getName()]
);
- // Stop propagation for pre-mutation events.
- if (method_exists($event, 'stopPropagation') === true) {
- $event->stopPropagation();
- }
+ // Stop propagation for pre-mutation events. OCP\EventDispatcher\Event
+ // declares stopPropagation(), so no method_exists() probe is needed —
+ // unlike setErrors() below, which is not on the base class.
+ $event->stopPropagation();
if (method_exists($event, 'setErrors') === true) {
$event->setErrors($result->getErrors());
diff --git a/lib/Service/ApprovalService.php b/lib/Service/ApprovalService.php
index c2481bf98b..8fa9e3c459 100644
--- a/lib/Service/ApprovalService.php
+++ b/lib/Service/ApprovalService.php
@@ -380,10 +380,8 @@ private function resolveSeparationOfDuties(ApprovalChain $chain): bool {
return false;
}
- if (($schema instanceof Schema) === false) {
- return false;
- }
-
+ // No instanceof re-check: the lookup above is declared to return Schema
+ // and throws otherwise, which the catch already handles.
$config = ($schema->getConfiguration() ?? []);
$chains = ($config['x-openregister-approval-chains'] ?? null);
if (is_array($chains) === false) {
diff --git a/lib/Service/CalendarLinkService.php b/lib/Service/CalendarLinkService.php
index fd94c3394d..539f2e2e15 100644
--- a/lib/Service/CalendarLinkService.php
+++ b/lib/Service/CalendarLinkService.php
@@ -383,7 +383,7 @@ private function mergeXorEvent(array &$merged, array $event, string $eventUid, s
// Already present from link-table; mark as both and refresh free-text fields.
$merged[$key]['source'] = 'both';
foreach (['summary', 'dtstart', 'dtend', 'location', 'description', 'attendees', 'status'] as $field) {
- if (isset($event[$field]) === true && $event[$field] !== null && $event[$field] !== '') {
+ if (isset($event[$field]) === true && $event[$field] !== '') {
$merged[$key][$field] = $event[$field];
}
}
diff --git a/lib/Service/Chat/ContextRetrievalHandler.php b/lib/Service/Chat/ContextRetrievalHandler.php
index b65d3f083f..b7c6ebdf73 100644
--- a/lib/Service/Chat/ContextRetrievalHandler.php
+++ b/lib/Service/Chat/ContextRetrievalHandler.php
@@ -245,11 +245,9 @@ public function retrieveContext(
$results = [];
}
- // Determine raw results count for logging.
- $rawResultsCount = gettype($results);
- if (is_array($results) === true) {
- $rawResultsCount = count($results);
- }
+ // Determine raw results count for logging. $results is always an
+ // array here, so the old gettype() fallback was unreachable.
+ $rawResultsCount = count($results);
// Filter and build context - track file and object counts separately.
$fileSourceCount = 0;
diff --git a/lib/Service/Chat/ResponseGenerationHandler.php b/lib/Service/Chat/ResponseGenerationHandler.php
index 180b6e168c..96e80421e9 100644
--- a/lib/Service/Chat/ResponseGenerationHandler.php
+++ b/lib/Service/Chat/ResponseGenerationHandler.php
@@ -581,7 +581,6 @@ private function invokeChat(
if ($channel !== null
&& $ollamaWithTools === false
- && method_exists($chat, 'generateStreamOfText') === true
) {
try {
return $this->streamChat(chat: $chat, messageHistory: $messageHistory, channel: $channel);
diff --git a/lib/Service/Configuration/ExportHandler.php b/lib/Service/Configuration/ExportHandler.php
index 73a3534a9c..74d7a41ebf 100644
--- a/lib/Service/Configuration/ExportHandler.php
+++ b/lib/Service/Configuration/ExportHandler.php
@@ -371,7 +371,7 @@ public function exportConfig(
}//end foreach
// Export mappings associated with this configuration.
- if (isset($configuration) === true && $configuration instanceof Configuration) {
+ if (isset($configuration) === true) {
$mappingIds = $configuration->getMappings() ?? [];
foreach ($mappingIds as $mappingId) {
try {
diff --git a/lib/Service/Configuration/GitHubHandler.php b/lib/Service/Configuration/GitHubHandler.php
index ddfa69785a..06f440886d 100644
--- a/lib/Service/Configuration/GitHubHandler.php
+++ b/lib/Service/Configuration/GitHubHandler.php
@@ -993,7 +993,9 @@ function (array $repo): array {
'file' => __FILE__,
'line' => __LINE__,
'status_code' => $statusCode,
- 'has_token' => (empty($token) === false),
+ // Always true: $token is a non-empty-string by this point, so
+ // the only way into this branch is the 401.
+ 'has_token' => true,
]
);
return [];
@@ -1553,7 +1555,7 @@ public function createIssue(
*
* @spec openspec/changes/add-features-roadmap-menu/tasks.md#task-2
*/
- private function resolveCreateIssueToken(string $userId, ?bool &$useServerPat): string {
+ private function resolveCreateIssueToken(string $userId, bool &$useServerPat): string {
$userToken = $this->getUserToken(userId: $userId) ?? '';
$useServerPat = $userToken === '';
if ($useServerPat === false) {
@@ -1763,10 +1765,9 @@ private function extractGitHubStatus(Exception $exception): int {
return 0;
}
- // The class is confirmed by the is_a() check above, so getResponse() is safe.
- if (method_exists($exception, 'getResponse') === false) {
- return 0;
- }
+ // The class is confirmed by the is_a() check above, so getResponse() is
+ // safe — the method_exists() bail-out that used to sit here could never
+ // be reached.
// BadResponseException always carries a response (it is required by its constructor),
// so getResponse() is non-null here.
diff --git a/lib/Service/Configuration/ImportHandler.php b/lib/Service/Configuration/ImportHandler.php
index badf952b74..dee30ce553 100644
--- a/lib/Service/Configuration/ImportHandler.php
+++ b/lib/Service/Configuration/ImportHandler.php
@@ -2573,11 +2573,11 @@ public function importFromJson(
version: $version,
force: $force
);
- if ($register !== null) {
- // Store register in map by slug for reference.
- $this->registersMap[$slug] = $register;
- $result['registers'][] = $register;
- }
+ // Store register in map by slug for reference. The import call
+ // above is declared non-nullable and throws on failure, which
+ // the catch below handles.
+ $this->registersMap[$slug] = $register;
+ $result['registers'][] = $register;
} catch (\Throwable $e) {
$result['skipped']['registers']++;
$this->logger->warning(
@@ -3411,6 +3411,10 @@ private function findExistingSeedUuid(Register $register, Schema $schema, string
* @param array